diff --git a/.gitignore b/.gitignore index 01dc9f697..5272bd9a9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,10 @@ BuildLogic/out/ **/build/ lib/ +# Android local SDK location + native build intermediates +local.properties +**/.cxx/ + # Ignore JVM crash logs **/*.log diff --git a/Samples/SwiftKitAndroidComposeSample/Package.swift b/Samples/SwiftKitAndroidComposeSample/Package.swift new file mode 100644 index 000000000..bd40ad14b --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import CompilerPluginSupport +import PackageDescription + +let package = Package( + name: "SwiftKitAndroidComposeSample", + platforms: [ + .macOS(.v15) + ], + products: [ + .library( + name: "MySwiftLibrary", + type: .dynamic, + targets: ["MySwiftLibrary"] + ), + ], + dependencies: [ + .package(name: "swift-java", path: "../../"), + .package(url: "https://github.com/swift-android-sdk/swift-android-native.git", from: "2.0.0") + ], + targets: [ + .target( + name: "MySwiftLibrary", + dependencies: [ + .product(name: "SwiftJava", package: "swift-java"), + .product(name: "AndroidLooper", package: "swift-android-native", condition: .when(platforms: [.android])) + ], + exclude: [ + "swift-java.config" + ], + swiftSettings: [ + .swiftLanguageMode(.v5) + ], + plugins: [ + .plugin(name: "JExtractSwiftPlugin", package: "swift-java") + ] + ), + ] +) diff --git a/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/CounterModel.swift b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/CounterModel.swift new file mode 100644 index 000000000..5b384c35a --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/CounterModel.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import Observation + +@Observable +public class CounterModel { + public var count: Int64 = 0 + + public init() {} + + public func increment() { + count += 1 + } + + public func reset() { + count = 0 + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/DashboardModel.swift b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/DashboardModel.swift new file mode 100644 index 000000000..027b33ab6 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/DashboardModel.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import Observation + +/// A view model with **many observable states** of different primitive types. +/// Each property drives a different Compose control (text, slider, switch, +/// stepper). Only the property that actually changed should recompose the +/// widgets that read it. +@Observable +public class DashboardModel { + public var title: String = "My Dashboard" + public var counter: Int64 = 0 + public var level: Int32 = 1 + public var temperature: Double = 20.5 + public var progress: Double = 0.25 + public var isEnabled: Bool = true + public var isFavorite: Bool = false + + public init() {} + + public func increment() { counter += 1 } + public func decrement() { counter -= 1 } + public func levelUp() { level += 1 } + public func warmer() { temperature += 0.5 } + public func cooler() { temperature -= 0.5 } + public func toggleEnabled() { isEnabled.toggle() } + public func toggleFavorite() { isFavorite.toggle() } + + public func reset() { + counter = 0 + level = 1 + temperature = 20.5 + progress = 0.25 + isEnabled = true + isFavorite = false + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/EdgeCasesModel.swift b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/EdgeCasesModel.swift new file mode 100644 index 000000000..f35236657 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/EdgeCasesModel.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import Observation + +/// Exercises the things that should **NOT** participate in observation: +/// +/// - `static let` / `static var`: type-level, never tracked per-instance. +/// - `let`: immutable stored property; `@Observable` does not track it. +/// - `@ObservationIgnored var`: explicitly opted out of tracking. +/// +/// Only `visibleCounter` is a normal observed `var`. The UI should recompose +/// when `visibleCounter` changes, but NOT when `hiddenCounter` changes — even +/// though `hiddenCounter` really is mutating under the hood (which we can prove +/// by copying it into `visibleCounter`, forcing a legitimate refresh). +@Observable +public class EdgeCasesModel { + /// Type-level constant — must not generate any per-instance observation. + public static let appName: String = "SwiftJava Observable Sample" + + /// Type-level mutable — also must not be observed per instance. + public static var launchCount: Int64 = 0 + + /// Immutable stored property — never changes, so must not be observed. + public let createdLabel: String = "Created once at init" + + /// Explicitly excluded from observation tracking. + @ObservationIgnored public var hiddenCounter: Int64 = 0 + + /// The only genuinely observed property. + public var visibleCounter: Int64 = 0 + + public init() { + EdgeCasesModel.launchCount += 1 + } + + /// Mutates an ignored property — the UI should NOT react to this. + public func bumpHidden() { hiddenCounter += 1 } + + /// Mutates an observed property — the UI SHOULD react to this. + public func bumpVisible() { visibleCounter += 1 } + + /// Pulls the (silently changed) hidden value into the observed one, so a + /// real notification fires and the UI finally reflects the hidden mutations. + public func revealHidden() { visibleCounter = hiddenCounter } +} diff --git a/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/FormModel.swift b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/FormModel.swift new file mode 100644 index 000000000..590dacd74 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/FormModel.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import Foundation +import Observation + +/// Demonstrates **two-way bindings**: each `var` is read by a Compose +/// `TextField` and written back from its `onValueChange`. `fullName` is a +/// read-only computed property that should recompute (and recompose) whenever +/// `firstName` or `lastName` change. +@Observable +public class FormModel { + public var firstName: String = "" + public var lastName: String = "" + public var email: String = "" + public var bio: String = "" + + public init() {} + + public var fullName: String { + let joined = "\(firstName) \(lastName)" + return joined.trimmingCharacters(in: .whitespaces) + } + + public var isComplete: Bool { + !firstName.isEmpty && !lastName.isEmpty && !email.isEmpty + } + + public func clear() { + firstName = "" + lastName = "" + email = "" + bio = "" + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/Library.swift b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/Library.swift new file mode 100644 index 000000000..8aa557f51 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/Library.swift @@ -0,0 +1,5 @@ +import AndroidLooper + +public func setupAndroidMainLooper() { + AndroidMainActor.setupMainLooper() +} diff --git a/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/ProfileModel.swift b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/ProfileModel.swift new file mode 100644 index 000000000..42acf6ea6 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/ProfileModel.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import Observation + +/// A child `@Observable` object, nested inside `ProfileModel`. +@Observable +public class AddressModel { + public var street: String = "1 Infinite Loop" + public var city: String = "Cupertino" + public var country: String = "USA" + + public init() {} + + public var oneLine: String { + "\(street), \(city), \(country)" + } +} + +/// Demonstrates **nested observable objects**. `ProfileModel` owns an +/// `AddressModel`. Mutating a field on the nested object (e.g. `address.city`) +/// should be observable to a UI that is tracking the nested object. +/// +/// Note: the parent only directly observes its own `name` and the `address` +/// *reference*. To react to changes *inside* the nested object, the Compose +/// layer observes the child as well (see `NestedScreen`). +@Observable +public class ProfileModel { + public var name: String = "Jane Appleseed" + public var address: AddressModel = AddressModel() + + public init() {} + + public func moveToLondon() { + address.street = "10 Downing Street" + address.city = "London" + address.country = "UK" + } + + /// Replaces the whole nested object (reference change on the parent). + public func replaceAddress() { + address = AddressModel() + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/TodoListModel.swift b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/TodoListModel.swift new file mode 100644 index 000000000..450fe7a64 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/TodoListModel.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import Observation + +/// Demonstrates **arrays**. Mutating the `items` array (append/remove) is an +/// observable change, so a Compose list that reads `items` should recompose. +@Observable +public class TodoListModel { + public var items: [String] = ["Buy milk", "Walk the dog", "Learn swift-java"] + + public init() {} + + /// Read-only computed property derived from the array. + public var count: Int64 { + Int64(items.count) + } + + public var isEmpty: Bool { + items.isEmpty + } + + public func add(_ item: String) { + items.append(item) + } + + public func removeLast() { + if !items.isEmpty { + items.removeLast() + } + } + + public func removeAll() { + items.removeAll() + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/swift-java.config b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/swift-java.config new file mode 100644 index 000000000..4d5d6f6e0 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/Sources/MySwiftLibrary/swift-java.config @@ -0,0 +1,6 @@ +{ + "javaPackage": "com.example.swift.compose", + "mode": "jni", + "observableComposeBridging": true, + "memoryManagementMode": "allowGlobalAutomatic" +} diff --git a/Samples/SwiftKitAndroidComposeSample/build.gradle.kts b/Samples/SwiftKitAndroidComposeSample/build.gradle.kts new file mode 100644 index 000000000..e4bae15a1 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/build.gradle.kts @@ -0,0 +1,221 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + // AGP 9+ provides built-in Kotlin support, so the standalone + // org.jetbrains.kotlin.android plugin must NOT be applied. The Compose + // compiler plugin is still applied separately. + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) +} + +repositories { + google() + mavenCentral() + mavenLocal() +} + +// API level the app targets at minimum. This value is substituted into the +// Swift target triples below (e.g. aarch64-unknown-linux-android28), so it must +// match an API level supported by the installed Swift Android SDK. +val androidMinSdk = 28 + +android { + namespace = "com.example.swift.compose" + compileSdk = 36 + + defaultConfig { + minSdk = androidMinSdk + targetSdk = 36 + applicationId = "com.example.swift.compose" + versionCode = 1 + versionName = "1.0" + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} + +dependencies { + implementation(project(":SwiftKitCore")) + implementation(project(":SwiftKitCompose")) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) +} + +// ==== ----------------------------------------------------------------------- +// MARK: Swift -> Android cross-compilation + jextract +// +// Mirrors swiftlang/swift-android-examples (hello-swift-java/hashing-lib). The +// Swift target carries the JExtractSwiftPlugin, so building it (even for an +// Android triple) also emits the generated Java sources as a side effect. + +// Swift toolchain version passed to swiftly (e.g. "6.3"). Overridable via the +// SWIFT_VERSION environment variable for CI matrices. +val swiftVersion: String = System.getenv("SWIFT_VERSION") ?: "6.3" +// Android Swift SDK artifactbundle suffix; the bundle dir is +// "swift-${androidSdkVersion}.artifactbundle". +val androidSdkVersion: String = System.getenv("SWIFT_ANDROID_SDK_VERSION") ?: "$swiftVersion-RELEASE_android" +val sdkName = "swift-$androidSdkVersion.artifactbundle" + +// Swift runtime libraries to bundle into jniLibs alongside the built product. +val swiftRuntimeLibs = listOf( + "swiftCore", "swift_Concurrency", "swift_StringProcessing", "swift_RegexParser", + "swift_Builtin_float", "swift_math", "swiftAndroid", "dispatch", "BlocksRuntime", + "swiftSwiftOnoneSupport", "swiftDispatch", "Foundation", "FoundationEssentials", + "FoundationInternationalization", "_FoundationICU", "swiftSynchronization", + "swiftObservation", +) + +// Android ABI -> Swift triple / swift-resources arch dir / NDK sysroot arch dir. +val abis: Map> = mapOf( + "arm64-v8a" to Triple("aarch64-unknown-linux-android$androidMinSdk", "swift-aarch64", "aarch64-linux-android"), + "x86_64" to Triple("x86_64-unknown-linux-android$androidMinSdk", "swift-x86_64", "x86_64-linux-android"), + "armeabi-v7a" to Triple("armv7-unknown-linux-android$androidMinSdk", "swift-armv7", "arm-linux-android"), +) + +// Default to arm64-v8a only for a fast local loop; -PandroidAllAbis=true builds all. +val enableAllAbis = (project.findProperty("androidAllAbis") as String?)?.toBoolean() ?: false +val activeAbis = if (enableAllAbis) abis else abis.filterKeys { it == "arm64-v8a" } + +val generatedJniLibsDir = layout.buildDirectory.dir("generated/jniLibs") + +// Directory the JExtractSwiftPlugin writes generated Java into during `swift build`. +val generatedJavaDir = File( + projectDir, + ".build/plugins/outputs/${projectDir.name.lowercase()}/MySwiftLibrary/destination/JExtractSwiftPlugin/src/generated/java" +) + +// These resolve lazily (inside task config blocks) so projects that have an +// Android SDK but not the Swift Android SDK / swiftly don't fail configuration. +fun findSwiftly(): String { + (project.findProperty("swiftly.path") as String? ?: System.getenv("SWIFTLY_PATH"))?.let { return it } + val home = System.getProperty("user.home") + return listOf( + "$home/.swiftly/bin/swiftly", + "$home/.local/share/swiftly/bin/swiftly", + "$home/.local/bin/swiftly", + "/usr/local/bin/swiftly", + "/opt/homebrew/bin/swiftly", + "/root/.local/share/swiftly/bin/swiftly", + ).firstOrNull { File(it).exists() } + ?: throw GradleException("swiftly not found. Set -Pswiftly.path= or the SWIFTLY_PATH environment variable.") +} + +fun findSwiftSdkRoot(): String { + (project.findProperty("swift.sdk.path") as String? ?: System.getenv("SWIFT_SDK_PATH"))?.let { return it } + val home = System.getProperty("user.home") + return listOf( + "$home/Library/org.swift.swiftpm/swift-sdks", + "$home/.config/swiftpm/swift-sdks", + "$home/.swiftpm/swift-sdks", + "/root/.swiftpm/swift-sdks", + ).firstOrNull { File(it).isDirectory } + ?: throw GradleException("Swift SDK path not found. Set -Pswift.sdk.path= or the SWIFT_SDK_PATH environment variable.") +} + +// Aggregator that also exposes the jextract-generated Java directory as an output. +// The directory itself is produced by the per-ABI `swift build` invocations. +val buildSwiftAll = tasks.register("buildSwiftAll") { + group = "build" + description = "Builds the Swift code for the active Android ABIs (and generates Java wrappers)." + + inputs.file(layout.projectDirectory.file("Package.swift")) + inputs.dir(layout.projectDirectory.dir("Sources/MySwiftLibrary")) + outputs.dir(generatedJavaDir) +} + +activeAbis.forEach { (abi, info) -> + val (triple, _, _) = info + val task = tasks.register("buildSwift${abi.replaceFirstChar { it.uppercase() }}") { + group = "build" + description = "Builds the Swift code for the $abi ABI ($triple)." + + outputs.dir(layout.projectDirectory.dir(".build/$triple/debug")) + + workingDir = projectDir + executable = findSwiftly() + args("run", "swift", "build", "+$swiftVersion", "--swift-sdk", triple, "--build-system", "native") + + doFirst { logger.lifecycle("Building Swift for $abi ($triple)…") } + } + buildSwiftAll.configure { dependsOn(task) } +} + +val copyJniLibs = tasks.register("copyJniLibs") { + group = "build" + description = "Collects the built Swift .so files + Swift runtime + libc++_shared.so into jniLibs." + dependsOn(buildSwiftAll) + + val swiftSdkPath = "${findSwiftSdkRoot()}/$sdkName" + + activeAbis.forEach { (abi, info) -> + val (triple, swiftArchDir, ndkDir) = info + + // Built products. + from(layout.projectDirectory.dir(".build/$triple/debug")) { + include("*.so") + into(abi) + } + + // C++ runtime from the NDK sysroot. + from("$swiftSdkPath/swift-android/ndk-sysroot/usr/lib/$ndkDir/libc++_shared.so") { + into(abi) + } + + // Swift runtime libraries. + from(swiftRuntimeLibs.map { "$swiftSdkPath/swift-android/swift-resources/usr/lib/$swiftArchDir/android/lib$it.so" }) { + into(abi) + } + } + + into(generatedJniLibsDir) +} + +android { + sourceSets.getByName("main") { + // jextract-generated Java sources (produced by buildSwiftAll's swift build). + // AGP 9 rejects Provider/TaskProvider here, so we register the plain path + // and rely on the preBuild -> copyJniLibs -> buildSwiftAll chain below to + // generate the files before compilation. + java.srcDir(generatedJavaDir) + // Native libraries assembled by copyJniLibs. + jniLibs.srcDir(generatedJniLibsDir.get().asFile) + } +} + +// Ensure native libs + generated sources exist before the Android build runs. +// preBuild is the per-variant anchor that compile tasks run after. +tasks.named("preBuild").configure { + dependsOn(copyJniLibs) +} diff --git a/Samples/SwiftKitAndroidComposeSample/src/main/AndroidManifest.xml b/Samples/SwiftKitAndroidComposeSample/src/main/AndroidManifest.xml new file mode 100644 index 000000000..f8bd54c3b --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + diff --git a/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/App.kt b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/App.kt new file mode 100644 index 000000000..4147ed274 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/App.kt @@ -0,0 +1,179 @@ +//===----------------------------------------------------------------------===// +// +// 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.swift.compose + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * The set of example screens. Each demonstrates a different facet of bridging + * a Swift `@Observable` model into Jetpack Compose. + */ +enum class Example(val title: String, val subtitle: String) { + Counter( + title = "Counter", + subtitle = "The basics: one observed Int64, recomposing on change.", + ), + Form( + title = "Two-way Bindings", + subtitle = "TextFields read/write String properties; computed values update live.", + ), + Dashboard( + title = "Many Observable States", + subtitle = "Several properties of mixed types driving sliders, switches, steppers.", + ), + EdgeCases( + title = "static / @ObservationIgnored / let", + subtitle = "Properties that must NOT trigger observation.", + ), + Nested( + title = "Nested Observable Objects", + subtitle = "A model that owns another @Observable model.", + ), + TodoList( + title = "Arrays", + subtitle = "Observing append/remove on an array property.", + ); +} + +/** Top-level navigation host. `null` selection means we're on the home list. */ +@Composable +fun App() { + var selected by remember { mutableStateOf(null) } + + // Hardware/gesture back returns to the home list when inside an example. + BackHandler(enabled = selected != null) { selected = null } + + when (val example = selected) { + null -> HomeScreen(onSelect = { selected = it }) + else -> ExampleScaffold(title = example.title, onBack = { selected = null }) { + when (example) { + Example.Counter -> CounterScreen() + Example.Form -> FormScreen() + Example.Dashboard -> DashboardScreen() + Example.EdgeCases -> EdgeCasesScreen() + Example.Nested -> NestedScreen() + Example.TodoList -> TodoListScreen() + } + } + } +} + +@Composable +private fun HomeScreen(onSelect: (Example) -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "swift-java × Compose", + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = "Swift @Observable models bridged into Jetpack Compose.", + style = MaterialTheme.typography.bodyMedium, + ) + for (example in Example.entries) { + ExampleCard(example, onClick = { onSelect(example) }) + } + } +} + +@Composable +private fun ExampleCard(example: Example, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = example.title, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = example.subtitle, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +/** A simple title bar with a back button, plus the example content below. */ +@Composable +fun ExampleScaffold( + title: String, + onBack: () -> Unit, + content: @Composable () -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onBack) { Text("← Back") } + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .weight(1f) + .padding(end = 64.dp), + ) + } + HorizontalDivider() + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + content() + } + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/CounterApplication.kt b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/CounterApplication.kt new file mode 100644 index 000000000..494d91e72 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/CounterApplication.kt @@ -0,0 +1,32 @@ +//===----------------------------------------------------------------------===// +// +// 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.swift.compose + +import android.app.Application + +class CounterApplication : Application() { + override fun onCreate() { + super.onCreate() + + // Install the Android main-queue executor as early as possible — before + // any Activity, composable, or @Observable code runs — so Swift's main + // actor / concurrency work is drained on Android's main Looper. + // + // This is the first per-process entry point, and the call also forces + // the Swift native libraries to load up front (via MySwiftLibrary's + // static initializer). + MySwiftLibrary.setupAndroidMainLooper() + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/CounterScreen.kt b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/CounterScreen.kt new file mode 100644 index 000000000..cf5612d91 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/CounterScreen.kt @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// +// 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.swift.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import org.swift.swiftkit.compose.rememberSwiftObservable + +/** The basic case: a single observed `Int64` that recomposes on change. */ +@Composable +fun CounterScreen() { + val model = rememberSwiftObservable { CounterModel.init() } + + Text( + text = "Count: ${model.count}", + style = MaterialTheme.typography.headlineMedium, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { model.increment() }) { Text("Increment") } + OutlinedButton(onClick = { model.reset() }) { Text("Reset") } + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/DashboardScreen.kt b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/DashboardScreen.kt new file mode 100644 index 000000000..a6dd39a33 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/DashboardScreen.kt @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// +// 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.swift.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.swift.swiftkit.compose.rememberSwiftObservable + +/** + * Many observable states of mixed types on a single model. Each control reads + * and writes a different property; mutating one should only recompose the + * widgets that read it. + */ +@Composable +fun DashboardScreen() { + val model = rememberSwiftObservable { DashboardModel.init() } + + OutlinedTextField( + value = model.title, + onValueChange = { model.title = it }, + label = { Text("Title") }, + modifier = Modifier.fillMaxWidth(), + ) + + HorizontalDivider() + + LabeledStepper( + label = "Counter: ${model.counter}", + onMinus = { model.decrement() }, + onPlus = { model.increment() }, + ) + LabeledStepper( + label = "Level: ${model.level}", + onMinus = { model.levelUp() }, // only "up" provided by the model + onPlus = { model.levelUp() }, + ) + LabeledStepper( + label = "Temperature: ${"%.1f".format(model.temperature)}°", + onMinus = { model.cooler() }, + onPlus = { model.warmer() }, + ) + + Text("Progress: ${(model.progress * 100).toInt()}%") + Slider( + value = model.progress.toFloat(), + onValueChange = { model.setProgress(it.toDouble()) }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Enabled") + Switch(checked = model.isEnabled, onCheckedChange = { model.isEnabled = it }) + Text("Favorite") + Switch(checked = model.isFavorite, onCheckedChange = { model.isFavorite = it }) + } + + OutlinedButton(onClick = { model.reset() }) { Text("Reset all") } +} + +@Composable +private fun LabeledStepper(label: String, onMinus: () -> Unit, onPlus: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + OutlinedButton(onClick = onMinus) { Text("–") } + Button(onClick = onPlus) { Text("+") } + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/EdgeCasesScreen.kt b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/EdgeCasesScreen.kt new file mode 100644 index 000000000..837d08eb5 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/EdgeCasesScreen.kt @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// +// 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.swift.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.swift.swiftkit.compose.rememberSwiftObservable + +/** + * Verifies that properties which should NOT be observed don't trigger + * recomposition: + * + * - `appName` (`static let`) and `launchCount` (`static var`) are type-level. + * - `createdLabel` (`let`) is immutable. + * - `hiddenCounter` (`@ObservationIgnored var`) opts out of tracking. + * + * Tapping "Bump hidden" mutates `hiddenCounter` in Swift, but the UI should + * NOT update (its read isn't tracked). "Reveal hidden" copies that value into + * the observed `visibleCounter`, forcing a legitimate notification — at which + * point the previously-hidden value finally shows up. + */ +@Composable +fun EdgeCasesScreen() { + val model = rememberSwiftObservable { EdgeCasesModel.init() } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("Not observed", style = MaterialTheme.typography.titleSmall) + Text("appName (static let): ${EdgeCasesModel.getAppName()}") + Text("launchCount (static var): ${EdgeCasesModel.getLaunchCount()}") + Text("createdLabel (let): ${model.createdLabel}") + Text("hiddenCounter (@ObservationIgnored): ${model.hiddenCounter}") + } + } + + HorizontalDivider() + + Text( + text = "Observed", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "visibleCounter: ${model.visibleCounter}", + style = MaterialTheme.typography.headlineSmall, + ) + + OutlinedButton(onClick = { model.bumpHidden() }, modifier = Modifier.fillMaxWidth()) { + Text("Bump hidden (no UI update expected)") + } + Button(onClick = { model.bumpVisible() }, modifier = Modifier.fillMaxWidth()) { + Text("Bump visible (recomposes)") + } + OutlinedButton(onClick = { model.revealHidden() }, modifier = Modifier.fillMaxWidth()) { + Text("Reveal hidden → visible (forces refresh)") + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/FormScreen.kt b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/FormScreen.kt new file mode 100644 index 000000000..24de83697 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/FormScreen.kt @@ -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 +// +//===----------------------------------------------------------------------===// + +package com.example.swift.compose + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.swift.swiftkit.compose.rememberSwiftObservable + +/** + * Two-way bindings. Each `OutlinedTextField` reads a Swift `String` property + * and writes it back via the setter. The computed `fullName` / `isComplete` + * properties update live as you type, proving computed values track their + * dependencies through the bridge. + */ +@Composable +fun FormScreen() { + val model = rememberSwiftObservable { FormModel.init() } + + OutlinedTextField( + value = model.firstName, + onValueChange = { model.firstName = it }, + label = { Text("First name") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = model.lastName, + onValueChange = { model.lastName = it }, + label = { Text("Last name") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = model.email, + onValueChange = { model.email = it }, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = model.bio, + onValueChange = { model.bio = it }, + label = { Text("Bio") }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 96.dp), + ) + + Text( + text = "Full name: ${model.fullName}", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = if (model.isComplete) "✅ All required fields complete" else "⚠️ Missing required fields", + style = MaterialTheme.typography.bodyMedium, + ) + + Button(onClick = { model.clear() }) { Text("Clear") } +} diff --git a/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/MainActivity.kt b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/MainActivity.kt new file mode 100644 index 000000000..72aa34054 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/MainActivity.kt @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// 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.swift.compose + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.fillMaxSize + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + App() + } + } + } + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/NestedScreen.kt b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/NestedScreen.kt new file mode 100644 index 000000000..63d4cda24 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/NestedScreen.kt @@ -0,0 +1,74 @@ +//===----------------------------------------------------------------------===// +// +// 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.swift.compose + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.swift.swiftkit.compose.rememberSwiftObservable + +/** + * Nested observable objects: `ProfileModel` owns an `AddressModel`. + * + * The parent is observed for its own properties (`name`, and the `address` + * *reference*). To react to changes *inside* the nested object, we observe the + * child too — `rememberSwiftObservable { profile.address }` — so reads of + * `address.city` etc. are tracked and field edits recompose. + */ +@Composable +fun NestedScreen() { + val profile = rememberSwiftObservable { ProfileModel.init() } + val address = rememberSwiftObservable { profile.address } + + Text("Profile", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = profile.name, + onValueChange = { profile.name = it }, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth(), + ) + + HorizontalDivider() + + Text("Nested address", style = MaterialTheme.typography.titleMedium) + Text("One-line: ${address.oneLine}", style = MaterialTheme.typography.bodyMedium) + OutlinedTextField( + value = address.street, + onValueChange = { address.street = it }, + label = { Text("Street") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = address.city, + onValueChange = { address.city = it }, + label = { Text("City") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = address.country, + onValueChange = { address.country = it }, + label = { Text("Country") }, + modifier = Modifier.fillMaxWidth(), + ) + + Button(onClick = { profile.moveToLondon() }, modifier = Modifier.fillMaxWidth()) { + Text("Move to London (mutates nested object)") + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/TodoListScreen.kt b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/TodoListScreen.kt new file mode 100644 index 000000000..4d119ff49 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/src/main/kotlin/com/example/swift/compose/TodoListScreen.kt @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// +// 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.swift.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.unit.dp +import org.swift.swiftkit.compose.rememberSwiftObservable + +/** + * Arrays: appending to / removing from the Swift `items: [String]` is an + * observable change, so the list below recomposes when the array mutates. + */ +@Composable +fun TodoListScreen() { + val model = rememberSwiftObservable { TodoListModel.init() } + + // Purely-local Compose state for the "new item" text field. + var newItem by remember { mutableStateOf("") } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = newItem, + onValueChange = { newItem = it }, + label = { Text("New item") }, + modifier = Modifier.weight(1f), + ) + Button( + onClick = { + if (newItem.isNotBlank()) { + model.add(newItem) + newItem = "" + } + }, + ) { Text("Add") } + } + + Text( + text = "${model.count} item(s)", + style = MaterialTheme.typography.titleMedium, + ) + + HorizontalDivider() + + for ((index, item) in model.items.withIndex()) { + Card(modifier = Modifier.fillMaxWidth()) { + Text( + text = "${index + 1}. $item", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) + } + } + + HorizontalDivider() + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { model.removeLast() }) { Text("Remove last") } + OutlinedButton(onClick = { model.removeAll() }) { Text("Clear") } + } +} diff --git a/Samples/SwiftKitAndroidComposeSample/src/main/res/values/strings.xml b/Samples/SwiftKitAndroidComposeSample/src/main/res/values/strings.xml new file mode 100644 index 000000000..0380f9dc2 --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + SwiftKit Counter + diff --git a/Samples/SwiftKitAndroidComposeSample/src/main/res/values/themes.xml b/Samples/SwiftKitAndroidComposeSample/src/main/res/values/themes.xml new file mode 100644 index 000000000..d852a648a --- /dev/null +++ b/Samples/SwiftKitAndroidComposeSample/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + +