diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b2ba7d..d1b1f14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -262,6 +262,9 @@ jobs: - name: Build swift-java-weather-app APK run: ./gradlew :swift-java-weather-app-weather-app:assemble${{ matrix.configuration }} --stacktrace + - name: Build swift-java-ui-showcase APK + run: ./gradlew :swift-java-ui-showcase-showcase-app:assemble${{ matrix.configuration }} --stacktrace + - name: Build hello-cpp-swift cpp-lib working-directory: hello-cpp-swift/cpp-lib run: ./build-android-static.sh diff --git a/README.md b/README.md index 28b8132..567906c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,14 @@ The **[swift-java-weather-app](swift-java-weather-app/)** example showcases auto Swift language features, such as `async` functions and implementing a Swift `protocol` in Java, which can be passed ~~back~~ to Swift. +The **[swift-java-ui-showcase](swift-java-ui-showcase/)** example is a "kitchen sink" +catalog of UI components (buttons, selection controls, sliders, text inputs, and a +validated form) where Swift owns the whole UI model: each screen's component tree, +state, event handling, and validation are declared in Swift, and a small generic +Kotlin/Compose renderer draws whatever Swift describes over a single JSON/JNI +boundary. See [swift-java-ui-showcase/README.md](swift-java-ui-showcase/README.md) +for the architecture and for how to add your own screen without touching Kotlin. + ## C++ Integration Example The **[hello-cpp-swift](hello-cpp-swift/)** example demonstrates how to integrate diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 62424a8..9d44098 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ constraintlayout = "2.2.1" lifecycleRuntimeKtx = "2.9.2" activityCompose = "1.10.1" composeBom = "2024.09.00" +navigationCompose = "2.8.9" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -30,6 +31,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index a4f557b..def24a1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -48,6 +48,11 @@ project(":swift-java-weather-app-weather-lib").projectDir = file("swift-java-wea include(":swift-java-weather-app-weather-app") project(":swift-java-weather-app-weather-app").projectDir = file("swift-java-weather-app/weather-app") +include(":swift-java-ui-showcase-showcase-lib") +project(":swift-java-ui-showcase-showcase-lib").projectDir = file("swift-java-ui-showcase/showcase-lib") +include(":swift-java-ui-showcase-showcase-app") +project(":swift-java-ui-showcase-showcase-app").projectDir = file("swift-java-ui-showcase/showcase-app") + // raw-jni examples include(":hello-swift-raw-jni") include(":hello-swift-raw-jni-callback") diff --git a/swift-java-ui-showcase/.gitignore b/swift-java-ui-showcase/.gitignore new file mode 100644 index 0000000..6e582d1 --- /dev/null +++ b/swift-java-ui-showcase/.gitignore @@ -0,0 +1,41 @@ +.DS_STORE + +# Swift +.build/ +.swiftpm/ +Package.resolved + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.aab +*.apk +output-metadata.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof \ No newline at end of file diff --git a/swift-java-ui-showcase/PLAN.md b/swift-java-ui-showcase/PLAN.md new file mode 100644 index 0000000..879bb32 --- /dev/null +++ b/swift-java-ui-showcase/PLAN.md @@ -0,0 +1,146 @@ +# Swift-Java UI Showcase — new example app + +## Context + +Swift Mentorship Program project: add a new example to `swift-android-examples` — a "kitchen sink" showcase app navigating between screens that demo UI component categories (buttons, selection controls, sliders, text inputs, and a form with validation). + +**Agreed architecture** (confirmed with user): Swift owns everything conceptual — the declarative component model, all state, event handling, and form validation. Jetpack Compose cannot be invoked from Swift (it's a Kotlin compiler-plugin feature), so "instantiate components in Swift" is realized as Swift declaring per-screen component trees and a **thin generic Kotlin/Compose (Material 3) renderer** interpreting them — a unidirectional data-flow loop with Swift as the single source of truth. Built on the repo's recommended **swift-java/JExtract** pattern, cloned from `hello-swift-java/`. + +**Design constraints**: keep it as simple as a reference example demands (tiny diff against the canonical `hello-swift-java` example, minimal dependencies and files), and treat documentation as a per-milestone deliverable, not an afterthought. + +## Key decisions + +- **Boundary = JSON strings over three top-level Swift functions** (`showcaseScreens()`, `showcaseScreen(id)`, `showcaseDispatch(screenId, componentId, eventJSON)` — all `(String…) -> String`, the exact shape JExtract `mode:"jni"` proves out with `SwiftHashing.hash`). No `enableJavaCallbacks` — avoids the `--disable-sandbox` flag weather-lib needs. Pull-based loop: Kotlin pulls screen JSON → user event → dispatch → new screen JSON → recompose. +- **Exactly one new repo dependency**: `androidx.navigation:navigation-compose` **2.8.9** (compatible with Compose BOM 2024.09.00 / Compose 1.7 — do NOT use 2.9+). No ViewModel: the current screen JSON lives in `remember { mutableStateOf(...) }` (same pattern `hashing-app/MainActivity.kt` already uses); real state lives in Swift, so it survives navigation regardless. +- **Kotlin decodes with `org.json`** (platform built-in; no serialization plugin, no wrapper data classes — render straight from `JSONObject`). +- **Swift model**: `enum Component` with hand-written `Codable` (flat `"kind"`-discriminated JSON, exact wire schema pinned by Swift tests). `FoundationEssentials` JSONEncoder is already in the `swiftRuntimeLibs` allowlist. +- **Minimal component surface, one variant each** (variants are follow-up PRs / good first issues): filled button only (no `enabled` flag), no `steps` on slider, textField keeps `keyboard: text|email|number` (the form demo needs it) but no `secure`. +- **File budget** — Kotlin: `MainActivity.kt` (nav graph + home list + screen scaffold), `ComponentRenderer.kt` (the single `when`), copied `ui/theme/` files; ~250 lines total. Swift: `Component.swift`, `Event.swift`, `ShowcaseStore.swift` (registry + dispatch + JSON), `Screens.swift` (all five screens — they're short), `ShowcaseAPI.swift` (the three entry points, heavily commented — it's *the* boundary readers came to see). +- **Naming**: dir `swift-java-ui-showcase/` with `showcase-lib/` + `showcase-app/`; Gradle `:swift-java-ui-showcase-showcase-lib` / `:swift-java-ui-showcase-showcase-app`; Swift package/target `ShowcaseKit`; `javaPackage` `com.example.showcasekit` (generated class `com.example.showcasekit.ShowcaseKit`); lib namespace `com.example.showcaselib`; app namespace/appId `com.example.showcaseapp`. + +## JSON wire schema (contract, pinned by Swift tests) + +```jsonc +// showcaseScreens() +{"screens":[{"id":"buttons","title":"Buttons"},{"id":"selection","title":"Selection Controls"}, + {"id":"sliders","title":"Sliders"},{"id":"textInputs","title":"Text Inputs"}, + {"id":"form","title":"Form & Validation"}]} + +// showcaseScreen("buttons") — also the return of showcaseDispatch +{"id":"buttons","title":"Buttons","components":[ + {"kind":"sectionHeader","id":"hdr1","text":"Buttons"}, + {"kind":"text","id":"tapCount","text":"Tapped 0 times"}, + {"kind":"button","id":"tapMe","label":"Tap me"}, + {"kind":"toggle","id":"wifi","label":"Wi-Fi","isOn":true}, + {"kind":"checkbox","id":"terms","label":"Accept terms","isChecked":false}, + {"kind":"radioGroup","id":"size","label":"Size","options":["S","M","L"],"selectedIndex":1}, + {"kind":"slider","id":"volume","label":"Volume","value":0.5,"min":0.0,"max":1.0}, + {"kind":"textField","id":"email","label":"Email","text":"","placeholder":"you@example.com", + "keyboard":"email","error":null}]} + +// eventJSON for showcaseDispatch: +{"type":"tap"} | {"type":"setBool","value":true} | {"type":"setString","value":"abc"} | +{"type":"setNumber","value":0.75} | {"type":"select","index":2} +``` + +## Milestone 0 — Branch + committed plan + +- `git switch -c swift-java-ui-showcase-plan` (from `main`). +- Write this plan (merged: decisions + simplicity/docs revision) to `swift-java-ui-showcase/PLAN.md` (`.md` is `.licenseignore`-exempt, no header needed) and commit it: `git add swift-java-ui-showcase/PLAN.md && git commit -m "Add swift-java-ui-showcase example implementation plan"`. + +## Milestone 1 — Scaffolding + registration (repo builds again by end of milestone) + +**`swift-java-ui-showcase/showcase-lib/`** (clone of `hello-swift-java/hashing-lib/`): +- `Package.swift`: rename package/target to `ShowcaseKit`, drop swift-crypto, keep swift-java `from: "0.1.2"` + `JExtractSwiftPlugin` + `.swiftLanguageMode(.v5)`, add `ShowcaseKitTests` test target. +- `Sources/ShowcaseKit/swift-java.config`: `{"javaPackage": "com.example.showcasekit", "mode": "jni"}`. +- `build.gradle` (Groovy): copy **weather-lib's** version (already `compileSdkVersion 36`; hashing-lib is stuck at 34), then change: `namespace "com.example.showcaselib"`; `inputs.dir(… "Sources/ShowcaseKit")`; the JExtract outputs path segment `…/ShowcaseKit/destination/JExtractSwiftPlugin/src/generated/java`; remove `--disable-sandbox` + its comment (no Java callbacks). Keep `swiftRuntimeLibs` as-is. **These two hardcoded target-name paths are the #1 copy-paste failure** — add "why" comments at both. +- `Sources/ShowcaseKit/ShowcaseAPI.swift` stub with the three public functions; copy `gradle.properties` and `.gitignore` (at `swift-java-ui-showcase/.gitignore`). + +**`swift-java-ui-showcase/showcase-app/`** (clone of `hello-swift-java/hashing-app/`, near-verbatim `build.gradle.kts` diff): +- `build.gradle.kts`: namespace/appId `com.example.showcaseapp`; deps `project(":swift-java-ui-showcase-showcase-lib")`, `org.swift.swiftkit:swiftkit-core:+`, `libs.androidx.navigation.compose`. +- Copy manifest, `res/**` (app label in `strings.xml`), `ui/theme/*.kt` → `ShowcaseAppTheme`, stock unit/instrumented tests, `proguard-rules.pro`, `.gitignore`. + +**Registration edits:** +- `settings.gradle.kts` — after line 49 (weather-app block), same include+projectDir-remap pattern: + ```kotlin + include(":swift-java-ui-showcase-showcase-lib") + project(":swift-java-ui-showcase-showcase-lib").projectDir = file("swift-java-ui-showcase/showcase-lib") + include(":swift-java-ui-showcase-showcase-app") + project(":swift-java-ui-showcase-showcase-app").projectDir = file("swift-java-ui-showcase/showcase-app") + ``` +- `gradle/libs.versions.toml` — add `navigationCompose = "2.8.9"` and the `androidx-navigation-compose` library entry. + +Verify: `./gradlew :swift-java-ui-showcase-showcase-app:assembleDebug --stacktrace` (needs swiftly + Android Swift SDK + swiftkit-core published to mavenLocal, per `hello-swift-java/README.md`). + +## Milestone 2 — Walking skeleton + first README + +Swift `ShowcaseStore` with a single `buttons` screen holding a tap counter; `dispatch` increments and re-renders. Kotlin: minimal `ComponentRenderer` (`text` + `button` only); `MainActivity` shows the screen directly (no nav yet), JSON held in `remember { mutableStateOf(...) }`. On emulator: tap increments label — proves JExtract, JSON, state, recomposition. + +Swift shapes (`Sources/ShowcaseKit/`): +```swift +// Component.swift — hand-written Codable, flat keys + "kind" discriminator +public enum Component: Equatable { + case sectionHeader(id: String, text: String) + case text(id: String, text: String) + case button(id: String, label: String) + case toggle(id: String, label: String, isOn: Bool) + case checkbox(id: String, label: String, isChecked: Bool) + case radioGroup(id: String, label: String, options: [String], selectedIndex: Int?) + case slider(id: String, label: String, value: Double, min: Double, max: Double) + case textField(id: String, label: String, text: String, placeholder: String, + keyboard: Keyboard, error: String?) +} +// Event.swift — "type" discriminator +public enum Event: Codable { case tap, setBool(Bool), setString(String), setNumber(Double), select(Int) } +// ScreenDefinition.swift protocol lives in ShowcaseStore.swift or Screens.swift: +protocol ScreenDefinition: AnyObject { + var id: String { get }; var title: String { get } + func handle(_ event: Event, componentId: String) + func body() -> [Component] +} +// ShowcaseStore.swift — singleton registry + JSON encode, main-thread only (documented invariant) +``` + +Kotlin renderer core (`ComponentRenderer.kt`): one `when (c.getString("kind"))` → Material 3 composables, emitting event `JSONObject`s back through an `onEvent(id, event)` lambda. Text-field note (inline comment in code): keep in-flight text in local `remember` state keyed on the Swift value; dispatch `setString` per change but don't re-seed from JSON while focused (avoids IME cursor jitter). + +**Docs (this milestone)**: create `swift-java-ui-showcase/README.md` modeled on `hello-swift-java/README.md` — what it demonstrates, setup (link to hashing-lib README for swiftly/SDK/`publishToMavenLocal` long-form), and an ASCII sequence diagram of the loop: `Compose tap → showcaseDispatch(screen, id, eventJSON) → Swift mutates state → returns screen JSON → recompose`. + +## Milestone 3 — Full model, five screens, validation, Swift tests + +- `Screens.swift`: `ButtonsScreen` (tap counter), `SelectionScreen` (switch, checkboxes, radio group whose selection affects another component — cross-component state in Swift), `SlidersScreen` (value readout formatted in Swift), `TextInputsScreen` (text/email/number keyboards), `FormScreen` (name/email/age + submit; `Validation` pure functions → `textField.error`; success replaces form with a success `text`). +- `Tests/ShowcaseKitTests/` (swift-testing `@Test`/`#expect`, mirroring `SwiftHashingTests`): exact-JSON schema lock for a known screen (references the README schema table by name), event decoding, dispatch round-trip, validation rules. +- Verify: `cd swift-java-ui-showcase/showcase-lib && swift test` (pure Swift model — host build, no JNI). +- **Docs**: expand README with the **JSON schema table** (every `kind`, its keys, events it emits) — the contract document; `///` doc comments on every public Swift symbol. + +## Milestone 4 — Navigation + +`MainActivity.kt`: `NavHost` with `"home"` (Scaffold + TopAppBar + LazyColumn of screens from `showcaseScreens()`) and `"screen/{screenId}"` (load JSON, render components, back nav). Graph is data-driven from Swift's registry — adding a Swift screen requires zero Kotlin changes. + +## Milestone 5 — CI + final docs + +- `.github/workflows/ci.yml` — add after the weather-app step (~line 263): + ```yaml + - name: Build swift-java-ui-showcase APK + run: ./gradlew :swift-java-ui-showcase-showcase-app:assemble${{ matrix.configuration }} --stacktrace + ``` +- Finish `swift-java-ui-showcase/README.md`: one screenshot per screen in `swift-java-ui-showcase/resources/` (repo convention); **"Add your own screen in ~20 lines"** tutorial (conform to `ScreenDefinition`, append to registry, done — no Kotlin changes) and **"Add your own component kind"** tutorial (four touch points: enum case, Codable case, `when` branch, schema table row). +- Root `README.md`: 5-6 line subsection under "Additional swift-java examples" — Swift-owned declarative UI state over a single JSON/JNI boundary, link to `swift-java-ui-showcase/README.md`. +- License sweep: every new `.swift`, `.kt`, `.gradle`, `.gradle.kts` gets the `//===---…===//` Apache-2.0 Swift.org header, `Copyright (c) 2026` (CI soundness-enforced; `.xml`, `.md`, `.json`, `.config`, `Package.swift` exempt per `.licenseignore`). + +## Verification (end-to-end) + +1. `cd swift-java-ui-showcase/showcase-lib && swift build && swift test` (host). +2. `./gradlew :swift-java-ui-showcase-showcase-lib:buildSwiftAll` — generated Java appears under `showcase-lib/.build/plugins/outputs/showcase-lib/ShowcaseKit/destination/…`. +3. `./gradlew :swift-java-ui-showcase-showcase-app:assembleDebug --stacktrace`, then `assembleRelease`. +4. Emulator: navigate all five screens; tap counter works, toggle state survives navigation (state lives in Swift), form errors + success path. +5. `./gradlew assembleDebug` — all other examples still build. + +## Risks / mitigations + +- **JExtract jni-mode limits** → only `(String…) -> String` top-level functions cross the boundary; never structs/enums/optionals. +- **Schema drift** → hand-written Codable + exact-JSON Swift tests; Kotlin uses `opt*` with defaults. +- **navigation-compose clash** → pin 2.8.9; don't bump the Compose BOM. +- **Stale/wrong JExtract paths in build.gradle** → the two `ShowcaseKit` path edits called out in Milestone 1, with inline comments. +- **`swiftkit-core:+` unresolved locally** → README documents `publishToMavenLocal` (CI already does it). +- **IME jitter on per-keystroke JNI dispatch** → local buffered text state in the textField renderer. +- **Singleton concurrency warnings** → language mode v5 keeps them warnings; all entry points are main-thread, documented invariant. diff --git a/swift-java-ui-showcase/README.md b/swift-java-ui-showcase/README.md new file mode 100644 index 0000000..231a6e3 --- /dev/null +++ b/swift-java-ui-showcase/README.md @@ -0,0 +1,156 @@ +# Swift-driven UI showcase + +A "kitchen sink" catalog app where **Swift declares the UI and owns all of its +state**, and Kotlin is only a thin generic renderer. Browse screens that demo +buttons, selection controls (switch, checkboxes, radio group), sliders, text +inputs, and a form with validation — every component you see was declared by +Swift code, and every interaction is handled by Swift code. + +| Home (Swift's screen registry) | Buttons (Swift tap counter) | Form (Swift validation) | +|---|---|---| +| ![Home screen](resources/home.png) | ![Buttons screen](resources/buttons.png) | ![Form validation errors](resources/form-validation.png) | + +## Overview + +The example consists of two components: + +- **showcase-lib**: A Swift package (`ShowcaseKit`) that declares the component + tree of every screen, holds all UI state, reduces every action, and validates + the form. It is exposed to Java by + [swift-java](https://github.com/swiftlang/swift-java)'s JExtract in JNI mode. +- **showcase-app**: A Kotlin Android app whose Compose code never hard-codes a + screen. It interprets whatever component tree Swift returns + (`ComponentRenderer.kt` is a single `when` over the component kind) and + builds its navigation graph from Swift's screen registry. + +Jetpack Compose cannot be called from Swift (composables are a Kotlin compiler +feature), so "the UI is written in Swift" takes this shape: a unidirectional +data-flow loop with Swift as the single source of truth. + +``` + Compose (Kotlin) ShowcaseKit (Swift) + ┌──────────────────────────┐ ┌────────────────────────────┐ + │ user taps / types / drags │──── action ───▶│ showcaseDispatch(screen, │ + │ │ (JSON) │ component, action) │ + │ render component tree │ │ → screen reduces action, │ + │ (one `when` over "kind") │◀─ new screen ───│ mutates its Swift state│ + └──────────────────────────┘ (JSON) └────────────────────────────┘ +``` + +Only three functions cross the boundary, all `(String...) -> String` +(see `showcase-lib/Sources/ShowcaseKit/ShowcaseAPI.swift`): + +| Function | Purpose | +|---|---| +| `showcaseScreens()` | Screen registry (ids + titles) — drives the navigation graph | +| `showcaseScreen(id)` | Current component tree of one screen | +| `showcaseDispatch(screenId, componentId, actionJSON)` | Apply a user action, return the new tree | + +Because state lives in Swift, it survives navigation: toggle a switch, leave +the screen, come back — the switch is still on, with no ViewModel or saved +state on the Kotlin side. + +## Prerequisites + +Same as [hello-swift-java](../hello-swift-java/README.md): a Swift 6.3+ +toolchain via `swiftly`, the Swift SDK for Android, and the `swiftkit-core` +package published to your local Maven repository. Follow the +[Setup and Configuration](../hello-swift-java/README.md#setup-and-configuration) +steps there (run the `swift package resolve` / `publishToMavenLocal` step from +`swift-java-ui-showcase/showcase-lib` or any other swift-java example module — +the published package is shared). + +## Running the example + +```console +./gradlew :swift-java-ui-showcase-showcase-app:assembleDebug +``` + +or open the repository in Android Studio and run the +`swift-java-ui-showcase-showcase-app` configuration on an emulator or device. + +To iterate on the Swift model without an Android device, the package is plain +Swift — build and test it on your host machine: + +```console +cd swift-java-ui-showcase/showcase-lib +swift test +``` + +## JSON schema + +The wire format between Swift and Kotlin. Every component is a flat JSON +object discriminated by `"kind"`; the schema is pinned by +`buttonsScreenSchemaIsStable()` in +`showcase-lib/Tests/ShowcaseKitTests/ShowcaseKitTests.swift` — update this +table and that test together. + +`showcaseScreens()` returns `{"screens": [{"id", "title"}, ...]}`. +`showcaseScreen(id)` and `showcaseDispatch(...)` return +`{"id", "title", "components": [...]}` where each component is: + +| `kind` | Keys | Actions it emits | +|---|---|---| +| `sectionHeader` | `id`, `text` | — | +| `text` | `id`, `text` | — | +| `button` | `id`, `label`, `role` (`primary`\|`secondary`) | `{"type":"tap"}` | +| `toggle` | `id`, `label`, `isOn` | `{"type":"setBool","value":…}` | +| `checkbox` | `id`, `label`, `isChecked` | `{"type":"setBool","value":…}` | +| `radioGroup` | `id`, `label`, `options`, `selectedIndex` (nullable) | `{"type":"select","index":…}` | +| `segmentedControl` | `id`, `label`, `options`, `selectedIndex` | `{"type":"select","index":…}` | +| `slider` | `id`, `label`, `value`, `min`, `max` | `{"type":"setNumber","value":…}` | +| `progressIndicator` | `id`, `label`, `value` (`0.0`–`1.0`) | — (display-only) | +| `stepper` | `id`, `label`, `value`, `min`, `max` (all `Int`) | `{"type":"setNumber","value":±1}` (a delta, not the new value) | +| `datePicker` | `id`, `label`, `date` (`"yyyy-MM-dd"`) | `{"type":"setString","value":…}` | +| `alert` | `id`, `title`, `message`, `confirmLabel`, `cancelLabel` | `{"type":"select","index":0}` (confirm) or `{"type":"select","index":1}` (cancel/dismiss) | +| `textField` | `id`, `label`, `text`, `placeholder`, `keyboard` (`text`\|`email`\|`number`), `error` (nullable) | `{"type":"setString","value":…}` | +| `textEditor` | `id`, `label`, `text`, `placeholder` | `{"type":"setString","value":…}` | +| `code` | `id`, `title`, `code` | — (opens a fullscreen modal with Copy and Wrap actions; modal visibility and wrap state stay Kotlin-local) | + +## Add your own screen (~20 lines, no Kotlin) + +Conform to `ScreenDefinition` in its own file under +`showcase-lib/Sources/ShowcaseKit/` (one screen per file, e.g. `ButtonsScreen.swift`): + +```swift +final class GreetingScreen: ScreenDefinition { + let id = "greeting" + let title = "Greeting" + + private var name = "" + + func reduce(_ action: Action, componentId: String) { + if case .setString(let value) = action, componentId == "name" { name = value } + } + + func body() -> [Component] { + [ + .textField(id: "name", label: "Your name", text: name, + placeholder: "World", keyboard: .text, error: nil), + .text(id: "greeting", text: "Hello, \(name.isEmpty ? "World" : name)!"), + ] + } +} +``` + +then append `GreetingScreen()` to `defaultScreens()` in `ScreenRegistry.swift`. +That's it — the home list and navigation are data-driven from Swift's +registry, so the new screen appears with no Kotlin changes. + +## Add your own component kind + +Four touch points: + +1. A new case on `Component` in `showcase-lib/Sources/ShowcaseKit/Component.swift` +2. Its `encode(to:)` / `init(from:)` clauses in the same file +3. A new branch in the `when` in `showcase-app/.../ComponentRenderer.kt` +4. A row in the schema table above (and, if you change existing shapes, the + pinned-schema test) + +## Troubleshooting + +See the [hello-swift-java troubleshooting section](../hello-swift-java/README.md#troubleshooting). +The two paths in `showcase-lib/build.gradle` that hardcode the Swift target +name (`Sources/ShowcaseKit` and the JExtract generated-java output directory) +are the usual suspects if generated Java classes are missing or stale after +renaming things. diff --git a/swift-java-ui-showcase/resources/buttons.png b/swift-java-ui-showcase/resources/buttons.png new file mode 100644 index 0000000..4d600df Binary files /dev/null and b/swift-java-ui-showcase/resources/buttons.png differ diff --git a/swift-java-ui-showcase/resources/form-validation.png b/swift-java-ui-showcase/resources/form-validation.png new file mode 100644 index 0000000..12fa452 Binary files /dev/null and b/swift-java-ui-showcase/resources/form-validation.png differ diff --git a/swift-java-ui-showcase/resources/home.png b/swift-java-ui-showcase/resources/home.png new file mode 100644 index 0000000..8dd4d06 Binary files /dev/null and b/swift-java-ui-showcase/resources/home.png differ diff --git a/swift-java-ui-showcase/showcase-app/.gitignore b/swift-java-ui-showcase/showcase-app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/swift-java-ui-showcase/showcase-app/build.gradle.kts b/swift-java-ui-showcase/showcase-app/build.gradle.kts new file mode 100644 index 0000000..9570405 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/build.gradle.kts @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.example.showcaseapp" + compileSdk = 36 + + defaultConfig { + applicationId = "com.example.showcaseapp" + minSdk = 28 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.navigation.compose) + implementation("org.swift.swiftkit:swiftkit-core:+") + implementation(project(":swift-java-ui-showcase-showcase-lib")) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/swift-java-ui-showcase/showcase-app/proguard-rules.pro b/swift-java-ui-showcase/showcase-app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/swift-java-ui-showcase/showcase-app/src/androidTest/java/com/example/showcaseapp/ExampleInstrumentedTest.kt b/swift-java-ui-showcase/showcase-app/src/androidTest/java/com/example/showcaseapp/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..6373d96 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/androidTest/java/com/example/showcaseapp/ExampleInstrumentedTest.kt @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.showcaseapp + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.showcaseapp", appContext.packageName) + } +} diff --git a/swift-java-ui-showcase/showcase-app/src/main/AndroidManifest.xml b/swift-java-ui-showcase/showcase-app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..339da58 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ComponentRenderer.kt b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ComponentRenderer.kt new file mode 100644 index 0000000..3c0e59e --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ComponentRenderer.kt @@ -0,0 +1,413 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.showcaseapp + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Checkbox +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import org.json.JSONObject + +/** + * The generic interpreter for ShowcaseKit's component model: one Material 3 + * composable per `"kind"` the Swift library can declare. All state shown + * here comes from the Swift-provided JSON; every interaction is reported + * back to Swift through [onEvent] and re-rendered from Swift's response. + */ +@Composable +fun ComponentView(component: JSONObject, onEvent: (componentId: String, event: JSONObject) -> Unit) { + val id = component.getString("id") + when (val kind = component.getString("kind")) { + "sectionHeader" -> Text( + text = component.getString("text"), + style = MaterialTheme.typography.titleMedium + ) + + "text" -> Text( + text = component.getString("text"), + style = MaterialTheme.typography.bodyLarge + ) + + "button" -> when (component.getString("role")) { + "secondary" -> OutlinedButton(onClick = { onEvent(id, event("tap")) }) { + Text(component.getString("label")) + } + else -> Button(onClick = { onEvent(id, event("tap")) }) { + Text(component.getString("label")) + } + } + + "toggle" -> Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(component.getString("label")) + Switch( + checked = component.getBoolean("isOn"), + onCheckedChange = { onEvent(id, event("setBool").put("value", it)) } + ) + } + + "checkbox" -> Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = component.getBoolean("isChecked"), + onCheckedChange = { onEvent(id, event("setBool").put("value", it)) } + ) + Text(component.getString("label")) + } + + "radioGroup" -> Column { + Text(component.getString("label")) + val options = component.getJSONArray("options") + val selectedIndex = component.optInt("selectedIndex", -1) + for (index in 0 until options.length()) { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = index == selectedIndex, + onClick = { onEvent(id, event("select").put("index", index)) } + ) + Text(options.getString(index)) + } + } + } + + "segmentedControl" -> Column { + Text(component.getString("label")) + val options = component.getJSONArray("options") + val selectedIndex = component.getInt("selectedIndex") + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + for (index in 0 until options.length()) { + SegmentedButton( + selected = index == selectedIndex, + onClick = { onEvent(id, event("select").put("index", index)) }, + shape = SegmentedButtonDefaults.itemShape(index, options.length()) + ) { + Text(options.getString(index)) + } + } + } + } + + "progressIndicator" -> Column { + Text(component.getString("label")) + val value = component.getDouble("value").toFloat() + LinearProgressIndicator( + progress = { value }, + modifier = Modifier.fillMaxWidth() + ) + } + + "alert" -> AlertDialog( + onDismissRequest = { onEvent(id, event("select").put("index", 1)) }, + title = { Text(component.getString("title")) }, + text = { Text(component.getString("message")) }, + confirmButton = { + TextButton(onClick = { onEvent(id, event("select").put("index", 0)) }) { + Text(component.getString("confirmLabel")) + } + }, + dismissButton = { + TextButton(onClick = { onEvent(id, event("select").put("index", 1)) }) { + Text(component.getString("cancelLabel")) + } + } + ) + + "slider" -> Column { + Text(component.getString("label")) + Slider( + value = component.getDouble("value").toFloat(), + valueRange = component.getDouble("min").toFloat()..component.getDouble("max").toFloat(), + onValueChange = { onEvent(id, event("setNumber").put("value", it.toDouble())) } + ) + } + + "stepper" -> Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(component.getString("label")) + val value = component.getInt("value") + val min = component.getInt("min") + val max = component.getInt("max") + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = { onEvent(id, event("setNumber").put("value", -1)) }, + enabled = value > min + ) { Text("−", style = MaterialTheme.typography.titleLarge) } + Text( + text = value.toString(), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 12.dp) + ) + IconButton( + onClick = { onEvent(id, event("setNumber").put("value", 1)) }, + enabled = value < max + ) { Text("+", style = MaterialTheme.typography.titleLarge) } + } + } + + "datePicker" -> DatePickerView(component, id, onEvent) + + "textField" -> TextFieldView(component, id, onEvent) + + "textEditor" -> TextEditorView(component, id, onEvent) + + "code" -> CodeSnippetView(component, sectionTitle = component.getString("title")) + + else -> Text("Unknown component kind: $kind") + } +} + +/** + * An info icon that opens the Swift source driving the section it sits in, + * in a fullscreen modal. Placed at the top-trailing corner of each section + * card by [ShowcaseScreen] — its position is what identifies which section + * it belongs to, so no label is needed here. Whether the modal is open never + * round-trips through Swift: like scroll position, it is presentation-only + * state, so it stays Kotlin-local while all app state remains Swift-owned. + */ +@Composable +fun CodeSnippetView(component: JSONObject, sectionTitle: String) { + var showModal by remember { mutableStateOf(false) } + IconButton(onClick = { showModal = true }) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = "View Swift code for $sectionTitle", + tint = MaterialTheme.colorScheme.primary + ) + } + if (showModal) { + CodeSnippetDialog( + title = component.getString("title"), + code = component.getString("code"), + onDismiss = { showModal = false } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CodeSnippetDialog(title: String, code: String, onDismiss: () -> Unit) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + var wrapEnabled by remember { mutableStateOf(false) } + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close") + } + }, + actions = { + TextButton(onClick = { wrapEnabled = !wrapEnabled }) { + Text(if (wrapEnabled) "No wrap" else "Wrap") + } + TextButton(onClick = { + clipboard.setText(AnnotatedString(code)) + Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() + }) { + Text("Copy") + } + } + ) + } + ) { padding -> + // Long lines either scroll horizontally (default, so the Swift + // indentation stays intact) or wrap to the modal's width, + // toggled by the "Wrap" action above. Wrap state is + // presentation-only, so it stays local like `showModal`. + val verticalScroll = rememberScrollState() + val horizontalScroll = rememberScrollState() + var codeModifier = Modifier + .fillMaxSize() + .padding(padding) + .background(MaterialTheme.colorScheme.surfaceVariant) + .verticalScroll(verticalScroll) + if (!wrapEnabled) { + codeModifier = codeModifier.horizontalScroll(horizontalScroll) + } + Text( + text = code, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + modifier = codeModifier.padding(16.dp) + ) + } + } +} + +@Composable +private fun TextFieldView( + component: JSONObject, + id: String, + onEvent: (componentId: String, event: JSONObject) -> Unit +) { + // Buffer the in-flight text locally, re-seeding only when the + // Swift-provided value changes. Feeding every dispatched round trip + // straight back into the field would fight the IME cursor. + val swiftText = component.getString("text") + var localText by remember(swiftText) { mutableStateOf(swiftText) } + val error = if (component.isNull("error")) null else component.getString("error") + + OutlinedTextField( + value = localText, + onValueChange = { + localText = it + onEvent(id, event("setString").put("value", it)) + }, + label = { Text(component.getString("label")) }, + placeholder = { Text(component.getString("placeholder")) }, + isError = error != null, + supportingText = { error?.let { Text(it) } }, + keyboardOptions = KeyboardOptions( + keyboardType = when (component.getString("keyboard")) { + "email" -> KeyboardType.Email + "number" -> KeyboardType.Number + else -> KeyboardType.Text + } + ), + modifier = Modifier.fillMaxWidth() + ) +} + +/** + * A button showing the selected date; tapping it opens Material 3's calendar + * dialog. The wire value is a plain "yyyy-MM-dd" string — the same `setString` + * action every other text-bearing component already dispatches. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DatePickerView( + component: JSONObject, + id: String, + onEvent: (componentId: String, event: JSONObject) -> Unit +) { + var showPicker by remember { mutableStateOf(false) } + val label = component.getString("label") + val dateText = component.getString("text") + + OutlinedButton(onClick = { showPicker = true }, modifier = Modifier.fillMaxWidth()) { + Text("$label: $dateText") + } + + if (showPicker) { + val initialMillis = runCatching { + LocalDate.parse(dateText).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + }.getOrNull() + val state = rememberDatePickerState(initialSelectedDateMillis = initialMillis) + DatePickerDialog( + onDismissRequest = { showPicker = false }, + confirmButton = { + TextButton(onClick = { + showPicker = false + state.selectedDateMillis?.let { millis -> + val isoDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate() + onEvent(id, event("setString").put("value", isoDate.toString())) + } + }) { Text("OK") } + }, + dismissButton = { + TextButton(onClick = { showPicker = false }) { Text("Cancel") } + } + ) { + DatePicker(state = state) + } + } +} + +/** A multi-line text area — SwiftUI's TextEditor to [TextFieldView]'s TextField. */ +@Composable +private fun TextEditorView( + component: JSONObject, + id: String, + onEvent: (componentId: String, event: JSONObject) -> Unit +) { + // Same IME-buffering pattern as TextFieldView. + val swiftText = component.getString("text") + var localText by remember(swiftText) { mutableStateOf(swiftText) } + + OutlinedTextField( + value = localText, + onValueChange = { + localText = it + onEvent(id, event("setString").put("value", it)) + }, + label = { Text(component.getString("label")) }, + placeholder = { Text(component.getString("placeholder")) }, + minLines = 4, + modifier = Modifier.fillMaxWidth() + ) +} + +private fun event(type: String) = JSONObject().put("type", type) diff --git a/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/MainActivity.kt b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/MainActivity.kt new file mode 100644 index 0000000..44b1135 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/MainActivity.kt @@ -0,0 +1,197 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.showcaseapp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.example.showcaseapp.ui.theme.ShowcaseAppTheme +import com.example.showcasekit.ShowcaseKit +import org.json.JSONArray +import org.json.JSONObject + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + ShowcaseAppTheme { + ShowcaseNavHost() + } + } + } +} + +/** + * The navigation graph is data-driven: the list of destinations comes from + * Swift's screen registry, so adding a screen in ShowcaseKit requires no + * change on the Kotlin side. + */ +@Composable +fun ShowcaseNavHost() { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = "home") { + composable("home") { + HomeScreen(onOpenScreen = { navController.navigate("screen/$it") }) + } + composable("screen/{screenId}") { backStackEntry -> + ShowcaseScreen( + screenId = backStackEntry.arguments?.getString("screenId").orEmpty(), + onBack = { navController.popBackStack() } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen(onOpenScreen: (String) -> Unit) { + // This calls the Swift function `showcaseScreens` from ShowcaseAPI.swift. + val screens = remember { JSONObject(ShowcaseKit.showcaseScreens()).getJSONArray("screens") } + + Scaffold(topBar = { TopAppBar(title = { Text("UI Showcase") }) }) { padding -> + LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { + items(count = screens.length()) { index -> + val screen = screens.getJSONObject(index) + ListItem( + headlineContent = { Text(screen.getString("title")) }, + modifier = Modifier.clickable { onOpenScreen(screen.getString("id")) } + ) + HorizontalDivider() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShowcaseScreen(screenId: String, onBack: () -> Unit) { + // Swift is the single source of truth: this holds only the latest JSON + // returned by ShowcaseKit, and every event replaces it wholesale. + var screen by remember { + // This calls the Swift function `showcaseScreen` from ShowcaseAPI.swift. + mutableStateOf(JSONObject(ShowcaseKit.showcaseScreen(screenId))) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(screen.getString("title")) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + // Sections aren't a wire concept — Swift only ever emits a flat + // component list. Every screen happens to start each section with a + // `sectionHeader`, so grouping on that boundary here is enough to + // give each section its own visually distinct card. + val sections = groupIntoSections(screen.getJSONArray("components")) + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(count = sections.size) { index -> + val section = sections[index] + // The header and its code snippet move into one top row — + // title leading, the snippet's info icon trailing — instead + // of flowing through the section like an ordinary component. + val header = section.firstOrNull { it.getString("kind") == "sectionHeader" } + val codeSnippet = section.firstOrNull { it.getString("kind") == "code" } + val bodyComponents = section.filterNot { it === header || it === codeSnippet } + + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + val headerText = header?.getString("text").orEmpty() + Text(text = headerText, style = MaterialTheme.typography.titleMedium) + if (codeSnippet != null) { + CodeSnippetView(codeSnippet, sectionTitle = headerText) + } + } + for (component in bodyComponents) { + ComponentView(component) { componentId, event -> + // This calls the Swift function + // `showcaseDispatch` from ShowcaseAPI.swift; + // Swift handles the event and returns the + // screen's new component tree. + screen = JSONObject( + ShowcaseKit.showcaseDispatch(screenId, componentId, event.toString()) + ) + } + } + } + } + } + } + } +} + +/** Splits a flat component list into sections, starting a new one at every `sectionHeader`. */ +private fun groupIntoSections(components: JSONArray): List> { + val sections = mutableListOf>() + for (index in 0 until components.length()) { + val component = components.getJSONObject(index) + if (sections.isEmpty() || component.getString("kind") == "sectionHeader") { + sections.add(mutableListOf()) + } + sections.last().add(component) + } + return sections +} diff --git a/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Color.kt b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Color.kt new file mode 100644 index 0000000..d232562 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Color.kt @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.showcaseapp.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) diff --git a/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Theme.kt b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Theme.kt new file mode 100644 index 0000000..9ef4db6 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Theme.kt @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.showcaseapp.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun ShowcaseAppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Type.kt b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Type.kt new file mode 100644 index 0000000..eb88b50 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/java/com/example/showcaseapp/ui/theme/Type.kt @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +package com.example.showcaseapp.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/drawable/ic_launcher_background.xml b/swift-java-ui-showcase/showcase-app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/drawable/ic_launcher_foreground.xml b/swift-java-ui-showcase/showcase-app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-anydpi/ic_launcher.xml b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-hdpi/ic_launcher.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-mdpi/ic_launcher.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/swift-java-ui-showcase/showcase-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/values/colors.xml b/swift-java-ui-showcase/showcase-app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/values/strings.xml b/swift-java-ui-showcase/showcase-app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a7cceb8 --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + UI Showcase + diff --git a/swift-java-ui-showcase/showcase-app/src/main/res/values/themes.xml b/swift-java-ui-showcase/showcase-app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4e55e4b --- /dev/null +++ b/swift-java-ui-showcase/showcase-app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +