356 lines
26 KiB
Markdown
356 lines
26 KiB
Markdown
# Attendi Sample App
|
||
|
||
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. On iOS, the flow also surfaces as a Live Activity, so the recording can be followed and controlled from the
|
||
Lock Screen and the Dynamic Island without opening the app.
|
||
|
||
A screen recording of the full – end-to-end – recording flow on all supported Apple platforms:
|
||
|
||
| iOS | macOS | visionOS |
|
||
| :---: | :---: | :---: |
|
||
| <video src=".README/Attendi_Demo_E2E_iOS.MP4" controls width="320"></video> | <video src=".README/Attendi_Demo_E2E_macOS.MP4" controls width="420"></video> | <video src=".README/Attendi_Demo_E2E_visionOS.MP4" controls width="420"></video> |
|
||
|
||
Live Activity interactions – for iOS only – can't be captured in a screen recording, so these screenshots stand in — the permission prompt on first record, the active and paused Lock Screen banners, and the collapsed Dynamic Island:
|
||
|
||
| User permission | Microphone recording | Microphone paused | Dynamic Island |
|
||
| --- | --- | --- | --- |
|
||
|  |  |  |  |
|
||
|
||
On iPhone, starting a recording also starts a Live Activity, so the flow can be followed and controlled without the app
|
||
in the foreground. On the Lock Screen it appears as a banner, and while the app is open it collapses into the Dynamic
|
||
Island. Both surfaces show the recording's current state and a timer that the system counts up on its own — no app
|
||
updates needed to keep it ticking — alongside buttons that pause, resume, send, or discard the recording. Tapping pause
|
||
or resume acts silently in place; send and discard bring the app forward to finish the flow. Each button routes back into
|
||
the same recording state machine as the in-app controls, so the Live Activity and the app never disagree, and the
|
||
activity ends the moment the recording does.
|
||
|
||
## Context
|
||
|
||
This sample app was created for a technical interview for a senior Mobile (iOS + Android) engineer role at
|
||
[Attendi](https://attendi.nl), a Dutch health-tech company that makes voice-driven reporting software for
|
||
healthcare professionals, based on [a given set of requirements](.README/Assignment.pdf).
|
||
|
||
## Requirements
|
||
|
||
| Requirement | Version |
|
||
| :--- | :--- |
|
||
| Xcode | _26_ or later |
|
||
| Swift | _6.3_ or later |
|
||
| Platforms | _iOS_, _macOS_, and _visionOS 26_ or later |
|
||
|
||
## 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. On an iPhone, lock the device or leave the app to follow and
|
||
control the recording from its Live Activity, as described above.
|
||
|
||
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
|
||
its speech model in the background, reported through the in-app notifications, and a recording sent before the
|
||
download finishes simply waits for it. Every transcription after that happens entirely on device.
|
||
|
||
## Project structure
|
||
|
||
The project separates the app shell from the feature code, which lives in a local Swift package:
|
||
|
||
```
|
||
Attendi/
|
||
├── Attendi.xcodeproj
|
||
├── Apps/
|
||
│ └── 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 shared symbol images, and the state-to-symbol/tint presentation mapping
|
||
│ │ ├── 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
|
||
│ ├── 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
|
||
│ ├── Errors/ # The AudioCapturingError, AudioTranscribingError, RecordingError errors
|
||
│ ├── Models/ # The Transcription model
|
||
│ ├── 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 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
|
||
|
||
The project follows an MVVM architecture on SwiftUI, split across a thin app target and a local Swift package:
|
||
|
||
- **Views** render state and forward user events; they own no logic beyond layout, styling, and presentation, and
|
||
decompose into private subviews and reusable styles.
|
||
- **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`, `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.
|
||
|
||
The package boundary enforces the separation: the app depends on the `Features` library and composes its public
|
||
surface, while everything else in the `Recording` target stays internal.
|
||
|
||
### Platforms
|
||
|
||
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. 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
|
||
|
||
- **Swift 6 strict concurrency** — async/await throughout, `@MainActor` isolation for the UI-facing types, `Sendable`
|
||
protocols and models, `AsyncStream` for service events, and `withTaskCancellationHandler` for cancellable
|
||
downloads. The Combine framework is not used.
|
||
- **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
|
||
accessibility labels on every icon-only control, timer values spelled out in full units, and the transient
|
||
notifications announced to assistive technologies when posted.
|
||
- **Logging** — `OSLog` subsystems for the app and the package, so failures that are not surfaced remain diagnosable.
|
||
|
||
### Strategies
|
||
|
||
- **Model-owned side effects** — a button press and a unit test drive the exact same transition code, keeping the
|
||
views pure renderers and the state machine fully testable.
|
||
- **Serialized service access** — separate task chains guarantee the capture and the reporting calls each reach their
|
||
service in transition order, no matter how quickly the user taps.
|
||
- **Drift-free timing** — the recording time derives from a monotonic clock anchor instead of counted ticks, so
|
||
scheduling latency never accumulates.
|
||
- **File-based audio hand-off** — the capture and the transcription exchange a file URL, never an in-memory copy of
|
||
the audio.
|
||
- **Preflighted downloads** — speech model assets install when a locale is picked, as a cancellable task reported
|
||
through notifications, instead of mid-transcription; locale reservations are recycled to respect the system's
|
||
per-app quota.
|
||
- **Typed error surfacing** — service failures map to a small user-facing error enum with tailored recovery, such as
|
||
opening the app's settings for a denied permission, while the underlying errors go to the log.
|
||
- **Behavior-driven tests** — Swift Testing suites drive the model exclusively through its public API, with
|
||
parameterized cases across every state and mocks that record their invocations in call order.
|
||
|
||
## The Recording feature
|
||
|
||
### State machine
|
||
|
||
The flow is modeled as a state machine in `RecordingView.Model`, an `@Observable`, `@MainActor` view model:
|
||
|
||
| State | Meaning |
|
||
| --- | --- |
|
||
| `notRecording` | Idle; no recording in progress. |
|
||
| `recording` | A recording is in progress and the timer is ticking. |
|
||
| `paused` | The recording is paused; it can be resumed, discarded, or sent. |
|
||
| `processing` | The sent recording is being stopped and transcribed. |
|
||
|
||
The main button starts, pauses, and resumes a recording. While paused, two more buttons appear: discard throws the
|
||
recording away, and send moves the flow into processing — stopping the capture and transcribing the audio — before
|
||
returning to idle. While processing, the send button shows a progress indicator and is disabled, and the discard
|
||
button stays available — there it cancels the in-flight transcription and throws the recording away without
|
||
publishing anything. The model owns every side effect of a transition (timer,
|
||
capture, processing), so the state machine works the same whether it is driven by the view's buttons or by the unit
|
||
tests.
|
||
|
||
The calls to the capturing service are serialized through a task chain: no matter how quickly the states change,
|
||
`start`, `pause`, `resume`, and `stop` always reach the service in order. When the system interrupts the audio session
|
||
mid-recording — a phone call or Siri, for example — the service emits an event and the model pauses exactly like a
|
||
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 against a `ContinuousClock`: 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.
|
||
|
||
### Errors
|
||
|
||
Failures of either service are published by the model as a `RecordingError` and presented by the view in an alert: a
|
||
denied microphone permission additionally offers to open the app's settings, and unavailable speech model assets are
|
||
distinguished from an ordinary transcription failure. The underlying errors are logged through `OSLog` under the
|
||
`Features.Recording` subsystem.
|
||
|
||
### Views
|
||
|
||
- **`RecordingView`** — the public entry point of the feature. It renders the timer label and the control buttons for
|
||
the model's current state, forwards every transcription to a closure given at initialization, and presents the
|
||
error alert. All controls carry localized accessibility labels matching their current action, and the timer
|
||
exposes its value to assistive technologies spelled out in full units.
|
||
- **`RecordingButtonStyle`** — a custom `ButtonStyle` used by the controls: the label sits on a padded, circular
|
||
Liquid Glass background, tinted by the color given at initialization — the feature colors the main button yellow,
|
||
discard red, and send green — that reacts fluidly to presses, and dims while disabled. The label and its padding
|
||
scale with Dynamic Type via `@ScaledMetric`.
|
||
|
||
### Services
|
||
|
||
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`
|
||
methods; `stop` hands the captured audio over as a file URL, avoiding any in-memory copies. An `events` stream
|
||
(empty by default) lets a service report interruptions of the capture.
|
||
- **`Transcribing`** — a callable service that transcribes a recorded audio file into text for a given locale,
|
||
returning a `Transcription`.
|
||
- **`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, plus a `reset` that clears any surface a previous run left behind; the app implements it
|
||
over `ActivityKit` to drive the Live Activity.
|
||
|
||
The real backends used by the app are:
|
||
|
||
- **`AudioCapturing`** — records single-channel AAC into a temporary file through `AVAudioRecorder`, requesting the
|
||
microphone permission and managing the shared audio session on the platforms that require it, and emitting an
|
||
event when the system interrupts the session while recording.
|
||
- **`AudioTranscribing`** — transcribes entirely on device through `SpeechAnalyzer` with a `SpeechTranscriber` module,
|
||
in the closest supported equivalent of the requested locale, installing any missing speech model assets through
|
||
the preinstalling service before the analysis.
|
||
- **`AssetPreinstalling`** — downloads speech model assets through the system's `AssetInventory`, recycling the
|
||
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`, `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, each named by its localized
|
||
language name paired with its region's flag emoji; 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. The navigation title tracks the pick, reading "Transcribe to" the
|
||
locale's name. 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
|
||
`Notifying` feature's `Notifier` — supplying the localized message and the symbol for each — which presents them as
|
||
transient in-app notifications: `Label`s wearing the `NotificationLabelStyle`, a capsule Liquid Glass banner whose icon
|
||
is tinted by the notification's kind (green for info, yellow for a warning, red for an error). The banners overlay the
|
||
feature just below the navigation bar, sliding in from the leading edge and dismissing themselves after a few seconds,
|
||
and every posted notification is announced to assistive technologies. A download that completes posts none.
|
||
|
||
The app itself no longer touches the `Speech` or `Accessibility` frameworks: all speech model management lives behind
|
||
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. Every update carries
|
||
a stale date, and a launch-time reset ends any activity a previous run left behind — a kill before it could end
|
||
its own — so an abandoned activity never lingers.
|
||
- **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 every intent in the app's process, where it sends its command through the shared commander and the
|
||
view model handles it exactly like a press of the matching button; the toggle intent runs there silently,
|
||
pausing or resuming without leaving the Live Activity, while the process and discard intents bring the app to
|
||
the foreground. 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, 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. `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 cannot meaningfully verify.
|
||
|
||
Run the package tests with the `Features` scheme in Xcode (`⌘U`), or from the command line. The suites run on any of the
|
||
three platforms; macOS needs no simulator:
|
||
|
||
```sh
|
||
xcodebuild test -scheme Features -destination 'platform=macOS'
|
||
```
|
||
|
||
For iOS, point the destination at a booted simulator, for example `-destination 'platform=iOS Simulator,name=iPhone 16'`.
|
||
|
||
The app has its own `AttendiTests` unit-test bundle, hosted by the app: `ContentViewModelTests.swift` drives
|
||
`ContentView.Model` with a mocked `Preinstalling` service, covering the locale loading and alignment, the
|
||
cancellable preinstallation guard, the mapping of the download events to the notifier's notifications, the locale
|
||
naming — pairing the localized language name with its region's flag emoji — and the navigation title tracking the
|
||
picked locale. Run it with the `Attendi` scheme in Xcode (`⌘U`).
|
||
|
||
## Localization
|
||
|
||
Both the app and the feature package localize their user-facing text through string catalogs (`Localizable.xcstrings`,
|
||
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. The widget extension carries a catalog of its own for the Live Activity's state label.
|
||
|
||
## Tooling
|
||
|
||
- **`.swift-format`** — configuration for Apple's `swift-format`, used by Xcode's built-in formatter (4-space
|
||
indentation, 200-column lines).
|