diff --git a/Apps/Attendi/Catalogs/Localizable.xcstrings b/Apps/Attendi/Catalogs/Localizable.xcstrings index 6c72970..4fe5c23 100644 --- a/Apps/Attendi/Catalogs/Localizable.xcstrings +++ b/Apps/Attendi/Catalogs/Localizable.xcstrings @@ -11,6 +11,42 @@ } } }, + "view.recording.notification.download.cancelled" : { + "comment" : "The message of the notification for a cancelled speech model download.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The speech model download for %@ was cancelled." + } + } + } + }, + "view.recording.notification.download.failed" : { + "comment" : "The message of the notification for a failed speech model download.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The speech model for %@ could not be downloaded." + } + } + } + }, + "view.recording.notification.download.started" : { + "comment" : "The message of the notification for a started speech model download. ", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloading the speech model for %@…" + } + } + } + }, "view.recording.picker.locale.title" : { "localizations" : { "en" : { diff --git a/Apps/Attendi/Views/ContentView.swift b/Apps/Attendi/Views/ContentView.swift index d5c881d..80a248d 100644 --- a/Apps/Attendi/Views/ContentView.swift +++ b/Apps/Attendi/Views/ContentView.swift @@ -5,21 +5,27 @@ import SwiftUI /// /// 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 overlaying the feature just below the navigation bar. /// -/// The services attached to the feature and the transcription shown in the sheet live in the view's ``Model``; the view itself only renders it and -/// forwards the feature's output and the sheet's dismissal. The navigation titles of the stacks are localized through the app's string catalog. +/// The services attached to the feature, the transcription shown in the sheet, and the posted 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 and the transcription presented in the modal sheet. + /// The model that owns the attached services, the transcription presented in the modal sheet, and the posted in-app notifications. @State private var model = Model() // MARK: Body - /// The content of the view: a navigation stack with the recording feature's view, attached to the model's services, 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 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 model'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. var body: some View { NavigationStack { RecordingView( @@ -29,81 +35,192 @@ struct ContentView: View { ) { transcription in model.received(transcription) } + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) .navigationTitle("view.recording.navigation.title") #if !os(macOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .primaryAction) { - Menu { - Picker( - "view.recording.picker.locale.title", - selection: $model.locale - ) { - ForEach( - model.locales, - id: \.self - ) { - Text(model.name(for: $0)) - .tag($0) - } - } - .pickerStyle(.inline) - } label: { - Label( - "view.recording.picker.locale.title", - systemImage: "globe" - ) - } - .disabled(model.locales.isEmpty) + menuLocale } } + .overlay( + alignment: .topLeading + ) { + stackNotifications + } .task { await model.load() } - .task(id: model.locale) { - await model.preinstallAssets() + .onChange( + of: model.locale, + initial: false + ) { + model.preinstall() } } .sheet(item: $model.transcription) { transcription in - NavigationStack { - Group { - if transcription.isEmpty { - ContentUnavailableView( - "view.transcription.unavailable.title", - systemImage: "text.page.slash", - description: Text("view.transcription.unavailable.description") - ) - } else { - ScrollView { - Text(transcription.text) - .font(.body) - .fontWeight(.regular) - .foregroundStyle(.primary) - .frame( - maxWidth: .infinity, - alignment: .leading - ) - .padding() - } - } - } - .navigationTitle("view.transcription.navigation.title") - #if !os(macOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - Button(role: .close) { - model.dismissed() - } - } - } - .presentationDetents([.medium, .large]) + 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( + "view.recording.picker.locale.title", + selection: $model.locale + ) { + ForEach( + model.locales, + id: \.self + ) { + Text(model.name(for: $0)) + .tag($0) + } + } + .pickerStyle(.inline) + } label: { + Label( + "view.recording.picker.locale.title", + systemImage: "globe" + ) + } + .disabled(model.locales.isEmpty) + } + + /// The stack of the model'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.notifications + ) { notification in + Label { + Text(notification.textMessage) + } icon: { + Image(systemName: notification.imageSymbol) + } + .labelStyle(.notification( + event: notification.event + )) + .transition(.move( + edge: .leading + ).combined( + with: .opacity + )) + } + } + .padding(.horizontal) + .animation( + .spring, + value: model.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( + "view.transcription.unavailable.title", + systemImage: "text.page.slash", + description: Text("view.transcription.unavailable.description") + ) + } else { + ScrollView { + Text(transcription.text) + .font(.body) + .fontWeight(.regular) + .foregroundStyle(.primary) + .frame( + maxWidth: .infinity, + alignment: .leading + ) + .padding() + } + } + } + .navigationTitle("view.transcription.navigation.title") + #if !os(macOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + Button(role: .close) { + model.dismissed() + } + } + } + .presentationDetents([.medium, .large]) + } + +} + +// MARK: - AppNotification+Extensions + +private extension AppNotification { + + // MARK: Properties + + /// The system symbol of the icon matching the notification's event. + var imageSymbol: String { + switch event { + case .cancelled: "xmark.circle" + case .failed: "exclamationmark.triangle" + case .started: "arrow.down.circle" + } + } + + // MARK: Methods + + /// The localized message of the notification, naming the locale of the speech model, from the app's string catalog. + var textMessage: LocalizedStringResource { + switch event { + case .cancelled: .viewRecordingNotificationDownloadCancelled(localeName) + case .failed: .viewRecordingNotificationDownloadFailed(localeName) + case .started: .viewRecordingNotificationDownloadStarted(localeName) + } + } + +} + + +// 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 #Preview(