2026-07-03 20:14:56 +02:00
|
|
|
# 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.
|
2026-07-03 20:14:56 +02:00
|
|
|
|
2026-07-05 17:03:24 +02:00
|
|
|
A screen recording of the full flow is included in the repo: [`Attendi_Sample_App.MP4`](Attendi_Sample_App.MP4).
|
|
|
|
|
|
2026-07-03 20:14:56 +02:00
|
|
|
## 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
|
2026-07-03 20:39:00 +02:00
|
|
|
healthcare professionals, based on [a given set of requirements](README.pdf).
|
|
|
|
|
|
2026-07-03 20:14:56 +02:00
|
|
|
## Requirements
|
|
|
|
|
|
|
|
|
|
| Requirement | Version |
|
2026-07-05 17:03:24 +02:00
|
|
|
| :--- | :--- |
|
|
|
|
|
| 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.
|
2026-07-03 20:14:56 +02:00
|
|
|
|
|
|
|
|
## 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)
|
2026-07-05 17:03:24 +02:00
|
|
|
│ ├── 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
|
2026-07-03 20:14:56 +02:00
|
|
|
└── Packages/
|
2026-07-04 16:00:13 +02:00
|
|
|
└── Features/ # Local Swift package with the feature code
|
2026-07-03 20:14:56 +02:00
|
|
|
├── Package.swift
|
|
|
|
|
├── Sources/
|
2026-07-05 17:03:24 +02:00
|
|
|
│ ├── 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
|
2026-07-05 17:03:24 +02:00
|
|
|
│ ├── 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
|
2026-07-03 20:14:56 +02:00
|
|
|
└── Tests/
|
2026-07-05 17:03:24 +02:00
|
|
|
├── Notifying/ # The notifier suite
|
|
|
|
|
├── Recording/ # The model, view model, and service suites, with their mocks
|
2026-07-03 20:14:56 +02:00
|
|
|
└── 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.
|
|
|
|
|
|
2026-07-05 17:03:24 +02:00
|
|
|
## 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.
|
|
|
|
|
|
2026-07-03 20:14:56 +02:00
|
|
|
## 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-03 20:14:56 +02:00
|
|
|
|
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
|
2026-07-05 17:03:24 +02:00
|
|
|
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
|
2026-07-05 17:03:24 +02:00
|
|
|
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.
|
2026-07-03 20:14:56 +02:00
|
|
|
|
|
|
|
|
### 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.
|
2026-07-03 20:14:56 +02:00
|
|
|
|
|
|
|
|
### 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.
|
2026-07-03 20:14:56 +02:00
|
|
|
- **`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.
|
2026-07-03 20:14:56 +02:00
|
|
|
|
2026-07-03 21:33:07 +02:00
|
|
|
### 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`.
|
2026-07-05 17:03:24 +02:00
|
|
|
- **`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-03 21:33:07 +02:00
|
|
|
|
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,
|
2026-07-05 17:03:24 +02:00
|
|
|
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
|
2026-07-05 17:03:24 +02:00
|
|
|
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`.
|
2026-07-03 21:33:07 +02:00
|
|
|
|
2026-07-03 20:14:56 +02:00
|
|
|
## Testing
|
|
|
|
|
|
2026-07-05 17:03:24 +02:00
|
|
|
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
|
2026-07-05 17:03:24 +02:00
|
|
|
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.
|
2026-07-03 20:14:56 +02:00
|
|
|
|
2026-07-05 17:03:24 +02:00
|
|
|
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:
|
2026-07-03 20:14:56 +02:00
|
|
|
|
|
|
|
|
```sh
|
2026-07-05 17:03:24 +02:00
|
|
|
xcodebuild test -scheme Features -destination 'platform=macOS'
|
2026-07-03 20:14:56 +02:00
|
|
|
```
|
|
|
|
|
|
2026-07-05 17:03:24 +02:00
|
|
|
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`,
|
2026-07-05 17:03:24 +02:00
|
|
|
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
|
|
|
|
2026-07-03 20:14:56 +02:00
|
|
|
## Tooling
|
|
|
|
|
|
|
|
|
|
- **`.swift-format`** — configuration for Apple's `swift-format`, used by Xcode's built-in formatter (4-space
|
|
|
|
|
indentation, 200-column lines).
|