import Foundation import Notifying import Observation import Recording extension ContentView { /// The observable model that drives ``ContentView``. /// /// The model owns the recording and transcribing 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()`` fetches. 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``. @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()`` has not finished yet. private(set) var locales: [Locale] /// The service that captures the audio from the device's microphone. @ObservationIgnored let capturer: AudioCapturing /// 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: AssetPreinstalling /// 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 on device. @ObservationIgnored let transcriber: AudioTranscribing // MARK: Initializers /// Creates a model set to the user's current locale, with no supported locales loaded yet, attached to the on-device recording, /// transcribing, and preinstalling services, and to a notifier for the download events. init() { self.locale = .current self.locales = [] self.capturer = .init() self.notifier = .init() self.preinstaller = .init() self.transcriber = .init() } // MARK: Methods /// Loads the locales the transcriber supports into ``locales``, sorted by their localized names, and aligns ``locale`` with the /// supported equivalent of its current value — falling back to the supported equivalent of a default locale when none exists — /// so the locale picker starts with a valid selection. func load() async { 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 name of a locale for the locale picker. /// /// - Parameter locale: The locale to name. /// - Returns: The name of the locale in the user's current locale, or its identifier when no name is available. func name( for locale: Locale ) -> String { Locale .current .localizedString( forIdentifier: locale.identifier )?.localizedCapitalized ?? locale.identifier } /// 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 /// 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 /// so picking it again retries. 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 let .cancelled(locale): localePreinstalling = nil notifier.post( .warning, message: String(localized: .viewRecordingNotificationDownloadCancelled(name(for: locale))), symbol: Constant.Symbol.cancelled ) case let .failed(locale): localePreinstalling = nil notifier.post( .error, message: String(localized: .viewRecordingNotificationDownloadFailed(name(for: locale))), symbol: Constant.Symbol.failed ) case let .started(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 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") }