import Notifying import Recording import SwiftUI /// The root view of the Attendi sample app. /// /// The view hosts the `RecordingView` feature from the `Recording` target of the `Features` package, which drives the whole recording flow, /// inside a navigation stack, and presents the transcribed text of every processed recording in a modal sheet of its own navigation stack. /// A toolbar menu picks the locale of the spoken language to transcribe, and every pick kicks off the preinstallation of the locale's speech /// model assets, whose download events surface as transient in-app notifications — banners wearing the `Notifying` target's label /// style — overlaying the feature just below the navigation bar. /// /// The services attached to the feature, the transcription shown in the sheet, and the notifier owning the notifications live in the view's /// ``Model``; the view itself only renders it and forwards the feature's output, the picker's changes, and the sheet's dismissal. All /// user-facing text is localized through the app's string catalog. struct ContentView: View { // MARK: Properties /// The model that owns the attached services, the transcription presented in the modal sheet, and the notifier of the in-app /// notifications. @State private var model: Model // MARK: Initializers /// Creates a content view driven by the given model. /// /// - Parameter model: The model that drives the view. Defaults to a model attached to the on-device services. init( model: Model = .init() ) { self._model = State(initialValue: model) } // MARK: Body /// The content of the view: a navigation stack with the recording feature's view, attached to the model's services and expanded to fill /// the available space, with a toolbar picker for the locale of the spoken language to transcribe, and a modal sheet presenting the /// transcribed text of every processed recording — or a content unavailable message when the transcription is empty — closable /// through its toolbar button or a swipe. The notifier's in-app notifications overlay the feature, stacking downward just below the /// navigation bar; each one slides in from the leading edge when posted and out again when its scheduled dismissal arrives, without /// displacing the recording controls underneath. The notifier also announces every posted notification to assistive technologies, /// since the banners themselves are transient. var body: some View { NavigationStack { RecordingView( capturer: model.capturer, transcriber: model.transcriber, locale: $model.locale, ) { transcription in model.received(transcription) } .frame( maxWidth: .infinity, maxHeight: .infinity ) .navigationTitle(.viewRecordingNavigationTitle) #if !os(macOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .primaryAction) { menuLocale } } .overlay( alignment: .topLeading ) { stackNotifications } .task { await model.load() } .onChange( of: model.locale, initial: false ) { model.preinstall() } } .sheet(item: $model.transcription) { transcription in sheetTranscription(for: transcription) } } } // MARK: - Subviews private extension ContentView { // MARK: Computed /// The toolbar menu that picks the locale of the spoken language to transcribe from the supported locales, disabled until /// they are loaded. var menuLocale: some View { Menu { Picker( .viewRecordingPickerLocaleTitle, selection: $model.locale ) { ForEach( model.locales, id: \.self ) { Text(model.name(for: $0)) .tag($0) } } .pickerStyle(.inline) } label: { Label( .viewRecordingPickerLocaleTitle, systemImage: "globe" ) } .disabled(model.locales.isEmpty) .accessibilityHint(Text(.viewRecordingPickerLocaleHint)) } /// The stack of the notifier's in-app notifications, each one sliding in from the leading edge when posted and out again when /// its scheduled dismissal arrives. var stackNotifications: some View { VStack( alignment: .leading, spacing: Constant.Spacing.stack ) { ForEach( model.notifier.notifications ) { notification in Label { Text(notification.message) } icon: { Image(systemName: notification.symbol) .accessibilityHidden(true) } .labelStyle( .notification( kind: notification.kind ) ) .accessibilityElement( children: .combine ) .transition( .move( edge: .leading ).combined( with: .opacity ) ) } } .padding(.horizontal) .animation( .spring, value: model.notifier.notifications ) } // MARK: Methods /// Returns the content of the modal sheet presenting the given transcription — or a content unavailable message when it is /// empty — inside a navigation stack of its own, closable through its toolbar button or a swipe. /// /// - Parameter transcription: The transcription to present. /// - Returns: The content of the modal sheet. func sheetTranscription( for transcription: Transcription ) -> some View { NavigationStack { Group { if transcription.isEmpty { ContentUnavailableView( .viewTranscriptionUnavailableTitle, systemImage: "text.page.slash", description: Text(.viewTranscriptionUnavailableDescription) ) } else { ScrollView { Text(transcription.text) .font(.body) .fontWeight(.regular) .foregroundStyle(.primary) .frame( maxWidth: .infinity, alignment: .leading ) .padding() } } } .navigationTitle(.viewTranscriptionNavigationTitle) #if !os(macOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { Button(role: .close) { model.dismissed() } } } .presentationDetents([.medium, .large]) } } // MARK: - Constants /// The constant values used across the view. private enum Constant { /// The spacing constants. enum Spacing { /// The spacing between the elements of a stack. static let stack: CGFloat = 8 } } // MARK: - Previews /// The preinstalling service used by the previews, which resolves every locale as supported and downloads nothing. private struct PreviewPreinstalling: Preinstalling { /// The stream of events the service emits, which finishes immediately. let events: AsyncStream = .init { continuation in continuation.finish() } func preinstall( for locale: Locale ) async { // The previews preinstall no speech model assets. } func supportedLocale( equivalentTo locale: Locale ) async -> Locale? { locale } func supportedLocales() async -> [Locale] { [ Locale(identifier: "en-US"), Locale(identifier: "nl-NL"), ] } } #Preview( "Content view" ) { ContentView( model: .init( preinstaller: PreviewPreinstalling() ) ) }