diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6deaa17..1e0b0d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ on: jobs: test: runs-on: macos-26 + # The Android half (Swift SDK install + cross-compile + Gradle/Robolectric) + # dominates the wall clock; the Apple half alone runs in ~2 minutes. + timeout-minutes: 90 steps: - uses: actions/checkout@v4 @@ -16,19 +19,43 @@ jobs: # uses: swift-actions/setup-swift@v2 # with: # swift-version: 6.2 + # (setup-skip below can also pin the host toolchain via its + # `swift-version` input, which routes through swiftly.) + # Robolectric against Android SDK 36 refuses to create a sandbox on + # anything below Java 21. The runner defaults to 17. + - name: Setup Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + # Installs Homebrew, Gradle, the `skip` CLI, and the Swift SDK for + # Android. FormsKit builds in Skip's Fuse (native) mode, so the Android + # SDK is required: without it the skipstone plugin still generates the + # Kotlin peers and Gradle project, but `:FormsKit:buildLocalSwiftTestLibs` + # shells out to `skip` and dies with "command not found". + - name: Setup Skip + uses: skiptools/actions/setup-skip@v1 + with: + install-swift-android-sdk: 'true' + + # Apple build plus the Android cross-compile via the skipstone plugin. - name: Build run: swift build + # Runs both halves: the Apple test bundle and, through the SkipTest + # harness, the Android side under Gradle/Robolectric. - name: Test with coverage run: swift test --enable-code-coverage + # Coverage is Apple-side only; the Android run has no llvm profile. - name: Export coverage to lcov run: | BIN=$(swift build --show-bin-path) xcrun llvm-cov export \ "$BIN/FormsKitPackageTests.xctest/Contents/MacOS/FormsKitPackageTests" \ - -instr-profile "$(find .build -name default.profdata -type f)" \ + -instr-profile "$(find .build -name default.profdata -type f | head -n1)" \ -format lcov > coverage.lcov - name: Upload coverage report artifact diff --git a/.gitignore b/.gitignore index 0023a53..f7b276a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store /.build +/.build-zero /Packages xcuserdata/ DerivedData/ diff --git a/CLAUDE.md b/CLAUDE.md index dac600e..2888771 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,21 +6,26 @@ Guidance for Claude Code when working in this repository. `FormsKit` — a small, opinionated SwiftUI form-validation library. Ships a `@Validated` property wrapper, composable typed `ValidationRule`s, a `FormController` with a submission state machine, and four SwiftUI modifiers (`.formValidationError(for:)`, `.formToolbar(controller:onSubmit:)`, `.formBindFocus(_:on:)`, and `.focused(on:equals:)`). -Target audience: SwiftUI apps on iOS 17+ that use `@Observable` (not `ObservableObject`/Combine). Intentionally no Combine, no third-party deps. +Target audience: SwiftUI apps on iOS 17+ that use `@Observable` (not `ObservableObject`/Combine). Intentionally no Combine, no third-party runtime deps. Also compiles for Android as a [Skip](https://skip.dev) Fuse native framework (see "Skip / Android support" below). ## Build / test ```bash -swift build -swift test +swift build # Apple build; with Skip installed, also cross-compiles for Android via the skipstone plugin +swift test # Apple tests; with Skip installed, also builds+runs the Android side via Gradle/Robolectric +SKIP_ZERO=1 swift test # the pure-Apple, zero-dependency path (Skip plugin + deps stripped from the manifest) ``` -Package is `swift-tools-version: 6.3`, Swift 6 language mode, platforms iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1. No dependencies. Don't add any. +Package is `swift-tools-version: 6.3`, Swift 6 language mode, platforms iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1. The only dependencies are the Skip build-time packages (`skip`, `skip-fuse`, `skip-fuse-ui`), which the `SKIP_ZERO=1` manifest block removes entirely; don't add any others, and don't remove that block. + +Android test runs need a Gradle JVM ≥ 21 (Robolectric / Android SDK 36 requirement). `~/.gradle/gradle.properties` on this machine pins `org.gradle.java.home` to a Java 17 JBR; override per-run with `GRADLE_OPTS="-Dorg.gradle.java.home="` rather than editing the global file. ## Source layout ``` Sources/FormsKit/ +├── Skip/ +│ └── skip.yml # Skip config: native (Fuse) mode — see "Skip / Android support" ├── Validated.swift # @Validated property wrapper + State/Mode ├── ValidatedField.swift # type-erased schema entry for a Validated field ├── ValidationRule.swift # protocol ValidationRule @@ -33,11 +38,12 @@ Sources/FormsKit/ ├── ValidationRules/ │ ├── StringValidationRule.swift # protocol StringValidationRule │ └── StringValidationRules/ # concrete rules (NotEmpty, MinLength, …) +├── AnyFormController.swift # internal closure-erased facade over FormController for the bridged modifiers └── ViewModifiers/ - ├── FormValidationErrorModifier.swift # .formValidationError(for:) - ├── FormToolbarViewModifier.swift # .formToolbar(controller:onSubmit:) - ├── FormBindFocusViewModifier.swift # .formBindFocus(_:on:) - └── FocusedOnViewModifier.swift # .focused(on:equals:) + ├── FormValidationErrorModifier.swift # .formValidationError(for:) — single non-generic bridged modifier + ├── FormToolbarViewModifier.swift # .formToolbar(controller:onSubmit:) — generic modifier + bridged erased twin + ├── FormBindFocus.swift # .formBindFocus(_:on:) — direct composition, no struct + └── FocusedOnViewModifier.swift # .focused(on:equals:) — generic modifier + bridged erased twin ``` Keep one type per file. Group concrete rules under `ValidationRules/ValidationRules/` (currently only `String`; add `Number`, `Date`, etc. the same way if needed). The three form-conformance protocols live in `Forms/`; everything else is a high-visibility public type and stays at root. @@ -89,11 +95,11 @@ The library has a deliberate isolation shape; deviating from it will produce con - **Public surface, narrow.** Default to `internal`; mark `public` only what consumers must touch. The `name` field on `Validated` and the closures on `ValidatedField` intentionally stay non-public — consumers don't need them. - **No Combine.** Ever. `@Observable` only. -- **No third-party dependencies.** Foundation + SwiftUI + Observation. If a feature seems to need a dep, find another way or push back. +- **No third-party runtime dependencies.** Foundation + SwiftUI + Observation. The Skip packages are the single sanctioned exception: build-time only, inert on Apple platforms, and strippable via `SKIP_ZERO=1`. If any other feature seems to need a dep, find another way or push back. - **Rules are value types.** A `ValidationRule` impl is a plain struct with a `validate(value:) -> String?` method. Add a static factory on `ValidationRule where Self == YourRule` for call-site sugar (`.minLength(3)` style). Mirror the existing `MinStringLengthValidationRule` pattern. - **Rule error messages are passed in.** Don't hardcode user-facing strings inside rules beyond English defaults; consumers localize at call site by passing `message:`. (Localizing the package's own defaults via `String(localized:bundle: .module)` is a future improvement — track it as such, not as a quiet refactor.) - **`@Validated` mode default is `.onChange`.** Means "stay quiet until the field becomes `.invalid`, then re-validate on each keystroke." Don't change the default; it's the UX consumers expect. -- **View modifier UI is intentionally minimal.** `FormValidationErrorModifier` hardcodes `.red` and `.caption`; `FormToolbarViewModifier` hardcodes English button titles + a discard dialog. Making these themeable / localizable is on the roadmap but hasn't shipped — don't sneak it in piecemeal; do it as one deliberate change with a public API. +- **View modifier UI is intentionally minimal.** `formValidationError` hardcodes `.red` and `.caption`; `FormToolbarViewModifier` hardcodes English button titles + a discard dialog. Making these themeable / localizable is on the roadmap but hasn't shipped — don't sneak it in piecemeal; do it as one deliberate change with a public API. - **View modifiers prefixed `form*` are package-original concepts; unprefixed ones (e.g. `.focused(on:equals:)`) deliberately overload existing SwiftUI vocabulary.** Don't prefix the overloads (it breaks discovery via SwiftUI muscle memory); do prefix new concepts (it groups the package's surface in autocomplete). ## Focus support @@ -184,6 +190,32 @@ The `bind` in `.formBindFocus` reflects the bidirectional sync: writes to `$focu What's intentionally **not** in this slice: next/previous chevron buttons above the keyboard. That likely needs a `FocusableForm` protocol with an explicit `focusableFields: [PartialKeyPath]` so non-validated fields participate in ordered traversal. Defer until there's a concrete consumer need. +## Skip / Android support + +FormsKit ships as a Skip **Fuse (native) framework**: `Sources/FormsKit/Skip/skip.yml` declares `mode: 'native'`, so the Swift compiles as-is for Android with the Swift SDK for Android, and the SwiftUI layer resolves to SkipFuseUI → Compose. This is the only viable mode — Skip's *transpiled* mode supports neither custom property wrappers (`@Validated`) nor key paths (the `ValidatedField` schema and the whole focus system), so never attempt a transpiled port. + +Rules that keep the Android build green: + +- **Custom `ViewModifier`s work on Android only when bridged — and only non-generic types bridge.** On Android, SkipSwiftUI's `View.modifier(_:)` never calls `body(content:)` itself; it applies the modifier's `Java_modifier`, whose protocol-default implementation is `SkipUI.EmptyModifier()`. For a *bridged* modifier, skipstone generates the override (`Java_modifier { return self }` plus a Kotlin peer whose `body()` calls back into Swift), and the modifier renders. For an *unbridged* one — generic, `@nobridge`d, or hidden from the generator — the default fires and the modifier silently renders its content unchanged, dropping everything else (this is how the toolbar/validation/focus modifiers originally shipped as no-ops: they were generic, hence unbridgeable). Genericity is the trap, not the `ViewModifier` protocol. +- **Generic modifiers therefore come in dual form: a typed variant for non-bridge builds plus an erased bridged twin.** The generic variant lives in `#if !SKIP_BRIDGE && !SKIP` (Apple + SKIP_ZERO builds; the `!SKIP` half hides it from the skipstone generator, which parses with `SKIP` defined but `SKIP_BRIDGE` undefined). The erased twin (`ErasedFormToolbarModifier`, `ErasedFocusedOnModifier`) is declared **unconditionally** — the generator must see it to emit its Kotlin peer, and the Robolectric host build compiles the generated `*_Bridge.swift` against it — and erases `FormController` behind the internal `AnyFormController` closure facade (`AnyKeyPath` for focus identity). Erased twins must NOT carry `// SKIP @nobridge`, keep their memberwise inits internal (no constructor bridging), and their two bodies must be kept in sync by hand — the mirrored unit tests in `ViewModifierTests.swift` cover both variants. `skip.yml` sets `bridging: true` for the peers. +- **View files import SwiftUI behind `#if SKIP || SKIP_BRIDGE`, never plain `import SwiftUI`.** Every file declaring a `View` or `ViewModifier` opens with: + + ```swift + #if SKIP || SKIP_BRIDGE + import SkipSwiftUI + #else + import SwiftUI + #endif + ``` + + The generated `*_Bridge.swift` files mirror their source file's imports, and that module has to resolve to SkipSwiftUI — whose `SkipUIBridging`/`SkipUI` machinery the bridges reference — in bridge builds, and to real SwiftUI everywhere else. **Both disjuncts are load-bearing.** `SKIP` covers the skipstone generator, which parses with `SKIP` defined and `SKIP_BRIDGE` undefined (the same evaluation that hides the generic modifier variants behind `!SKIP`); `SKIP_BRIDGE` covers the two real bridge compiles (Android cross-compile, Robolectric host). `SKIP_BRIDGE` alone makes the generator emit `import SwiftUI` into the bridges, which then can't find `SkipUI.ViewModifier` on the macOS Robolectric host; `SKIP` alone breaks both real compiles. Note the generator *does* evaluate `#if` — an earlier `FormsKitSwiftUI` shim target existed on the belief that it couldn't, and was removed in `5b2f898`; don't reintroduce it. `ViewModifierTests.swift` is guarded with `!SKIP_BRIDGE` in addition to `!os(Android)` (in bridge builds FormsKit's views are SkipSwiftUI-typed, so real-SwiftUI hosting doesn't apply). One sharp edge: switching between `SKIP_ZERO` and Skip-active builds in the same checkout can leave stale incremental state (`missing required module 'CJNI'`) — run `swift package clean` when that appears. +- **Everything else public carries `// SKIP @nobridge`.** With `bridging: true`, skipstone tries to bridge the whole public API, and FormsKit's is unbridgeable by design: key paths (`ValidatedField`), generic types with constructors (`FormController`, `Validated`), and statics added via constrained extensions (the `.minLength(3)`-style rule factories) all hard-error in the generator. FormsKit is consumed from Swift only, so the Kotlin-facing surface is deliberately empty except the bridged modifier structs. A new public declaration gets `// SKIP @nobridge` unless it is a non-generic `View` or `ViewModifier` that must render on Android. +- **Property-wrapper storage in public SwiftUI types must be `internal`, not `private`.** Skip's bridge diagnostics reject private `@State`/`@Environment`/`@FocusState` storage inside bridged types ("Private state property cannot be bridged"). This is why `dismiss`, `showsDiscardWarning`, and `isFocused` are internal. +- **`ViewModifierTests.swift` is wrapped in `#if !os(Android)`.** It hosts views via `ImageRenderer`/`NS-`/`UIHostingController`, which don't exist on Android. Logic tests (rules, `Validated`, controller, focus) run on both platforms — keep new UI-hosting tests inside that guard and new logic tests outside it. +- **`FormController.swift` imports `SkipFuse` behind `#if canImport(SkipFuse)`.** On Android this wires `@Observable` change tracking into Compose; under `SKIP_ZERO` the module doesn't exist, hence the guard. Give any future `@Observable` type the same import. +- **The `SKIP_ZERO` block in `Package.swift` is the no-dependency escape hatch** for Apple-only consumers. Preserve it when touching the manifest, and keep the Skip dependencies out of any code path it can't strip. +- **`.formBindFocus(_:on:)` is degraded on Android** (SkipUI doesn't fully support optional-valued `@FocusState`); `.focused(on:equals:)` is the cross-platform-safe variant. Don't build new features on optional `@FocusState`. + ## Things to leave alone - The `Validated.State.editing` case. It's recorded on value changes but not (yet) read anywhere. Reserved for "field has been touched but not yet validated" UX. Don't remove it without a replacement. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..1aa8523 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,114 @@ +{ + "originHash" : "00be0c89a5ce14089419b4292da34c97d8d1d84a9a76842a61fe1fb7ee29165a", + "pins" : [ + { + "identity" : "skip", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip.git", + "state" : { + "revision" : "885f0c520e1ebdbec1f0e296d713293dadc5a2f4", + "version" : "1.9.5" + } + }, + { + "identity" : "skip-android-bridge", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-android-bridge.git", + "state" : { + "revision" : "545ea1b2d7ba4abc82daa2be00f81f4ea8e64e5b", + "version" : "0.6.4" + } + }, + { + "identity" : "skip-bridge", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-bridge.git", + "state" : { + "revision" : "72b7b1d4734332cfdc4b519539b5beec0fb3ac00", + "version" : "0.17.2" + } + }, + { + "identity" : "skip-foundation", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-foundation.git", + "state" : { + "revision" : "94d47aeed3bb8027ef3ad8e07a8771b52529c238", + "version" : "1.4.2" + } + }, + { + "identity" : "skip-fuse", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-fuse.git", + "state" : { + "revision" : "8f3295094ad29075730284c5197c7f1d94c0f2d9", + "version" : "1.0.2" + } + }, + { + "identity" : "skip-fuse-ui", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-fuse-ui.git", + "state" : { + "revision" : "d27fc109268b21feb98a48bc1a3f4558e162d9ca", + "version" : "1.18.1" + } + }, + { + "identity" : "skip-lib", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-lib.git", + "state" : { + "revision" : "76e7da8a870b5b66ea0c3264f648b58b73bcdc0d", + "version" : "1.4.0" + } + }, + { + "identity" : "skip-model", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-model.git", + "state" : { + "revision" : "54c7914e985e5ae07b1a8fe29e7aac7156b88874", + "version" : "1.7.6" + } + }, + { + "identity" : "skip-ui", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-ui.git", + "state" : { + "revision" : "ef7bbdd541cdf2efd3ce6ecde72337e1beb92366", + "version" : "1.59.1" + } + }, + { + "identity" : "skip-unit", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-unit.git", + "state" : { + "revision" : "c89af47fd645e04db863e938ade39f91e1bb62b8", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-android-native", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/swift-android-native.git", + "state" : { + "revision" : "7e6e833e6f163a2b75340f75c70b1d96ea6b8135", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-jni", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/swift-jni.git", + "state" : { + "revision" : "fe76ac21aca639976833b5ea3e875dc072519ac4", + "version" : "0.5.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 6b7b798..5269450 100644 --- a/Package.swift +++ b/Package.swift @@ -19,16 +19,68 @@ let package = Package( targets: ["FormsKit"] ), ], + dependencies: [ + .package(url: "https://source.skip.tools/skip.git", from: "1.9.5"), + .package(url: "https://source.skip.tools/skip-fuse.git", from: "1.0.0"), + .package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0"), + ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. + .target( - name: "FormsKit" + name: "FormsKit", + dependencies: [ + .product(name: "SkipFuse", package: "skip-fuse"), + // SkipFuseUI (not the SkipSwiftUI product): depending on the + // dynamic SkipSwiftUI product alongside SkipFuseUI's static use + // of the same target is a SwiftPM linkage conflict; the + // SkipSwiftUI *module* is importable transitively. + .product(name: "SkipFuseUI", package: "skip-fuse-ui"), + ], + plugins: [.plugin(name: "skipstone", package: "skip")] ), .testTarget( name: "FormsKitTests", - dependencies: ["FormsKit"] + dependencies: [ + "FormsKit", + .product(name: "SkipTest", package: "skip"), + ], + plugins: [.plugin(name: "skipstone", package: "skip")] ), ], swiftLanguageModes: [.v6] ) + +// Setting the SKIP_ZERO=1 environment strips out the Skip plugin and all Skip dependencies, +// restoring FormsKit to a zero-dependency package for consumers that don't target Android. +if Context.environment["SKIP_ZERO"] ?? "0" != "0" { + package.targets.forEach { target in + // remove the Skip plugin + target.plugins?.removeAll(where: { + if case .plugin(let name, _) = $0 { + return name == "skipstone" + } else { + return false + } + }) + + // remove the Skip target dependencies + target.dependencies.removeAll(where: { dependency in + if case .productItem(_, let package, _, _) = dependency { + return package == "skip" || package?.hasPrefix("skip-") == true + } else { + return false + } + }) + } + + // remove the Skip package dependencies + package.dependencies.removeAll(where: { dependency in + if case .sourceControl(_, let url, _) = dependency.kind { + return url.hasPrefix("https://source.skip.dev/") || url.hasPrefix("https://source.skip.tools/") + } else { + return false + } + }) +} diff --git a/README.md b/README.md index 66c3929..0ff2e73 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # FormsKit -[![Platforms](https://img.shields.io/badge/Platforms-iOS_|_macOS_|_watchOS_|_tvOS_|_visionOS-blue.svg)](https://developer.apple.com/xcode/) +[![Platforms](https://img.shields.io/badge/Platforms-iOS_|_macOS_|_watchOS_|_tvOS_|_visionOS_|_Android_(Skip)-blue.svg)](https://developer.apple.com/xcode/) [![Swift 6.3](https://img.shields.io/badge/Swift-6.3-orange.svg)](https://swift.org) [![Release](https://img.shields.io/github/v/release/rozd/forms-kit)](https://github.com/rozd/forms-kit/releases) [![codecov](https://codecov.io/gh/rozd/forms-kit/branch/main/graph/badge.svg)](https://codecov.io/gh/rozd/forms-kit) @@ -35,7 +35,8 @@ struct CreatePlanForm: ValidatableForm, SubmittableForm { - 🧰 **Built-in string rules** — `isNotEmpty`, `minLength`, `maxLength`, `pattern`, `email`. - 🎨 **SwiftUI modifiers** — `.formValidationError(for:)` for inline field errors, `.formToolbar(...)` for a Cancel/Submit toolbar, `.focused(on:equals:)` and `.formBindFocus(_:on:)` for focus traversal. - 🛡️ **Dirty-state-aware dismiss** — discard confirmation dialog + `interactiveDismissDisabled` when the form has unsaved changes. -- 🪶 **Zero dependencies** — Foundation + SwiftUI + Observation. No Combine, no third-party packages. +- 🪶 **Zero runtime dependencies** — Foundation + SwiftUI + Observation. No Combine. The only external packages are [Skip](https://skip.dev)'s build-time integration for Android, and resolving with `SKIP_ZERO=1` strips even those (see [Android (Skip)](#android-skip)). +- 🤖 **Android via [Skip](https://skip.dev)** — compiles natively for Android as a Skip Fuse module; the same `@Validated` forms and modifiers drive Jetpack Compose through SkipFuseUI. - ⚡ **`@Observable` native** — built for iOS 17+ / Swift 5.9+ macros, not `ObservableObject`. - 🔒 **Swift 6 concurrency** — explicit `@MainActor` isolation on the form lifecycle, no `Sendable` headaches for consumers. @@ -46,6 +47,7 @@ struct CreatePlanForm: ValidatableForm, SubmittableForm { - Swift 6.0+ (built with tools 6.3, language mode v6) - iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1 - Xcode 16+ +- Android: via [Skip](https://skip.dev) 1.9.5+ (optional — see [Android (Skip)](#android-skip)) ## Installation @@ -64,6 +66,31 @@ targets: [ Or in Xcode: **File → Add Package Dependencies…** and paste the repository URL. +## Android (Skip) + +FormsKit is a [Skip](https://skip.dev) **Fuse (native) framework**: the Swift source — including the `@Validated` property wrapper and the `KeyPath`-driven focus system — is compiled natively for Android with the Swift SDK for Android, and the SwiftUI modifiers render through [SkipFuseUI](https://github.com/skiptools/skip-fuse-ui) → Jetpack Compose. (Skip's *transpiled* mode is not supported: its Swift-to-Kotlin transpiler handles neither custom property wrappers nor key paths, both of which are the heart of this library.) + +**In a Skip app**, add FormsKit as an ordinary dependency of your Fuse module — no extra configuration; Skip detects the module's `Skip/skip.yml` and wires the Gradle side automatically: + +```swift +.target(name: "MyApp", dependencies: [ + .product(name: "SkipFuseUI", package: "skip-fuse-ui"), + .product(name: "FormsKit", package: "forms-kit"), +], plugins: [.plugin(name: "skipstone", package: "skip")]) +``` + +**In an Apple-only project**, nothing changes at the call site, and the Skip packages are build-time-only (on Apple platforms `SkipFuseUI` simply re-exports SwiftUI and compiles away). If you don't want them in your dependency graph at all, resolve with the `SKIP_ZERO` environment variable set — the manifest then strips the Skip plugin and every Skip dependency, restoring a zero-dependency package: + +```bash +SKIP_ZERO=1 swift build +``` + +Platform notes: + +- The view modifiers are implemented as custom `ViewModifier`s with a twist: skip-bridge cannot represent generic types, so on Android the generic modifiers are swapped for non-generic, type-erased twins that skipstone bridges into Kotlin peers (an *unbridged* custom `ViewModifier` renders as a silent no-op on Android). `.formToolbar`, `.formValidationError`, and `.focused(on:equals:)` render identically on both platforms. +- `.formBindFocus(_:on:)` relies on an optional-valued `@FocusState`, which SkipUI does not fully support yet — prefer `.focused(on:equals:)` (internally `Bool`-based) in cross-platform forms. +- The view-modifier test suite runs on Apple platforms only (it hosts views via `ImageRenderer`/`HostingController`, which don't exist on Android); all validation, controller, and focus-logic tests run on both platforms. + --- ## Quick start @@ -568,7 +595,7 @@ FormsKit ships an **agent skill** at [`Skills/formskit-expert/`](Skills/formskit ## Roadmap - Localized default error messages via `String(localized:bundle: .module)`. -- Themeable error color on `FormValidationErrorModifier` (currently hardcoded `.red`). +- Themeable error color on `formValidationError` (currently hardcoded `.red`). - Localizable strings in `FormToolbarViewModifier` ("Discard Changes?", etc.). - Additional rule families (`Number`, `Date`, `Collection`). diff --git a/Skills/formskit-expert.skill b/Skills/formskit-expert.skill index 4eb6173..5fa0190 100644 Binary files a/Skills/formskit-expert.skill and b/Skills/formskit-expert.skill differ diff --git a/Skills/formskit-expert/references/api-cheatsheet.md b/Skills/formskit-expert/references/api-cheatsheet.md index 5c81005..4454945 100644 --- a/Skills/formskit-expert/references/api-cheatsheet.md +++ b/Skills/formskit-expert/references/api-cheatsheet.md @@ -147,5 +147,9 @@ Sources/FormsKit/ ├── Forms/{ValidatableForm,SubmittableForm,PopulatableForm}.swift ├── ValidationRules/StringValidationRule.swift ├── ValidationRules/StringValidationRules/*.swift -└── ViewModifiers/{FormValidationErrorModifier,FormToolbarViewModifier,FocusedOnViewModifier,FormBindFocusViewModifier}.swift +├── AnyFormController.swift # internal erased facade for the bridged (Android) modifier twins +└── ViewModifiers/{FormValidationErrorModifier,FormToolbarViewModifier,FocusedOnViewModifier,FormBindFocus}.swift ``` + +Single target. View files select their SwiftUI module inline — +`#if SKIP || SKIP_BRIDGE` → `import SkipSwiftUI`, else `import SwiftUI`. diff --git a/Sources/FormsKit/AnyFormController.swift b/Sources/FormsKit/AnyFormController.swift new file mode 100644 index 0000000..6c02e70 --- /dev/null +++ b/Sources/FormsKit/AnyFormController.swift @@ -0,0 +1,31 @@ +// Type-erased facade over `FormController` for the bridged (non-generic) +// view modifiers: skip-bridge cannot represent generic types, so the erased +// modifier variants store this instead of `FormController` and reach the +// controller through closures. Internal: consumers only ever see the generic +// `FormController` through the public `View` extension functions. +@MainActor +struct AnyFormController { + let isDirty: () -> Bool + let isLoading: () -> Bool + let validateReturningIsValid: () -> Bool + let getFocus: () -> AnyKeyPath? + let setFocus: (AnyKeyPath?) -> Void + + init(_ controller: FormController) { + self.isDirty = { controller.isDirty } + self.isLoading = { controller.isLoading } + self.validateReturningIsValid = { controller.validate(); return controller.form.isValid } + self.getFocus = { controller.focus } + self.setFocus = { controller.focus = $0.flatMap { $0 as? PartialKeyPath } } + } + + /// Focus-only erasure for `focused(on:equals:)`, whose form type carries no + /// validation constraints. The validation closures are inert stubs. + init(focusing controller: FormController) { + self.isDirty = { false } + self.isLoading = { false } + self.validateReturningIsValid = { false } + self.getFocus = { controller.focus } + self.setFocus = { controller.focus = $0.flatMap { $0 as? PartialKeyPath } } + } +} diff --git a/Sources/FormsKit/FormController.swift b/Sources/FormsKit/FormController.swift index 4f76a15..71a8acd 100644 --- a/Sources/FormsKit/FormController.swift +++ b/Sources/FormsKit/FormController.swift @@ -1,5 +1,13 @@ import Observation - +#if canImport(SkipFuse) +// On Android (Skip Fuse), SkipFuse wires @Observable state tracking into +// Compose so views re-render when the controller changes. On Apple +// platforms the import is inert, and it disappears entirely when the +// package is resolved with SKIP_ZERO=1. +import SkipFuse +#endif + +// SKIP @nobridge @MainActor @Observable public final class FormController { @@ -25,6 +33,7 @@ public final class FormController { // MARK: Controller Extension for Validatable Forms +// SKIP @nobridge extension FormController where T: ValidatableForm { var isDirty: Bool { @@ -57,6 +66,7 @@ extension FormController where T: ValidatableForm { // MARK: Controller Extension for Submittable and Validatable Forms +// SKIP @nobridge public extension FormController where T: SubmittableForm, T: ValidatableForm { var isLoading: Bool { diff --git a/Sources/FormsKit/Forms/PopulatableForm.swift b/Sources/FormsKit/Forms/PopulatableForm.swift index d65355f..5f306da 100644 --- a/Sources/FormsKit/Forms/PopulatableForm.swift +++ b/Sources/FormsKit/Forms/PopulatableForm.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public protocol PopulatableForm { associatedtype Data diff --git a/Sources/FormsKit/Forms/SubmittableForm.swift b/Sources/FormsKit/Forms/SubmittableForm.swift index 9a95978..21b9ccc 100644 --- a/Sources/FormsKit/Forms/SubmittableForm.swift +++ b/Sources/FormsKit/Forms/SubmittableForm.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public protocol SubmittableForm { associatedtype Output diff --git a/Sources/FormsKit/Forms/ValidatableForm.swift b/Sources/FormsKit/Forms/ValidatableForm.swift index 46aa4a4..10041fc 100644 --- a/Sources/FormsKit/Forms/ValidatableForm.swift +++ b/Sources/FormsKit/Forms/ValidatableForm.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public protocol ValidatableForm { var validatedFields: [ValidatedField] { get } } diff --git a/Sources/FormsKit/Skip/skip.yml b/Sources/FormsKit/Skip/skip.yml new file mode 100644 index 0000000..08f60b5 --- /dev/null +++ b/Sources/FormsKit/Skip/skip.yml @@ -0,0 +1,15 @@ +# Skip (https://skip.dev) configuration for the FormsKit module. +# +# FormsKit is a natively-compiled Skip Fuse module: the Swift source is +# compiled as-is for Android with the Swift SDK for Android, so the +# @Validated property wrapper and the KeyPath-driven focus system work +# unchanged (neither is expressible in Skip's transpiled mode). +# +# Bridging is required: FormsKit's wrapper Views (the four form-control +# modifiers) need Kotlin peers so they render on Android. Custom Views bridge +# fine; module-wide bridging is safe. If the generator produces problematic +# Kotlin for other API, scope it down with `// SKIP @nobridge` on those +# declarations (never on the wrapper Views themselves). +skip: + mode: 'native' + bridging: true diff --git a/Sources/FormsKit/Validated.swift b/Sources/FormsKit/Validated.swift index 062b310..74ce59c 100644 --- a/Sources/FormsKit/Validated.swift +++ b/Sources/FormsKit/Validated.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge @propertyWrapper public struct Validated { diff --git a/Sources/FormsKit/ValidatedField.swift b/Sources/FormsKit/ValidatedField.swift index c41baa5..18f7bd6 100644 --- a/Sources/FormsKit/ValidatedField.swift +++ b/Sources/FormsKit/ValidatedField.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public struct ValidatedField
{ public let keyPath: PartialKeyPath diff --git a/Sources/FormsKit/ValidationError.swift b/Sources/FormsKit/ValidationError.swift index 038effb..a5191ea 100644 --- a/Sources/FormsKit/ValidationError.swift +++ b/Sources/FormsKit/ValidationError.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public enum ValidationError: Error { case misconfigured(message: String) case invalid(errors: [String: [String]]) diff --git a/Sources/FormsKit/ValidationRule.swift b/Sources/FormsKit/ValidationRule.swift index 26a6d95..75ee76a 100644 --- a/Sources/FormsKit/ValidationRule.swift +++ b/Sources/FormsKit/ValidationRule.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public protocol ValidationRule { associatedtype Value func validate(value: Value) -> String? diff --git a/Sources/FormsKit/ValidationRules/StringValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRule.swift index 8afe674..155580d 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRule.swift @@ -1 +1,2 @@ +// SKIP @nobridge public protocol StringValidationRule: ValidationRule where Value == String {} diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/EmailValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/EmailValidationRule.swift index 61779a9..a5d790b 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/EmailValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/EmailValidationRule.swift @@ -1,5 +1,6 @@ import Foundation +// SKIP @nobridge public struct EmailValidator: StringValidationRule { let message: String @@ -16,6 +17,9 @@ public struct EmailValidator: StringValidationRule { } } +// Kotlin companion objects cannot express static members added via +// generically-constrained extensions; these factories are Swift-only sugar. +// SKIP @nobridge public extension ValidationRule where Self == EmailValidator { static func email(message: String = "Invalid email address") -> EmailValidator { EmailValidator(message) diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/MaxStringLengthValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/MaxStringLengthValidationRule.swift index a29cb13..fd1008b 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/MaxStringLengthValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/MaxStringLengthValidationRule.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public struct MaxStringLengthValidationRule: StringValidationRule { let maxLength: Int let message: String @@ -12,6 +13,9 @@ public struct MaxStringLengthValidationRule: StringValidationRule { } } +// Kotlin companion objects cannot express static members added via +// generically-constrained extensions; these factories are Swift-only sugar. +// SKIP @nobridge public extension ValidationRule where Self == MaxStringLengthValidationRule { static func maxLength(_ length: Int, message: String? = nil) -> MaxStringLengthValidationRule { MaxStringLengthValidationRule(length, message) diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/MinStringLengthValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/MinStringLengthValidationRule.swift index 30bc46b..3d02374 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/MinStringLengthValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/MinStringLengthValidationRule.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public struct MinStringLengthValidationRule: StringValidationRule { let minLength: Int let message: String @@ -12,6 +13,9 @@ public struct MinStringLengthValidationRule: StringValidationRule { } } +// Kotlin companion objects cannot express static members added via +// generically-constrained extensions; these factories are Swift-only sugar. +// SKIP @nobridge public extension ValidationRule where Self == MinStringLengthValidationRule { static func minLength(_ length: Int, message: String? = nil) -> MinStringLengthValidationRule { MinStringLengthValidationRule(length, message) diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/NotEmptyStringRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/NotEmptyStringRule.swift index b52a4e4..138e997 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/NotEmptyStringRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/NotEmptyStringRule.swift @@ -1,3 +1,4 @@ +// SKIP @nobridge public struct NotEmptyStringRule: StringValidationRule { let message: String @@ -9,6 +10,9 @@ public struct NotEmptyStringRule: StringValidationRule { } } +// Kotlin companion objects cannot express static members added via +// generically-constrained extensions; these factories are Swift-only sugar. +// SKIP @nobridge public extension ValidationRule where Self == NotEmptyStringRule { static func isNotEmpty(message: String) -> NotEmptyStringRule { NotEmptyStringRule(message: message) diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/RegularExpressionValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/RegularExpressionValidationRule.swift index 0b1e8e3..6dccb46 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/RegularExpressionValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/RegularExpressionValidationRule.swift @@ -1,5 +1,6 @@ import Foundation +// SKIP @nobridge public struct RegularExpressionValidationRule: StringValidationRule { let pattern: String let message: String @@ -11,6 +12,9 @@ public struct RegularExpressionValidationRule: StringValidationRule { } } +// Kotlin companion objects cannot express static members added via +// generically-constrained extensions; these factories are Swift-only sugar. +// SKIP @nobridge public extension ValidationRule where Self == RegularExpressionValidationRule { static func pattern(_ pattern: String, message: String) -> RegularExpressionValidationRule { RegularExpressionValidationRule(pattern: pattern, message: message) diff --git a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift index 1e54322..eb7abc4 100644 --- a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift @@ -1,19 +1,67 @@ +// `SKIP` covers the skipstone bridge generator (which parses with SKIP defined +// and SKIP_BRIDGE undefined); `SKIP_BRIDGE` covers the two real bridge compiles +// (Android cross-compile, Robolectric host). Apple builds take the else branch. +#if SKIP || SKIP_BRIDGE +import SkipSwiftUI +#else import SwiftUI +#endif +// Dual structure: the fully-typed generic modifier serves non-bridge builds; +// the erased twin below serves bridge builds (skip-bridge cannot represent +// generic types). `!SKIP` additionally hides the generic variant from the +// skipstone generator, which parses with SKIP defined but SKIP_BRIDGE +// undefined and would otherwise try to bridge it. +#if !SKIP_BRIDGE && !SKIP public struct FocusedOnViewModifier: ViewModifier { + let controller: FormController + let fieldKeyPath: KeyPath - let controller: Binding> + @FocusState var isFocused: Bool - let keyPath: KeyPath + public func body(content: Content) -> some View { + content + .focused($isFocused) + .onChange(of: controller.focus) { _, new in + let shouldBeFocused = (new == fieldKeyPath) + guard isFocused != shouldBeFocused else { + return + } + Task { @MainActor in + isFocused = shouldBeFocused + } + } + .onChange(of: isFocused) { _, new in + if new { + if controller.focus != fieldKeyPath { + controller.focus = fieldKeyPath + } + } else if controller.focus == fieldKeyPath { + controller.focus = nil + } + } + } +} +#endif + +// Bridged twin for Android: non-generic (skip-bridge requirement), reaching +// the controller through `AnyFormController`'s closures and comparing focus +// identity as `AnyKeyPath`. Must NOT carry `// SKIP @nobridge` — the +// generated Kotlin peer is what makes the modifier apply on Android. Keep +// this body in sync with the generic variant above. +public struct ErasedFocusedOnModifier: ViewModifier { + let controller: AnyFormController + let fieldKeyPath: AnyKeyPath - @FocusState private var isFocused: Bool + // internal (not private): Skip's Android bridge for SwiftUI types + // cannot reach private property-wrapper storage. + @FocusState var isFocused: Bool public func body(content: Content) -> some View { - let myKeyPath: PartialKeyPath = keyPath - return content + content .focused($isFocused) - .onChange(of: controller.wrappedValue.focus) { _, new in - let shouldBeFocused = (new == myKeyPath) + .onChange(of: controller.getFocus()) { _, new in + let shouldBeFocused = (new == fieldKeyPath) guard isFocused != shouldBeFocused else { return } @@ -23,11 +71,11 @@ public struct FocusedOnViewModifier: ViewModifier { } .onChange(of: isFocused) { _, new in if new { - if controller.wrappedValue.focus != myKeyPath { - controller.wrappedValue.focus = myKeyPath + if controller.getFocus() != fieldKeyPath { + controller.setFocus(fieldKeyPath) } - } else if controller.wrappedValue.focus == myKeyPath { - controller.wrappedValue.focus = nil + } else if controller.getFocus() == fieldKeyPath { + controller.setFocus(nil) } } } @@ -35,17 +83,23 @@ public struct FocusedOnViewModifier: ViewModifier { // MARK: - View Extension +// SKIP @nobridge public extension View { func focused( on controller: Binding>, equals keyPath: KeyPath ) -> some View { - modifier( - FocusedOnViewModifier( - controller: controller, - keyPath: keyPath - ) - ) + #if SKIP || SKIP_BRIDGE + return modifier(ErasedFocusedOnModifier( + controller: AnyFormController(focusing: controller.wrappedValue), + fieldKeyPath: keyPath + )) + #else + return modifier(FocusedOnViewModifier( + controller: controller.wrappedValue, + fieldKeyPath: keyPath + )) + #endif } } diff --git a/Sources/FormsKit/ViewModifiers/FormBindFocus.swift b/Sources/FormsKit/ViewModifiers/FormBindFocus.swift new file mode 100644 index 0000000..91b35c3 --- /dev/null +++ b/Sources/FormsKit/ViewModifiers/FormBindFocus.swift @@ -0,0 +1,32 @@ +// `SKIP` covers the skipstone bridge generator (which parses with SKIP defined +// and SKIP_BRIDGE undefined); `SKIP_BRIDGE` covers the two real bridge compiles +// (Android cross-compile, Robolectric host). Apple builds take the else branch. +#if SKIP || SKIP_BRIDGE +import SkipSwiftUI +#else +import SwiftUI +#endif + +// MARK: - View Extension + +// SKIP @nobridge +public extension View { + + func formBindFocus( + _ focus: FocusState?>.Binding, + on controller: FormController + ) -> some View { + self + .onChange(of: focus.wrappedValue) { _, new in + if controller.focus != new { + controller.focus = new + } + } + .onChange(of: controller.focus) { _, new in + guard focus.wrappedValue != new else { return } + Task { @MainActor in + focus.wrappedValue = new + } + } + } +} diff --git a/Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift b/Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift deleted file mode 100644 index 36b9229..0000000 --- a/Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftUI - -public struct FormBindFocusViewModifier: ViewModifier { - - let focus: FocusState?>.Binding - - let controller: FormController - - public func body(content: Content) -> some View { - content - .onChange(of: focus.wrappedValue) { _, new in - Self.syncControllerFocus(controller, to: new) - } - .onChange(of: controller.focus) { _, new in - guard focus.wrappedValue != new else { return } - Task { @MainActor in - focus.wrappedValue = new - } - } - } - - static func syncControllerFocus( - _ controller: FormController, - to new: PartialKeyPath? - ) { - if controller.focus != new { - controller.focus = new - } - } -} - -// MARK: - View Extension - -public extension View { - - func formBindFocus( - _ focus: FocusState?>.Binding, - on controller: FormController - ) -> some View { - modifier( - FormBindFocusViewModifier( - focus: focus, - controller: controller - ) - ) - } -} diff --git a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift b/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift index feba1c7..539adcf 100644 --- a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift @@ -1,19 +1,28 @@ +// `SKIP` covers the skipstone bridge generator (which parses with SKIP defined +// and SKIP_BRIDGE undefined); `SKIP_BRIDGE` covers the two real bridge compiles +// (Android cross-compile, Robolectric host). Apple builds take the else branch. +#if SKIP || SKIP_BRIDGE +import SkipSwiftUI +#else import SwiftUI +#endif +// Dual structure: the fully-typed generic modifier serves non-bridge builds; +// the erased twin below serves bridge builds (skip-bridge cannot represent +// generic types). `!SKIP` additionally hides the generic variant from the +// skipstone generator, which parses with SKIP defined but SKIP_BRIDGE +// undefined and would otherwise try to bridge it. +#if !SKIP_BRIDGE && !SKIP public struct FormToolbarViewModifier: ViewModifier { - @Environment(\.dismiss) private var dismiss + @Environment(\.dismiss) var dismiss - @State private var showsDiscardWarning: Bool = false + @State var showsDiscardWarning: Bool = false let controller: FormController - let cancelTitle: String - let submitTitle: String - let preventsAccidentalDismiss: Bool - - let onSubmit: (() -> Void) + let onSubmit: () -> Void public func body(content: Content) -> some View { content @@ -51,9 +60,66 @@ public struct FormToolbarViewModifier: Vie } } } +#endif + +// Bridged twin for Android: non-generic (skip-bridge requirement), reaching +// the controller through `AnyFormController`'s closures. Must NOT carry +// `// SKIP @nobridge` — the generated Kotlin peer is exactly what makes the +// modifier apply on Android (an unbridged custom ViewModifier falls back to +// SkipSwiftUI's default `Java_modifier`, an EmptyModifier, and silently +// renders nothing). Keep this body in sync with the generic variant above. +public struct ErasedFormToolbarModifier: ViewModifier { + // internal (not private): Skip's Android bridge for SwiftUI types + // cannot reach private property-wrapper storage. + @Environment(\.dismiss) var dismiss + + @State var showsDiscardWarning: Bool = false + + let controller: AnyFormController + let cancelTitle: String + let submitTitle: String + let preventsAccidentalDismiss: Bool + let onSubmit: () -> Void + + public func body(content: Content) -> some View { + content + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(cancelTitle, action: cancelTapped) + } + ToolbarItem(placement: .confirmationAction) { + Button(submitTitle, action: submitTapped) + .bold() + .disabled(!controller.isDirty() || controller.isLoading()) + } + } + .interactiveDismissDisabled(preventsAccidentalDismiss && controller.isDirty()) + .confirmationDialog("Discard Changes?", isPresented: $showsDiscardWarning) { + Button("Discard Changes", role: .destructive) { dismiss() } + Button("Keep Editing", role: .cancel) { } + } message: { + Text("You have unsaved changes. Are you sure you want to discard them?") + } + } + + func cancelTapped() { + if preventsAccidentalDismiss && controller.isDirty() { + showsDiscardWarning = true + } else { + dismiss() + } + } + + func submitTapped() { + if controller.validateReturningIsValid() { + onSubmit() + } + } +} // MARK: - View Extension +// SKIP @nobridge public extension View { func formToolbar( @@ -61,16 +127,24 @@ public extension View { cancelTitle: String = "Cancel", submitTitle: String = "Submit", preventsAccidentalDismiss: Bool = true, - onSubmit: @escaping () -> Void, + onSubmit: @escaping () -> Void ) -> some View { - self.modifier( - FormToolbarViewModifier( - controller: controller, - cancelTitle: cancelTitle, - submitTitle: submitTitle, - preventsAccidentalDismiss: preventsAccidentalDismiss, - onSubmit: onSubmit - ) - ) + #if SKIP || SKIP_BRIDGE + return modifier(ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: cancelTitle, + submitTitle: submitTitle, + preventsAccidentalDismiss: preventsAccidentalDismiss, + onSubmit: onSubmit + )) + #else + return modifier(FormToolbarViewModifier( + controller: controller, + cancelTitle: cancelTitle, + submitTitle: submitTitle, + preventsAccidentalDismiss: preventsAccidentalDismiss, + onSubmit: onSubmit + )) + #endif } } diff --git a/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift b/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift index 88431c7..7b2ac3d 100644 --- a/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift @@ -1,27 +1,26 @@ +// `SKIP` covers the skipstone bridge generator (which parses with SKIP defined +// and SKIP_BRIDGE undefined); `SKIP_BRIDGE` covers the two real bridge compiles +// (Android cross-compile, Robolectric host). Apple builds take the else branch. +#if SKIP || SKIP_BRIDGE +import SkipSwiftUI +#else import SwiftUI - -public struct FormValidationErrorModifier: ViewModifier { - - let state: Validated.State - +#endif + +// Non-generic on every platform (the generic `Validated.State` is unpacked +// into plain `[String]?` at the call site), so a single bridged modifier +// serves both. Must NOT carry `// SKIP @nobridge` — the generated Kotlin peer +// is what makes the modifier apply on Android. +public struct FormValidationErrorModifier: ViewModifier { + let errorMessages: [String]? let alignment: HorizontalAlignment let spacing: CGFloat? - init( - state: Validated.State, - alignment: HorizontalAlignment = .leading, - spacing: CGFloat? = 4 - ) { - self.state = state - self.alignment = alignment - self.spacing = spacing - } - public func body(content: Content) -> some View { VStack(alignment: alignment, spacing: spacing) { content - if case let .invalid(messages) = state { - ForEach(messages, id: \.self) { message in + if let errorMessages { + ForEach(errorMessages, id: \.self) { message in Text(message) .foregroundStyle(.red) .font(.caption) @@ -33,6 +32,7 @@ public struct FormValidationErrorModifier: ViewModifier { // MARK: - View Extension +// SKIP @nobridge public extension View { func formValidationError( @@ -40,12 +40,16 @@ public extension View { alignment: HorizontalAlignment = .leading, spacing: CGFloat? = 4 ) -> some View { - modifier( - FormValidationErrorModifier( - state: state, - alignment: alignment, - spacing: spacing - ) - ) + let errorMessages: [String]? + if case let .invalid(messages) = state { + errorMessages = messages + } else { + errorMessages = nil + } + return modifier(FormValidationErrorModifier( + errorMessages: errorMessages, + alignment: alignment, + spacing: spacing + )) } } diff --git a/Tests/FormsKitTests/Skip/skip.yml b/Tests/FormsKitTests/Skip/skip.yml new file mode 100644 index 0000000..1ef653f --- /dev/null +++ b/Tests/FormsKitTests/Skip/skip.yml @@ -0,0 +1,3 @@ +# Skip (https://skip.dev) configuration for the FormsKitTests module. +skip: + mode: 'native' diff --git a/Tests/FormsKitTests/ViewModifierTests.swift b/Tests/FormsKitTests/ViewModifierTests.swift index a409884..b544fc8 100644 --- a/Tests/FormsKitTests/ViewModifierTests.swift +++ b/Tests/FormsKitTests/ViewModifierTests.swift @@ -1,3 +1,12 @@ +// These tests drive the modifiers through real SwiftUI hosts +// (ImageRenderer / NS-/UIHostingController), which don't exist on Android — +// and in bridge builds (SKIP_BRIDGE: the Android cross-compile and the +// Robolectric host build) FormsKit's views are SkipSwiftUI-typed, so real +// SwiftUI hosting doesn't apply there either. On Android (Skip Fuse) the +// modifiers are exercised by consumers' own UI tests instead; the library's +// logic tests all run on both platforms. +#if !os(Android) && !SKIP_BRIDGE + import Testing import SwiftUI @testable import FormsKit @@ -10,7 +19,7 @@ import UIKit // MARK: - Fixtures -private struct VMForm: ValidatableForm, SubmittableForm { +struct VMForm: ValidatableForm, SubmittableForm { @Validated(name: "name", .isNotEmpty(message: "Required")) var name: String = "" @@ -26,10 +35,10 @@ private struct VMForm: ValidatableForm, SubmittableForm { func submit() async throws -> String { name } } -// MARK: - FormValidationErrorModifier +// MARK: - formValidationError @MainActor -@Suite("FormValidationErrorModifier") +@Suite("formValidationError") struct FormValidationErrorModifierTests { @Test("View extension `.formValidationError(for:)` builds a modified view — .idle branch") @@ -132,7 +141,7 @@ struct FormToolbarViewModifierTests { @Test("cancelTapped on a clean form invokes dismiss (no warning shown)") func cancelTappedCleanDismisses() { let controller = FormController(form: VMForm()) - let modifier = FormToolbarViewModifier( + let modifier = FormToolbarViewModifier( controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", @@ -148,7 +157,7 @@ struct FormToolbarViewModifierTests { func cancelTappedDirtyShowsWarning() { let controller = FormController(form: VMForm()) controller.form.name = "edited" - let modifier = FormToolbarViewModifier( + let modifier = FormToolbarViewModifier( controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", @@ -165,7 +174,7 @@ struct FormToolbarViewModifierTests { func cancelTappedNoPreventDismissesWhenDirty() { let controller = FormController(form: VMForm()) controller.form.name = "edited" - let modifier = FormToolbarViewModifier( + let modifier = FormToolbarViewModifier( controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", @@ -184,7 +193,7 @@ struct FormToolbarViewModifierTests { controller.form.name = "Alice" controller.form.email = "alice@example.com" var didSubmit = false - let modifier = FormToolbarViewModifier( + let modifier = FormToolbarViewModifier( controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", @@ -201,7 +210,7 @@ struct FormToolbarViewModifierTests { let controller = FormController(form: VMForm()) // Both fields empty → invalid after validate(). var didSubmit = false - let modifier = FormToolbarViewModifier( + let modifier = FormToolbarViewModifier( controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", @@ -213,12 +222,172 @@ struct FormToolbarViewModifierTests { // validate() ran — both fields are now in .invalid state. #expect(controller.form.isValid == false) } + + // MARK: ErasedFormToolbarModifier (the variant Android actually runs) + + @Test("Erased twin: cancelTapped on a clean form invokes dismiss") + func erasedCancelTappedCleanDismisses() { + let controller = FormController(form: VMForm()) + let modifier = ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: true, + onSubmit: { Issue.record("onSubmit should not fire for cancel") } + ) + modifier.cancelTapped() + } + + @Test("Erased twin: cancelTapped on a dirty form takes the warning branch") + func erasedCancelTappedDirtyShowsWarning() { + let controller = FormController(form: VMForm()) + controller.form.name = "edited" + let modifier = ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: true, + onSubmit: { Issue.record("onSubmit should not fire for cancel") } + ) + modifier.cancelTapped() + } + + @Test("Erased twin: submitTapped with valid form runs validate() then onSubmit") + func erasedSubmitTappedValidCallsOnSubmit() { + let controller = FormController(form: VMForm()) + controller.form.name = "Alice" + controller.form.email = "alice@example.com" + var didSubmit = false + let modifier = ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: true, + onSubmit: { didSubmit = true } + ) + modifier.submitTapped() + #expect(didSubmit == true) + #expect(controller.form.isValid == true) + } + + @Test("Erased twin: submitTapped with invalid form runs validate() but skips onSubmit") + func erasedSubmitTappedInvalidSkipsOnSubmit() { + let controller = FormController(form: VMForm()) + var didSubmit = false + let modifier = ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: true, + onSubmit: { didSubmit = true } + ) + modifier.submitTapped() + #expect(didSubmit == false) + #expect(controller.form.isValid == false) + } + + @Test("Erased twin: cancelTapped with preventsAccidentalDismiss=false dismisses even when dirty") + func erasedCancelTappedNoPreventDismissesWhenDirty() { + let controller = FormController(form: VMForm()) + controller.form.name = "edited" + let modifier = ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: false, + onSubmit: { Issue.record("onSubmit should not fire for cancel") } + ) + modifier.cancelTapped() + } + + @Test("Erased twin: body renders for a dirty form (submit button disabled path)") + func erasedBodyRendersDirty() { + let controller = FormController(form: VMForm()) + controller.form.name = "edited" + let view = NavigationStack { + Text("body").modifier(ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: true, + onSubmit: { } + )) + } + _renderOnce(view) + } + + @Test("Erased twin: body renders for a clean form") + func erasedBodyRendersClean() { + let controller = FormController(form: VMForm()) + let view = NavigationStack { + Text("body").modifier(ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Close", + submitTitle: "Create", + preventsAccidentalDismiss: false, + onSubmit: { } + )) + } + _renderOnce(view) + } + + @Test("Erased twin: hosting the toolbar drives the @State property initializer") + func erasedBodyHosted() async { + // As with the generic variant: `ImageRenderer` doesn't always install + // `@State` containers, an `NSHostingController` does — so this is what + // fires `showsDiscardWarning`'s default initializer on the erased twin. + let controller = FormController(form: VMForm()) + controller.form.name = "edited" + let view = NavigationStack { + Text("body").modifier(ErasedFormToolbarModifier( + controller: AnyFormController(controller), + cancelTitle: "Cancel", + submitTitle: "Submit", + preventsAccidentalDismiss: true, + onSubmit: { } + )) + } + await _withHostedView(view) { /* nothing to mutate */ } + } + + @Test("AnyFormController(_:) forwards isDirty / isLoading / validate to the controller") + func anyFormControllerFullErasure() { + let controller = FormController(form: VMForm()) + let erased = AnyFormController(controller) + #expect(erased.isDirty() == false) + #expect(erased.isLoading() == false) + controller.form.name = "edited" + #expect(erased.isDirty() == true) + // Still invalid — `email` is empty. + #expect(erased.validateReturningIsValid() == false) + controller.form.email = "alice@example.com" + #expect(erased.validateReturningIsValid() == true) + // Focus round-trips through the same facade the toolbar erasure builds. + erased.setFocus(\VMForm.email) + #expect(controller.focus == \VMForm.email) + #expect(erased.getFocus() == \VMForm.email) + } + + @Test("AnyFormController(focusing:) reads and writes controller.focus; validation stubs are inert") + func anyFormControllerFocusingErasure() { + let controller = FormController(form: VMForm()) + let erased = AnyFormController(focusing: controller) + #expect(erased.getFocus() == nil) + erased.setFocus(\VMForm.name) + #expect(controller.focus == \VMForm.name) + #expect(erased.getFocus() == \VMForm.name) + erased.setFocus(nil) + #expect(controller.focus == nil) + #expect(erased.validateReturningIsValid() == false) + #expect(erased.isDirty() == false) + #expect(erased.isLoading() == false) + } } // MARK: - FormBindFocusViewModifier /// Hosts the modifier under a real SwiftUI runtime so its `onChange` handlers fire. -private struct FormBindFocusHostView: View { +struct FormBindFocusHostView: View { let controller: FormController @FocusState var focus: PartialKeyPath? @@ -232,7 +401,7 @@ private struct FormBindFocusHostView: View { /// `onChange(of: focus.wrappedValue)` handler (focus → controller direction) /// gets exercised. The fields are real `TextField`s bound to the same /// `@FocusState` so SwiftUI can actually grant focus on the write. -private struct FormBindFocusAppearHost: View { +struct FormBindFocusAppearHost: View { let controller: FormController @FocusState var focus: PartialKeyPath? let appearAction: (FocusState?>.Binding) -> Void @@ -255,7 +424,7 @@ private struct FormBindFocusAppearHost: View { } @MainActor -@Suite("FormBindFocusViewModifier", .serialized) +@Suite("formBindFocus", .serialized) struct FormBindFocusViewModifierTests { @Test("View extension `.formBindFocus(_:on:)` builds a modified view without crashing") @@ -309,37 +478,13 @@ struct FormBindFocusViewModifierTests { // SwiftUI focus-system assertion, not a FormsKit one. } - // MARK: syncControllerFocus (deterministic) - - @Test("syncControllerFocus writes a new value into controller.focus") - func syncControllerFocusWritesNewValue() { - let controller = FormController(form: VMForm()) - FormBindFocusViewModifier.syncControllerFocus(controller, to: \VMForm.name) - #expect(controller.focus == \VMForm.name) - } - - @Test("syncControllerFocus is a no-op when the controller is already there") - func syncControllerFocusNoOpWhenEqual() { - let controller = FormController(form: VMForm()) - controller.focus = \VMForm.email - FormBindFocusViewModifier.syncControllerFocus(controller, to: \VMForm.email) - #expect(controller.focus == \VMForm.email) - } - - @Test("syncControllerFocus clears controller.focus when handed nil") - func syncControllerFocusClears() { - let controller = FormController(form: VMForm()) - controller.focus = \VMForm.email - FormBindFocusViewModifier.syncControllerFocus(controller, to: nil) - #expect(controller.focus == nil) - } } // MARK: - FocusedOnViewModifier /// `.focused(on:equals:)` owns its `@FocusState` internally and takes a /// `Binding>`; the host materialises that binding via `@State`. -private struct FocusedOnHostView: View { +struct FocusedOnHostView: View { @State var controller: FormController var body: some View { @@ -355,7 +500,7 @@ private struct FocusedOnHostView: View { /// inside `onAppear` causes SwiftUI to grant focus to the chosen field, /// which flips the modifier's internal `@FocusState` and exercises the /// `onChange(of: isFocused)` handler. -private struct FocusedOnFocusableHost: View { +struct FocusedOnFocusableHost: View { enum Scenario { case setInitial(PartialKeyPath) case setThenClear(PartialKeyPath) @@ -399,8 +544,81 @@ private struct FocusedOnFocusableHost: View { } } +/// Mirror of `FocusedOnHostView` for the erased twin. The public +/// `.focused(on:equals:)` extension only routes to `ErasedFocusedOnModifier` +/// under `SKIP_BRIDGE`, so on Apple builds the twin has to be applied by hand +/// via `.modifier(_:)` — which is also exactly what skipstone's generated +/// Kotlin peer ends up doing on Android. +struct ErasedFocusedOnHostView: View { + let controller: FormController + + var body: some View { + VStack { + Text("name field").modifier(ErasedFocusedOnModifier( + controller: AnyFormController(focusing: controller), + fieldKeyPath: \VMForm.name + )) + Text("email field").modifier(ErasedFocusedOnModifier( + controller: AnyFormController(focusing: controller), + fieldKeyPath: \VMForm.email + )) + } + } +} + +/// Erased mirror of `FocusedOnFocusableHost`: real `TextField`s plus a parent +/// `@FocusState` driver, so SwiftUI actually grants focus and the twin's +/// `onChange(of: isFocused)` handler fires. +struct ErasedFocusedOnFocusableHost: View { + enum Scenario { + case setInitial(PartialKeyPath) + case setThenClear(PartialKeyPath) + case setThenSwitch(PartialKeyPath, PartialKeyPath) + } + + @FocusState var parentFocus: PartialKeyPath? + @State var controller: FormController + let scenario: Scenario + + var body: some View { + VStack { + TextField("name", text: $controller.form.name) + .focused($parentFocus, equals: \VMForm.name) + .modifier(ErasedFocusedOnModifier( + controller: AnyFormController(focusing: controller), + fieldKeyPath: \VMForm.name + )) + TextField("email", text: $controller.form.email) + .focused($parentFocus, equals: \VMForm.email) + .modifier(ErasedFocusedOnModifier( + controller: AnyFormController(focusing: controller), + fieldKeyPath: \VMForm.email + )) + } + .onAppear { + // Same deferral rationale as `FocusedOnFocusableHost`: writes made + // synchronously in `onAppear` race the focus system's wire-up. + Task { @MainActor in + try? await Task.sleep(nanoseconds: 20_000_000) + switch scenario { + case .setInitial(let kp): + parentFocus = kp + case .setThenClear(let kp): + parentFocus = kp + try? await Task.sleep(nanoseconds: 30_000_000) + parentFocus = nil + case .setThenSwitch(let a, let b): + parentFocus = a + try? await Task.sleep(nanoseconds: 30_000_000) + parentFocus = b + } + } + } + } +} + @MainActor -@Suite("FocusedOnViewModifier", .serialized) +@Suite("focused(on:equals:)", .serialized) struct FocusedOnViewModifierTests { @Test("View extension `.focused(on:equals:)` builds a modified view without crashing") @@ -467,6 +685,78 @@ struct FocusedOnViewModifierTests { await _withHostedView(host) { /* mutations happen via deferred Task */ } #expect(controller.focus == nil) } + + // MARK: ErasedFocusedOnModifier (the variant Android actually runs) + // + // These live in the `focused(on:equals:)` suite rather than a suite of + // their own on purpose: SwiftUI's focus system is process-global (one key + // window), and `.serialized` only orders tests *within* a suite — sibling + // top-level suites still run concurrently, so a second window-hosting + // focus suite steals first responder from this one and both flake. + + @Test("Erased twin builds a modified view without crashing") + func erasedFocusedOnBuilds() { + _renderOnce(ErasedFocusedOnHostView(controller: FormController(form: VMForm()))) + } + + @Test("Erased twin: setting controller.focus to this field's key path drives the sync handler") + func erasedControllerFocusTriggersHandler() async { + let controller = FormController(form: VMForm()) + let host = ErasedFocusedOnHostView(controller: controller) + await _withHostedView(host) { + controller.focus = \VMForm.name + } + } + + @Test("Erased twin: setting controller.focus elsewhere triggers the not-mine branch") + func erasedControllerFocusOtherKeyPath() async { + let controller = FormController(form: VMForm()) + controller.focus = \VMForm.name + let host = ErasedFocusedOnHostView(controller: controller) + await _withHostedView(host) { + controller.focus = \VMForm.email + controller.focus = nil + } + } + + @Test("Erased twin: SwiftUI focus on a TextField exercises the focus → controller handler") + func erasedSwiftUIFocusPropagatesToController() async { + let controller = FormController(form: VMForm()) + let host = ErasedFocusedOnFocusableHost( + controller: controller, + scenario: .setInitial(\VMForm.name) + ) + await _withHostedView(host) { /* mutation happens via deferred Task */ } + // No strict assertion — same reasoning as the generic variant's + // `swiftUIFocusPropagatesToController`: whether the first programmatic + // focus write lands in a non-key window varies; the setThenSwitch and + // setThenClear scenarios below carry the assertions. + } + + @Test("Erased twin: switching SwiftUI focus writes the new key path through setFocus") + func erasedSwiftUIFocusSwitchUpdatesController() async { + let controller = FormController(form: VMForm()) + let host = ErasedFocusedOnFocusableHost( + controller: controller, + scenario: .setThenSwitch(\VMForm.name, \VMForm.email) + ) + await _withHostedView(host) { /* mutations happen via deferred Task */ } + // Proves the AnyKeyPath → PartialKeyPath downcast inside + // `AnyFormController.setFocus` round-trips to a value that still + // compares equal to the literal `\VMForm.email` a consumer writes. + #expect(controller.focus == \VMForm.email) + } + + @Test("Erased twin: clearing SwiftUI focus clears controller.focus (else-if branch)") + func erasedSwiftUIUnfocusClearsController() async { + let controller = FormController(form: VMForm()) + let host = ErasedFocusedOnFocusableHost( + controller: controller, + scenario: .setThenClear(\VMForm.name) + ) + await _withHostedView(host) { /* mutations happen via deferred Task */ } + #expect(controller.focus == nil) + } } // MARK: - Helpers @@ -535,3 +825,5 @@ private func _spin() async { await Task.yield() } } + +#endif