From a1a27a51c2613cd5ee2d4721560c357471042c9b Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 6 Jul 2026 03:48:37 +0200 Subject: [PATCH] Updated the documentation of the README in the Xcode project. --- README.md | 108 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 84 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 02e57aa..1e698b8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ A sample app that implements an audio recording flow in SwiftUI: start, pause, resume, or discard a recording while a timer tracks the elapsed time, then send the recording to be transcribed on device — and read the resulting text in a -sheet. +sheet. On iOS, the flow also surfaces as a Live Activity: the Lock Screen and the Dynamic Island show the recording's +state and timer, with buttons that pause, resume, send, or discard it without opening the app. A screen recording of the full flow is included in the repo: [`Attendi_Sample_App.MP4`](Attendi_Sample_App.MP4). @@ -23,7 +24,8 @@ healthcare professionals, based on [a given set of requirements](README.pdf). ## Running the app Open `Attendi.xcodeproj` in Xcode, pick the `Attendi` scheme, and run it on an iOS device or simulator, a Mac, or an -Apple Vision Pro — the same code base covers all three. +Apple Vision Pro — the same code base covers all three. On an iPhone, starting a recording also starts a Live +Activity: lock the device or leave the app to follow and control the flow from the Lock Screen or the Dynamic Island. Two things to expect on a first run: the app asks for the microphone permission when the first recording starts, and the first transcription in a language needs a network connection — picking a locale from the toolbar menu downloads @@ -41,37 +43,57 @@ Attendi/ │ └── Attendi/ # The app target (thin shell) │ ├── Sources/ │ │ ├── App/ # The @main entry point +│ │ ├── Services/ # The ActivityReporting service driving the recording Live Activity │ │ ├── View Models/ # The attached services, the supported locales, and the download-to-notification mapping │ │ └── Views/ # The root view: hosts the feature, the locale picker, the notifications, and the transcription sheet │ ├── Previews/ # The PreviewPreinstalling service backing the previews │ ├── Tests/ # The AttendiTests bundle: the content view model suite, with its mock │ ├── Catalogs/ # The app's assets and localized strings │ └── Resources/ # The Info.plist, including the microphone usage description +├── Widgets/ +│ └── Attendi/ # The widget extension target +│ ├── Sources/ +│ │ ├── Bundle/ # The @main widget bundle +│ │ ├── Extensions/ # The system symbol images shared across the extension +│ │ ├── Intents/ # The toggle, process, and discard App Intents behind the activity's buttons +│ │ ├── Styles/ # The ActionButtonStyle button style +│ │ ├── Views/ # The activity's timer text, state label, controls, and Lock Screen banner +│ │ └── Widgets/ # The RecordingLiveActivity configuration, and the template AttendiWidget placeholder +│ ├── Catalogs/ # The extension's assets and localized strings +│ └── Resources/ # The extension's Info.plist └── Packages/ └── Features/ # Local Swift package with the feature code ├── Package.swift ├── Sources/ + │ ├── Commanding/ # The Commanding feature target, shared between the app and the widget extension + │ │ ├── Activities/ # The RecordingActivityAttributes of the Live Activity + │ │ ├── Commands/ # The RecordingCommand commands controlling the flow from outside the feature + │ │ ├── Services/ # The RecordingCommander bridge carrying the commands into the flow + │ │ └── States/ # The RecordingActivityState states a Live Activity can show │ ├── Notifying/ # The Notifying feature target │ │ ├── Models/ # The AppNotification model │ │ ├── Styles/ # The NotificationLabelStyle label style │ │ └── View Models/ # The Notifier view model │ └── Recording/ # The Recording feature target │ ├── Catalogs/ # The record, pause, and send icons; the localized strings + │ ├── Clocks/ # The AnyClock eraser behind the model's injectable clock │ ├── Errors/ # The AudioCapturingError, AudioTranscribingError, RecordingError errors │ ├── Models/ # The Transcription model - │ ├── Protocols/ # The Capturing, Preinstalling, Transcribing protocols - │ ├── Services/ # The AudioCapturing, AudioTranscribing, and AssetPreinstalling services, with its respective development dummies + │ ├── Protocols/ # The Capturing, Preinstalling, Reporting, Transcribing protocols + │ ├── Services/ # The AudioCapturing, AudioTranscribing, and AssetPreinstalling services, with their respective development dummies │ ├── Styles/ # The RecordingButtonStyle button style │ ├── View Models/ # The RecordingViewModel view model │ └── Views/ # The RecordingView view └── Tests/ + ├── Commanding/ # The commander suite ├── Notifying/ # The notifier suite ├── Recording/ # The model, view model, and service suites, with their mocks └── Features.xctestplan ``` -The app target only depends on the `Features` package and renders its public `RecordingView` view; all recording logic -and UI live in the package's `Recording` target. +The app target depends on the `Features` package and renders its public `RecordingView` view; all recording logic +and UI live in the package's `Recording` target. The widget extension depends only on the package's `Commanding` +library — the vocabulary of activity attributes, states, and commands it shares with the app. ## Architecture @@ -82,8 +104,8 @@ The project follows an MVVM architecture on SwiftUI, split across a thin app tar - **View models** (`@Observable`, `@MainActor` classes) own the state and every side effect of its transitions — the recording state machine and the notification lifecycle in the package; the locale and the mapping of download events to notifications in the app. -- **Services** sit behind protocols (`Capturing`, `Transcribing`, `Preinstalling`) injected at initialization, so the - same logic runs against the real audio backends, the preview dummies, or the test mocks. +- **Services** sit behind protocols (`Capturing`, `Transcribing`, `Preinstalling`, `Reporting`) injected at + initialization, so the same logic runs against the real backends, the preview dummies, or the test mocks. - **Models** are plain value types (`Transcription`, `AppNotification`) with injectable identifiers, so tests can construct comparable values. @@ -94,8 +116,9 @@ surface, while everything else in the `Recording` target stays internal. A single multiplatform code base targets iOS, macOS, and visionOS 26. Platform differences are isolated behind conditional compilation: the shared `AVAudioSession` — its configuration, deactivation, and interruption observation — -only exists on iOS and visionOS, and the navigation bar title display mode is skipped on macOS. Everything else, -including the Liquid Glass styling, is shared. +only exists on iOS and visionOS, and the navigation bar title display mode is skipped on macOS. The Live Activity is +iOS-only: `ActivityKit` and the activity attributes exist only there, and the app's reporting service reduces to a +no-op on the other platforms. Everything else, including the Liquid Glass styling, is shared. ### Techniques @@ -105,6 +128,8 @@ including the Liquid Glass styling, is shared. - **Observation** — view models are `@Observable`, so views react to exactly the properties they read. - **On-device speech** — the `SpeechAnalyzer`/`SpeechTranscriber` stack with `AssetInventory` asset management; no audio ever leaves the device. +- **Live Activities** — `ActivityKit` reporting from the app, system-driven timer text that ticks without content + updates, and `LiveActivityIntent`s that steer the flow back from the Lock Screen and the Dynamic Island. - **Liquid Glass design** — custom `ButtonStyle` and `LabelStyle` types over `glassEffect` backgrounds, scaling with Dynamic Type via `@ScaledMetric`. - **Localization and accessibility** — string catalogs with generated string symbols on both targets, localized @@ -156,9 +181,16 @@ mid-recording — a phone call or Siri, for example — the service emits an eve press of the main button would; once the interruption ends, the recording resumes by itself — but only when the system hints the capture may continue, and never a recording the user paused themselves. +Every transition of the flow is also reported through the attached `Reporting` service, surfacing the recording +outside the feature's UI — in the Live Activity on iOS — and the flow can be controlled from outside that UI in +return: the model listens to the `Commanding` target's `RecordingCommander`, translating every received command — +toggle, process, or discard — into the button press it mirrors, so an external control obeys the exact same state +machine as a tap in the app. + ### Timer -The recording time is measured with a monotonic `ContinuousClock`: an anchor marks the start of the current recording +The recording time is measured against a monotonic clock injected at initialization — the `ContinuousClock` by +default, a manual clock in the tests: an anchor marks the start of the current recording stretch, and pausing folds the stretch into an accumulated duration, so scheduling latency never accumulates as drift. While recording, an async task republishes the measured time once per second. The view formats the count as `mm:ss` and animates digit changes with a numeric text content transition. @@ -183,7 +215,7 @@ distinguished from an ordinary transcription failure. The underlying errors are ### Services -The actual recording work is abstracted behind two protocols, injected into `RecordingView` at initialization and +The actual recording work is abstracted behind protocols, injected into `RecordingView` at initialization and attached to its view model: - **`Capturing`** — captures the audio from a microphone, with throwing async `start`, `pause`, `resume`, and `stop` @@ -194,6 +226,8 @@ attached to its view model: - **`Preinstalling`** — resolves the locales the transcriber supports and preinstalls the speech model assets of a locale ahead of its first transcription, emitting the started, cancelled, and failed downloads through an `events` stream instead of throwing. +- **`Reporting`** — reports the lifecycle of the recording flow — started, paused, resumed, processing, and ended — + outside the feature's UI; the app implements it over `ActivityKit` to drive the Live Activity. The real backends used by the app are: @@ -207,17 +241,19 @@ The real backends used by the app are: locale reservations beforehand since the system only permits a limited number per app, and discriminating a cancelled download from a failed one. -The internal `DummyCapturing` and `DummyTranscribing` fake the work for previews and default model values (the latter -with a two-second delay and a dummy transcription); the unit tests inject configurable mocks, and other backends can -be plugged in the same way without touching the feature's state machine. +The internal `DummyCapturing`, `DummyTranscribing`, and `DummyReporting` fake the work for previews and default model +values (the transcribing dummy with a two-second delay and a dummy transcription); the unit tests inject configurable +mocks, and other backends can be plugged in the same way without touching the feature's state machine. ## The app shell `ContentView` hosts the feature inside a navigation stack and presents the transcribed text of every processed recording in a modal sheet — or a content-unavailable message when the transcription is empty. A toolbar menu picks -the locale of the spoken language from the locales the `Preinstalling` service supports, aligned at launch with the -closest supported equivalent of the user's locale. Picking a locale preinstalls its speech model assets in the -background through the service, so the first transcription does not have to download them mid-processing. +the locale of the spoken language from the locales the `Preinstalling` service supports; the pick persists across +launches in the user defaults and is restored — realigned to the supported locales — before the feature loads, +starting from the closest supported equivalent of the user's locale on a first launch. Picking a locale preinstalls +its speech model assets in the background through the service, so the first transcription does not have to download +them mid-processing. The preinstallation runs as a cancellable task owned by the model: picking another locale cancels a download still in flight before starting the new one. The model maps the service's started, cancelled, and failed download events to the @@ -231,15 +267,39 @@ The app itself no longer touches the `Speech` or `Accessibility` frameworks: all the package's `Preinstalling` service, and the notification lifecycle — posting, announcing, and auto-dismissal — behind the `Notifier`. +## The Live Activity + +On iOS, the recording flow surfaces outside the app through a Live Activity, built from three pieces: + +- **`ActivityReporting`** (app target) implements the `Recording` feature's `Reporting` port over `ActivityKit`: it + starts a Live Activity when a recording starts, updates its content on every pause, resumption, and processing + transition, and ends it — dismissing it immediately — when the flow ends. The activity is an auxiliary surface, + never a required one: a start the user disallowed or the system refused is silently ignored. +- **The `Commanding` target** (package) is the vocabulary the app and the widget extension share: the activity's + attributes and content state — the flow's state, the timer anchor, and the frozen elapsed time — the commands + that control the flow, and the `RecordingCommander` bridge that carries them. The commander exists because the + App Intents behind the activity's buttons are instantiated by the system, out of reach of the feature's + dependency injection: they send through a process-wide shared instance, and the feature's model listens. +- **The widget extension** renders the activity and sends the commands back. `RecordingLiveActivity` configures the + Lock Screen banner and the Dynamic Island presentations from shared subviews: `RecordingTimerText` — a timer the + system itself counts up from the content state's anchor, formatted as `mm:ss`, so a running recording needs no + content updates to tick — `RecordingStateLabel`, and `RecordingControls`, whose buttons wear the circular, + color-coded `ActionButtonStyle` and fire the toggle, process, and discard `LiveActivityIntent`s. The system + executes those intents in the app's process, where each sends its command through the shared commander and the + view model handles it exactly like a press of the matching button — closing the loop. The Lock Screen banner + keeps the system's default background material, Liquid Glass. + ## Testing The package is covered by Swift Testing suites. In the `Recording` target, `RecordingViewModelTests.swift` groups the view model's behaviors with nested `@Suite` types (initial state, button presses, computed properties, timer, capturer, -errors, interruptions, and processing) and `TranscriptionTests.swift` covers the `Transcription` model. In the -`Notifying` target, `NotifierTests.swift` covers the posting and auto-dismissal lifecycle with an injected, shortened -dismissal delay, and `AppNotificationTests.swift` covers the `AppNotification` model. The state-dependent behaviors are -exercised with parameterized tests across all four states, driven through the model's public press handlers, and the -service mocks record their invocations in call order. `AudioCapturingTests.swift` covers the one piece of pure logic in +errors, interruptions, processing, reporter, and commands) and `TranscriptionTests.swift` covers the `Transcription` +model. In the `Notifying` target, `NotifierTests.swift` covers the posting and auto-dismissal lifecycle with an +injected, shortened dismissal delay, and `AppNotificationTests.swift` covers the `AppNotification` model. In the +`Commanding` target, `RecordingCommanderTests.swift` covers the ordered delivery of the commands. The state-dependent +behaviors are exercised with parameterized tests across all four states, driven through the model's public press +handlers, and the service mocks record their invocations in call order. The timer behaviors run against a manual test +clock advanced by hand, so no test sleeps in real time to see a tick. `AudioCapturingTests.swift` covers the one piece of pure logic in the audio services — the parsing of audio session interruption notifications into capturing events, extracted so it compiles on every platform. The rest of the system-bound service code, `AssetPreinstalling` included, is deliberately untested: thin wrappers over device-global frameworks like `AssetInventory` and `SpeechAnalyzer`, whose behavior mocks @@ -265,7 +325,7 @@ Both the app and the feature package localize their user-facing text through str currently English only), resolved through Xcode's generated string symbols (`LocalizedStringResource`): the app's catalog covers the navigation titles, the transcription sheet, the locale picker, and the notification messages — the latter taking the locale's name as their argument — and the package's catalog covers the error alert and the -accessibility labels. +accessibility labels. The widget extension carries a catalog of its own for the Live Activity's state label. ## Tooling