diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e903ae6e..a338218c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "fosmvvm-generators", "description": "FOSMVVM architecture generators for ViewModels, Fields, DataModels, ServerRequests, Leaf Views, and ViewModel Tests", - "version": "2.26.0", + "version": "2.27.0", "author": { "name": "FOS Computer Services" }, diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1f74e8ae..6b16df7d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -115,6 +115,7 @@ Before hand-writing a helper, check whether it already exists — the catalog in - Credential rejection / typed 401 recovery → `FOSMVVM.md § Protocols` - Form fields and input validation → `FOSMVVM.md § Forms`, `§ Validation` - SwiftUI binding/app setup, property versioning, deployment URLs → `FOSMVVM.md § SwiftUI Support`, `§ Versioning` +- Async Button actions (error routing, re-entry, cancel), localized error alerts → `FOSMVVM.md § SwiftUI Support`, `§ Protocols` - Vapor boot/Leaf, routes, Fluent factories, versioned middleware → `FOSMVVMVapor.md § Extensions`, `§ Vapor Support`, `§ Protocols`, `§ Middleware` - Live ViewModel refresh (server push), incl. nudging live clients from non-Fluent/hybrid sources → `FOSMVVMVapor.md § Live Invalidation` - Testing ViewModels / UI / ServerRequests → `FOSTesting.md § FOSTesting`, `§ FOSTestingUI`, `§ FOSTestingVapor` diff --git a/.claude/skills/fosmvvm-serverrequest-generator/SKILL.md b/.claude/skills/fosmvvm-serverrequest-generator/SKILL.md index be0cb160..b484964f 100644 --- a/.claude/skills/fosmvvm-serverrequest-generator/SKILL.md +++ b/.claude/skills/fosmvvm-serverrequest-generator/SKILL.md @@ -166,6 +166,35 @@ encapsulation in `CLAUDE.md`). The typed error IS the contract; the moment a client sniffs a status, the error vocabulary you declared stops being the only door. +**User-presentable errors conform to `LocalizableError`.** When the error will +be shown to the user (the common case — the client's `alert(error:)` presents +the screen's error binding), compose the `ResponseError` like a ViewModel: +`@LocalizableError` macro, `@LocalizedString`/`@LocalizedSubs` message +property, exposed as `localizedMessage`. `ErrorMiddleware` localizes the +message as it encodes the throw, so the client presents it with no +localization store of its own — the same encode-time localization every +ViewModel property gets (SRP: the error carries its meaning; presentation +never invents copy for it): + +```swift +@LocalizableError +public struct ResponseError: ServerRequestError { + public let maximum: Int + + @LocalizedSubs(substitutions: \.subs) public var errorMessage + public var localizedMessage: any Localizable { errorMessage } + + private var subs: [String: any Localizable] { + ["maximum": LocalizableInt(value: maximum)] + } + + public init(maximum: Int) { self.maximum = maximum } +} +``` + +The YAML rides the request's existing localization file — keys derive from +the error's type + property names, exactly as ViewModel properties do. + --- ## Request Protocol Selection diff --git a/.claude/skills/fosmvvm-swiftui-view-generator/SKILL.md b/.claude/skills/fosmvvm-swiftui-view-generator/SKILL.md index e70fa918..70c9fc6d 100644 --- a/.claude/skills/fosmvvm-swiftui-view-generator/SKILL.md +++ b/.claude/skills/fosmvvm-swiftui-view-generator/SKILL.md @@ -198,7 +198,7 @@ public struct MyView: ViewModelView { **Why `toggleRepaint()` exists.** Client-hosted ops mutate `@Observable` storage that test harnesses (and occasionally SwiftUI itself) don't always re-observe in time for the next assertion. Toggling a `@State` flag forces a deterministic re-render at the View boundary, so UI tests see the post-op state instead of the pre-op state. In production builds the toggle is compiled out — it costs nothing at runtime. -**Async vs sync op shape.** Server-backed ops are typically async (`try await operations.performAction()`) and pair with `Button(errorBinding:asyncAction:)` / `.task(errorBinding:)` / `.onAsyncSubmit`. Client-hosted scalar-mutation ops are typically sync — the live op writes a property on `@Observable` storage and returns. Don't add `async` to an op method that doesn't need it; don't omit `async` on one that calls a `ServerRequest`. +**Async vs sync op shape.** Server-backed ops are typically async (`try await operations.performAction()`) and pair with the async `Button` forms — `Button(error:action:)` and its `Localizable`-titled twins — which deposit a thrown error into the screen's `error:` binding (add `activity:` for re-entry refusal, `cancelTitle:` for tap-to-cancel; see the FOSMVVM DocC article *Async Actions and Error Presentation*). For view-lifetime loads, catch into the binding by hand inside `.task { }` — an error-routing `.task` twin is queued in FOSUtilities but not yet shipped. Client-hosted scalar-mutation ops are typically sync — the live op writes a property on `@Observable` storage and returns. Don't add `async` to an op method that doesn't need it; don't omit `async` on one that calls a `ServerRequest`. The example above shows a **server-backed** op — `operations.performAction()` dispatches a `ServerRequest`. For **client-hosted** ops (those that mutate local `@Observable` storage), the call site shape is different — the View must inject storage from the environment and hand it to the op explicitly. See below. @@ -338,16 +338,14 @@ public struct MyFormView: ViewModelView { validations: validations ) - Button(errorBinding: $error, asyncAction: submit) { - Text(viewModel.submitButtonLabel) - } - .disabled(validations.hasError) + Button(viewModel.submitButtonLabel, error: $error, action: submit) + .disabled(validations.hasError) } - .onAsyncSubmit { - await submit() + .onSubmit { + Task { do { try await submit() } catch { self.error = error } } } .alert( - errorBinding: $error, + error: $error, title: viewModel.errorTitle, message: viewModel.errorMessage, dismissButtonLabel: viewModel.dismissButtonLabel @@ -359,7 +357,7 @@ public struct MyFormView: ViewModelView { **Form patterns:** - `@Environment(Validations.self)` for validation state - `FormFieldView` for each input field -- `Button(errorBinding:asyncAction:)` for async actions +- `Button(error:action:)` (and its `Localizable`-titled twins) for async actions - `.disabled(validations.hasError)` on submit button - Separate handling for validation errors vs general errors @@ -454,7 +452,7 @@ public struct ActionView: ViewModelView { } } .alert( - errorBinding: $error, + error: $error, title: viewModel.errorTitle, message: viewModel.errorMessage, dismissButtonLabel: viewModel.dismissButtonLabel @@ -595,57 +593,47 @@ Skill references information from: ### Error Handling Pattern +The async `Button` forms own the catch: a thrown error lands in the `error:` binding (cleared on each launch), and one `alert(error:)` per screen presents it. The action closure is `@Sendable () async throws` — a thin dispatch into ops, never a hand-rolled `do/catch`: + ```swift @State private var error: Error? var body: some View { VStack { - Button(errorBinding: $error, asyncAction: submit) { - Text(viewModel.submitLabel) + Button(viewModel.submitLabel, error: $error) { + try await operations.submit() } } .alert( - errorBinding: $error, + error: $error, title: viewModel.errorTitle, message: viewModel.errorMessage, dismissButtonLabel: viewModel.dismissButtonLabel ) } - -private func submit() async { - do { - try await operations.submit() - } catch { - self.error = error - } - toggleRepaint() -} ``` +Add `activity: $activity` (an `@State AsyncButtonActivity`) to refuse re-entry while a run is in flight and to drive `disabled(activity.isRunning)`; add `cancelTitle:` to make the button tap-to-cancel. See the FOSMVVM DocC article *Async Actions and Error Presentation*. + ### Validation Error Pattern -For forms, handle validation errors separately: +For forms, intercept validation failures inside the action and let everything else flow to the `error:` binding: ```swift -private func submit() async { - let validations = validations +Button(viewModel.submitLabel, error: $error) { do { try await operations.submit(data: viewModel.data) - } catch let error as MyRequest.ResponseError { - if !error.validationResults.isEmpty { - validations.replace(with: error.validationResults) - } else { - self.error = error - } - } catch { - self.error = error + } catch let responseError as MyRequest.ResponseError + where !responseError.validationResults.isEmpty { + await MainActor.run { validations.replace(with: responseError.validationResults) } } - toggleRepaint() } ``` ### Async Task Pattern +For view-lifetime loads, catch into the binding by hand (an error-routing `.task` twin is queued in FOSUtilities but not yet shipped): + ```swift var body: some View { VStack { @@ -655,8 +643,8 @@ var body: some View { contentView } } - .task(errorBinding: $error) { - try await loadData() + .task { + do { try await loadData() } catch { self.error = error } } } @@ -888,9 +876,7 @@ Button(action: submit) { } // ✅ GOOD - Error binding for async actions -Button(errorBinding: $error, asyncAction: submit) { - Text(viewModel.submitLabel) -} +Button(viewModel.submitLabel, error: $error, action: submit) ``` ### Storing Operations in Body Instead of Init @@ -1004,20 +990,21 @@ See [reference.md](reference.md) for complete file templates. ```swift // Error alert with ViewModel strings .alert( - errorBinding: $error, + error: $error, title: viewModel.errorTitle, message: viewModel.errorMessage, dismissButtonLabel: viewModel.dismissButtonLabel ) -// Async task with error handling -.task(errorBinding: $error) { - try await loadData() +// Async task — catch into the binding by hand (an error-routing +// .task twin is queued in FOSUtilities but not yet shipped) +.task { + do { try await loadData() } catch { self.error = error } } -// Async submit handler -.onAsyncSubmit { - await submit() +// Keyboard submit routing into the same binding +.onSubmit { + Task { do { try await submit() } catch { self.error = error } } } // Test data transporter (DEBUG only) diff --git a/.claude/skills/fosmvvm-swiftui-view-generator/reference.md b/.claude/skills/fosmvvm-swiftui-view-generator/reference.md index 2249e9ee..b93fc948 100644 --- a/.claude/skills/fosmvvm-swiftui-view-generator/reference.md +++ b/.claude/skills/fosmvvm-swiftui-view-generator/reference.md @@ -150,7 +150,7 @@ public struct {ViewName}View: ViewModelView { } .padding() .alert( - errorBinding: $error, + error: $error, title: viewModel.errorTitle, message: viewModel.errorMessage, dismissButtonLabel: viewModel.dismissButtonLabel @@ -367,16 +367,16 @@ public struct {ViewName}View: ViewModelView { validations: validations ) - Button(errorBinding: $error, asyncAction: submit) { + Button(error: $error, action: submit) { Text(viewModel.submitButtonLabel) } .disabled(validations.hasError) } - .onAsyncSubmit { - await submit() + .onSubmit { + Task { await submit() } } .alert( - errorBinding: $error, + error: $error, title: viewModel.errorTitle, message: viewModel.errorMessage, dismissButtonLabel: viewModel.dismissButtonLabel @@ -501,7 +501,7 @@ public struct {ViewName}View: ViewModelView { Spacer() - Button(errorBinding: $error, asyncAction: refresh) { + Button(error: $error, action: refresh) { HStack { Image(systemName: "arrow.clockwise") Text(viewModel.refreshButtonLabel) @@ -509,11 +509,11 @@ public struct {ViewName}View: ViewModelView { } .padding() } - .task(errorBinding: $error) { - try await loadItems() + .task { + do { try await loadItems() } catch { self.error = error } } .alert( - errorBinding: $error, + error: $error, title: viewModel.errorTitle, message: viewModel.errorMessage, dismissButtonLabel: viewModel.dismissButtonLabel @@ -844,7 +844,7 @@ public struct {ViewName}View: ViewModelView { Spacer() - Button(errorBinding: $error, asyncAction: submit) { + Button(error: $error, action: submit) { Text(viewModel.submitButtonLabel) .fontWeight(.semibold) .frame(minWidth: 120) @@ -854,9 +854,9 @@ public struct {ViewName}View: ViewModelView { } .padding() } - .task(errorBinding: $error) { try await loadItems() } + .task { do { try await loadItems() } catch { self.error = error } } .alert( - errorBinding: $error, + error: $error, title: viewModel.errorTitle, message: viewModel.errorMessage, dismissButtonLabel: viewModel.dismissButtonLabel @@ -972,7 +972,7 @@ private extension {ViewName}View { @State private var error: Error? .alert( - errorBinding: $error, + error: $error, title: viewModel.errorTitle, message: viewModel.errorMessage, dismissButtonLabel: viewModel.dismissButtonLabel @@ -981,26 +981,23 @@ private extension {ViewName}View { ### Async Actions -```swift -Button(errorBinding: $error, asyncAction: submit) { - Text(viewModel.submitLabel) -} +The async `Button` forms own the catch — the action is a thin throwing dispatch, and the thrown error lands in the `error:` binding: -private func submit() async { - do { - try await operations.submit() - } catch { - self.error = error - } - toggleRepaint() +```swift +Button(viewModel.submitLabel, error: $error) { + try await operations.submit() } ``` +Add `activity: $activity` (an `@State AsyncButtonActivity`) for re-entry refusal and running-state display; add `cancelTitle:` for tap-to-cancel. See the FOSMVVM DocC article *Async Actions and Error Presentation*. + ### Task on Appear +Catch into the binding by hand — an error-routing `.task` twin is queued in FOSUtilities but not yet shipped: + ```swift -.task(errorBinding: $error) { - try await loadData() +.task { + do { try await loadData() } catch { self.error = error } } private func loadData() async throws { diff --git a/.claude/skills/fosmvvm-ui-tests-generator/reference.md b/.claude/skills/fosmvvm-ui-tests-generator/reference.md index a233bf21..18117bab 100644 --- a/.claude/skills/fosmvvm-ui-tests-generator/reference.md +++ b/.claude/skills/fosmvvm-ui-tests-generator/reference.md @@ -561,11 +561,11 @@ public struct {ViewName}View: ViewModelView { contentView } } - .task(errorBinding: $error) { - try await loadData() + .task { + do { try await loadData() } catch { self.error = error } } .alert( - errorBinding: $error, + error: $error, title: viewModel.errorTitle, message: viewModel.errorMessage, dismissButtonLabel: viewModel.dismissButtonLabel @@ -579,7 +579,7 @@ public struct {ViewName}View: ViewModelView { VStack { Text(viewModel.title) - Button(errorBinding: $error, asyncAction: submit) { + Button(error: $error, action: submit) { Text(viewModel.submitButtonLabel) } .uiTestingIdentifier("submitButton") @@ -691,7 +691,7 @@ public struct {ViewName}View: ViewModelView { Spacer() - Button(errorBinding: $error, asyncAction: submit) { + Button(error: $error, action: submit) { Text(viewModel.submitButtonLabel) } .buttonStyle(PrimaryButtonStyle()) @@ -699,11 +699,11 @@ public struct {ViewName}View: ViewModelView { .disabled(selectedId == nil) } } - .task(errorBinding: $error) { - try await loadItems() + .task { + do { try await loadItems() } catch { self.error = error } } .alert( - errorBinding: $error, + error: $error, title: viewModel.errorTitle, message: viewModel.errorMessage, dismissButtonLabel: viewModel.dismissButtonLabel diff --git a/.claude/skills/fosutilities-api-catalog/SKILL.md b/.claude/skills/fosutilities-api-catalog/SKILL.md index 729c471f..bf9fe076 100644 --- a/.claude/skills/fosutilities-api-catalog/SKILL.md +++ b/.claude/skills/fosutilities-api-catalog/SKILL.md @@ -54,6 +54,8 @@ line via the `fosutilities-api-catalog-update` skill. - Attaching auth headers (bearer token, API key) to every client request, rotation-safe — or recovering when the server refuses one (refresh the credential and retry the request once) → `FOSMVVM.md § Protocols` - Declaring the data a server-rendered body needs — composable factory, load requirements, rooted scopes → `FOSMVVM.md § Protocols` - Rendering a ViewModel in SwiftUI — app setup, view binding, previews, form views → `FOSMVVM.md § SwiftUI Support` +- A Button whose action is `async throws` — error routing, re-entry refusal, running state, tap-to-cancel → `FOSMVVM.md § SwiftUI Support` +- Showing errors to the user — localized error messages on error types, one alert fed by a shared error binding → `FOSMVVM.md § Protocols`, `§ SwiftUI Support` - Versioning ViewModel properties, choosing deployment URLs, negotiating versions over HTTP → `FOSMVVM.md § Versioning` - Booting a Vapor server for MVVM — YAML localization store, environment, locale, Leaf rendering → `FOSMVVMVapor.md § Extensions` - Registering request routes (reads and CRUD writes) — including mounting one behind a credential/middleware group — or serving a request outside the guarded verbs → `FOSMVVMVapor.md § Vapor Support` diff --git a/.claude/skills/shared/api-catalog/FOSMVVM.md b/.claude/skills/shared/api-catalog/FOSMVVM.md index 767d3549..2e786b7b 100644 --- a/.claude/skills/shared/api-catalog/FOSMVVM.md +++ b/.claude/skills/shared/api-catalog/FOSMVVM.md @@ -375,6 +375,46 @@ caller (never `requestErrorHandler`). } ``` +### Give an error a user-presentable localized message — `LocalizableError` / `ClientHostedLocalizableError` / `localized()` +Reach for this when: an error will be shown to the user — canonically a +`ServerRequestError` conformer — and its message belongs in your YAML, not in +code. Compose it exactly like a ViewModel: `@Localized…` message property, +plumbing from the `@LocalizableError` macro. The error-alert modifier (see +*Present errors from the shared binding* in § SwiftUI Support) presents +conformers; non-conforming errors surface as their debug description. +Don't hand-write localization plumbing or build messages from raw strings — +the macro and the wrappers carry the keys. + +```swift +@LocalizableError +public struct QuotaError: ServerRequestError { + public let maximum: Int + + @LocalizedSubs(substitutions: \.subs) public var errorMessage + public var localizedMessage: any Localizable { errorMessage } + + private var subs: [String: any Localizable] { + ["maximum": LocalizableInt(value: maximum)] + } + + public init(maximum: Int) { self.maximum = maximum } +} +``` + +Localization happens as it does for a ViewModel — during the localizing +encode: the server's `ErrorMiddleware` resolves the message as it encodes the +thrown error, and the client decodes it already localized (`localizedMessage`'s +name states that expectation). An error created **on the client** declares +`@LocalizableError(options: [.clientHosted])` instead (emitting the +`ClientHostedLocalizableError` marker) and is resolved at presentation: +`localized(mvvmEnv:locale:)` runs the same round-trip a +`ClientHostedViewModelFactory` runs for a ViewModel, against the app's own +localization YAML, returning `nil` when it cannot (present the debug +description then); `localized(locale:localizationStore:)` is the underlying +throwing mechanism. An error type belongs to one localization domain — its +YAML lives where it is thrown; an unresolved message at presentation surfaces +as the debug description. + ### Placeholders for unused pieces — `EmptyQuery` / `EmptyFragment` / `EmptyBody` / `EmptyError` Reach for this when: a request has no query, fragment, body, or well-defined error — typealias the slot to the Empty type and the protocol's default @@ -882,6 +922,51 @@ Button(viewModel.cta) { save() } .accessibilityHint(viewModel.saveHint) ``` +### Run an async operation from a Button — `AsyncButtonActivity` / `cancel()` / `isRunning` +Reach for this when: a button's action is `async throws` — typically a +ViewModel operation performing a `ServerRequest`. Every `Localizable` Button +form (and the ViewBuilder forms) has an async twin: the thrown error lands in +a required `error:` binding (cleared on each launch — the binding holds the +outcome of the most recent invocation), and an optional `@State`-owned +`AsyncButtonActivity` refuses re-entry while a run is in flight and reports +`phase`/`isRunning` for `disabled(_:)` and progress display. +Don't wrap the call in your own `Task` inside a sync Button — double-taps +double-submit and the error handling gets re-invented per call site. + +```swift +@State private var activity = AsyncButtonActivity() +@State private var error: Error? + +Button(viewModel.saveTitle, activity: $activity, error: $error) { + try await viewModel.operations.save() +} +.disabled(activity.isRunning) +``` + +Providing the cancel face turns the button two-faced — tap to start, tap again +to cancel (cooperatively): pass `cancelTitle:` (optionally +`cancelSystemImage:`/`cancelImage:`) on the titled forms, or a phase-aware +label closure on the ViewBuilder forms; the activity binding is then required, +and `activity.cancel()` also cancels from outside the button (e.g. +`.onDisappear`). Share one activity across buttons to make them mutually +exclusive. Pair the `error:` binding with the error alert in the next entry. + +### Present errors from the shared binding — `alert()` +Reach for this when: showing the errors your async buttons (or any hand-written +catch) deposit in the screen's `@State var error: Error?` — one modifier is the +screen's single presentation point. Shows while non-`nil`, clears on dismissal, +localizes `LocalizableError` conformers through the client store (others show +their debug description), and fills the message's `%{error}` substitution point +at presentation. `title:` and `dismissButtonLabel:` are required and YAML-owned; +omitting `message:` presents the error message alone. + +```swift +.alert(error: $error, + title: viewModel.errorTitle, + message: viewModel.errorMessage, // "The save failed: %{error}" + dismissButtonLabel: viewModel.dismissTitle) +``` + ### Refresh a stale ViewModel binding — `invalidateBinding()` / `refreshedViewModel()` Reach for this when: a mutation makes a bound ViewModel out of date — `invalidateBinding($flag)` re-pulls from the server when the flag turns true; diff --git a/CHANGELOG.md b/CHANGELOG.md index 705473bb..87da0eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Async Button surface** (FOSMVVM) — every `Localizable` Button form (and the ViewBuilder + forms) gains an async twin: the action is `@Sendable () async throws -> Void`, a thrown + error lands in a required `error: Binding` (cleared on each launch — the binding + holds the outcome of the most recent invocation), and an optional caller-owned + `AsyncButtonActivity` adds deterministic re-entry refusal plus running-state for + `disabled(_:)` and progress display. Providing the cancel face — `cancelTitle:` (with + optional `cancelSystemImage:`/`cancelImage:`) or a phase-aware label closure — is what + enables cancellation: the button becomes two-faced, tap-to-start / tap-to-cancel, with a + `cancelling` phase while the work unwinds cooperatively and protection against taps aimed + at a face that just flipped. The titled forms are generated by the overload sweep's new + Stage 6b (`--emit-async-only` re-renders just that file from the checked-in SDK stamp). +- **`LocalizableError` + `@LocalizableError`** (FOSMVVM) — opt an error type into + user-presentable, YAML-localized messaging, composed exactly like a ViewModel: declare + the message with `@LocalizedString`/`@LocalizedSubs`, let the macro provide the + localization plumbing, and expose it as `localizedMessage`. The server's + `ErrorMiddleware` resolves the message as it encodes the thrown error, so the client + displays it with no localization store of its own. Client-*created* errors declare + `options: [.clientHosted]` (the `ClientHostedLocalizableError` marker) and resolve at + presentation via `localized(mvvmEnv:locale:)` — the same localizing round-trip a + `ClientHostedViewModelFactory` runs for a ViewModel, against the app's own YAML. +- **`alert(error:title:message:dismissButtonLabel:)`** (FOSMVVM) — one View modifier + presents whatever lands in the shared error binding: shows while non-`nil`, clears on + dismissal, localizes `LocalizableError` conformers through the client store (others show + their debug description), and fills the message's `%{error}` substitution point at + presentation. Designed as the single presentation point the async buttons' `error:` + parameter feeds. + ## [0.12.7] - 2026-08-20 ### Fixed diff --git a/Sources/FOSMVVM/FOSMVVM.docc/AsyncActionsAndErrors.md b/Sources/FOSMVVM/FOSMVVM.docc/AsyncActionsAndErrors.md new file mode 100644 index 00000000..220d85ba --- /dev/null +++ b/Sources/FOSMVVM/FOSMVVM.docc/AsyncActionsAndErrors.md @@ -0,0 +1,135 @@ +# Async Actions and Error Presentation + +Run a throwing async operation from a Button, route its failure to one screen-level binding, and present it localized. + +## Overview + +Most user-initiated actions in an FOSMVVM application are asynchronous and can fail: the View dispatches through ``ViewModelOperations`` (see ), the operation performs a ``ServerRequest``, and the server may answer with a typed error. + +Three pieces carry that flow from tap to alert, and they are designed to be wired together: + +- **Async `Button` forms** run a `@Sendable () async throws` action and deposit any thrown error into an `error:` binding. +- **`alert(error:title:message:dismissButtonLabel:)`** presents whatever lands in that binding, localized. +- **``LocalizableError``** gives your error types user-presentable, YAML-localized messages — composed exactly like a ``ViewModel``. + +## The Basic Wiring + +One `@State` error per screen, fed by every async button on it, presented by one alert: + +```swift +struct DocumentView: ViewModelView { + let viewModel: DocumentViewModel + + @State private var error: Error? + + var body: some View { + VStack { + // ... the document form ... + + Button(viewModel.saveTitle, error: $error) { + try await viewModel.operations.save() + } + } + .alert(error: $error, + title: viewModel.errorTitle, + message: viewModel.errorMessage, + dismissButtonLabel: viewModel.dismissTitle) + } +} +``` + +```yaml +en: + DocumentViewModel: + saveTitle: "Save" + errorTitle: "An Error Occurred" + errorMessage: "The operation failed: %{error}" + dismissTitle: "OK" +``` + +Tapping starts the action; a thrown error lands in `error` and the alert shows. Starting a new invocation clears the binding first — it always holds the outcome of the most recent invocation. Dismissing the alert clears it. + +If `message` contains an `%{error}` substitution point, the presented error's localized message fills it. Omitting `message:` presents the error's message alone. + +## Preventing Re-Entry + +Every `Localizable` Button form has an async twin, and each accepts an optional ``AsyncButtonActivity``. Without one, every tap starts a new concurrent invocation. With one, taps are refused while a run is in flight, and the running state is available for `disabled(_:)` or a progress indicator: + +```swift +@State private var activity = AsyncButtonActivity() + +Button(viewModel.saveTitle, activity: $activity, error: $error) { + try await viewModel.operations.save() +} +.disabled(activity.isRunning) +``` + +Share one activity between several buttons to make them mutually exclusive — while any of them runs, the others refuse to start. + +## Cancellable Operations + +Providing the cancel face turns a button two-faced: tap to start, tap again to cancel. On the titled forms that means `cancelTitle:` (optionally `cancelSystemImage:` or `cancelImage:`); on the ViewBuilder forms it means a phase-aware label closure. The `activity:` binding is required — cancellation needs state that survives view updates: + +```swift +Button(viewModel.uploadTitle, cancelTitle: viewModel.cancelTitle, + systemImage: "arrow.up", cancelSystemImage: "xmark", + activity: $activity, error: $error) { + try await viewModel.operations.upload() +} +``` + +While running, the button shows the cancel face and a tap cancels the operation; the phase is `.cancelling` until the work unwinds. A cancelled invocation writes nothing to `error`. Call ``AsyncButtonActivity/cancel()`` to cancel from outside the button — a toolbar ✕, or `.onDisappear { activity.cancel() }`. + +> Important: Cancellation is cooperative — the action must run cancellation-aware work (any `URLSession`-backed ``ServerRequest`` is) for the cancel face to take effect. + +For long-running *server* work, model the operation as a server-tracked resource and cancel it with another request — client-side cancellation only abandons the response. + +## Localizing Your Errors + +Conform an error to ``LocalizableError`` and the alert presents it in the user's language; errors that do not conform are presented with their debug description. Compose the conformer exactly like a ``ViewModel`` — a `@LocalizedString` or `@LocalizedSubs` message property, plumbing from the `@LocalizableError` macro: + +```swift +@LocalizableError +public struct QuotaError: ServerRequestError { + public let requested: Int + public let maximum: Int + + @LocalizedSubs(substitutions: \.subs) public var errorMessage + + public var localizedMessage: any Localizable { errorMessage } + + private var subs: [String: any Localizable] { [ + "requested": LocalizableInt(value: requested), + "maximum": LocalizableInt(value: maximum) + ] } + + public init(requested: Int, maximum: Int) { + self.requested = requested + self.maximum = maximum + } +} +``` + +```yaml +en: + QuotaError: + errorMessage: "Requested %{requested} exceeds the maximum of %{maximum}" +``` + +Localization happens as it does for a ``ViewModel`` — during the localizing encode. The server throws the error, its middleware resolves the message as it encodes the response, and the client decodes a message that is *already localized*. + +## One Localization Domain per Error Type + +An error type belongs to exactly one localization domain: its YAML lives where the error is thrown. + +- **Server-domain** errors (the canonical case, above) arrive at the client already resolved. +- **Client-domain** errors — created in an app that hosts its own localization YAML — declare `@LocalizableError(options: [.clientHosted])` and are resolved at presentation against ``MVVMEnvironment/resourceBundles``. The alert does this automatically; custom presentation surfaces call ``LocalizableError/localized(mvvmEnv:locale:)`` themselves. + +The same type never straddles domains. An error reaching presentation unresolved surfaces as its debug description — the symptom of a domain violation. + +## Topics + +- ``AsyncButtonActivity`` +- ``LocalizableError`` +- ``ClientHostedLocalizableError`` +- ``ViewModelOperations`` diff --git a/Sources/FOSMVVM/FOSMVVM.docc/FOSMVVM.md b/Sources/FOSMVVM/FOSMVVM.docc/FOSMVVM.md index 77c1f1b6..0f35d956 100644 --- a/Sources/FOSMVVM/FOSMVVM.docc/FOSMVVM.md +++ b/Sources/FOSMVVM/FOSMVVM.docc/FOSMVVM.md @@ -52,4 +52,5 @@ To enable Xcode Cloud builds to build using macros check out this [Stack Overflo - - - +- - diff --git a/Sources/FOSMVVM/Macros/Macros.swift b/Sources/FOSMVVM/Macros/Macros.swift index 58fd0018..948b6928 100644 --- a/Sources/FOSMVVM/Macros/Macros.swift +++ b/Sources/FOSMVVM/Macros/Macros.swift @@ -31,6 +31,19 @@ public macro FieldValidationModel() = #externalMacro( type: "FieldValidationModelMacro" ) +public enum LocalizableErrorOptions { + /// The error is created — and therefore localized — on the client; see + /// ``ClientHostedLocalizableError`` + case clientHosted +} + +@attached(extension, conformances: RetrievablePropertyNames, LocalizableError, ClientHostedLocalizableError) +@attached(member, names: named(propertyNames)) +public macro LocalizableError(options: Set = []) = #externalMacro( + module: "FOSMacros", + type: "LocalizableErrorMacro" +) + @attached(extension, conformances: RetrievablePropertyNames, ViewModel, ClientHostedViewModelFactory, RequestableViewModel, LiveViewModel) @attached(member, names: named(propertyNames), named(Request), named(AppState), named(model), named(modelSync), named(ClientHostedRequest), named(stub)) public macro ViewModel(options: Set = []) = #externalMacro( diff --git a/Sources/FOSMVVM/Protocols/LocalizableError.swift b/Sources/FOSMVVM/Protocols/LocalizableError.swift new file mode 100644 index 00000000..6e769d9a --- /dev/null +++ b/Sources/FOSMVVM/Protocols/LocalizableError.swift @@ -0,0 +1,161 @@ +// LocalizableError.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// Give an error a localized, user-presentable message — compose it like a ViewModel, and +/// the `alert(error:title:message:dismissButtonLabel:)` View modifier presents it in the +/// user's language +/// +/// ## Example +/// +/// Declare the message with the same `@Localized…` vocabulary a ViewModel uses; the +/// `@LocalizableError` macro provides the localization plumbing: +/// +/// ```swift +/// @LocalizableError +/// public struct DocumentSaveError: ServerRequestError { +/// @LocalizedString public var errorMessage +/// +/// public var localizedMessage: any Localizable { errorMessage } +/// +/// public init() {} +/// } +/// ``` +/// +/// ```yaml +/// en: +/// DocumentSaveError: +/// errorMessage: "The document could not be saved" +/// ``` +/// +/// A message that carries the error's own values uses `@LocalizedSubs`, again exactly as a +/// ViewModel would: +/// +/// ```swift +/// @LocalizableError +/// public struct QuotaError: ServerRequestError { +/// public let requested: Int +/// public let maximum: Int +/// +/// @LocalizedSubs(substitutions: \.subs) public var errorMessage +/// +/// public var localizedMessage: any Localizable { errorMessage } +/// +/// private var subs: [String: any Localizable] { [ +/// "requested": LocalizableInt(value: requested), +/// "maximum": LocalizableInt(value: maximum) +/// ] } +/// +/// public init(requested: Int, maximum: Int) { +/// self.requested = requested +/// self.maximum = maximum +/// } +/// } +/// ``` +/// +/// Localization happens exactly as it does for a ViewModel — during the localizing +/// encode. The server throws the error, `ErrorMiddleware` encodes it (resolving every +/// `@Localized…` property in the request's locale), and the client decodes a message that +/// is *already localized* — which is what ``localizedMessage``'s name promises. +/// +/// An error type belongs to exactly **one** localization domain: its YAML lives where the +/// error is thrown. A server-domain error arrives resolved as above. A **client-domain** +/// error — created in an app that hosts its own localization YAML — declares itself with +/// `@LocalizableError(options: [.clientHosted])` and is resolved at presentation (see +/// ``ClientHostedLocalizableError``). The same type never straddles domains — an error +/// reaching presentation unresolved surfaces as its debug description, the symptom of a +/// domain violation. +/// +/// Errors that do not conform are presented with their debug description — conforming is +/// what turns an error from developer output into user-facing copy. +public protocol LocalizableError: Error, RetrievablePropertyNames { + /// The error's user-facing message — named for the expectation that by the time + /// anyone reads it, localization has already happened (the localizing encode + /// resolved it, as with every ViewModel property) + var localizedMessage: any Localizable { get } +} + +/// A ``LocalizableError`` created — and therefore localized — on the client +/// +/// Declared via the macro flag, never by hand: +/// +/// ```swift +/// @LocalizableError(options: [.clientHosted]) +/// public struct ImportInterruptedError { +/// @LocalizedString public var errorMessage +/// +/// public var localizedMessage: any Localizable { errorMessage } +/// +/// public init() {} +/// } +/// ``` +/// +/// A client-created error never rides the server's localizing encode, so presentation +/// localizes it instead: ``LocalizableError/localized(mvvmEnv:locale:)`` runs the same +/// round-trip a `ClientHostedViewModelFactory` runs for a ViewModel, against the app's +/// own localization YAML (`MVVMEnvironment.resourceBundles`). The +/// `alert(error:title:message:dismissButtonLabel:)` modifier does this automatically. +public protocol ClientHostedLocalizableError: LocalizableError {} + +public extension LocalizableError { + /// A copy of the error with its `@Localized…` properties resolved for the locale — + /// the client-domain twin of the wire's `ErrorMiddleware` encode + /// + /// ```swift + /// let localized = try error.localized(locale: locale, localizationStore: store) + /// Text(localized.localizedMessage) + /// ``` + /// + /// This is the same localizing round-trip a `ClientHostedViewModelFactory` performs + /// for a ViewModel. Presentation code usually wants + /// ``localized(mvvmEnv:locale:)`` instead, which selects the store and degrades + /// gracefully. + func localized(locale: Locale, localizationStore: LocalizationStore) throws -> Self { + try toJSON(encoder: .localizingEncoder( + locale: locale, + localizationStore: localizationStore + )) + .fromJSON() + } + + /// The error, ready to present — client-hosted errors are resolved against the + /// client's localization store; everything else (wire-localized already) passes + /// through unchanged + /// + /// ```swift + /// guard let presentable = error.localized(mvvmEnv: mvvmEnv, locale: locale) else { + /// // present error's debug description instead + /// } + /// Text(presentable.localizedMessage) + /// ``` + /// + /// Returns `nil` when a ``ClientHostedLocalizableError`` cannot be resolved (no + /// client localization store, or its keys are absent) — a message you can read is + /// only ever the localized one, so present the error's debug description in that + /// case. + func localized(mvvmEnv: MVVMEnvironment, locale: Locale) -> Self? { + guard self is any ClientHostedLocalizableError else { + return self + } + guard let store = (try? mvvmEnv.clientLocalizationStore) ?? nil else { + print("LocalizableError: \(type(of: self)) is clientHosted but no client localization store is configured — see MVVMEnvironment.resourceBundles") + return nil + } + + return try? localized(locale: locale, localizationStore: store) + } +} diff --git a/Sources/FOSMVVM/SwiftUI Support/AsyncButtonActivity.swift b/Sources/FOSMVVM/SwiftUI Support/AsyncButtonActivity.swift new file mode 100644 index 00000000..dd607c50 --- /dev/null +++ b/Sources/FOSMVVM/SwiftUI Support/AsyncButtonActivity.swift @@ -0,0 +1,333 @@ +// AsyncButtonActivity.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(SwiftUI) +import SwiftUI + +/// The in-flight state of an async button — declare one as `@State` and hand it to the button +/// +/// ```swift +/// @State private var activity = AsyncButtonActivity() +/// @State private var error: Error? +/// +/// var body: some View { +/// Button(viewModel.uploadTitle, cancelTitle: viewModel.cancelTitle, +/// activity: $activity, error: $error) { +/// try await viewModel.operations.upload() +/// } +/// .disabled(activity.phase == .cancelling) +/// } +/// ``` +/// +/// While the button's work runs, ``phase`` is `.running`; a cancel-capable button that has +/// been asked to stop is `.cancelling` until its work unwinds. Use ``phase`` (or +/// ``isRunning``) to drive `disabled(_:)`, progress indicators, and phase-aware labels. +/// +/// Share one activity between several buttons to make them mutually exclusive — while any +/// of them is running, the others refuse to start, and (for cancel-capable buttons) any of +/// their faces can stop the running operation. +/// +/// Call ``cancel()`` to stop the running operation from outside the button — a toolbar ✕, +/// or `.onDisappear { activity.cancel() }`. +public struct AsyncButtonActivity: Sendable { + /// The lifecycle position of the button's current invocation + /// + /// `idle` — no work in flight; a tap starts the action. `running` — the action is in + /// flight. `cancelling` — cancellation was requested and the action is unwinding; the + /// button refuses taps until it returns to `idle`. + public enum Phase: Equatable, Sendable { + case idle + case running + case cancelling + } + + /// The current lifecycle position — drive `disabled(_:)`, progress indicators, and + /// phase-aware labels from it + public private(set) var phase: Phase = .idle + + /// **true** while the button should not accept new work (`phase` is `.running` or + /// `.cancelling`) + /// + /// ```swift + /// ProgressView() + /// .opacity(activity.isRunning ? 1 : 0) + /// ``` + public var isRunning: Bool { + phase != .idle + } + + /// Requests cancellation of the running operation + /// + /// ```swift + /// .onDisappear { activity.cancel() } + /// ``` + /// + /// The phase moves to `.cancelling` until the operation's task unwinds, then returns to + /// `.idle`. Does nothing unless the phase is `.running`. + /// + /// > Important: Cancellation is cooperative — the button's action must run + /// > cancellation-aware work (any `URLSession`-backed request is) for the request to + /// > take effect. + public mutating func cancel() { + guard phase == .running else { return } + + phase = .cancelling + task?.cancel() + } + + /// Creates an idle activity — the only constructible state + public init() {} + + // Sealed engine state: reachable only through the AsyncButtonEngine tap flow (and the + // engine's tests). Internal, never public — a forgeable `running` state or an exposed + // task handle would break every invariant the phases guarantee. + private var task: Task? + var lastIdleFlip: ContinuousClock.Instant? + + mutating func beginRun(_ task: Task) { + phase = .running + self.task = task + } + + mutating func finishRun(recordFlip: Bool) { + phase = .idle + task = nil + if recordFlip { + lastIdleFlip = ContinuousClock().now + } + } + + func isInRefractoryWindow(now: ContinuousClock.Instant) -> Bool { + guard let lastIdleFlip else { return false } + + return now < lastIdleFlip + AsyncButtonEngine.refractoryWindow + } +} + +/// The single home of the async-button tap semantics — every async `Button` init forwards +/// here, so a semantic change lands once and is never re-stamped. +enum AsyncButtonEngine { + enum Mode { + /// No cancel face: a tap while running has no observable effect + case refuse + /// Cancel face provided: a tap while running cancels the operation + case toggle + } + + /// The window after a toggle button's running→idle flip during which taps are presumed + /// aimed at the old (Cancel) face and discarded. Pinned by AsyncButtonActivityTests; + /// deliberately not part of the public contract. + static let refractoryWindow: Duration = .milliseconds(500) + + static func tapAction( + mode: Mode, + activity: Binding?, + error: Binding, + action: @escaping @Sendable () async throws -> Void + ) -> @MainActor () -> Void { + { @MainActor in + handleTap(mode: mode, activity: activity, error: error, action: action) + } + } + + /// `now` is an internal determinism seam for the refractory tests; production taps use + /// the default. + @MainActor static func handleTap( + mode: Mode, + activity: Binding?, + error: Binding, + now: ContinuousClock.Instant = ContinuousClock().now, + action: @escaping @Sendable () async throws -> Void + ) { + if let activity { + switch activity.wrappedValue.phase { + case .cancelling: + return + + case .running: + if mode == .toggle { + activity.wrappedValue.cancel() + } + return + + case .idle: + if mode == .toggle, activity.wrappedValue.isInRefractoryWindow(now: now) { + return + } + } + } + + error.wrappedValue = nil + + let task = Task { @MainActor in + var failure: (any Error)? + do { + try await action() + } catch let actionError { + failure = actionError + } + + if !Task.isCancelled, let failure { + error.wrappedValue = failure + } + activity?.wrappedValue.finishRun(recordFlip: mode == .toggle) + } + + activity?.wrappedValue.beginRun(task) + } +} + +public extension Button { + /// Async form of SwiftUI's `Button.init(action:label:)` — runs a throwing async action + /// and routes its error to a binding + /// + /// ```swift + /// @State private var error: Error? + /// + /// Button(error: $error) { + /// try await viewModel.operations.save() + /// } label: { + /// Text(viewModel.saveTitle) + /// } + /// .alert(error: $error, + /// title: viewModel.errorTitle, + /// dismissButtonLabel: viewModel.dismissTitle) + /// ``` + /// + /// Tapping starts the action; a thrown error lands in `error`. Starting a new + /// invocation clears `error` first — the binding always holds the outcome of the most + /// recent invocation. + /// + /// Pass `activity:` to prevent re-entry: while a run is in flight, further taps are + /// ignored, and `activity` reports the running state for `disabled(_:)` or a progress + /// indicator. Without `activity:`, every tap starts a new concurrent invocation. + /// + /// The action runs in a task that is not cancelled by the view disappearing; it runs to + /// completion. For user-cancellable work, use the initializers that take a phase-aware + /// label (or a `cancelTitle:`). For long-running *server* work, model the operation as + /// a server-tracked resource and cancel it with another request — client-side + /// cancellation only abandons the response. + nonisolated init( + activity: Binding? = nil, + error: Binding, + action: @escaping @Sendable () async throws -> Void, + @ViewBuilder label: () -> Label + ) { + self.init( + action: AsyncButtonEngine.tapAction( + mode: .refuse, + activity: activity, + error: error, + action: action + ), + label: label + ) + } + + /// Async form of SwiftUI's `Button.init(role:action:label:)` — runs a throwing async + /// action and routes its error to a binding + /// + /// See ``SwiftUI/Button/init(activity:error:action:label:)-swift.init`` for the + /// behavior contract; `role` is passed through to SwiftUI unchanged. + nonisolated init( + role: ButtonRole?, + activity: Binding? = nil, + error: Binding, + action: @escaping @Sendable () async throws -> Void, + @ViewBuilder label: () -> Label + ) { + self.init( + role: role, + action: AsyncButtonEngine.tapAction( + mode: .refuse, + activity: activity, + error: error, + action: action + ), + label: label + ) + } + + /// A two-faced async button: tap to start the operation, tap again to cancel it + /// + /// Providing the phase-aware label is what enables cancellation — you have taken + /// responsibility for rendering both faces: + /// + /// ```swift + /// @State private var activity = AsyncButtonActivity() + /// @State private var error: Error? + /// + /// Button(activity: $activity, error: $error) { + /// try await viewModel.operations.upload() + /// } label: { phase in + /// phase == .idle + /// ? Label(viewModel.uploadTitle, systemImage: "arrow.up") + /// : Label(viewModel.cancelTitle, systemImage: "xmark") + /// } + /// ``` + /// + /// While idle the button starts the action when tapped. While running, a tap cancels + /// the operation; the button then refuses taps until the work unwinds + /// (`activity.phase == .cancelling`). A cancelled invocation writes nothing to `error`. + /// A tap arriving in the instant after the button changes faces is ignored rather than + /// misread against the old face. + /// + /// > Important: Cancellation is cooperative. Your action must run cancellation-aware + /// > work (any `URLSession`-backed `ServerRequest` is) for the cancel face to take + /// > effect. + nonisolated init( + activity: Binding, + error: Binding, + action: @escaping @Sendable () async throws -> Void, + @ViewBuilder label: (AsyncButtonActivity.Phase) -> Label + ) { + self.init( + action: AsyncButtonEngine.tapAction( + mode: .toggle, + activity: activity, + error: error, + action: action + ), + label: { label(activity.wrappedValue.phase) } + ) + } + + /// A two-faced async button with a role: tap to start the operation, tap again to + /// cancel it + /// + /// See ``SwiftUI/Button/init(activity:error:action:label:)-swift.init`` (the phase-aware + /// label form) for the behavior contract; `role` is passed through to SwiftUI + /// unchanged. + nonisolated init( + role: ButtonRole?, + activity: Binding, + error: Binding, + action: @escaping @Sendable () async throws -> Void, + @ViewBuilder label: (AsyncButtonActivity.Phase) -> Label + ) { + self.init( + role: role, + action: AsyncButtonEngine.tapAction( + mode: .toggle, + activity: activity, + error: error, + action: action + ), + label: { label(activity.wrappedValue.phase) } + ) + } +} +#endif diff --git a/Sources/FOSMVVM/SwiftUI Support/ErrorAlert.swift b/Sources/FOSMVVM/SwiftUI Support/ErrorAlert.swift new file mode 100644 index 00000000..95085ac4 --- /dev/null +++ b/Sources/FOSMVVM/SwiftUI Support/ErrorAlert.swift @@ -0,0 +1,140 @@ +// ErrorAlert.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(SwiftUI) +import SwiftUI + +public extension View { + /// Presents a localized alert whenever an error lands in the binding + /// + /// ```swift + /// @State private var error: Error? + /// + /// var body: some View { + /// DocumentForm(viewModel: viewModel, error: $error) + /// .alert(error: $error, + /// title: viewModel.errorTitle, + /// message: viewModel.errorMessage, + /// dismissButtonLabel: viewModel.dismissTitle) + /// } + /// ``` + /// + /// ```yaml + /// en: + /// DocumentViewModel: + /// errorTitle: "An Error Occurred" + /// errorMessage: "The operation failed: %{error}" + /// dismissTitle: "OK" + /// ``` + /// + /// The alert shows while `error` is non-`nil`; dismissing it clears the binding. If + /// `message` contains an `%{error}` substitution point, the presented error's message + /// fills it: a ``LocalizableError`` contributes its ``LocalizableError/localizedMessage`` + /// (a ``ClientHostedLocalizableError`` is resolved against the client store first); + /// any other error contributes its debug description. Omitting `message` presents the + /// error's message alone. + /// + /// Feed one binding from every async button on the screen — this modifier is the single + /// presentation point the buttons' `error:` parameter is designed to pair with. + nonisolated func alert( + error: Binding, + title: some Localizable, + message: LocalizableString? = nil, + dismissButtonLabel: some Localizable + ) -> some View { + modifier(ErrorAlertModifier( + error: error, + title: title, + message: message ?? .constant("%{error}"), + dismissButtonLabel: dismissButtonLabel + )) + } +} + +private struct ErrorAlertModifier: ViewModifier { + let error: Binding + let title: Title + let message: LocalizableString + let dismissButtonLabel: Dismiss + + @Environment(\.locale) private var locale + @Environment(MVVMEnvironment.self) private var installedEnv: MVVMEnvironment? + + func body(content: Content) -> some View { + content.alert( + title, + isPresented: isPresented, + presenting: error.wrappedValue, + actions: { _ in + Button(dismissButtonLabel) { + error.wrappedValue = nil + } + }, + message: { presentedError in + Text(message.bind(substitutions: [ + "error": ErrorAlertMessage.substitutionValue( + for: presentedError, + mvvmEnv: installedEnv, + locale: locale + ) + ])) + } + ) + } + + private var isPresented: Binding { + Binding( + get: { error.wrappedValue != nil }, + set: { showing in + if !showing { + error.wrappedValue = nil + } + } + ) + } +} + +/// Maps the presented error to the `Localizable` that fills the message's `%{error}` +/// substitution point +/// +/// > Factored off the View layer so the ladder is testable. +enum ErrorAlertMessage { + static func substitutionValue( + for error: any Error, + mvvmEnv: MVVMEnvironment?, + locale: Locale + ) -> any Localizable { + guard let localizable = error as? any LocalizableError else { + return LocalizableString.constant("\(error)") + } + + guard let mvvmEnv else { + if localizable is any ClientHostedLocalizableError { + print("ErrorAlert: no MVVMEnvironment installed — presenting \(type(of: error))'s debug description") + return LocalizableString.constant("\(error)") + } + return localizable.localizedMessage + } + + guard let presentable = localizable.localized(mvvmEnv: mvvmEnv, locale: locale) else { + print("ErrorAlert: unable to localize \(type(of: error)) — presenting its debug description") + return LocalizableString.constant("\(error)") + } + + return presentable.localizedMessage + } +} +#endif diff --git a/Sources/FOSMVVM/SwiftUI Support/Generated/Button+AsyncAction.swift b/Sources/FOSMVVM/SwiftUI Support/Generated/Button+AsyncAction.swift new file mode 100644 index 00000000..9ec7d54e --- /dev/null +++ b/Sources/FOSMVVM/SwiftUI Support/Generated/Button+AsyncAction.swift @@ -0,0 +1,339 @@ +// Button+AsyncAction.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// GENERATED FILE — DO NOT EDIT +// Generated by scripts/localizable-overload-sweep.swift (Stage 6b — FOS-designed async twins) +// SDKs: macosx 26.5 | iphoneos 26.5 | appletvos 26.5 | watchos 26.5 | xros 26.5 +// Regenerate: swift scripts/localizable-overload-sweep.swift [--emit-async-only] + +#if canImport(SwiftUI) +import DeveloperToolsSupport +import SwiftUI + +public extension Button where Label == SwiftUI.Label { + /// Async form of the `Localizable` Button — runs a throwing async action and routes + /// its error to a binding + /// + /// ## Example + /// + /// ```swift + /// @ViewModel public struct MyViewModel: RequestableViewModel { + /// @LocalizedString public var saveTitle + /// ... + /// } + /// + /// @State private var error: Error? + /// + /// Button(viewModel.saveTitle, systemImage: "tray.and.arrow.down", error: $error) { + /// try await viewModel.operations.save() + /// } + /// .alert(error: $error, + /// title: viewModel.errorTitle, + /// dismissButtonLabel: viewModel.dismissTitle) + /// ``` + /// + /// Tapping starts the action; a thrown error lands in `error`, and every launch + /// clears it first — the binding holds the outcome of the most recent invocation. + /// Pass `activity:` to refuse taps while a run is in flight; without it every tap + /// starts a new concurrent invocation. The task runs to completion — for + /// user-cancellable work use the `cancelTitle:` forms. + /// + /// - Parameters: + /// - localizable: The ``Localizable`` title to display. + /// - defaultValue: Fallback text used if localization did not complete. + /// - activity: Optional caller-owned ``AsyncButtonActivity`` enabling re-entry + /// refusal and running-state display. + /// - error: Receives the outcome of the most recent invocation. + nonisolated init(_ localizable: some Localizable, defaultValue: String? = nil, systemImage: String, role: ButtonRole?, activity: Binding? = nil, error: Binding, action: @escaping @Sendable () async throws -> Void) { + self.init(role: role, activity: activity, error: error, action: action, label: { + SwiftUI.Label(localizable.defaultedLocalizedString(defaultValue: defaultValue), systemImage: systemImage) + }) + } + + /// A two-faced async button: tap to start the operation, tap again to cancel it + /// + /// While idle the button shows `localizable`; while running it shows `cancelTitle` and + /// a tap cancels the operation. During the unwind (`activity.phase == .cancelling`) + /// taps are refused. A cancelled invocation writes nothing to `error`; a failed one + /// deposits its error there, and every launch clears it first. + /// + /// > Important: Cancellation is cooperative — the action must run cancellation-aware + /// > work (any `URLSession`-backed `ServerRequest` is) for the cancel face to take + /// > effect. + /// + /// - Parameters: + /// - localizable: The ``Localizable`` idle-face title. + /// - defaultValue: Fallback text used if localization did not complete. + /// - cancelTitle: The ``Localizable`` title shown while the operation runs. + /// - cancelDefaultValue: Fallback text for `cancelTitle`. + /// - cancelSystemImage: The running-face symbol; `nil` keeps `systemImage`. + /// - activity: The caller-owned ``AsyncButtonActivity`` (required — cancellation + /// needs state that survives re-renders). + /// - error: Receives the outcome of the most recent invocation. + nonisolated init(_ localizable: some Localizable, defaultValue: String? = nil, systemImage: String, cancelTitle: some Localizable, cancelDefaultValue: String? = nil, cancelSystemImage: String? = nil, role: ButtonRole?, activity: Binding, error: Binding, action: @escaping @Sendable () async throws -> Void) { + self.init(role: role, activity: activity, error: error, action: action, label: { phase in + SwiftUI.Label( + phase == .idle ? localizable.defaultedLocalizedString(defaultValue: defaultValue) : cancelTitle.defaultedLocalizedString(defaultValue: cancelDefaultValue), + systemImage: phase == .idle ? systemImage : (cancelSystemImage ?? systemImage) + ) + }) + } + + /// Async form of the `Localizable` Button — runs a throwing async action and routes + /// its error to a binding + /// + /// Tapping starts the action; a thrown error lands in `error`, and every launch + /// clears it first — the binding holds the outcome of the most recent invocation. + /// Pass `activity:` to refuse taps while a run is in flight; without it every tap + /// starts a new concurrent invocation. The task runs to completion — for + /// user-cancellable work use the `cancelTitle:` forms. + /// + /// - Parameters: + /// - localizable: The ``Localizable`` title to display. + /// - defaultValue: Fallback text used if localization did not complete. + /// - activity: Optional caller-owned ``AsyncButtonActivity`` enabling re-entry + /// refusal and running-state display. + /// - error: Receives the outcome of the most recent invocation. + nonisolated init(_ localizable: some Localizable, defaultValue: String? = nil, systemImage: String, activity: Binding? = nil, error: Binding, action: @escaping @Sendable () async throws -> Void) { + self.init(activity: activity, error: error, action: action, label: { + SwiftUI.Label(localizable.defaultedLocalizedString(defaultValue: defaultValue), systemImage: systemImage) + }) + } + + /// A two-faced async button: tap to start the operation, tap again to cancel it + /// + /// While idle the button shows `localizable`; while running it shows `cancelTitle` and + /// a tap cancels the operation. During the unwind (`activity.phase == .cancelling`) + /// taps are refused. A cancelled invocation writes nothing to `error`; a failed one + /// deposits its error there, and every launch clears it first. + /// + /// > Important: Cancellation is cooperative — the action must run cancellation-aware + /// > work (any `URLSession`-backed `ServerRequest` is) for the cancel face to take + /// > effect. + /// + /// - Parameters: + /// - localizable: The ``Localizable`` idle-face title. + /// - defaultValue: Fallback text used if localization did not complete. + /// - cancelTitle: The ``Localizable`` title shown while the operation runs. + /// - cancelDefaultValue: Fallback text for `cancelTitle`. + /// - cancelSystemImage: The running-face symbol; `nil` keeps `systemImage`. + /// - activity: The caller-owned ``AsyncButtonActivity`` (required — cancellation + /// needs state that survives re-renders). + /// - error: Receives the outcome of the most recent invocation. + nonisolated init(_ localizable: some Localizable, defaultValue: String? = nil, systemImage: String, cancelTitle: some Localizable, cancelDefaultValue: String? = nil, cancelSystemImage: String? = nil, activity: Binding, error: Binding, action: @escaping @Sendable () async throws -> Void) { + self.init(activity: activity, error: error, action: action, label: { phase in + SwiftUI.Label( + phase == .idle ? localizable.defaultedLocalizedString(defaultValue: defaultValue) : cancelTitle.defaultedLocalizedString(defaultValue: cancelDefaultValue), + systemImage: phase == .idle ? systemImage : (cancelSystemImage ?? systemImage) + ) + }) + } + + /// Async form of the `Localizable` Button — runs a throwing async action and routes + /// its error to a binding + /// + /// Tapping starts the action; a thrown error lands in `error`, and every launch + /// clears it first — the binding holds the outcome of the most recent invocation. + /// Pass `activity:` to refuse taps while a run is in flight; without it every tap + /// starts a new concurrent invocation. The task runs to completion — for + /// user-cancellable work use the `cancelTitle:` forms. + /// + /// - Parameters: + /// - localizable: The ``Localizable`` title to display. + /// - defaultValue: Fallback text used if localization did not complete. + /// - activity: Optional caller-owned ``AsyncButtonActivity`` enabling re-entry + /// refusal and running-state display. + /// - error: Receives the outcome of the most recent invocation. + nonisolated init(_ localizable: some Localizable, defaultValue: String? = nil, image: ImageResource, role: ButtonRole?, activity: Binding? = nil, error: Binding, action: @escaping @Sendable () async throws -> Void) { + self.init(role: role, activity: activity, error: error, action: action, label: { + SwiftUI.Label(localizable.defaultedLocalizedString(defaultValue: defaultValue), image: image) + }) + } + + /// A two-faced async button: tap to start the operation, tap again to cancel it + /// + /// While idle the button shows `localizable`; while running it shows `cancelTitle` and + /// a tap cancels the operation. During the unwind (`activity.phase == .cancelling`) + /// taps are refused. A cancelled invocation writes nothing to `error`; a failed one + /// deposits its error there, and every launch clears it first. + /// + /// > Important: Cancellation is cooperative — the action must run cancellation-aware + /// > work (any `URLSession`-backed `ServerRequest` is) for the cancel face to take + /// > effect. + /// + /// - Parameters: + /// - localizable: The ``Localizable`` idle-face title. + /// - defaultValue: Fallback text used if localization did not complete. + /// - cancelTitle: The ``Localizable`` title shown while the operation runs. + /// - cancelDefaultValue: Fallback text for `cancelTitle`. + /// - cancelImage: The running-face image; `nil` keeps `image`. + /// - activity: The caller-owned ``AsyncButtonActivity`` (required — cancellation + /// needs state that survives re-renders). + /// - error: Receives the outcome of the most recent invocation. + nonisolated init(_ localizable: some Localizable, defaultValue: String? = nil, image: ImageResource, cancelTitle: some Localizable, cancelDefaultValue: String? = nil, cancelImage: ImageResource? = nil, role: ButtonRole?, activity: Binding, error: Binding, action: @escaping @Sendable () async throws -> Void) { + self.init(role: role, activity: activity, error: error, action: action, label: { phase in + SwiftUI.Label( + phase == .idle ? localizable.defaultedLocalizedString(defaultValue: defaultValue) : cancelTitle.defaultedLocalizedString(defaultValue: cancelDefaultValue), + image: phase == .idle ? image : (cancelImage ?? image) + ) + }) + } + + /// Async form of the `Localizable` Button — runs a throwing async action and routes + /// its error to a binding + /// + /// Tapping starts the action; a thrown error lands in `error`, and every launch + /// clears it first — the binding holds the outcome of the most recent invocation. + /// Pass `activity:` to refuse taps while a run is in flight; without it every tap + /// starts a new concurrent invocation. The task runs to completion — for + /// user-cancellable work use the `cancelTitle:` forms. + /// + /// - Parameters: + /// - localizable: The ``Localizable`` title to display. + /// - defaultValue: Fallback text used if localization did not complete. + /// - activity: Optional caller-owned ``AsyncButtonActivity`` enabling re-entry + /// refusal and running-state display. + /// - error: Receives the outcome of the most recent invocation. + nonisolated init(_ localizable: some Localizable, defaultValue: String? = nil, image: ImageResource, activity: Binding? = nil, error: Binding, action: @escaping @Sendable () async throws -> Void) { + self.init(activity: activity, error: error, action: action, label: { + SwiftUI.Label(localizable.defaultedLocalizedString(defaultValue: defaultValue), image: image) + }) + } + + /// A two-faced async button: tap to start the operation, tap again to cancel it + /// + /// While idle the button shows `localizable`; while running it shows `cancelTitle` and + /// a tap cancels the operation. During the unwind (`activity.phase == .cancelling`) + /// taps are refused. A cancelled invocation writes nothing to `error`; a failed one + /// deposits its error there, and every launch clears it first. + /// + /// > Important: Cancellation is cooperative — the action must run cancellation-aware + /// > work (any `URLSession`-backed `ServerRequest` is) for the cancel face to take + /// > effect. + /// + /// - Parameters: + /// - localizable: The ``Localizable`` idle-face title. + /// - defaultValue: Fallback text used if localization did not complete. + /// - cancelTitle: The ``Localizable`` title shown while the operation runs. + /// - cancelDefaultValue: Fallback text for `cancelTitle`. + /// - cancelImage: The running-face image; `nil` keeps `image`. + /// - activity: The caller-owned ``AsyncButtonActivity`` (required — cancellation + /// needs state that survives re-renders). + /// - error: Receives the outcome of the most recent invocation. + nonisolated init(_ localizable: some Localizable, defaultValue: String? = nil, image: ImageResource, cancelTitle: some Localizable, cancelDefaultValue: String? = nil, cancelImage: ImageResource? = nil, activity: Binding, error: Binding, action: @escaping @Sendable () async throws -> Void) { + self.init(activity: activity, error: error, action: action, label: { phase in + SwiftUI.Label( + phase == .idle ? localizable.defaultedLocalizedString(defaultValue: defaultValue) : cancelTitle.defaultedLocalizedString(defaultValue: cancelDefaultValue), + image: phase == .idle ? image : (cancelImage ?? image) + ) + }) + } +} + +public extension Button where Label == Text { + /// Async form of the `Localizable` Button — runs a throwing async action and routes + /// its error to a binding + /// + /// Tapping starts the action; a thrown error lands in `error`, and every launch + /// clears it first — the binding holds the outcome of the most recent invocation. + /// Pass `activity:` to refuse taps while a run is in flight; without it every tap + /// starts a new concurrent invocation. The task runs to completion — for + /// user-cancellable work use the `cancelTitle:` forms. + /// + /// - Parameters: + /// - localizable: The ``Localizable`` title to display. + /// - defaultValue: Fallback text used if localization did not complete. + /// - activity: Optional caller-owned ``AsyncButtonActivity`` enabling re-entry + /// refusal and running-state display. + /// - error: Receives the outcome of the most recent invocation. + nonisolated init(_ localizable: some Localizable, defaultValue: String? = nil, role: ButtonRole?, activity: Binding? = nil, error: Binding, action: @escaping @Sendable () async throws -> Void) { + self.init(role: role, activity: activity, error: error, action: action, label: { + Text(localizable.defaultedLocalizedString(defaultValue: defaultValue)) + }) + } + + /// A two-faced async button: tap to start the operation, tap again to cancel it + /// + /// While idle the button shows `localizable`; while running it shows `cancelTitle` and + /// a tap cancels the operation. During the unwind (`activity.phase == .cancelling`) + /// taps are refused. A cancelled invocation writes nothing to `error`; a failed one + /// deposits its error there, and every launch clears it first. + /// + /// > Important: Cancellation is cooperative — the action must run cancellation-aware + /// > work (any `URLSession`-backed `ServerRequest` is) for the cancel face to take + /// > effect. + /// + /// - Parameters: + /// - localizable: The ``Localizable`` idle-face title. + /// - defaultValue: Fallback text used if localization did not complete. + /// - cancelTitle: The ``Localizable`` title shown while the operation runs. + /// - cancelDefaultValue: Fallback text for `cancelTitle`. + /// - activity: The caller-owned ``AsyncButtonActivity`` (required — cancellation + /// needs state that survives re-renders). + /// - error: Receives the outcome of the most recent invocation. + nonisolated init(_ localizable: some Localizable, defaultValue: String? = nil, cancelTitle: some Localizable, cancelDefaultValue: String? = nil, role: ButtonRole?, activity: Binding, error: Binding, action: @escaping @Sendable () async throws -> Void) { + self.init(role: role, activity: activity, error: error, action: action, label: { phase in + Text(phase == .idle ? localizable.defaultedLocalizedString(defaultValue: defaultValue) : cancelTitle.defaultedLocalizedString(defaultValue: cancelDefaultValue)) + }) + } + + /// Async form of the `Localizable` Button — runs a throwing async action and routes + /// its error to a binding + /// + /// Tapping starts the action; a thrown error lands in `error`, and every launch + /// clears it first — the binding holds the outcome of the most recent invocation. + /// Pass `activity:` to refuse taps while a run is in flight; without it every tap + /// starts a new concurrent invocation. The task runs to completion — for + /// user-cancellable work use the `cancelTitle:` forms. + /// + /// - Parameters: + /// - localizable: The ``Localizable`` title to display. + /// - defaultValue: Fallback text used if localization did not complete. + /// - activity: Optional caller-owned ``AsyncButtonActivity`` enabling re-entry + /// refusal and running-state display. + /// - error: Receives the outcome of the most recent invocation. + nonisolated init(_ localizable: some Localizable, defaultValue: String? = nil, activity: Binding? = nil, error: Binding, action: @escaping @Sendable () async throws -> Void) { + self.init(activity: activity, error: error, action: action, label: { + Text(localizable.defaultedLocalizedString(defaultValue: defaultValue)) + }) + } + + /// A two-faced async button: tap to start the operation, tap again to cancel it + /// + /// While idle the button shows `localizable`; while running it shows `cancelTitle` and + /// a tap cancels the operation. During the unwind (`activity.phase == .cancelling`) + /// taps are refused. A cancelled invocation writes nothing to `error`; a failed one + /// deposits its error there, and every launch clears it first. + /// + /// > Important: Cancellation is cooperative — the action must run cancellation-aware + /// > work (any `URLSession`-backed `ServerRequest` is) for the cancel face to take + /// > effect. + /// + /// - Parameters: + /// - localizable: The ``Localizable`` idle-face title. + /// - defaultValue: Fallback text used if localization did not complete. + /// - cancelTitle: The ``Localizable`` title shown while the operation runs. + /// - cancelDefaultValue: Fallback text for `cancelTitle`. + /// - activity: The caller-owned ``AsyncButtonActivity`` (required — cancellation + /// needs state that survives re-renders). + /// - error: Receives the outcome of the most recent invocation. + nonisolated init(_ localizable: some Localizable, defaultValue: String? = nil, cancelTitle: some Localizable, cancelDefaultValue: String? = nil, activity: Binding, error: Binding, action: @escaping @Sendable () async throws -> Void) { + self.init(activity: activity, error: error, action: action, label: { phase in + Text(phase == .idle ? localizable.defaultedLocalizedString(defaultValue: defaultValue) : cancelTitle.defaultedLocalizedString(defaultValue: cancelDefaultValue)) + }) + } +} +#endif diff --git a/Sources/FOSMacros/FOSMacros.swift b/Sources/FOSMacros/FOSMacros.swift index c1eb4c7a..e8f883b8 100644 --- a/Sources/FOSMacros/FOSMacros.swift +++ b/Sources/FOSMacros/FOSMacros.swift @@ -23,6 +23,7 @@ import SwiftSyntaxMacros struct FOSMacros: CompilerPlugin { let providingMacros: [Macro.Type] = [ FieldValidationModelMacro.self, + LocalizableErrorMacro.self, ViewModelMacro.self, ViewModelFactoryMacro.self, ViewModelFactoryMethodMacro.self diff --git a/Sources/FOSMacros/LocalizableErrorMacro.swift b/Sources/FOSMacros/LocalizableErrorMacro.swift new file mode 100644 index 00000000..1aa77d83 --- /dev/null +++ b/Sources/FOSMacros/LocalizableErrorMacro.swift @@ -0,0 +1,210 @@ +// LocalizableErrorMacro.swift +// +// Copyright 2025 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if os(macOS) || os(Linux) || os(Windows) +public import SwiftSyntax +public import SwiftSyntaxMacros +import SwiftSyntaxBuilder + +public enum LocalizableErrorMacroError: Error, CustomDebugStringConvertible { + case onlyStructs + + public var debugDescription: String { + switch self { + case .onlyStructs: + "LocalizableErrorMacroError: @LocalizableError can only be applied to structs" + } + } +} + +// Example: +// +// @LocalizableError +// struct MyError/* : LocalizableError <- No need to add, will be added automatically */ { +// @LocalizedString public var errorMessage +// +// public var localizedMessage: any Localizable { errorMessage } +// +// // This function is generated by the macro +// public func propertyNames() -> [LocalizableId: String] { [ +// errorMessage.localizationId: "errorMessage" +// ] } +// } +// + +/// Mirrors FOSMVVM.LocalizableErrorOptions for attribute parsing — keep the two in sync +private enum LocalizableErrorOptions: String { + case clientHosted +} + +public struct LocalizableErrorMacro: ExtensionMacro, MemberMacro { + /// Keep in sync with ViewModelMacro.knownLocalizedPropertyNames and + /// FieldValidationModelMacro.knownLocalizedPropertyNames + private static let knownLocalizedPropertyNames = [ + // _LocalizedProperty + "LocalizedString", + "LocalizedInt", + "LocalizedDouble", + "LocalizedDate", + "LocalizedCompoundString", + "LocalizedSubs", + + // _LocalizedArrayProperty + "LocalizedStrings" + ] + + // MARK: Extension Macro Protocol + + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard node.attributeName.description.trimmingCharacters(in: .whitespaces) == "LocalizableError" else { + return [] + } + + // Ensure the declaration is a struct + guard let structDecl = declaration.as(StructDeclSyntax.self) else { + throw LocalizableErrorMacroError.onlyStructs + } + + var result = [ExtensionDeclSyntax]() + + // .clientHosted lists the marker BESIDE the base conformance in one clause — a + // macro-emitted extension registers only the conformances it spells out; the + // marker's refinement of LocalizableError is not implied through it. + let errorConformances = node.errorOptions.contains(.clientHosted) + ? ["LocalizableError", "ClientHostedLocalizableError"] + : ["LocalizableError"] + + // Skip extension generation if the conformance is explicitly declared + // Note: Indirect conformances (via other protocols) are not detected due to SwiftSyntax limitations + if !structDecl.conformsTo("LocalizableError"), !structDecl.conformsTo("ClientHostedLocalizableError") { + let extensionDecl = ExtensionDeclSyntax( + extendedType: type, + inheritanceClause: InheritanceClauseSyntax { + for conformance in errorConformances { + InheritedTypeSyntax(type: TypeSyntax(stringLiteral: conformance)) + } + }, + memberBlock: MemberBlockSyntax(members: []) + ) + + result.append(extensionDecl) + } + + // Skip extension generation if RetrievablePropertyNames is explicitly declared + // Note: Indirect conformances (via other protocols) are not detected due to SwiftSyntax limitations + if !structDecl.conformsTo("RetrievablePropertyNames") { + let extensionDecl = ExtensionDeclSyntax( + extendedType: type, + inheritanceClause: InheritanceClauseSyntax { + InheritedTypeSyntax(type: TypeSyntax(stringLiteral: "RetrievablePropertyNames")) + }, + memberBlock: MemberBlockSyntax(members: []) + ) + + result.append(extensionDecl) + } + + return result + } + + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard node.attributeName.description.trimmingCharacters(in: .whitespaces) == "LocalizableError" else { + return [] + } + + // Ensure the declaration is a struct + guard let structDecl = declaration.as(StructDeclSyntax.self) else { + throw LocalizableErrorMacroError.onlyStructs + } + + // Collect properties with _LocalizedProperty wrapper + let properties = structDecl.memberBlock.members.compactMap { member -> (name: String, id: String)? in + guard let varDecl = member.decl.as(VariableDeclSyntax.self), + let binding = varDecl.bindings.first, + let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text + else { + return nil + } + + let hasLocalizedWrapper = varDecl.attributes.contains { attribute in + guard let attr = attribute.as(AttributeSyntax.self) else { return false } + let attrName = attr.attributeName.description.trimmingCharacters(in: .whitespaces) + return Self.knownLocalizedPropertyNames.contains(attrName) + } + + guard hasLocalizedWrapper else { return nil } + + return (name: identifier, id: "\(identifier).localizationId") + } + + var newDecls: [DeclSyntax] = [] + + // Generate the propertyNames function + var pairs = properties.map { "_\($0.id): \"\($0.name)\"" }.joined(separator: ", ") + if pairs.isEmpty { + pairs = ":" + } + let propertyNamesDecl = try FunctionDeclSyntax( + """ + public func propertyNames() -> [LocalizableId: String] { + [\(raw: pairs)] + } + """ + ) + newDecls.append(DeclSyntax(propertyNamesDecl)) + + return newDecls + } +} + +private extension AttributeSyntax { + var errorOptions: Set { + if let argumentList = arguments?.as(LabeledExprListSyntax.self), + let optionsElement = argumentList.first(where: { $0.label?.text == "options" }), + let arrayExpr = optionsElement.expression.as(ArrayExprSyntax.self) { + Set(arrayExpr.elements.map(\.expression) + .compactMap { $0.as(MemberAccessExprSyntax.self)?.declName } + .map(\.baseName.text) + .compactMap { LocalizableErrorOptions(rawValue: $0) }) + } else { + [] + } + } +} + +private extension StructDeclSyntax { + func conformsTo(_ protocolName: String) -> Bool { + inheritanceClause?.inheritedTypes.contains { inheritedType in + if let identifierType = inheritedType.type.as(IdentifierTypeSyntax.self) { + identifierType.name.text == protocolName + } else { + false + } + } ?? false + } +} +#endif diff --git a/Tests/FOSMVVMTests/Localization/LocalizableErrorTests.swift b/Tests/FOSMVVMTests/Localization/LocalizableErrorTests.swift new file mode 100644 index 00000000..23f6ad0b --- /dev/null +++ b/Tests/FOSMVVMTests/Localization/LocalizableErrorTests.swift @@ -0,0 +1,147 @@ +// LocalizableErrorTests.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FOSFoundation +import FOSMVVM +import FOSTesting +import Foundation +import Testing + +/// A `LocalizableError` localizes exactly as a ViewModel does — during the localizing +/// encode. These tests round-trip the whole error to model the wire flow: the server +/// throws, `ErrorMiddleware` encodes with the localizing encoder (resolving the +/// `@Localized…` message), and the client decodes an already-localized value it displays +/// with no localization store of its own. +struct LocalizableErrorTests: LocalizableTestCase { + @Test func wireRoundTrip_deliversTheLocalizedMessage() throws { + let thrown = TestQuotaError(requested: 5, maximum: 3) + + // Server leg: ErrorMiddleware encodes the error with the localizing encoder + // Client leg: decode the typed error — no store anywhere after this point + let received: TestQuotaError = try thrown + .toJSON(encoder: encoder()) + .fromJSON() + + #expect(received.localizedMessage.localizationStatus == .localized) + #expect(try received.localizedMessage.localizedString == "Requested 5 exceeds the maximum of 3") + } + + @Test func wireRoundTrip_localizesInTheRequestLocale() throws { + let thrown = TestQuotaError(requested: 5, maximum: 3) + + let received: TestQuotaError = try thrown + .toJSON(encoder: encoder(locale: Self.es)) + .fromJSON() + + #expect(try received.localizedMessage.localizedString == "Lo solicitado 5 supera el máximo de 3") + } + + @Test func wireRoundTrip_plainMessageLocalizes() throws { + let received: TestSaveError = try TestSaveError() + .toJSON(encoder: encoder()) + .fromJSON() + + #expect(try received.localizedMessage.localizedString == "The document could not be saved") + } + + @Test func conformance_isReachableThroughAnyError() throws { + let received: TestQuotaError = try TestQuotaError(requested: 5, maximum: 3) + .toJSON(encoder: encoder()) + .fromJSON() + let error: any Error = received + + let localizable = try #require(error as? any LocalizableError) + #expect(localizable.localizedMessage.localizationStatus == .localized) + } + + // MARK: Client-hosted domain — localized(locale:localizationStore:) + + @Test func clientHosted_localizes_viaTheSameRoundTripAsAViewModel() throws { + let localized = try TestOfflineError().localized( + locale: Self.en, + localizationStore: locStore + ) + + #expect(try localized.localizedMessage.localizedString == "This action requires a network connection") + } + + @Test func clientHosted_localizes_perLocale() throws { + let localized = try TestOfflineError().localized( + locale: Self.es, + localizationStore: locStore + ) + + #expect(try localized.localizedMessage.localizedString == "Esta acción requiere conexión de red") + } + + @Test func clientHostedMarker_isEmittedByTheOptionsFlag() { + #expect(TestOfflineError() is any ClientHostedLocalizableError) + #expect(!(TestQuotaError(requested: 1, maximum: 1) is any ClientHostedLocalizableError)) + } + + let locStore: LocalizationStore + init() throws { + self.locStore = try Self.loadLocalizationStore( + bundle: Bundle.module, + resourceDirectoryName: "TestYAML" + ) + } +} + +/// The canonical conformer: composed like a ViewModel — `@Localized…` message, values +/// carried via `@LocalizedSubs`, plumbing from the `@LocalizableError` macro. +@LocalizableError +private struct TestQuotaError: ServerRequestError { + let requested: Int + let maximum: Int + + @LocalizedSubs(substitutions: \.subs) var errorMessage + + var localizedMessage: any Localizable { + errorMessage + } + + private var subs: [String: any Localizable] { + [ + "requested": LocalizableInt(value: requested), + "maximum": LocalizableInt(value: maximum) + ] + } +} + +/// Client-domain conformer — created in the app, localized at presentation against the +/// client-hosted store. +@LocalizableError(options: [.clientHosted]) +private struct TestOfflineError { + @LocalizedString var errorMessage + + var localizedMessage: any Localizable { + errorMessage + } + + init() {} +} + +@LocalizableError +private struct TestSaveError: ServerRequestError { + @LocalizedString var errorMessage + + var localizedMessage: any Localizable { + errorMessage + } + + init() {} +} diff --git a/Tests/FOSMVVMTests/SwiftUI Support/AsyncButtonActivityTests.swift b/Tests/FOSMVVMTests/SwiftUI Support/AsyncButtonActivityTests.swift new file mode 100644 index 00000000..7dbd92c1 --- /dev/null +++ b/Tests/FOSMVVMTests/SwiftUI Support/AsyncButtonActivityTests.swift @@ -0,0 +1,335 @@ +// AsyncButtonActivityTests.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(SwiftUI) +@testable import FOSMVVM +import Foundation +import SwiftUI +import Testing + +/// Each ratified tap semantic gets a test, driven through AsyncButtonEngine.handleTap — +/// the seam every async Button init forwards to. +@MainActor +struct AsyncButtonActivityTests { + // MARK: Public surface + + @Test func freshActivity_isIdle() { + let activity = AsyncButtonActivity() + + #expect(activity.phase == .idle) + #expect(!activity.isRunning) + } + + @Test func cancel_onIdle_isNoOp() { + var activity = AsyncButtonActivity() + + activity.cancel() + + #expect(activity.phase == .idle) + } + + @Test func isRunning_coversRunningAndCancelling() async { + let harness = Harness() + let gate = Gate() + + harness.tap(.refuse) { await gate.wait() } + #expect(harness.activity.value.isRunning) + + gate.open() + await harness.waitUntilIdle() + #expect(!harness.activity.value.isRunning) + } + + // MARK: Clear-on-launch + + @Test func launch_clearsPreviousErrorSynchronously() { + let harness = Harness() + harness.error.value = TestTapError.previous + + harness.tap(.refuse) {} + + // Before the action even completes: the clear happens at the tap + #expect(harness.error.value == nil) + } + + // MARK: Outcomes + + @Test func failure_landsInErrorBinding_andActivityReturnsToIdle() async { + let harness = Harness() + + harness.tap(.refuse) { throw TestTapError.failed } + await harness.waitUntilIdle() + + #expect(harness.error.value as? TestTapError == .failed) + #expect(harness.activity.value.phase == .idle) + } + + @Test func success_writesNothing_andActivityReturnsToIdle() async { + let harness = Harness() + + harness.tap(.refuse) {} + await harness.waitUntilIdle() + + #expect(harness.error.value == nil) + #expect(harness.activity.value.phase == .idle) + } + + // MARK: Refuse mode + + @Test func refuseMode_tapWhileRunning_hasNoObservableEffect() async { + let harness = Harness() + let gate = Gate() + let starts = Counter() + + harness.tap(.refuse) { await starts.increment(); await gate.wait() } + #expect(harness.activity.value.phase == .running) + + harness.tap(.refuse) { await starts.increment(); await gate.wait() } + #expect(harness.activity.value.phase == .running) + + gate.open() + await harness.waitUntilIdle() + #expect(starts.count == 1) + } + + @Test func fireAndForget_withoutActivity_everyTapStartsAnInvocation() async { + let harness = Harness() + let starts = Counter() + + harness.tap(.refuse, withActivity: false) { await starts.increment() } + harness.tap(.refuse, withActivity: false) { await starts.increment() } + + await harness.waitUntil { starts.count == 2 } + #expect(starts.count == 2) + } + + // MARK: Toggle mode + + @Test func toggleMode_tapWhileRunning_cancelsCooperatively() async { + let harness = Harness() + + harness.tap(.toggle) { try await Task.sleep(for: .seconds(600)) } + #expect(harness.activity.value.phase == .running) + + harness.tap(.toggle) {} + #expect(harness.activity.value.phase == .cancelling) + + await harness.waitUntilIdle() + #expect(harness.error.value == nil) + } + + @Test func cancelledInvocation_writesNothingToError() async { + let harness = Harness() + + harness.tap(.toggle) { try await Task.sleep(for: .seconds(600)) } + harness.tap(.toggle) {} + await harness.waitUntilIdle() + + #expect(harness.error.value == nil) + #expect(harness.activity.value.phase == .idle) + } + + @Test func externalCancel_viaActivity_worksInRefuseMode() async { + let harness = Harness() + + harness.tap(.refuse) { try await Task.sleep(for: .seconds(600)) } + #expect(harness.activity.value.phase == .running) + + harness.activity.value.cancel() + #expect(harness.activity.value.phase == .cancelling) + + await harness.waitUntilIdle() + #expect(harness.error.value == nil) + } + + @Test func cancellingPhase_refusesTaps() async { + let harness = Harness() + let unwindGate = Gate() + let starts = Counter() + + harness.tap(.toggle) { + await starts.increment() + do { + try await Task.sleep(for: .seconds(600)) + } catch { + // Hold the unwind open so the cancelling phase is observable + await unwindGate.wait() + } + } + harness.tap(.toggle) { await starts.increment() } + #expect(harness.activity.value.phase == .cancelling) + + harness.tap(.toggle) { await starts.increment() } + #expect(harness.activity.value.phase == .cancelling) + + unwindGate.open() + await harness.waitUntilIdle() + #expect(starts.count == 1) + } + + // MARK: Refractory window + + @Test func toggleMode_tapInsideRefractoryWindow_isDiscarded() { + let harness = Harness() + let flip = ContinuousClock().now + harness.activity.value.lastIdleFlip = flip + + harness.tap(.toggle, now: flip + .milliseconds(1)) {} + + #expect(harness.activity.value.phase == .idle) + } + + @Test func toggleMode_tapAfterRefractoryWindow_starts() { + let harness = Harness() + let flip = ContinuousClock().now + harness.activity.value.lastIdleFlip = flip + + harness.tap(.toggle, now: flip + AsyncButtonEngine.refractoryWindow + .milliseconds(1)) {} + + #expect(harness.activity.value.phase == .running) + } + + @Test func refuseMode_isUnaffectedByRefractoryWindow() { + let harness = Harness() + let flip = ContinuousClock().now + harness.activity.value.lastIdleFlip = flip + + harness.tap(.refuse, now: flip + .milliseconds(1)) {} + + #expect(harness.activity.value.phase == .running) + } + + @Test func toggleCompletion_recordsTheIdleFlip_refuseDoesNot() async { + let toggleHarness = Harness() + toggleHarness.tap(.toggle) {} + await toggleHarness.waitUntilIdle() + #expect(toggleHarness.activity.value.lastIdleFlip != nil) + + let refuseHarness = Harness() + refuseHarness.tap(.refuse) {} + await refuseHarness.waitUntilIdle() + #expect(refuseHarness.activity.value.lastIdleFlip == nil) + } + + /// Pins the internal tunable so an accidental change is a conscious one; the duration is + /// deliberately NOT public contract. + @Test func refractoryWindow_pinnedValue() { + #expect(AsyncButtonEngine.refractoryWindow == .milliseconds(500)) + } + + // MARK: Primitive surface (compiles + forwards) + + @Test func primitives_construct() { + let activity = Binding.constant(AsyncButtonActivity()) + let error = Binding.constant(nil) + + let _: Button = Button(error: error, action: {}, label: { Text("Go") }) + let _: Button = Button(role: .destructive, activity: activity, error: error, action: {}, label: { Text("Go") }) + let _: Button = Button(activity: activity, error: error, action: {}, label: { phase in + Text(phase == .idle ? "Go" : "Stop") + }) + let _: Button = Button(role: nil, activity: activity, error: error, action: {}, label: { phase in + Text(phase == .idle ? "Go" : "Stop") + }) + } +} + +// MARK: - Test Support + +private enum TestTapError: Error, Equatable { + case previous + case failed +} + +/// Caller-side state (what a view's `@State` would hold) plus the tap entry point +@MainActor +private final class Harness { + let activity = ValueBox(.init()) + let error = ValueBox(nil) + + func tap( + _ mode: AsyncButtonEngine.Mode, + withActivity: Bool = true, + now: ContinuousClock.Instant = ContinuousClock().now, + action: @escaping @Sendable () async throws -> Void + ) { + AsyncButtonEngine.handleTap( + mode: mode, + activity: withActivity ? activity.binding : nil, + error: error.binding, + now: now, + action: action + ) + } + + func waitUntilIdle() async { + await waitUntil { self.activity.value.phase == .idle } + } + + func waitUntil(_ condition: @escaping @MainActor () -> Bool) async { + var yields = 0 + while !condition(), yields < 100000 { + await Task.yield() + yields += 1 + } + #expect(condition(), "condition not reached after \(yields) yields") + } +} + +@MainActor +private final class ValueBox { + var value: V + + var binding: Binding { + Binding( + get: { self.value }, + set: { self.value = $0 } + ) + } + + init(_ value: V) { + self.value = value + } +} + +@MainActor +private final class Counter { + private(set) var count = 0 + + func increment() { + count += 1 + } +} + +@MainActor +private final class Gate { + private var isOpen = false + private var continuations: [CheckedContinuation] = [] + + func open() { + isOpen = true + continuations.forEach { $0.resume() } + continuations.removeAll() + } + + func wait() async { + if isOpen { + return + } + await withCheckedContinuation { continuations.append($0) } + } +} +#endif diff --git a/Tests/FOSMVVMTests/SwiftUI Support/AsyncButtonGeneratedSurfaceTests.swift b/Tests/FOSMVVMTests/SwiftUI Support/AsyncButtonGeneratedSurfaceTests.swift new file mode 100644 index 00000000..f8758204 --- /dev/null +++ b/Tests/FOSMVVMTests/SwiftUI Support/AsyncButtonGeneratedSurfaceTests.swift @@ -0,0 +1,114 @@ +// AsyncButtonGeneratedSurfaceTests.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(SwiftUI) +import FOSFoundation +import FOSMVVM +import FOSTesting +import Foundation +import SwiftUI +import Testing + +/// Compile-exercise coverage of the generated async Button surface +/// (`Generated/Button+AsyncAction.swift`): every titled form constructs +/// through its public signature. `Button` is not `Equatable`, so — as with +/// the sibling generated surface — construction is the assertable contract; +/// the tap semantics all twelve forward to are covered behaviorally in +/// `AsyncButtonActivityTests`. +@Suite("Async Button Generated Surface") +@MainActor +struct AsyncButtonGeneratedSurfaceTests: LocalizableTestCase { + let locStore: LocalizationStore + init() throws { + self.locStore = try Self.loadLocalizationStore( + bundle: Bundle.module, + resourceDirectoryName: "TestYAML" + ) + } + + @Test func titledForms_construct() throws { + let title: LocalizableString = try LocalizableString + .localized(key: "test") + .toJSON(encoder: encoder()) + .fromJSON() + let cancel = LocalizableString.constant("Cancel") + let requiredActivity = Binding.constant(AsyncButtonActivity()) + let optionalActivity: Binding? = requiredActivity + let error = Binding.constant(nil) + let action: @Sendable () async throws -> Void = {} + + // Label == SwiftUI.Label — systemImage decorations + let _: Button> = Button( + title, systemImage: "tray", role: nil, + activity: optionalActivity, error: error, action: action + ) + let _: Button> = Button( + title, systemImage: "tray", + activity: optionalActivity, error: error, action: action + ) + let _: Button> = Button( + title, systemImage: "tray", + cancelTitle: cancel, cancelSystemImage: "xmark", role: nil, + activity: requiredActivity, error: error, action: action + ) + let _: Button> = Button( + title, systemImage: "tray", + cancelTitle: cancel, + activity: requiredActivity, error: error, action: action + ) + + // Label == SwiftUI.Label — ImageResource decorations + let _: Button> = Button( + title, image: ImageResource(name: "test", bundle: .module), role: nil, + activity: optionalActivity, error: error, action: action + ) + let _: Button> = Button( + title, image: ImageResource(name: "test", bundle: .module), + activity: optionalActivity, error: error, action: action + ) + let _: Button> = Button( + title, image: ImageResource(name: "test", bundle: .module), + cancelTitle: cancel, cancelImage: nil, role: nil, + activity: requiredActivity, error: error, action: action + ) + let _: Button> = Button( + title, image: ImageResource(name: "test", bundle: .module), + cancelTitle: cancel, + activity: requiredActivity, error: error, action: action + ) + + // Label == Text + let _: Button = Button( + title, role: nil, + activity: optionalActivity, error: error, action: action + ) + let _: Button = Button( + title, + activity: optionalActivity, error: error, action: action + ) + let _: Button = Button( + title, + cancelTitle: cancel, role: nil, + activity: requiredActivity, error: error, action: action + ) + let _: Button = Button( + title, + cancelTitle: cancel, + activity: requiredActivity, error: error, action: action + ) + } +} +#endif diff --git a/Tests/FOSMVVMTests/SwiftUI Support/ErrorAlertTests.swift b/Tests/FOSMVVMTests/SwiftUI Support/ErrorAlertTests.swift new file mode 100644 index 00000000..346d00e4 --- /dev/null +++ b/Tests/FOSMVVMTests/SwiftUI Support/ErrorAlertTests.swift @@ -0,0 +1,208 @@ +// ErrorAlertTests.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(SwiftUI) +import FOSFoundation +@testable import FOSMVVM +import FOSTesting +import Foundation +import SwiftUI +import Testing + +/// The alert is Localizable *composition* — the generated twins render; the only logic is +/// the `%{error}` substitution-value ladder, tested here rung by rung across both +/// localization domains, plus the composed message as a value. +struct ErrorAlertTests: LocalizableTestCase { + // MARK: Substitution-value ladder + + @Test func wireError_contributesItsDecodedMessage() throws { + let received: TestQuotaError = try TestQuotaError(requested: 5, maximum: 3) + .toJSON(encoder: encoder()) + .fromJSON() + + let value = ErrorAlertMessage.substitutionValue( + for: received, + mvvmEnv: clientHostedEnv, + locale: Self.en + ) + + #expect(try value.localizedString == "Requested 5 exceeds the maximum of 3") + } + + @Test func clientHostedError_resolvesAgainstTheClientStore() throws { + let value = ErrorAlertMessage.substitutionValue( + for: TestOfflineError(), + mvvmEnv: clientHostedEnv, + locale: Self.en + ) + + #expect(try value.localizedString == "This action requires a network connection") + } + + @Test func clientHostedError_resolvesInTheEnvironmentLocale() throws { + let value = ErrorAlertMessage.substitutionValue( + for: TestOfflineError(), + mvvmEnv: clientHostedEnv, + locale: Self.es + ) + + #expect(try value.localizedString == "Esta acción requiere conexión de red") + } + + @Test func clientHostedError_withoutResolvableStore_fallsBackToDebugDescription() { + let error = TestOfflineError() + + let value = ErrorAlertMessage.substitutionValue( + for: error, + mvvmEnv: storelessEnv, + locale: Self.en + ) + + #expect((try? value.localizedString) == "\(error)") + } + + @Test func clientHostedError_withoutEnvironment_fallsBackToDebugDescription() { + let error = TestOfflineError() + + let value = ErrorAlertMessage.substitutionValue( + for: error, + mvvmEnv: nil, + locale: Self.en + ) + + #expect((try? value.localizedString) == "\(error)") + } + + @Test func nonConformingError_contributesItsDebugDescription() { + let value = ErrorAlertMessage.substitutionValue( + for: PlainError.boom, + mvvmEnv: clientHostedEnv, + locale: Self.en + ) + + #expect((try? value.localizedString) == "\(PlainError.boom)") + } + + // MARK: The composed message value + + @Test func message_fillsItsErrorSlot() throws { + let received: TestQuotaError = try TestQuotaError(requested: 5, maximum: 3) + .toJSON(encoder: encoder()) + .fromJSON() + + let composed = LocalizableString.constant("Failed: %{error}").bind(substitutions: [ + "error": ErrorAlertMessage.substitutionValue( + for: received, + mvvmEnv: nil, + locale: Self.en + ) + ]) + + #expect(try composed.localizedString == "Failed: Requested 5 exceeds the maximum of 3") + } + + @Test func slotFreeMessage_passesThroughUnchanged() throws { + let composed = LocalizableString.constant("A static message").bind(substitutions: [ + "error": ErrorAlertMessage.substitutionValue( + for: PlainError.boom, + mvvmEnv: nil, + locale: Self.en + ) + ]) + + #expect(try composed.localizedString == "A static message") + } + + // MARK: Public modifier surface + + @MainActor @Test func modifier_appliesToAView() { + let error = Binding.constant(nil) + + _ = Text("content").alert( + error: error, + title: LocalizableString.constant("An Error Occurred"), + message: .constant("%{error}"), + dismissButtonLabel: LocalizableString.constant("OK") + ) + _ = Text("content").alert( + error: error, + title: LocalizableString.constant("An Error Occurred"), + dismissButtonLabel: LocalizableString.constant("OK") + ) + } + + let locStore: LocalizationStore + let clientHostedEnv: MVVMEnvironment + let storelessEnv: MVVMEnvironment + + init() throws { + self.locStore = try Self.loadLocalizationStore( + bundle: Bundle.module, + resourceDirectoryName: "TestYAML" + ) + let url = try #require(URL(string: "http://localhost:8080")) + self.clientHostedEnv = MVVMEnvironment( + appBundle: Bundle.module, + resourceBundles: [Bundle.module], + resourceDirectoryName: "TestYAML", + deploymentURLs: [.debug: url] + ) + self.storelessEnv = MVVMEnvironment( + appBundle: Bundle.module, + resourceBundles: [], + deploymentURLs: [.debug: url] + ) + } +} + +/// Wire-domain conformer — shares the `TestQuotaError` fixture keys with +/// `LocalizableErrorTests`. +@LocalizableError +private struct TestQuotaError: ServerRequestError { + let requested: Int + let maximum: Int + + @LocalizedSubs(substitutions: \.subs) var errorMessage + + var localizedMessage: any Localizable { + errorMessage + } + + private var subs: [String: any Localizable] { + [ + "requested": LocalizableInt(value: requested), + "maximum": LocalizableInt(value: maximum) + ] + } +} + +/// Client-domain conformer — created in the app, localized at presentation against the +/// client-hosted store. +@LocalizableError(options: [.clientHosted]) +private struct TestOfflineError { + @LocalizedString var errorMessage + + var localizedMessage: any Localizable { + errorMessage + } + + init() {} +} + +private enum PlainError: Error { + case boom +} +#endif diff --git a/Tests/FOSMVVMTests/TestYAML/LocalizableErrorTests.yml b/Tests/FOSMVVMTests/TestYAML/LocalizableErrorTests.yml new file mode 100644 index 00000000..35758a2b --- /dev/null +++ b/Tests/FOSMVVMTests/TestYAML/LocalizableErrorTests.yml @@ -0,0 +1,14 @@ +en: + TestQuotaError: + errorMessage: "Requested %{requested} exceeds the maximum of %{maximum}" + TestSaveError: + errorMessage: "The document could not be saved" + TestOfflineError: + errorMessage: "This action requires a network connection" +es: + TestQuotaError: + errorMessage: "Lo solicitado %{requested} supera el máximo de %{maximum}" + TestSaveError: + errorMessage: "No se pudo guardar el documento" + TestOfflineError: + errorMessage: "Esta acción requiere conexión de red" diff --git a/Tests/FOSMacrosTests/LocalizableErrorMacroTests.swift b/Tests/FOSMacrosTests/LocalizableErrorMacroTests.swift new file mode 100644 index 00000000..f13f4fbe --- /dev/null +++ b/Tests/FOSMacrosTests/LocalizableErrorMacroTests.swift @@ -0,0 +1,163 @@ +// LocalizableErrorMacroTests.swift +// +// Copyright 2026 FOS Computer Services, LLC +// +// Licensed under the Apache License, Version 2.0 (the License); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if os(macOS) +import FOSFoundation +import FOSMacros +import Foundation +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +final class LocalizableErrorMacroTests: XCTestCase { + private let testMacros: [String: any Macro.Type] = [ + "LocalizableError": LocalizableErrorMacro.self + ] + + func testLocalizedStringExpansion() { + assertMacroExpansion( + #""" + @LocalizableError struct TestError { + @LocalizedString public var errorMessage + } + """#, + expandedSource: #""" + struct TestError { + @LocalizedString public var errorMessage + + public func propertyNames() -> [LocalizableId: String] { + [_errorMessage.localizationId: "errorMessage"] + } + } + + extension TestError: LocalizableError { + } + + extension TestError: RetrievablePropertyNames { + } + """#, + macros: testMacros, + indentationWidth: .spaces(4) + ) + } + + func testLocalizedSubsExpansion() { + assertMacroExpansion( + #""" + @LocalizableError struct TestError { + @LocalizedSubs(substitutions: \.subs) public var errorMessage + private var subs: [String: any Localizable] { [:] } + } + """#, + expandedSource: #""" + struct TestError { + @LocalizedSubs(substitutions: \.subs) public var errorMessage + private var subs: [String: any Localizable] { [:] } + + public func propertyNames() -> [LocalizableId: String] { + [_errorMessage.localizationId: "errorMessage"] + } + } + + extension TestError: LocalizableError { + } + + extension TestError: RetrievablePropertyNames { + } + """#, + macros: testMacros, + indentationWidth: .spaces(4) + ) + } + + func testClientHostedOptionEmitsTheMarkerConformance() { + assertMacroExpansion( + #""" + @LocalizableError(options: [.clientHosted]) struct TestError { + @LocalizedString public var errorMessage + } + """#, + expandedSource: #""" + struct TestError { + @LocalizedString public var errorMessage + + public func propertyNames() -> [LocalizableId: String] { + [_errorMessage.localizationId: "errorMessage"] + } + } + + extension TestError: LocalizableError, ClientHostedLocalizableError { + } + + extension TestError: RetrievablePropertyNames { + } + """#, + macros: testMacros, + indentationWidth: .spaces(4) + ) + } + + func testExplicitConformancesAreNotDuplicated() { + assertMacroExpansion( + #""" + @LocalizableError struct TestError: LocalizableError, RetrievablePropertyNames { + @LocalizedString public var errorMessage + } + """#, + expandedSource: #""" + struct TestError: LocalizableError, RetrievablePropertyNames { + @LocalizedString public var errorMessage + + public func propertyNames() -> [LocalizableId: String] { + [_errorMessage.localizationId: "errorMessage"] + } + } + """#, + macros: testMacros, + indentationWidth: .spaces(4) + ) + } + + func testEnumIsRejected() { + assertMacroExpansion( + #""" + @LocalizableError enum TestError { + case boom + } + """#, + expandedSource: #""" + enum TestError { + case boom + } + """#, + diagnostics: [ + DiagnosticSpec( + message: "LocalizableErrorMacroError: @LocalizableError can only be applied to structs", + line: 1, + column: 1 + ), + DiagnosticSpec( + message: "LocalizableErrorMacroError: @LocalizableError can only be applied to structs", + line: 1, + column: 1 + ) + ], + macros: testMacros, + indentationWidth: .spaces(4) + ) + } +} +#endif diff --git a/planning/stream/feat-async-button-surface.md b/planning/stream/feat-async-button-surface.md new file mode 100644 index 00000000..7ef1ac71 --- /dev/null +++ b/planning/stream/feat-async-button-surface.md @@ -0,0 +1,340 @@ +# Async Button Surface — Implementation Plan + +**Status:** UNRATIFIED — awaiting David's review of this plan (the *design* it implements was ratified decision-by-decision on 2026-08-20; this document only adds the implementation shape). + +**Scope:** `AsyncButtonActivity` engine + 4 hand-written ViewBuilder `Button` primitives, 12 generated Localizable-titled async `Button` inits (sweep second stage), `LocalizableError` protocol, `View.alert(error:)` modifier. + +**Deferred by ratified decision (not silently):** `.task(error:)` twin (queued, own arc). Cancellation-handle parameter (Option C, shelved; additive later). Server-side long-op cancellation is guidance (operation-as-resource DocC), not API. + +--- + +## 1. Public surface — every symbol justified + +All new API lives in `FOSMVVM`, `#if canImport(SwiftUI)` where SwiftUI is involved. No other module changes. + +### `AsyncButtonActivity` (struct) — `Sources/FOSMVVM/SwiftUI Support/AsyncButtonActivity.swift` + +The caller-owned in-flight state for one async button (or one shared "operation slot" across several). + +- `public init()` — the *only* constructible state (idle). Caller need: declare `@State`. No other public construction; a forgeable `running` state would break every invariant. +- `public var phase: Phase { get }` — read-only. Caller need: drive `.disabled(...)`, progress overlays, and phase-aware labels. No setter — phases advance only through the engine. +- `public enum Phase { case idle, running, cancelling }` — the phase taxonomy is contract (callers render from it); it carries no associated values, so it publishes no representation. +- `public var isRunning: Bool { get }` — convenience over `phase`, **ratified keep**. Justification against the two-ways-to-do-one-thing rule: `phase != .idle` vs `== .running` reads ambiguously at call sites (`cancelling` is also "busy"); `isRunning` is defined as `phase != .idle` — the "should this control be interactable" question, which is the only question callers ask of it. +- `public mutating func cancel()` — caller need: cancel affordances beyond the button's own toggle face (✕ on an overlay, `.onDisappear`). No-op unless `phase == .running`. +- **Sealed:** the `Task` handle and the refractory timestamp. No getters, ever. Not `Codable` — this is view-local interaction state; it is never serialized, and conforming it would publish a representation with no consumer. + +### `Button` async inits — 4 hand-written primitives (same file), 12 generated (`Generated/Button+AsyncAction.swift`) + +Refuse-mode primitives (zero-arg label): + +- `init(role:activity:error:action:label:)` and `init(activity:error:action:label:)` — `activity: Binding? = nil`, `error: Binding` required, `action: @escaping @Sendable () async throws -> Void`. + +Toggle-mode primitives (phase-taking label — providing the second face's presentation is what enables cancellation): + +- `init(role:activity:error:action:label:)` / `init(activity:error:action:label:)` where `label: (AsyncButtonActivity.Phase) -> Label` and `activity: Binding` (required — cancel needs caller-owned storage). + +Generated Localizable-titled forms: the 6 existing `Button+Localizable` decorations × {refuse, toggle}. Toggle forms add the cancel face **face-grouped**: do-face args, then `cancelTitle:`/`cancelDefaultValue:` (+ `cancelSystemImage:`/`cancelImage:` on decorated forms, `nil` default = image holds constant). Apple/our ingredient order is otherwise preserved verbatim — a sync call site becomes async by insertion only. + +Existential note (governance flag, answered): `error: Binding` is ratified — `any Error` is the language's error currency at a UI boundary; typed throws was ruled out (Apple's standing guidance: untyped for API surfaces whose errors are rendered, not exhaustively handled). + +**No `String`/`LocalizedStringKey` titled async forms, ever** — they would be a first-class door around YAML localization on a brand-new surface (hole threat model: the prior-art string door stays sealed). + +### `LocalizableError` (protocol) — `Sources/FOSMVVM/Protocols/LocalizableError.swift` + +```swift +public protocol LocalizableError: Error { + var localizableMessage: any Localizable { get } +} +``` + +*Amended in implementation review (2026-08-20):* originally ratified as +`var localizableString: LocalizableString`; David widened it — a `LocalizableString` cannot +express a message carrying the error's own values (`LocalizableSubstitutions` can — the +framework's canonical `QuotaError` example), nothing downstream needs the concrete type, +and the property was renamed so it no longer implies one. The existential is consumed +behind `any Error` and resolved to a `String` immediately (legitimate-use column). + +*Second amendment, same review:* the canonical conformer's message is a **stored** property +forwarded by the witness, never a computed one — the YAML lives on the server, and only +stored properties ride `ErrorMiddleware`'s localizing encode; a computed property re-mints +an unresolved reference on the client, where the YAML is not. Computed/unresolved remains +valid only for client-created errors in apps hosting their own localization YAML. The DocC, +catalog entry, and tests all model the wire flow as primary. + +*Third amendment, same review — full ViewModel alignment (supersedes the second's conformer +shape):* the protocol became `LocalizableError: Error, RetrievablePropertyNames` with the +requirement renamed **`localizedMessage`** (-ed: by the time anyone reads it, localization +happened). Conformers compose exactly like ViewModels — `@LocalizedString`/`@LocalizedSubs` +message properties, plumbing synthesized by the new **`@LocalizableError` macro** (the +family's third member, cloned from `@FieldValidationModel`). YAML keys derive from +type+property; the manual `localizationKey` ceremony and the stored-`LocalizableSubstitutions` +hand-shape are gone from the docs (only the wrapper composition is taught). An error type +belongs to exactly ONE localization domain (David's ruling — same type never straddles +server/client YAML). + +*Fourth amendment — the client-domain unification (designed and shipped in the same review):* +`@LocalizableError(options: [.clientHosted])` (mirroring `@ViewModel`'s options) emits the +`ClientHostedLocalizableError` marker. Client-created errors are resolved **at presentation** +— the error's `bind()`-moment — by `localized(mvvmEnv:locale:) -> Self?`, which runs the same +localizing round-trip `ClientHostedViewModelFactory` runs for a ViewModel (nil ⇒ present the +debug description; a manufactured default error was explicitly rejected). The throwing core is +`localized(locale:localizationStore:)`. The alert was rewritten as pure Localizable **twin +composition** (generated alert/Text/Button twins; the `%{error}` substitution receives the +message as a typed `Localizable`, never a String) — the entire `ErrorAlertResolver` String +ladder was deleted, along with the store rung and the hand-composed conformer shape. Traced +gotcha recorded for posterity: pre-round-trip wrapper reads do NOT throw — `@LocalizedString` +seeds `.empty` ("" ) and `@LocalizedSubs` seeds a constant stub, both reporting `.localized` — +so consumers must localize-then-read, never check-then-read; `localizationUnbound` fires only +for bare `.localized` refs. + +Caller need: opt an error type (canonically a `ServerRequestError` conformer) into user-presentable, YAML-localized messaging. Opt-in by ratified decision — `ServerRequestError` does *not* refine it. Name ratified against the Foundation `LocalizedError` adjacency: *-able* = localization pending (the framework's value-until-resolved model), *-ed* = completed fact. + +### `View.alert(error:title:message:dismissButtonLabel:)` — `Sources/FOSMVVM/SwiftUI Support/ErrorAlert.swift` (filename ratified) + +```swift +func alert( + error: Binding, + title: some Localizable, + message: LocalizableString? = nil, // default: constant "%{error}" — locale-neutral pure substitution + dismissButtonLabel: some Localizable +) -> some View +``` + +- Presents when `error != nil`; dismissal (button or system) writes `nil`. One mechanism — the consumer sample's `dismissErrorAction` environment does not come forward. +- `title:`/`dismissButtonLabel:` required (ratified: no framework default copy; the caller owns defaulting) and therefore `some Localizable` — opaque, no existential box. +- `message:` is the **bind target**: a `LocalizableString` whose `%{error}` slot the modifier fills via `.bind(substitutions:)`. The concrete type enforces bindability. +- Resolution is presentation-time, synchronous, inside the modifier: `MVVMEnvironment.clientLocalizationStore` (sync, cached — `MVVMEnvironment.swift:240`) + `Locale.localize(_:localizationStore:)`. No JSON round-trip, no store-caching layer — both consumer-sample hacks are obsolete against current FOSMVVM. +- Fallback ladder (contract): `LocalizableError` → localized message; anything else → `"\(error)"` with a logged notice. The `as? LocalizableError` runtime cast inside the modifier is an existential downcast — raised here per the gate: it is the only possible mechanism (errors arrive as `any Error` by ratified decision), it is internal, and the typed path is one conformance away for any adopter. + +### Encapsulation review (separate axis, per repo discipline) + +No raw getters anywhere: the task handle, refractory timestamp, and the engine's internals are unreachable. The refractory *duration* is not part of the contract — DocC says "a tap arriving immediately after the button changes faces is ignored", never the number (internal `//` + internal test pin it). `AsyncButtonActivity`'s phase taxonomy is deliberately public contract; everything else about its insides is not. + +--- + +## 2. Customer-facing DocC — drafted first + +Representative drafts; the generator stamps per-overload variants of the init DocC in the existing sweep style. Contract only — rationale lives in §4. + +### `AsyncButtonActivity` + +```swift +/// The in-flight state of an async button — declare one as `@State` and hand it to the button +/// +/// ```swift +/// @State private var activity = AsyncButtonActivity() +/// @State private var error: Error? +/// +/// var body: some View { +/// Button(viewModel.uploadTitle, cancelTitle: viewModel.cancelTitle, +/// activity: $activity, error: $error) { +/// try await viewModel.operations.upload() +/// } +/// .disabled(activity.phase == .cancelling) +/// } +/// ``` +/// +/// While the button's work runs, `phase` is `.running`; a cancel-capable button that has been +/// asked to stop is `.cancelling` until its work unwinds. Use `phase` (or `isRunning`) to drive +/// `disabled(_:)`, progress indicators, and phase-aware labels. +/// +/// Share one activity between several buttons to make them mutually exclusive — while any of +/// them is running, the others refuse to start, and (for cancel-capable buttons) any of their +/// faces can stop the running operation. +/// +/// Call ``cancel()`` to stop the running operation from outside the button — a toolbar ✕, +/// or `.onDisappear { activity.cancel() }`. +``` + +### Refuse-mode primitive (representative) + +```swift +/// Async form of SwiftUI's `Button.init(action:label:)` — runs a throwing async action and +/// routes its error to a binding +/// +/// ```swift +/// @State private var error: Error? +/// +/// Button(error: $error) { +/// try await viewModel.operations.save() +/// } label: { +/// Text(viewModel.saveTitle) +/// } +/// .alert(error: $error, +/// title: viewModel.errorTitle, +/// dismissButtonLabel: viewModel.dismissTitle) +/// ``` +/// +/// Tapping starts the action; a thrown error lands in `error`. Starting a new invocation +/// clears `error` first — the binding always holds the outcome of the most recent invocation. +/// +/// Pass `activity:` to prevent re-entry: while a run is in flight, further taps are ignored, +/// and `activity` reports the running state for `disabled(_:)` or a progress indicator. +/// Without `activity:`, every tap starts a new concurrent invocation. +/// +/// The action runs in a task that is not cancelled by the view disappearing; it runs to +/// completion. For user-cancellable work, use the `cancelTitle:` forms. For long-running +/// *server* work, model the operation as a server-tracked resource and cancel it with another +/// request — client-side cancellation only abandons the response. +``` + +### Toggle-mode titled form (representative) + +```swift +/// A two-faced async button: tap to start the operation, tap again to cancel it +/// +/// ```swift +/// @State private var activity = AsyncButtonActivity() +/// @State private var error: Error? +/// +/// Button(viewModel.uploadTitle, cancelTitle: viewModel.cancelTitle, +/// systemImage: "arrow.up", cancelSystemImage: "xmark", +/// activity: $activity, error: $error) { +/// try await viewModel.operations.upload() +/// } +/// ``` +/// +/// While idle the button shows the title and starts the action when tapped. While running it +/// shows `cancelTitle` and a tap cancels the operation; the button then refuses taps until the +/// work unwinds (`activity.phase == .cancelling`). A cancelled invocation writes nothing to +/// `error`. A tap arriving in the instant after the button changes faces is ignored rather +/// than misread against the old face. +/// +/// > Important: Cancellation is cooperative. Your action must run cancellation-aware work +/// > (any `URLSession`-backed `ServerRequest` is) for the cancel face to take effect. +``` + +### `LocalizableError` + +```swift +/// Give an error a localized, user-presentable message — conform, and ``SwiftUICore/View/alert(error:title:message:dismissButtonLabel:)`` presents it in the user's language +/// +/// ```swift +/// public enum DocumentError: ServerRequestError, LocalizableError { +/// case quotaExceeded +/// +/// public var localizableString: LocalizableString { +/// .localized(for: Self.self, propertyName: localizationKey) +/// } +/// +/// private var localizationKey: String { +/// switch self { +/// case .quotaExceeded: "quotaExceeded" +/// } +/// } +/// } +/// ``` +/// +/// ```yaml +/// en: +/// DocumentError: +/// quotaExceeded: "Your document quota has been reached" +/// ``` +/// +/// Errors that do not conform are presented with their debug description — conforming is what +/// turns an error from developer output into user-facing copy. +``` + +### `View.alert(error:)` + +```swift +/// Presents a localized alert whenever an error lands in the binding +/// +/// ```swift +/// @State private var error: Error? +/// +/// var body: some View { +/// DocumentForm(viewModel: viewModel, error: $error) +/// .alert(error: $error, +/// title: viewModel.errorTitle, +/// message: viewModel.errorMessage, +/// dismissButtonLabel: viewModel.dismissTitle) +/// } +/// ``` +/// +/// ```yaml +/// en: +/// DocumentViewModel: +/// errorTitle: "An Error Occurred" +/// errorMessage: "The operation failed: %{error}" +/// dismissTitle: "OK" +/// ``` +/// +/// The alert shows while `error` is non-`nil`; dismissing it clears the binding. If `message` +/// contains an `%{error}` substitution point, the presented error's localized message fills it +/// — errors conforming to ``LocalizableError`` localize through your YAML; others fall back to +/// their debug description. Omitting `message` presents the error message alone. +/// +/// Feed one binding from every async button on the screen — this modifier is the single +/// presentation point the buttons' `error:` parameter is designed to pair with. +``` + +--- + +## 3. Contract tests + +Test targets: `Tests/FOSMVVMTests/` (Swift Testing) + existing TestYAML fixtures; UI-interaction behaviors through the FOSTestingUI harness where a real tap is the only honest trigger. + +**`AsyncButtonActivity` (public path only):** fresh value is `.idle`; `cancel()` on idle is a no-op; `isRunning` derivation (if kept). + +**Engine semantics** — each ratified behavior gets a test, exercised through the hand-written primitives hosted in the existing test-host infrastructure (public construction, real bindings; no `@testable` for contract): + +- clear-on-launch: deposited error is `nil`ed when a new invocation starts +- refuse mode, no activity: two taps → two invocations (fire-and-forget is the documented default) +- refuse mode with activity: tap-while-running has no observable effect (no error write, no phase write, no second invocation) +- toggle mode: tap-while-running cancels; the closure observes cooperative cancellation +- `cancelling` refuses taps until unwind; phase returns to `.idle` after unwind +- cancelled invocation writes nothing to `error` +- activity resets to `.idle` on error outcomes as well as success +- refractory: a tap immediately after running→idle is discarded (the *duration* is pinned by an internal test, not the public one) +- shared activity: two buttons, one binding — second button refuses while first runs + +**`LocalizableError` + alert resolution:** conforming error's message localizes through the store (multi-locale, via existing `LocalizableTestCase` patterns + a TestYAML fixture); non-conforming error falls back to `"\(error)"`; `%{error}` substitution fills; slot-free message passes through unchanged. Resolution behaviors test through the smallest public-behavior seam practical; SwiftUI's alert *presentation* itself is Apple's contract, not ours, and is not UI-tested here. + +**Generated surface:** compile-surface coverage of all 12 titled forms (call each overload) + one behavioral spot-check that a titled form forwards to the primitive (title swaps by phase). Matches how `Button+Localizable` output is covered today. + +--- + +## 4. Rationale (implementer prose — none of this goes in DocC) + +**Why the state lives in a caller binding:** an init owns no storage across renders; captured boxes die on re-render — and the engine's own phase flip *forces* a re-render. The binding through caller `@State` is the only deterministic home. This same re-render powers the title swap for free: each render re-reads `phase` and picks the face. + +**Why `@Sendable`, not `@MainActor`:** the canonical closure is `try await viewModel.operations.x()` — all captures `Sendable` by protocol contract (`ViewModelOperations: Sendable`). Post-success view-poking is the FOSMVVM anti-pattern (state flows by rebind/live invalidation); `@Sendable` makes the architectural pattern frictionless and puts `await MainActor.run` ceremony exactly on deviations. Engine detail: the tap handler does its binding writes on the MainActor before/after awaiting the closure; the `Task { @MainActor in ... }` wrapper owns sequencing. + +**Why toggle mode is enabled by presentation args:** a button that cancels while still reading "Save" is UX poison; requiring `cancelTitle:` (or the phase-taking label) makes the poisonous state unrepresentable. Same principle both families: *cancellation is enabled exactly when the call site provides for its presentation.* + +**Why the refractory window:** completion racing an incoming cancel-tap is a human-perception race (the finger committed before the face flipped); no lock fixes it. Discarding taps in a brief window after the flip absorbs it framework-side — the pain-class this framework exists to absorb once. The `cancelling` phase closes the other direction (double-tap on Cancel landing on a re-flipped face). + +**Rejected alternatives (do not resurrect):** wrapper `AsyncButton` View / runner type (David scoped to the init surface); typed throws (Apple guidance: untyped default for rendered errors); `Binding` guard (degenerate case of `AsyncButtonActivity`, two spellings of one meaning); `ServerRequestError` refining `LocalizableError` (breaking change; opt-in ratified); framework-shipped or well-known-key default alert copy (David: caller owns defaulting); overloading the guard binding as a cancel signal (bindings aren't observable; one Bool, two meanings); deposit-time error localization (couples alert to producers; store access is sync now, presentation-time is simpler and producer-agnostic). + +**Gotchas for the implementer:** + +- `Task {}` from the tap handler inherits MainActor; the awaited `@Sendable` closure hops off. Guard/error writes stay main-side. +- Overload resolution: sync closures convert to async-throws closure types; the required `error:` label is what keeps every async call site unambiguous against the sync family. Don't weaken it to a defaulted param. +- The sweep's second stage takes the *Localizable output set* as input (twin relationship stays structural); mirror the header/`swiftformat:disable` conventions of the existing generated files. +- No `Date.now` in the engine's refractory logic without thought to testability — inject the clock internally (an internal seam, not public API). +- Alert message dispatch: `LocalizableString` → `.bind(substitutions: ["error": ...])`; the substitution key `error` appears only here — internal constant, not public API. +- Zero client references anywhere: the `LocalizableError` bring-in is a re-authored FOS file (Apache header, FOS DocC); nothing from the consumer file's header, naming, or framework references survives. +- `@Localized…` properties are plain values at call sites — `viewModel.title`, never `viewModel.$title`. `_LocalizedProperty.projectedValue` merely returns `wrappedValue`, and the `$` spelling reads as a `Binding` it isn't; ViewModels contain no bindings. Applies to every DocC example the generator stamps. +- Error-type localization keys are type-rooted, never string-rooted: `.localized(for: Self.self, propertyName: localizationKey)` with a private per-case `localizationKey` switch — no type-name string literals in examples or fixtures. + +--- + +## 5. Decomposition (ordered tasks; PR gate once, at the end) + +1. **`LocalizableError`** — `Sources/FOSMVVM/Protocols/LocalizableError.swift` + localization contract tests + TestYAML fixture. No dependencies; smallest reviewable unit. +2. **`AsyncButtonActivity` + engine + 4 primitives** — `Sources/FOSMVVM/SwiftUI Support/AsyncButtonActivity.swift` + the engine-semantics test suite (§3). Depends on nothing new. +3. **Sweep second stage** — extend `scripts/localizable-overload-sweep.swift`; emit `Sources/FOSMVVM/SwiftUI Support/Generated/Button+AsyncAction.swift`; regen; compile-surface + forward tests. Depends on 2. +4. **`.alert(error:)`** — `Sources/FOSMVVM/SwiftUI Support/ErrorAlert.swift` + resolution tests. Depends on 1; parallel with 3. +5. **Bookkeeping** — `fosutilities-api-catalog-update` (new §§ entries: async buttons, LocalizableError, error alert), CHANGELOG, version bump (minor — new public API), `swiftformat`/`swiftlint`, full `swift test`. + +Each task lands as granular local commits; squash to logical commits before the branch is offered for review. No PR until David reviews the finished queue and says go. + +--- + +## Open items — all resolved (first pass, 2026-08-20) + +1. `isRunning` — **keep** (ratified; justification retained in §1). +2. Alert modifier filename — **`ErrorAlert.swift`** (ratified). +3. Plan slug — stands as `feat-async-button-surface.md`; formal numbering to come with the workflow's numbering source. +4. First-pass DocC corrections applied: `@Localized…` properties are plain values (`viewModel.title`, never `$`); error localization keys are type-rooted via `.localized(for:propertyName:)`. diff --git a/scripts/localizable-overload-sweep.swift b/scripts/localizable-overload-sweep.swift index 36982f2f..0b0aa215 100644 --- a/scripts/localizable-overload-sweep.swift +++ b/scripts/localizable-overload-sweep.swift @@ -24,6 +24,11 @@ // --check regenerate in memory and byte-compare against the // checked-in output; exit 1 on drift (used by CI). // --filter process only symbols whose extended type == . +// --emit-async-only re-render ONLY the FOS-designed Button+AsyncAction.swift +// from the checked-in SDK stamp — no extraction, no other +// files touched. For editing the async-twin table on a +// machine whose SDKs do not match the checked-in stamp; +// the twin-base cross-check still runs on every full sweep. // --keep-graphs leave the extracted symbol-graph JSON in the temp dir and // print its path (otherwise the temp dir is removed on exit). // @@ -53,6 +58,7 @@ struct Options { var check = false var keepGraphs = false var filter: String? + var emitAsyncOnly = false } func parseOptions() -> Options { @@ -69,6 +75,8 @@ func parseOptions() -> Options { fail("--filter requires a argument") } options.filter = value + case "--emit-async-only": + options.emitAsyncOnly = true default: fail("unknown argument: \(arg)") } @@ -2354,6 +2362,263 @@ func renderGeneratedFile( ) } +// MARK: Async Button emission (Stage 6b) + +/// The async-twin surface is FOS-DESIGNED, not an Apple mirror: the parameter +/// grouping (do-face, cancel-face, role, machinery), the refuse/toggle modes, +/// and every tap semantic live in AsyncButtonActivity.swift and the ratified +/// plan (planning/stream/feat-async-button-surface.md). The sweep's +/// contribution is (a) the emission slot — Generated/ is replaced wholesale on +/// regeneration, so the file must be rendered here — and (b) the twin-base +/// cross-check: the row table below was designed against Button's +/// action-taking Localizable overloads, and when that base grows or shifts in +/// a future SDK the full sweep FAILS in verifyAsyncTwinBase so the async table +/// is extended deliberately, never auto-stamped. +let asyncButtonFileName = "Button+AsyncAction.swift" + +/// Button's action-taking generated Localizable overloads, as parameter-label +/// rows. Grow this ONLY together with the async member renderer below. +let expectedAsyncTwinBase: Set<[String]> = [ + ["_", "defaultValue", "systemImage", "role", "action"], + ["_", "defaultValue", "systemImage", "action"], + ["_", "defaultValue", "image", "role", "action"], + ["_", "defaultValue", "image", "action"], + ["_", "defaultValue", "role", "action"], + ["_", "defaultValue", "action"] +] + +func verifyAsyncTwinBase(_ overloads: [TransformedOverload]) throws { + let actual = Set( + overloads + .filter { $0.extendedType == "Button" && $0.overloadParameterLabels.contains("action") } + .map(\.overloadParameterLabels) + ) + guard actual != expectedAsyncTwinBase else { return } + + func list(_ rows: Set<[String]>) -> String { + rows.isEmpty + ? "(none)" + : rows.map { $0.joined(separator: ":") }.sorted().joined(separator: ", ") + } + throw Failure(""" + async twin base drifted — Button's action-taking Localizable overloads no longer \ + match the FOS-designed async table (Stage 6b). + unexpected: \(list(actual.subtracting(expectedAsyncTwinBase))) + missing: \(list(expectedAsyncTwinBase.subtracting(actual))) + Extend expectedAsyncTwinBase AND the async member renderer together, deliberately. + """) +} + +enum AsyncDecoration { + case systemImage + case imageResource + case textOnly +} + +/// Renders one async init (DocC + declaration + body) at 4-space extension +/// indent. The refuse/toggle signature shapes here ARE the ratified design — +/// change them only against the plan, never to chase an Apple shape. +func renderAsyncMember( + decoration: AsyncDecoration, + hasRole: Bool, + isToggle: Bool, + isFirstInFile: Bool +) -> [String] { + // Signature — do-face, cancel-face, role, machinery (ratified face-grouped order) + var params = ["_ localizable: some Localizable", "defaultValue: String? = nil"] + switch decoration { + case .systemImage: params.append("systemImage: String") + case .imageResource: params.append("image: ImageResource") + case .textOnly: break + } + if isToggle { + params.append("cancelTitle: some Localizable") + params.append("cancelDefaultValue: String? = nil") + switch decoration { + case .systemImage: params.append("cancelSystemImage: String? = nil") + case .imageResource: params.append("cancelImage: ImageResource? = nil") + case .textOnly: break + } + } + if hasRole { + params.append("role: ButtonRole?") + } + params.append(isToggle + ? "activity: Binding" + : "activity: Binding? = nil") + params.append("error: Binding") + params.append("action: @escaping @Sendable () async throws -> Void") + + // Body — every member forwards to a hand-written ViewBuilder primitive + let forward = hasRole + ? "self.init(role: role, activity: activity, error: error, action: action, label: {" + : "self.init(activity: activity, error: error, action: action, label: {" + let titleExpr = "localizable.defaultedLocalizedString(defaultValue: defaultValue)" + let cancelExpr = "cancelTitle.defaultedLocalizedString(defaultValue: cancelDefaultValue)" + + var body: [String] = [] + if isToggle { + body.append("\(forward) phase in") + switch decoration { + case .systemImage: + body.append(" SwiftUI.Label(") + body.append(" phase == .idle ? \(titleExpr) : \(cancelExpr),") + body.append(" systemImage: phase == .idle ? systemImage : (cancelSystemImage ?? systemImage)") + body.append(" )") + case .imageResource: + body.append(" SwiftUI.Label(") + body.append(" phase == .idle ? \(titleExpr) : \(cancelExpr),") + body.append(" image: phase == .idle ? image : (cancelImage ?? image)") + body.append(" )") + case .textOnly: + body.append(" Text(phase == .idle ? \(titleExpr) : \(cancelExpr))") + } + body.append("})") + } else { + body.append(forward) + switch decoration { + case .systemImage: + body.append(" SwiftUI.Label(\(titleExpr), systemImage: systemImage)") + case .imageResource: + body.append(" SwiftUI.Label(\(titleExpr), image: image)") + case .textOnly: + body.append(" Text(\(titleExpr))") + } + body.append("})") + } + + // DocC + var docc: [String] = [] + if isToggle { + docc.append("/// A two-faced async button: tap to start the operation, tap again to cancel it") + docc.append("///") + docc.append("/// While idle the button shows `localizable`; while running it shows `cancelTitle` and") + docc.append("/// a tap cancels the operation. During the unwind (`activity.phase == .cancelling`)") + docc.append("/// taps are refused. A cancelled invocation writes nothing to `error`; a failed one") + docc.append("/// deposits its error there, and every launch clears it first.") + docc.append("///") + docc.append("/// > Important: Cancellation is cooperative — the action must run cancellation-aware") + docc.append("/// > work (any `URLSession`-backed `ServerRequest` is) for the cancel face to take") + docc.append("/// > effect.") + docc.append("///") + docc.append("/// - Parameters:") + docc.append("/// - localizable: The ``Localizable`` idle-face title.") + docc.append("/// - defaultValue: Fallback text used if localization did not complete.") + docc.append("/// - cancelTitle: The ``Localizable`` title shown while the operation runs.") + docc.append("/// - cancelDefaultValue: Fallback text for `cancelTitle`.") + switch decoration { + case .systemImage: + docc.append("/// - cancelSystemImage: The running-face symbol; `nil` keeps `systemImage`.") + case .imageResource: + docc.append("/// - cancelImage: The running-face image; `nil` keeps `image`.") + case .textOnly: + break + } + docc.append("/// - activity: The caller-owned ``AsyncButtonActivity`` (required — cancellation") + docc.append("/// needs state that survives re-renders).") + docc.append("/// - error: Receives the outcome of the most recent invocation.") + } else { + docc.append("/// Async form of the `Localizable` Button — runs a throwing async action and routes") + docc.append("/// its error to a binding") + docc.append("///") + if isFirstInFile { + docc.append("/// ## Example") + docc.append("///") + docc.append("/// ```swift") + docc.append("/// @ViewModel public struct MyViewModel: RequestableViewModel {") + docc.append("/// @LocalizedString public var saveTitle") + docc.append("/// ...") + docc.append("/// }") + docc.append("///") + docc.append("/// @State private var error: Error?") + docc.append("///") + docc.append("/// Button(viewModel.saveTitle, systemImage: \"tray.and.arrow.down\", error: $error) {") + docc.append("/// try await viewModel.operations.save()") + docc.append("/// }") + docc.append("/// .alert(error: $error,") + docc.append("/// title: viewModel.errorTitle,") + docc.append("/// dismissButtonLabel: viewModel.dismissTitle)") + docc.append("/// ```") + docc.append("///") + } + docc.append("/// Tapping starts the action; a thrown error lands in `error`, and every launch") + docc.append("/// clears it first — the binding holds the outcome of the most recent invocation.") + docc.append("/// Pass `activity:` to refuse taps while a run is in flight; without it every tap") + docc.append("/// starts a new concurrent invocation. The task runs to completion — for") + docc.append("/// user-cancellable work use the `cancelTitle:` forms.") + docc.append("///") + docc.append("/// - Parameters:") + docc.append("/// - localizable: The ``Localizable`` title to display.") + docc.append("/// - defaultValue: Fallback text used if localization did not complete.") + docc.append("/// - activity: Optional caller-owned ``AsyncButtonActivity`` enabling re-entry") + docc.append("/// refusal and running-state display.") + docc.append("/// - error: Receives the outcome of the most recent invocation.") + } + + var lines = docc.map { " \($0)" } + lines.append(" nonisolated init(\(params.joined(separator: ", "))) {") + lines.append(contentsOf: body.map { " \($0)" }) + lines.append(" }") + return lines +} + +/// The full async file — pure; depends only on the stamp. Member order is +/// fixed: per decoration (systemImage, image, text), role before plain, +/// refuse before toggle. +func renderAsyncButtonFile(stamp: GenerationStamp) -> GeneratedFile { + var lines: [String] = [] + lines.append(renderLicenseHeader(fileName: asyncButtonFileName)) + lines.append("") + lines.append(""" + // GENERATED FILE — DO NOT EDIT + // Generated by scripts/localizable-overload-sweep.swift (Stage 6b — FOS-designed async twins) + // SDKs: \(stamp.sdkLine) + // Regenerate: swift scripts/localizable-overload-sweep.swift [--emit-async-only] + """) + lines.append("") + lines.append("#if canImport(SwiftUI)") + lines.append("import DeveloperToolsSupport") + lines.append("import SwiftUI") + lines.append("") + lines.append("public extension Button where Label == SwiftUI.Label {") + + var memberCount = 0 + func appendMembers(_ decorations: [AsyncDecoration]) { + var first = true + for decoration in decorations { + for hasRole in [true, false] { + for isToggle in [false, true] { + if !first { + lines.append("") + } + lines.append(contentsOf: renderAsyncMember( + decoration: decoration, + hasRole: hasRole, + isToggle: isToggle, + isFirstInFile: memberCount == 0 + )) + first = false + memberCount += 1 + } + } + } + } + + appendMembers([.systemImage, .imageResource]) + lines.append("}") + lines.append("") + lines.append("public extension Button where Label == Text {") + appendMembers([.textOnly]) + lines.append("}") + lines.append("#endif") + + return GeneratedFile( + fileName: asyncButtonFileName, + contents: lines.joined(separator: "\n") + "\n", + overloadCount: memberCount + ) +} + // MARK: Manifest rendering /// The manifest's section order — the closed set of rejection reasons. @@ -2765,6 +3030,34 @@ func main(options: Options) throws -> Int32 { } } + // Async-only emission: the FOS-designed file depends only on the SDK stamp, + // so it can be re-rendered from the checked-in one without extraction — + // the affordance for editing the async table on a machine whose SDKs do + // not match the checked-in stamp (a full regen there would restamp the + // whole Apple-mirror surface, a separate deliberate act). + if options.emitAsyncOnly { + guard FileManager.default.fileExists( + atPath: packageRoot.appendingPathComponent("Package.swift").path + ) else { + throw Failure("run from the package root — no Package.swift in \(packageRoot.path)") + } + let checkedIn = try readCheckedInSDKStamp(packageRoot: packageRoot) + let sdkLine = try requiredSDKs.map { platform -> String in + guard let version = checkedIn[platform] else { + throw Failure("checked-in SDK stamp has no entry for \(platform)") + } + return "\(platform) \(version)" + }.joined(separator: " | ") + let file = renderAsyncButtonFile(stamp: GenerationStamp(sdkLine: sdkLine)) + let destination = packageRoot + .appendingPathComponent(generatedDirRelativePath, isDirectory: true) + .appendingPathComponent(file.fileName) + try Data(file.contents.utf8).write(to: destination) + print("wrote \(generatedDirRelativePath)/\(file.fileName) " + + "(\(file.overloadCount) overload(s), checked-in stamp)") + return 0 + } + // Staleness gate: before the expensive extraction, compare the runner's SDK // versions against the checked-in stamp. A mismatch (or a missing SDK) is an // informational SKIP (exit 0) — regeneration is a deliberate act, and a @@ -2938,7 +3231,7 @@ func main(options: Options) throws -> Int32 { } let stamp = makeGenerationStamp(sdks: sdks) - let output = try renderEmitOutput( + let baseOutput = try renderEmitOutput( overloads: transformed.overloads, rejects: manifestRejects, betaTierAPIs: union.apis.filter { !$0.betaTierDomains.isEmpty }, @@ -2948,6 +3241,16 @@ func main(options: Options) throws -> Int32 { typeAvailability: selection.typeAvailability ) + // Stage 6b: the FOS-designed async twins join the emit set — after the + // twin-base cross-check proves the designed table still matches + // Button's action-taking Localizable surface. + try verifyAsyncTwinBase(transformed.overloads) + let output = EmitOutput( + files: (baseOutput.files + [renderAsyncButtonFile(stamp: stamp)]) + .sorted { $0.fileName < $1.fileName }, + manifest: baseOutput.manifest + ) + print("\n== Emit ==") print(" files: \(output.files.count) overloads: \(transformed.overloads.count)") let gated = transformed.overloads.filter { !$0.gatePlatforms.isEmpty } @@ -2992,6 +3295,10 @@ if options.check, options.filter != nil { fail("--check cannot be combined with --filter") } +if options.emitAsyncOnly, options.check || options.filter != nil { + fail("--emit-async-only cannot be combined with --check or --filter") +} + do { let exitCode = try main(options: options) exit(exitCode)