Files
attendi/README.md
T

274 lines
18 KiB
Markdown
Raw Normal View History

# Attendi Sample App
2026-07-04 16:00:13 +02:00
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.
A screen recording of the full flow is included in the repo: [`Attendi_Sample_App.MP4`](Attendi_Sample_App.MP4).
## 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.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.
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/
2026-07-04 16:00:13 +02:00
│ └── Attendi/ # The app target (thin shell)
│ ├── Sources/
│ │ ├── App/ # The @main entry point
│ │ ├── 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
2026-07-04 16:00:13 +02:00
│ ├── Catalogs/ # The app's assets and localized strings
│ └── Resources/ # The Info.plist, including the microphone usage description
└── Packages/
2026-07-04 16:00:13 +02:00
└── Features/ # Local Swift package with the feature code
├── Package.swift
├── Sources/
│ ├── Notifying/ # The Notifying feature target
│ │ ├── Models/ # The AppNotification model
│ │ ├── Styles/ # The NotificationLabelStyle label style
│ │ └── View Models/ # The Notifier view model
2026-07-04 16:00:13 +02:00
│ └── 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, Transcribing protocols
│ ├── Services/ # The AudioCapturing, AudioTranscribing, and AssetPreinstalling services, with its respective development dummies
│ ├── Styles/ # The RecordingButtonStyle button style
│ ├── View Models/ # The RecordingViewModel view model
│ └── Views/ # The RecordingView view
└── Tests/
├── 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.
## 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`) injected at initialization, so the
same logic runs against the real audio 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. 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.
- **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** — a task chain guarantees the capture calls reach the 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. |
2026-07-04 16:00:13 +02:00
| `paused` | The recording is paused; it can be resumed, discarded, or sent. |
| `processing` | The sent recording is being stopped and transcribed. |
2026-07-04 16:00:13 +02:00
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. The discard button stays available while processing, where 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.
2026-07-04 16:00:13 +02:00
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.
### Timer
2026-07-04 16:00:13 +02:00
The recording time is measured with a monotonic `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
2026-07-04 16:00:13 +02:00
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 red
2026-07-04 16:00:13 +02:00
Liquid Glass background that reacts fluidly to presses, and dims while disabled. The label and its padding scale
with Dynamic Type via `@ScaledMetric`, and an `invertStyle` flag controls whether the label's color scheme is
inverted for contrast.
### Services
The actual recording work is abstracted behind two protocols, injected into `RecordingView` at initialization and
attached to its view model:
2026-07-04 16:00:13 +02:00
- **`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.
2026-07-04 16:00:13 +02:00
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.
2026-07-04 16:00:13 +02:00
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 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 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`.
## 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
2026-07-04 16:00:13 +02:00
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, and the mapping of the download events to the notifier's notifications. Run it
with the `Attendi` scheme in Xcode (`⌘U`).
2026-07-04 16:00:13 +02:00
## 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.
2026-07-04 16:00:13 +02:00
## Tooling
- **`.swift-format`** — configuration for Apple's `swift-format`, used by Xcode's built-in formatter (4-space
indentation, 200-column lines).