From 0d0705c95afa7faa9929b7078eaa8aeae84422db Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 4 Jul 2026 12:59:18 +0200 Subject: [PATCH] Added a toolbar locale picker to the ContentView view in the Sample app target. --- Apps/Attendi/Catalogs/Localizable.xcstrings | 10 ++++ .../View Models/ContentViewModel.swift | 56 +++++++++++++++++-- Apps/Attendi/Views/ContentView.swift | 33 ++++++++++- 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/Apps/Attendi/Catalogs/Localizable.xcstrings b/Apps/Attendi/Catalogs/Localizable.xcstrings index 92d4a6c..159afee 100644 --- a/Apps/Attendi/Catalogs/Localizable.xcstrings +++ b/Apps/Attendi/Catalogs/Localizable.xcstrings @@ -11,6 +11,16 @@ } } }, + "view.recording.picker.locale.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Locale" + } + } + } + }, "view.transcription.navigation.title" : { "extractionState" : "manual", "localizations" : { diff --git a/Apps/Attendi/View Models/ContentViewModel.swift b/Apps/Attendi/View Models/ContentViewModel.swift index c8009c1..7f4470e 100644 --- a/Apps/Attendi/View Models/ContentViewModel.swift +++ b/Apps/Attendi/View Models/ContentViewModel.swift @@ -1,13 +1,15 @@ import Foundation import Observation import Recording +import Speech extension ContentView { /// The observable model that drives ``ContentView``. /// - /// The model owns the recording and transcribing services attached to the recording feature, and holds the transcription currently - /// presented in the view's modal sheet: ``received(_:)`` wraps the transcribed text of a processed recording for presentation, and + /// 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. @MainActor @Observable @@ -16,21 +18,63 @@ extension ContentView { // MARK: Properties /// The locale of the spoken language to transcribe. - var locale: Locale = .current - + 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 recorder = AudioRecordingService() + let recorder: AudioRecordingService /// The service that transcribes the recorded audio into text on device. @ObservationIgnored - let transcriber = AudioTranscribing() + 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 + /// and transcribing services. + init() { + self.locale = .current + self.locales = [] + self.recorder = .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 so the locale picker starts with a valid selection. + func load() async { + locales = await SpeechTranscriber.supportedLocales.sorted { + name(for: $0) < name(for: $1) + } + + if let equivalent = await SpeechTranscriber.supportedLocale( + equivalentTo: locale + ) { + locale = equivalent + } + } + + /// 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. diff --git a/Apps/Attendi/Views/ContentView.swift b/Apps/Attendi/Views/ContentView.swift index bf8d9c0..e9c425a 100644 --- a/Apps/Attendi/Views/ContentView.swift +++ b/Apps/Attendi/Views/ContentView.swift @@ -17,8 +17,9 @@ struct ContentView: View { // MARK: Body - /// The content of the view: a navigation stack with the recording feature's view, attached to the model's services, and a modal sheet - /// presenting the transcribed text of every processed recording, 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, 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, + /// closable through its toolbar button or a swipe. var body: some View { NavigationStack { RecordingView( @@ -32,6 +33,34 @@ struct ContentView: View { #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) + } + } + .task { + await model.load() + } } .sheet(item: $model.transcription) { transcription in NavigationStack {