From cbb5703751886ef11de1be8ed08b3a18bede86a0 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 5 Jul 2026 21:04:29 +0200 Subject: [PATCH] Implemented the Persisting the selected locale across launches on the ContentView view in the Sample app target. --- .../View Models/ContentViewModel.swift | 23 +++++++++---- Apps/Attendi/Sources/Views/ContentView.swift | 22 ++++++++++-- Apps/Attendi/Tests/Attendi.xctestplan | 24 +++++++++++++ .../View Models/ContentViewModelTests.swift | 34 +++++++++++++++++-- .../xcshareddata/xcschemes/Attendi.xcscheme | 9 +++-- 5 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 Apps/Attendi/Tests/Attendi.xctestplan diff --git a/Apps/Attendi/Sources/View Models/ContentViewModel.swift b/Apps/Attendi/Sources/View Models/ContentViewModel.swift index b2e7dc2..109655e 100644 --- a/Apps/Attendi/Sources/View Models/ContentViewModel.swift +++ b/Apps/Attendi/Sources/View Models/ContentViewModel.swift @@ -8,7 +8,8 @@ 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 + /// 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. /// @@ -30,7 +31,7 @@ extension ContentView { /// 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. + /// 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. @@ -88,10 +89,20 @@ extension ContentView { // MARK: Methods /// 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 — falling back to the supported - /// equivalent of a default locale when none exists — so the locale picker starts with a valid selection. It then kicks off the - /// preinstallation of the aligned locale's speech model assets. - func load() async { + /// 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 !identifier.isEmpty { + locale = .init(identifier: identifier) + } + locales = await preinstaller .supportedLocales() diff --git a/Apps/Attendi/Sources/Views/ContentView.swift b/Apps/Attendi/Sources/Views/ContentView.swift index e02c0da..d9fa19d 100644 --- a/Apps/Attendi/Sources/Views/ContentView.swift +++ b/Apps/Attendi/Sources/Views/ContentView.swift @@ -8,7 +8,8 @@ import SwiftUI /// 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. +/// style — overlaying the feature just below the navigation bar. The picked locale persists across launches in the user defaults, and is +/// restored — realigned to the supported locales — before the feature loads. /// /// 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 @@ -17,6 +18,10 @@ struct ContentView: View { // MARK: Properties + /// The identifier of the picked locale, persisted across launches. + @AppStorage(Constant.Key.locale) + private var localeIdentifier: String = "" + /// The model that owns the attached services, the transcription presented in the modal sheet, and the notifier of the in-app /// notifications. @State @@ -36,7 +41,8 @@ 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 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 + /// the available space, with a toolbar picker for the locale of the spoken language to transcribe — restored from the user defaults + /// before the model loads, and persisted back on every change — 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 @@ -70,12 +76,16 @@ struct ContentView: View { stackNotifications } .task { - await model.load() + await model.load( + identifier: localeIdentifier + ) } .onChange( of: model.locale, initial: false ) { + localeIdentifier = model.locale.identifier + model.preinstall() } } @@ -211,6 +221,12 @@ private extension ContentView { /// The constant values used across the view. private enum Constant { + /// The key constants. + enum Key { + /// The user defaults key of the persisted locale identifier. + static let locale = "com.rock-n-code.app.attendi.sample.user-defaults.ket.selected-locale" + } + /// The spacing constants. enum Spacing { /// The spacing between the elements of a stack. diff --git a/Apps/Attendi/Tests/Attendi.xctestplan b/Apps/Attendi/Tests/Attendi.xctestplan new file mode 100644 index 0000000..6321586 --- /dev/null +++ b/Apps/Attendi/Tests/Attendi.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "D599315E-CE89-41A2-A181-811F9CF40731", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:Attendi.xcodeproj", + "identifier" : "0296F7992FFA954E00D2C5FC", + "name" : "AttendiTests" + } + } + ], + "version" : 1 +} diff --git a/Apps/Attendi/Tests/View Models/ContentViewModelTests.swift b/Apps/Attendi/Tests/View Models/ContentViewModelTests.swift index 7d089ad..64b2e4f 100644 --- a/Apps/Attendi/Tests/View Models/ContentViewModelTests.swift +++ b/Apps/Attendi/Tests/View Models/ContentViewModelTests.swift @@ -25,7 +25,7 @@ struct ContentViewModelTests { let model = Model(preinstaller: preinstaller) - await model.load() + await model.load(identifier: "") #expect(model.locale == .english) #expect(model.locales.count == 2) @@ -33,6 +33,34 @@ struct ContentViewModelTests { #expect(model.locales.contains(.english)) } + @Test + func `load restores the persisted locale`() async { + let preinstaller = PreinstallingMock() + + preinstaller.localesSupported = [.dutch, .english] + preinstaller.equivalents = [.dutch: .dutch] + + let model = Model(preinstaller: preinstaller) + + await model.load(identifier: Locale.dutch.identifier) + + #expect(model.locale == .dutch) + } + + @Test + func `load falls back to the default locale when the persisted locale is unsupported`() async { + let preinstaller = PreinstallingMock() + + preinstaller.localesSupported = [.dutch, .english] + preinstaller.equivalents = [.byDefault: .english] + + let model = Model(preinstaller: preinstaller) + + await model.load(identifier: "fr-FR") + + #expect(model.locale == .english) + } + @Test func `load preinstalls the aligned locale`() async throws { let preinstaller = PreinstallingMock() @@ -42,7 +70,7 @@ struct ContentViewModelTests { let model = Model(preinstaller: preinstaller) - await model.load() + await model.load(identifier: "") try await Task.sleep(for: .seconds(0.1)) @@ -203,6 +231,8 @@ struct ContentViewModelTests { // MARK: - Constants private extension Locale { + /// The locale the model falls back to when no equivalent of the current locale is supported. + static let byDefault = Locale(identifier: "en_US") /// A supported locale to preinstall in the tests. static let dutch = Locale(identifier: "nl-NL") /// Another supported locale to preinstall in the tests. diff --git a/Attendi.xcodeproj/xcshareddata/xcschemes/Attendi.xcscheme b/Attendi.xcodeproj/xcshareddata/xcschemes/Attendi.xcscheme index 0d81a64..c0dd16b 100644 --- a/Attendi.xcodeproj/xcshareddata/xcschemes/Attendi.xcscheme +++ b/Attendi.xcodeproj/xcshareddata/xcschemes/Attendi.xcscheme @@ -27,8 +27,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + +