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

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
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, a Dutch health-tech company that makes voice-driven reporting software for healthcare professionals, based on a given set of requirements.

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 ActivitiesActivityKit reporting from the app, system-driven timer text that ticks without content updates, and LiveActivityIntents 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.
  • LoggingOSLog 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: Labels 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 LiveActivityIntents. 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:

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).
Languages
Swift 100%