Updated the README file of the project.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
# Attendi Sample App
|
||||
|
||||
A sample app that implements an audio recording flow in SwiftUI: start, pause, and resume a recording while a timer
|
||||
tracks the elapsed time, then send the recording for (simulated) processing.
|
||||
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.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -9,10 +10,6 @@ This sample app was created for a technical interview for a senior Mobile (iOS +
|
||||
[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).
|
||||
|
||||
## Demo
|
||||
|
||||
[▶️ Watch the sample (iOS) app in action!](Attendi_Sample_App.MP4)
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Version |
|
||||
@@ -29,24 +26,26 @@ The project separates the app shell from the feature code, which lives in a loca
|
||||
Attendi/
|
||||
├── Attendi.xcodeproj
|
||||
├── Apps/
|
||||
│ └── Attendi/ # The app target (thin shell)
|
||||
│ ├── AttendiApp.swift # The @main entry point
|
||||
│ ├── ContentView.swift # The root view, hosting the Recording feature
|
||||
│ └── Assets.xcassets
|
||||
│ └── Attendi/ # The app target (thin shell)
|
||||
│ ├── App/ # The @main entry point
|
||||
│ ├── Views/ # The root view: hosts the feature, the locale picker, and the transcription sheet
|
||||
│ ├── View Models/ # The services, the supported locales, and the speech model preinstallation
|
||||
│ ├── Catalogs/ # The app's assets and localized strings
|
||||
│ └── Resources/ # The Info.plist, including the microphone usage description
|
||||
└── Packages/
|
||||
└── Features/ # Local Swift package with the feature code
|
||||
└── Features/ # Local Swift package with the feature code
|
||||
├── Package.swift
|
||||
├── Sources/
|
||||
│ └── Recording/ # The Recording feature target
|
||||
│ ├── RecordingView.swift
|
||||
│ ├── RecordingViewModel.swift
|
||||
│ ├── RecordingButtonStyle.swift
|
||||
│ ├── RecordingService.swift
|
||||
│ ├── TranscribingService.swift
|
||||
│ └── Images.xcassets # Record, pause, and send icons
|
||||
│ └── Recording/ # The Recording feature target
|
||||
│ ├── Catalogs/ # The record, pause, and send icons; the localized strings
|
||||
│ ├── Models/ # Transcription, RecordingError
|
||||
│ ├── Protocols/ # Capturing, Transcribing
|
||||
│ ├── Services/ # AudioCapturing, AudioTranscribing, and their development dummies
|
||||
│ ├── Styles/ # RecordingButtonStyle
|
||||
│ ├── View Models/ # RecordingViewModel
|
||||
│ └── Views/ # RecordingView
|
||||
└── Tests/
|
||||
├── Recording/
|
||||
│ └── RecordingViewModelTests.swift
|
||||
├── Recording/ # The model and view model suites, with their mocks
|
||||
└── Features.xctestplan
|
||||
```
|
||||
|
||||
@@ -63,46 +62,84 @@ The flow is modeled as a state machine in `RecordingView.Model`, an `@Observable
|
||||
| --- | --- |
|
||||
| `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 or sent. |
|
||||
| `processing` | The sent recording is being processed. |
|
||||
| `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, a send button appears; pressing it moves the
|
||||
flow into processing (currently simulated with a delay) before returning to idle.
|
||||
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 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; the capture is never resumed without the user asking for it.
|
||||
|
||||
### Timer
|
||||
|
||||
While recording, an async task increments the elapsed seconds once per second — pausing stops it, resuming continues
|
||||
it, and a new recording resets it. The view formats the count as `mm:ss` and animates digit changes with a numeric
|
||||
text content transition.
|
||||
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
|
||||
the model's current state, and forwards state changes back to the model.
|
||||
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
|
||||
background, shrinks while pressed, 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.
|
||||
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:
|
||||
|
||||
- **`RecordingService`** — captures the audio from a microphone, with throwing async `start`, `pause`, `resume`, and
|
||||
`stop` methods; `stop` returns the captured audio.
|
||||
- **`TranscribingService`** — transcribes the captured audio into text through a throwing async `transcribe` method;
|
||||
the view model stores the result once processing finishes.
|
||||
- **`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`.
|
||||
|
||||
The default `SimulatedRecordingService` and `SimulatedTranscribingService` fake the work (the latter with a two-second
|
||||
delay and a dummy transcription); the unit tests inject fast mocks, and real backends can be plugged in the same way
|
||||
without touching the feature's state machine. When either service fails, the view model falls back to the
|
||||
not-recording state.
|
||||
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 before
|
||||
the analysis.
|
||||
|
||||
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 transcriber 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, so
|
||||
the first transcription does not have to download them mid-processing; the app keeps a single asset locale reservation
|
||||
at a time, releasing stale ones, since the system only permits a limited number per app.
|
||||
|
||||
## Testing
|
||||
|
||||
The view model is covered by Swift Testing suites in `RecordingViewModelTests.swift`, grouped with nested `@Suite`
|
||||
types (initial state, button presses, computed properties, timer, and processing). The state-dependent behaviors are
|
||||
exercised with parameterized tests across all four states.
|
||||
The package is covered by Swift Testing suites: `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. 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.
|
||||
|
||||
Run the tests with the `Features` scheme in Xcode (`⌘U`), or from the command line:
|
||||
|
||||
@@ -110,6 +147,12 @@ Run the tests with the `Features` scheme in Xcode (`⌘U`), or from the command
|
||||
xcodebuild test -scheme Features -destination 'platform=iOS'
|
||||
```
|
||||
|
||||
## Localization
|
||||
|
||||
Both the app and the feature package localize their user-facing text through string catalogs (`Localizable.xcstrings`,
|
||||
currently English only): the app's catalog covers the navigation titles and the transcription sheet, and the package's
|
||||
catalog covers the error alert and the accessibility labels.
|
||||
|
||||
## Tooling
|
||||
|
||||
- **`.swift-format`** — configuration for Apple's `swift-format`, used by Xcode's built-in formatter (4-space
|
||||
|
||||
Reference in New Issue
Block a user