import Foundation import Notifying import Observation import Recording extension ContentView { /// The observable model that drives ``ContentView``. /// /// The model owns the recording, transcribing, and reporting services attached to the recording feature, along with the ``locale`` the feature /// transcribes in, picked in the view's toolbar from the supported ``locales`` that ``load(identifier:)`` fetches — restoring the /// locale the view persisted across launches, when one exists. It also holds the transcription /// currently presented in the view's modal sheet: ``received(_:)`` presents the transcription of a processed recording, and /// ``dismissed()`` clears it when the sheet closes. /// /// Every picked locale kicks off ``preinstall()``, a cancellable task that preinstalls the locale's speech model assets through the /// preinstalling service — cancelling any download still in flight for a previously picked locale. The service's started, cancelled, and /// failed download events are mapped to the transient, self-dismissing notifications of the ``notifier``. /// /// Every collaborator is injected at initialization behind its protocol, defaulting to the on-device services: the app runs the model /// against the real backends, while its unit tests and previews attach mocks and dummies without touching this type. @MainActor @Observable final class Model { // MARK: Properties /// The locale of the spoken language to transcribe. var locale: Locale /// The transcription currently presented in the modal sheet, or `nil` when none is shown. var transcription: Transcription? /// The locales the transcriber supports, sorted by their localized names, or empty while ``load(identifier:)`` has not finished yet. private(set) var locales: [Locale] /// The service that captures the audio from a microphone. @ObservationIgnored let capturer: any Capturing /// Whether the reporting surfaces left over from an earlier run have been discarded, so the discard runs only once per launch. @ObservationIgnored private var didReset = false /// The locale whose speech model assets are preinstalled or being preinstalled, or `nil` when none are. @ObservationIgnored private var localePreinstalling: Locale? /// The notifier that owns the transient in-app notifications reporting the download events. @ObservationIgnored let notifier: Notifier /// The service that preinstalls the speech model assets of a picked locale. @ObservationIgnored private let preinstaller: any Preinstalling /// The service that reports the lifecycle of the recording flow outside the app's UI. @ObservationIgnored let reporter: any Reporting /// The task that listens to the events emitted by the preinstalling service, or `nil` until the first preinstallation starts. @ObservationIgnored private var taskEvents: Task? /// The task that preinstalls the speech model assets of the last picked locale, or `nil` until the first locale is picked. @ObservationIgnored private var taskPreinstall: Task? /// The service that transcribes the recorded audio into text. @ObservationIgnored let transcriber: any Transcribing // MARK: Initializers /// Creates a model set to the user's current locale, with no supported locales loaded yet, attached to the given services /// and notifier. /// /// - Parameters: /// - capturer: The service that captures the audio from a microphone. Defaults to the on-device ``AudioCapturing``. /// - preinstaller: The service that preinstalls the speech model assets of a picked locale. Defaults to ``AssetPreinstalling``. /// - reporter: The service that reports the lifecycle of the recording flow outside the app's UI. Defaults to /// ``ActivityReporting``, which surfaces the flow in a Live Activity on the platforms that support it. /// - transcriber: The service that transcribes the recorded audio into text. Defaults to the on-device ``AudioTranscribing``. /// - notifier: The notifier that owns the transient in-app notifications. Defaults to a notifier with its standard dismissal delay. init( capturer: any Capturing = AudioCapturing(), preinstaller: any Preinstalling = AssetPreinstalling(), reporter: any Reporting = ActivityReporting(), transcriber: any Transcribing = AudioTranscribing(), notifier: Notifier = .init(), ) { self.locale = .current self.locales = [] self.capturer = capturer self.notifier = notifier self.preinstaller = preinstaller self.reporter = reporter self.transcriber = transcriber } // MARK: Methods /// Discards, once per launch, any reporting surface left over from an earlier run — a Live Activity the app was killed before it /// could end, for example — since a fresh launch has no recording it could still belong to. It then loads the locales the /// transcriber supports — resolved through the preinstalling service — into ``locales``, sorted by their /// localized names, and aligns ``locale`` with the supported equivalent of its current value — restored first from the given /// persisted identifier when one exists, and falling back to the supported equivalent of a default locale when no equivalent /// exists — so the locale picker starts with a valid selection. It then kicks off the preinstallation of the aligned locale's /// speech model assets. /// /// - Parameter identifier: The identifier of the locale persisted across launches, or an empty string when none has been /// persisted yet. func load( identifier: String ) async { if !didReset { didReset = true await reporter.reset() } if !identifier.isEmpty { locale = .init(identifier: identifier) } locales = await preinstaller .supportedLocales() .sorted { name(for: $0).localizedStandardCompare(name(for: $1)) == .orderedAscending } locale = if let equivalent = await preinstaller.supportedLocale(equivalentTo: locale) { equivalent } else if let fallback = await preinstaller.supportedLocale(equivalentTo: .byDefault) { fallback } else { .current } preinstall() } /// Preinstalls the speech model assets for the current ``locale`` in a cancellable task, cancelling the preinstallation of any /// previously picked locale still in flight — which the ``notifier`` reports as a cancelled download. Does nothing when the /// current locale is the one already preinstalled or being preinstalled. func preinstall() { guard localePreinstalling != locale else { return } taskPreinstall?.cancel() localePreinstalling = locale listenToEvents() taskPreinstall = Task { [locale] in await preinstaller.preinstall(for: locale) } } /// Returns the localized language name of a locale, followed by the flag emoji of its region, for the locale picker. /// /// - Parameter locale: The locale to name. /// - Returns: The localized language name — in the user's current locale — followed by the region's flag emoji when the /// locale has one, or the locale's identifier when the language has no localized name. func name( for locale: Locale ) -> String { let name = locale .language .languageCode .flatMap { Locale.current.localizedString(forLanguageCode: $0.identifier) }? .localizedCapitalized ?? locale.identifier guard let region = locale.region, let flag = flag(for: region) else { return name } return "\(name) \(flag)" } /// Handles the transcription of a processed recording, presenting it in the modal sheet. /// /// - Parameter transcription: The transcription of the processed recording. func received( _ transcription: Transcription ) { self.transcription = transcription } /// Handles the dismissal of the modal sheet. func dismissed() { transcription = nil } } } // MARK: - Helpers private extension ContentView.Model { // MARK: Methods /// Returns the flag emoji of the given region, built from its two-letter code, or `nil` when the region has no such code. /// /// - Parameter region: The region to build a flag emoji for. /// - Returns: The region's flag emoji, or `nil` when its identifier is not a two-letter code — a numeric UN M49 region, for example. func flag( for region: Locale.Region ) -> String? { let code = region.identifier guard code.count == 2, code.allSatisfy(\.isLetter) else { return nil } var flag = "" for scalar in code.uppercased().unicodeScalars { guard let indicator = Unicode.Scalar(Constant.Flag.base + scalar.value) else { return nil } flag.unicodeScalars.append(indicator) } return flag } /// Starts listening to the events emitted by the preinstalling service, unless already listening: every started, cancelled, and /// failed download is posted to the ``ContentView/Model/notifier``, and a cancellation or failure clears the preinstalled locale /// — only when it is still the affected one, so a newer preinstallation is never forgotten — letting a re-pick retry. func listenToEvents() { guard taskEvents == nil else { return } taskEvents = Task { [weak self, events = preinstaller.events] in for await event in events { guard let self else { return } switch event { case .cancelled(let locale): if localePreinstalling == locale { localePreinstalling = nil } notifier.post( .warning, message: String(localized: .viewRecordingNotificationDownloadCancelled(name(for: locale))), symbol: Constant.Symbol.cancelled ) case .failed(let locale): if localePreinstalling == locale { localePreinstalling = nil } notifier.post( .error, message: String(localized: .viewRecordingNotificationDownloadFailed(name(for: locale))), symbol: Constant.Symbol.failed ) case .started(let locale): notifier.post( .info, message: String(localized: .viewRecordingNotificationDownloadStarted(name(for: locale))), symbol: Constant.Symbol.started ) } } } } } // MARK: - Constants /// The constant values used across the model. private enum Constant { /// The flag constants. enum Flag { /// The distance from an ASCII uppercase letter to its Regional Indicator Symbol, used to build a flag emoji from a region code. static let base: UInt32 = 0x1F1E6 - 0x41 } /// The symbol constants. enum Symbol { /// The system symbol of a cancelled download. static let cancelled = "xmark.circle" /// The system symbol of a failed download. static let failed = "exclamationmark.triangle" /// The system symbol of a started download. static let started = "arrow.down.circle" } } private extension Locale { /// The locale to fall back to when the transcriber supports no equivalent of the user's current locale. static let byDefault: Locale = .init(identifier: "en_US") }