Updated the documentation of the README in the Xcode project.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user