From 3c1357e952e22a9c9ae20ad0607f6a91ab5d0556 Mon Sep 17 00:00:00 2001 From: Yutaka Kondo Date: Mon, 27 Apr 2026 06:26:02 -0700 Subject: [PATCH 1/6] core: add 4 MoraStrings fields for in-app language switch Co-Authored-By: Claude --- .../Sources/MoraCore/EnglishL1Profile.swift | 7 +++++-- .../Sources/MoraCore/JapaneseL1Profile.swift | 18 +++++++++++++++--- .../Sources/MoraCore/KoreanL1Profile.swift | 6 +++++- .../Sources/MoraCore/MoraStrings.swift | 16 +++++++++++++++- .../LocaleScriptBudgetTests.swift | 4 ++++ 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/Packages/MoraCore/Sources/MoraCore/EnglishL1Profile.swift b/Packages/MoraCore/Sources/MoraCore/EnglishL1Profile.swift index 2fcc16ad..1952dc1e 100644 --- a/Packages/MoraCore/Sources/MoraCore/EnglishL1Profile.swift +++ b/Packages/MoraCore/Sources/MoraCore/EnglishL1Profile.swift @@ -162,7 +162,10 @@ public struct EnglishL1Profile: L1Profile { completionComeBack: "See you tomorrow!", a11yCloseSession: "End the quest", a11yMicButton: "Mic", - a11yStreakChip: { days in "\(days)-day streak" } - // PR 3 will append the four language-switch fields. + a11yStreakChip: { days in "\(days)-day streak" }, + homeChangeLanguageButton: "Change language", + languageSwitchSheetTitle: "Pick a language", + languageSwitchSheetCancel: "Cancel", + languageSwitchSheetConfirm: "Done" ) } diff --git a/Packages/MoraCore/Sources/MoraCore/JapaneseL1Profile.swift b/Packages/MoraCore/Sources/MoraCore/JapaneseL1Profile.swift index 47f4f693..66380c28 100644 --- a/Packages/MoraCore/Sources/MoraCore/JapaneseL1Profile.swift +++ b/Packages/MoraCore/Sources/MoraCore/JapaneseL1Profile.swift @@ -297,7 +297,11 @@ public struct JapaneseL1Profile: L1Profile { a11yCloseSession: "クエストを おわる", a11yMicButton: "マイク", // `日` G1 kept. - a11yStreakChip: { days in "\(days)日 れんぞく" } + a11yStreakChip: { days in "\(days)日 れんぞく" }, + homeChangeLanguageButton: "ことばを かえる", + languageSwitchSheetTitle: "ことばを えらぶ", + languageSwitchSheetCancel: "キャンセル", + languageSwitchSheetConfirm: "OK" ) /// Ages ≤6 (entry tier). Kanji budget: empty — every kanji collapses to @@ -434,7 +438,11 @@ public struct JapaneseL1Profile: L1Profile { a11yCloseSession: "クエストを おわる", a11yMicButton: "マイク", // `日` G1 → にち - a11yStreakChip: { days in "\(days)にち れんぞく" } + a11yStreakChip: { days in "\(days)にち れんぞく" }, + homeChangeLanguageButton: "ことばを かえる", + languageSwitchSheetTitle: "ことばを えらぶ", + languageSwitchSheetCancel: "キャンセル", + languageSwitchSheetConfirm: "OK" ) /// Ages 8+ (advanced tier). Kanji budget: only JPKanjiLevel.grade1And2 @@ -554,6 +562,10 @@ public struct JapaneseL1Profile: L1Profile { completionComeBack: "明日も またね", a11yCloseSession: "クエストを おわる", a11yMicButton: "マイク", - a11yStreakChip: { days in "\(days)日 れんぞく" } + a11yStreakChip: { days in "\(days)日 れんぞく" }, + homeChangeLanguageButton: "ことばを かえる", + languageSwitchSheetTitle: "ことばを えらぶ", + languageSwitchSheetCancel: "キャンセル", + languageSwitchSheetConfirm: "OK" ) } diff --git a/Packages/MoraCore/Sources/MoraCore/KoreanL1Profile.swift b/Packages/MoraCore/Sources/MoraCore/KoreanL1Profile.swift index f0fa1a97..24737e2c 100644 --- a/Packages/MoraCore/Sources/MoraCore/KoreanL1Profile.swift +++ b/Packages/MoraCore/Sources/MoraCore/KoreanL1Profile.swift @@ -203,6 +203,10 @@ public struct KoreanL1Profile: L1Profile { completionComeBack: "내일 또 만나요", a11yCloseSession: "퀘스트를 끝내기", a11yMicButton: "마이크", - a11yStreakChip: { days in "\(days)일 연속" } + a11yStreakChip: { days in "\(days)일 연속" }, + homeChangeLanguageButton: "언어 바꾸기", + languageSwitchSheetTitle: "언어 선택", + languageSwitchSheetCancel: "취소", + languageSwitchSheetConfirm: "확인" ) } diff --git a/Packages/MoraCore/Sources/MoraCore/MoraStrings.swift b/Packages/MoraCore/Sources/MoraCore/MoraStrings.swift index feab455a..abe10b66 100644 --- a/Packages/MoraCore/Sources/MoraCore/MoraStrings.swift +++ b/Packages/MoraCore/Sources/MoraCore/MoraStrings.swift @@ -128,6 +128,12 @@ public struct MoraStrings: Sendable { public let a11yMicButton: String public let a11yStreakChip: @Sendable (Int) -> String + // In-app language switch (Home globe button + sheet) + public let homeChangeLanguageButton: String + public let languageSwitchSheetTitle: String + public let languageSwitchSheetCancel: String + public let languageSwitchSheetConfirm: String + public init( ageOnboardingPrompt: String, ageOnboardingCTA: String, @@ -199,7 +205,11 @@ public struct MoraStrings: Sendable { completionScore: @escaping @Sendable (Int, Int) -> String, completionComeBack: String, a11yCloseSession: String, a11yMicButton: String, - a11yStreakChip: @escaping @Sendable (Int) -> String + a11yStreakChip: @escaping @Sendable (Int) -> String, + homeChangeLanguageButton: String, + languageSwitchSheetTitle: String, + languageSwitchSheetCancel: String, + languageSwitchSheetConfirm: String ) { self.ageOnboardingPrompt = ageOnboardingPrompt self.ageOnboardingCTA = ageOnboardingCTA @@ -290,6 +300,10 @@ public struct MoraStrings: Sendable { self.a11yCloseSession = a11yCloseSession self.a11yMicButton = a11yMicButton self.a11yStreakChip = a11yStreakChip + self.homeChangeLanguageButton = homeChangeLanguageButton + self.languageSwitchSheetTitle = languageSwitchSheetTitle + self.languageSwitchSheetCancel = languageSwitchSheetCancel + self.languageSwitchSheetConfirm = languageSwitchSheetConfirm } } diff --git a/Packages/MoraCore/Tests/MoraCoreTests/LocaleScriptBudgetTests.swift b/Packages/MoraCore/Tests/MoraCoreTests/LocaleScriptBudgetTests.swift index 7794846c..203d6f53 100644 --- a/Packages/MoraCore/Tests/MoraCoreTests/LocaleScriptBudgetTests.swift +++ b/Packages/MoraCore/Tests/MoraCoreTests/LocaleScriptBudgetTests.swift @@ -147,5 +147,9 @@ func everyStringField(_ s: MoraStrings) -> [(name: String, value: String)] { ("a11yCloseSession", s.a11yCloseSession), ("a11yMicButton", s.a11yMicButton), ("a11yStreakChip(5)", s.a11yStreakChip(5)), + ("homeChangeLanguageButton", s.homeChangeLanguageButton), + ("languageSwitchSheetTitle", s.languageSwitchSheetTitle), + ("languageSwitchSheetCancel", s.languageSwitchSheetCancel), + ("languageSwitchSheetConfirm", s.languageSwitchSheetConfirm), ] } From 36b628e6fa01e9a228113a6a5f34cce7a90e6aeb Mon Sep 17 00:00:00 2001 From: Yutaka Kondo Date: Mon, 27 Apr 2026 06:31:22 -0700 Subject: [PATCH 2/6] ui: narrow age picker to 6/7/8 tiles Co-Authored-By: Claude --- .../MoraUI/LanguageAge/AgePickerView.swift | 18 ++++-------------- .../MoraUI/LanguageAge/LanguageAgeFlow.swift | 9 ++++++++- .../MoraUITests/LanguageAgeFlowTests.swift | 8 ++++++++ 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/Packages/MoraUI/Sources/MoraUI/LanguageAge/AgePickerView.swift b/Packages/MoraUI/Sources/MoraUI/LanguageAge/AgePickerView.swift index 17eadfb8..21fecab6 100644 --- a/Packages/MoraUI/Sources/MoraUI/LanguageAge/AgePickerView.swift +++ b/Packages/MoraUI/Sources/MoraUI/LanguageAge/AgePickerView.swift @@ -7,15 +7,6 @@ struct AgePickerView: View { @Binding var selectedAge: Int? let onContinue: () -> Void - /// 4..12 plus a sentinel `13` that renders as "13+" and maps to the - /// 13-and-over bucket internally. Under-4 is out of scope in alpha. - private let ages: [Int] = Array(4...12) + [13] - private let columns = [ - GridItem(.flexible(), spacing: MoraTheme.Space.md), - GridItem(.flexible(), spacing: MoraTheme.Space.md), - GridItem(.flexible(), spacing: MoraTheme.Space.md), - ] - var body: some View { ZStack { MoraTheme.Background.page.ignoresSafeArea() @@ -25,8 +16,8 @@ struct AgePickerView: View { .foregroundStyle(MoraTheme.Ink.primary) .padding(.top, MoraTheme.Space.xxl) - LazyVGrid(columns: columns, spacing: MoraTheme.Space.md) { - ForEach(ages, id: \.self) { age in + HStack(spacing: MoraTheme.Space.md) { + ForEach(LanguageAgeFlow.ageOptions, id: \.self) { age in tile(age) } } @@ -62,12 +53,11 @@ struct AgePickerView: View { private func tile(_ age: Int) -> some View { let selected = selectedAge == age - let label = age == 13 ? "13+" : "\(age)" return Button { selectedAge = age } label: { - Text(label) - .font(MoraType.hero(72)) + Text("\(age)") + .font(MoraType.hero(120)) .foregroundStyle(MoraTheme.Ink.primary) .frame(maxWidth: .infinity, minHeight: 120) .background( diff --git a/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageAgeFlow.swift b/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageAgeFlow.swift index d5c528bd..c6bc4989 100644 --- a/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageAgeFlow.swift +++ b/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageAgeFlow.swift @@ -9,7 +9,7 @@ import SwiftUI final class LanguageAgeState { var step: Step = .language var selectedLanguageID: String - var selectedAge: Int? = 8 // pre-selected per spec §6.2 + var selectedAge: Int? = LanguageAgeFlow.defaultAge // pre-selected per spec §6.2 static let onboardedKey = "tech.reenable.Mora.languageAgeOnboarded" @@ -99,6 +99,13 @@ public struct LanguageAgeFlow: View { self.onFinished = onFinished } + /// Age tiles shown in Step 2. Narrowed in PR 3 to the dyslexia + /// intervention window (6–8 = JP 小学校低学年). See spec §7.2. + public static let ageOptions: [Int] = [6, 7, 8] + + /// Default selected age in Step 2 — middle of the target range. + public static let defaultAge: Int = 7 + /// Identifiers of language rows that are tap-enabled. See spec §7.4. public static let activeLanguageIdentifiers: [String] = ["ja", "ko", "en"] diff --git a/Packages/MoraUI/Tests/MoraUITests/LanguageAgeFlowTests.swift b/Packages/MoraUI/Tests/MoraUITests/LanguageAgeFlowTests.swift index e3d1b670..d080eb2e 100644 --- a/Packages/MoraUI/Tests/MoraUITests/LanguageAgeFlowTests.swift +++ b/Packages/MoraUI/Tests/MoraUITests/LanguageAgeFlowTests.swift @@ -130,4 +130,12 @@ final class LanguageAgeFlowTests: XCTestCase { ) XCTAssertTrue(profiles.isEmpty) } + + func test_agePicker_showsThreeTiles_for6_7_8() { + XCTAssertEqual(LanguageAgeFlow.ageOptions, [6, 7, 8]) + } + + func test_agePicker_defaultSelection_is7() { + XCTAssertEqual(LanguageAgeFlow.defaultAge, 7) + } } From ef1aaea408cb8ee94c9e2ecfbf09380a4ed8e677 Mon Sep 17 00:00:00 2001 From: Yutaka Kondo Date: Mon, 27 Apr 2026 06:34:53 -0700 Subject: [PATCH 3/6] ui: extract LanguagePicker for reuse in switch sheet Co-Authored-By: Claude --- .../MoraUI/LanguageAge/LanguagePicker.swift | 99 +++++++++++++++++++ .../LanguageAge/LanguagePickerView.swift | 79 +-------------- 2 files changed, 100 insertions(+), 78 deletions(-) create mode 100644 Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguagePicker.swift diff --git a/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguagePicker.swift b/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguagePicker.swift new file mode 100644 index 00000000..6ca11c0b --- /dev/null +++ b/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguagePicker.swift @@ -0,0 +1,99 @@ +// Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguagePicker.swift +import MoraCore +import SwiftUI + +/// Reusable language-selection row list. +/// +/// Renders the locale-neutral header and one tappable row per language. +/// Active vs. coming-soon status is driven by `LanguageAgeFlow` so the +/// activation list stays in one place. +public struct LanguagePicker: View { + @Binding public var selection: String + + public init(selection: Binding) { + _selection = selection + } + + private struct Option: Identifiable { + let id: String + let label: String + let enabled: Bool + } + + private static let allOptions: [(id: String, label: String)] = [ + (id: "ja", label: "にほんご"), + (id: "ko", label: "한국어"), + (id: "zh", label: "中文"), + (id: "en", label: "English"), + ] + + private var options: [Option] { + let active = Set(LanguageAgeFlow.activeLanguageIdentifiers) + return Self.allOptions.map { entry in + Option(id: entry.id, label: entry.label, enabled: active.contains(entry.id)) + } + } + + public var body: some View { + VStack(spacing: MoraTheme.Space.xl) { + Text("Language / 言語 / 语言 / 언어") + .font(MoraType.label()) + .foregroundStyle(MoraTheme.Ink.muted) + .padding(.top, MoraTheme.Space.xxl) + + VStack(spacing: MoraTheme.Space.sm) { + ForEach(options) { option in + row(option) + } + } + .padding(.horizontal, MoraTheme.Space.xxl) + } + } + + private func row(_ option: Option) -> some View { + let selected = option.id == selection + return Button { + guard option.enabled else { return } + selection = option.id + } label: { + HStack { + Text(option.label) + .font(MoraType.heading()) + .foregroundStyle( + option.enabled + ? MoraTheme.Ink.primary + : MoraTheme.Ink.muted + ) + if !option.enabled { + Spacer() + Text("Coming soon") + .font(MoraType.pill()) + .foregroundStyle(MoraTheme.Ink.muted) + } else if selected { + Spacer() + Image(systemName: "checkmark") + .foregroundStyle(MoraTheme.Accent.orange) + } else { + Spacer() + } + } + .padding(MoraTheme.Space.md) + .background( + selected + ? MoraTheme.Background.peach + : MoraTheme.Background.cream, + in: RoundedRectangle(cornerRadius: MoraTheme.Radius.tile) + ) + .overlay( + RoundedRectangle(cornerRadius: MoraTheme.Radius.tile) + .stroke( + selected ? MoraTheme.Accent.orange : .clear, + lineWidth: 3 + ) + ) + } + .buttonStyle(.plain) + .disabled(!option.enabled) + .accessibilityAddTraits(selected ? .isSelected : []) + } +} diff --git a/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguagePickerView.swift b/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguagePickerView.swift index a916a820..edaed7e4 100644 --- a/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguagePickerView.swift +++ b/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguagePickerView.swift @@ -6,41 +6,11 @@ struct LanguagePickerView: View { @Binding var selectedLanguageID: String let onContinue: () -> Void - private struct Option: Identifiable { - let id: String - let label: String - let enabled: Bool - } - - private static let allOptions: [(id: String, label: String)] = [ - (id: "ja", label: "にほんご"), - (id: "ko", label: "한국어"), - (id: "zh", label: "中文"), - (id: "en", label: "English"), - ] - - private var options: [Option] { - let active = Set(LanguageAgeFlow.activeLanguageIdentifiers) - return Self.allOptions.map { entry in - Option(id: entry.id, label: entry.label, enabled: active.contains(entry.id)) - } - } - var body: some View { ZStack { MoraTheme.Background.page.ignoresSafeArea() VStack(spacing: MoraTheme.Space.xl) { - Text("Language / 言語 / 语言 / 언어") - .font(MoraType.label()) - .foregroundStyle(MoraTheme.Ink.muted) - .padding(.top, MoraTheme.Space.xxl) - - VStack(spacing: MoraTheme.Space.sm) { - ForEach(options) { option in - row(option) - } - } - .padding(.horizontal, MoraTheme.Space.xxl) + LanguagePicker(selection: $selectedLanguageID) Spacer() @@ -71,51 +41,4 @@ struct LanguagePickerView: View { } } } - - private func row(_ option: Option) -> some View { - let selected = option.id == selectedLanguageID - return Button { - guard option.enabled else { return } - selectedLanguageID = option.id - } label: { - HStack { - Text(option.label) - .font(MoraType.heading()) - .foregroundStyle( - option.enabled - ? MoraTheme.Ink.primary - : MoraTheme.Ink.muted - ) - if !option.enabled { - Spacer() - Text("Coming soon") - .font(MoraType.pill()) - .foregroundStyle(MoraTheme.Ink.muted) - } else if selected { - Spacer() - Image(systemName: "checkmark") - .foregroundStyle(MoraTheme.Accent.orange) - } else { - Spacer() - } - } - .padding(MoraTheme.Space.md) - .background( - selected - ? MoraTheme.Background.peach - : MoraTheme.Background.cream, - in: RoundedRectangle(cornerRadius: MoraTheme.Radius.tile) - ) - .overlay( - RoundedRectangle(cornerRadius: MoraTheme.Radius.tile) - .stroke( - selected ? MoraTheme.Accent.orange : .clear, - lineWidth: 3 - ) - ) - } - .buttonStyle(.plain) - .disabled(!option.enabled) - .accessibilityAddTraits(selected ? .isSelected : []) - } } From aac622ce05a1e71540d263fbd7c3a64ce17429c5 Mon Sep 17 00:00:00 2001 From: Yutaka Kondo Date: Mon, 27 Apr 2026 06:40:11 -0700 Subject: [PATCH 4/6] ui: add LanguageSwitchSheet model + view Co-Authored-By: Claude --- .../LanguageAge/LanguageSwitchSheet.swift | 81 +++++++++++++++++++ .../LanguageSwitchSheetTests.swift | 50 ++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageSwitchSheet.swift create mode 100644 Packages/MoraUI/Tests/MoraUITests/LanguageSwitchSheetTests.swift diff --git a/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageSwitchSheet.swift b/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageSwitchSheet.swift new file mode 100644 index 00000000..327a2367 --- /dev/null +++ b/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageSwitchSheet.swift @@ -0,0 +1,81 @@ +// Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageSwitchSheet.swift +import MoraCore +import SwiftUI + +/// Sheet that lets the user re-pick the L1 from Home, without re-running +/// onboarding. Reuses `LanguagePicker`. Writes only `LearnerProfile.l1Identifier`; +/// age / level / interests / font are not touched. See spec §7.3. +@MainActor +public final class LanguageSwitchSheet: ObservableObject { + public let currentIdentifier: String + private let onCommit: (String) -> Void + private let onCancel: () -> Void + + @Published public var pickedID: String + + public init( + currentIdentifier: String, + onCommit: @escaping (String) -> Void, + onCancel: @escaping () -> Void + ) { + self.currentIdentifier = currentIdentifier + self.onCommit = onCommit + self.onCancel = onCancel + self.pickedID = currentIdentifier + } + + public var isConfirmDisabled: Bool { + pickedID == currentIdentifier + } + + public func simulateSelect(identifier: String) { + pickedID = identifier + } + + public func simulateConfirm() { + guard !isConfirmDisabled else { return } + onCommit(pickedID) + } + + public func simulateCancel() { + onCancel() + } +} + +extension LanguageSwitchSheet: Identifiable { + public var id: String { currentIdentifier } +} + +/// SwiftUI rendering of the sheet model. Hosted as a `.sheet` from `HomeView`. +public struct LanguageSwitchSheetView: View { + @ObservedObject var model: LanguageSwitchSheet + @Environment(\.moraStrings) private var strings + + public init(model: LanguageSwitchSheet) { + self.model = model + } + + public var body: some View { + NavigationStack { + LanguagePicker(selection: $model.pickedID) + .padding() + .navigationTitle(strings.languageSwitchSheetTitle) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(strings.languageSwitchSheetCancel) { + model.simulateCancel() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(strings.languageSwitchSheetConfirm) { + model.simulateConfirm() + } + .disabled(model.isConfirmDisabled) + } + } + } + } +} diff --git a/Packages/MoraUI/Tests/MoraUITests/LanguageSwitchSheetTests.swift b/Packages/MoraUI/Tests/MoraUITests/LanguageSwitchSheetTests.swift new file mode 100644 index 00000000..5c5dde41 --- /dev/null +++ b/Packages/MoraUI/Tests/MoraUITests/LanguageSwitchSheetTests.swift @@ -0,0 +1,50 @@ +import XCTest +import MoraCore +@testable import MoraUI + +@MainActor +final class LanguageSwitchSheetTests: XCTestCase { + func test_onCommit_calledWithPickedID_whenConfirmTapped() { + var committed: String? + let sheet = LanguageSwitchSheet( + currentIdentifier: "ja", + onCommit: { committed = $0 }, + onCancel: {} + ) + sheet.simulateSelect(identifier: "ko") + sheet.simulateConfirm() + XCTAssertEqual(committed, "ko") + } + + func test_onCancel_called_whenCancelTapped() { + var cancelled = false + let sheet = LanguageSwitchSheet( + currentIdentifier: "ja", + onCommit: { _ in }, + onCancel: { cancelled = true } + ) + sheet.simulateCancel() + XCTAssertTrue(cancelled) + } + + func test_confirmDisabled_whenSelectionEqualsCurrent() { + let sheet = LanguageSwitchSheet( + currentIdentifier: "ja", + onCommit: { _ in XCTFail("should not commit") }, + onCancel: {} + ) + XCTAssertEqual(sheet.pickedID, "ja") + XCTAssertTrue(sheet.isConfirmDisabled) + } + + func test_confirmEnabled_whenSelectionDiffersFromCurrent() { + let sheet = LanguageSwitchSheet( + currentIdentifier: "ja", + onCommit: { _ in }, + onCancel: {} + ) + sheet.simulateSelect(identifier: "en") + XCTAssertEqual(sheet.pickedID, "en") + XCTAssertFalse(sheet.isConfirmDisabled) + } +} From 8a8096ac0483b45ea44a23fda9060daac8f7b26c Mon Sep 17 00:00:00 2001 From: Yutaka Kondo Date: Mon, 27 Apr 2026 06:44:57 -0700 Subject: [PATCH 5/6] ui: wire globe language-switch button into HomeView Adds a globe button between the wordmark and the spacer in HomeView's header. Tapping it presents LanguageSwitchSheetView via .sheet(item:); onCommit writes the new l1Identifier and saves the model context. Promotes @Environment(\.modelContext) to unconditional (removing the #if DEBUG guard) so the save call is available in non-debug builds. Adds HomeViewLanguageSwitchTests smoke-testing the a11y label string. Co-Authored-By: Claude --- .../MoraUI/Sources/MoraUI/Home/HomeView.swift | 43 ++++++++++++++----- .../HomeViewLanguageSwitchTests.swift | 14 ++++++ 2 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 Packages/MoraUI/Tests/MoraUITests/HomeViewLanguageSwitchTests.swift diff --git a/Packages/MoraUI/Sources/MoraUI/Home/HomeView.swift b/Packages/MoraUI/Sources/MoraUI/Home/HomeView.swift index 5eccd7c8..84e71c6a 100644 --- a/Packages/MoraUI/Sources/MoraUI/Home/HomeView.swift +++ b/Packages/MoraUI/Sources/MoraUI/Home/HomeView.swift @@ -60,11 +60,10 @@ public struct HomeView: View { // after downloading a premium voice flips the gate off immediately. @State private var needsBetterVoice: Bool = AppleTTSEngine.needsEnhancedVoice @State private var installedVoices: [String] = AppleTTSEngine.installedEnglishVoiceSummaries() + @State private var languageSheet: LanguageSwitchSheet? @Environment(\.scenePhase) private var scenePhase @Environment(\.moraStrings) private var strings - #if DEBUG - @Environment(\.modelContext) private var debugContext - #endif + @Environment(\.modelContext) private var modelContext public init() {} @@ -98,6 +97,10 @@ public struct HomeView: View { YokaiIntroFlow(mode: .replay) { showYokaiIntroReplay = false } .environment(\.moraStrings, strings) } + .sheet(item: $languageSheet) { model in + LanguageSwitchSheetView(model: model) + .environment(\.moraStrings, strings) + } #if os(iOS) .navigationBarHidden(true) #endif @@ -106,6 +109,26 @@ public struct HomeView: View { private var header: some View { HStack { wordmark + Button { + guard let profile = profiles.first else { return } + languageSheet = LanguageSwitchSheet( + currentIdentifier: profile.l1Identifier, + onCommit: { newID in + profile.l1Identifier = newID + try? modelContext.save() + languageSheet = nil + }, + onCancel: { + languageSheet = nil + } + ) + } label: { + Image(systemName: "globe") + .foregroundStyle(MoraTheme.Ink.muted) + } + .buttonStyle(.plain) + .accessibilityLabel(strings.homeChangeLanguageButton) + Spacer() StreakChip(count: streaks.first?.currentCount ?? 0) } @@ -390,7 +413,7 @@ public struct HomeView: View { streak = existing } else { streak = DailyStreak() - debugContext.insert(streak) + modelContext.insert(streak) } // Route through the real DailyStreak rules instead of mutating fields // directly: simulate "the next day" so each tap counts as one more @@ -430,7 +453,7 @@ public struct HomeView: View { var befriendedIDs = Set(bestiary.map(\.yokaiID)) if let id = currentYokaiID, !befriendedIDs.contains(id) { - debugContext.insert(BestiaryEntryEntity(yokaiID: id, befriendedAt: now)) + modelContext.insert(BestiaryEntryEntity(yokaiID: id, befriendedAt: now)) befriendedIDs.insert(id) } @@ -438,7 +461,7 @@ public struct HomeView: View { !befriendedIDs.contains($0) }) if let nextID { - debugContext.insert( + modelContext.insert( YokaiEncounterEntity( yokaiID: nextID, weekStart: now, @@ -460,9 +483,9 @@ public struct HomeView: View { private func resetCurriculum() { debugBarLog.info("resetCurriculum: wiping encounters, bestiary, cameos, streak, profile.createdAt") do { - try debugContext.delete(model: YokaiEncounterEntity.self) - try debugContext.delete(model: BestiaryEntryEntity.self) - try debugContext.delete(model: YokaiCameoEntity.self) + try modelContext.delete(model: YokaiEncounterEntity.self) + try modelContext.delete(model: BestiaryEntryEntity.self) + try modelContext.delete(model: YokaiCameoEntity.self) } catch { assertionFailure("Failed to delete debug models: \(error)") } @@ -479,7 +502,7 @@ public struct HomeView: View { private func persistDebugChanges() { do { - try debugContext.save() + try modelContext.save() } catch { assertionFailure("Failed to persist debug change: \(error)") } diff --git a/Packages/MoraUI/Tests/MoraUITests/HomeViewLanguageSwitchTests.swift b/Packages/MoraUI/Tests/MoraUITests/HomeViewLanguageSwitchTests.swift new file mode 100644 index 00000000..ec361de5 --- /dev/null +++ b/Packages/MoraUI/Tests/MoraUITests/HomeViewLanguageSwitchTests.swift @@ -0,0 +1,14 @@ +import XCTest +import MoraCore +@testable import MoraUI + +@MainActor +final class HomeViewLanguageSwitchTests: XCTestCase { + func test_globe_a11yLabel_matchesMoraStrings() { + let strings = JapaneseL1Profile().uiStrings(at: .advanced) + XCTAssertEqual(strings.homeChangeLanguageButton, "ことばを かえる") + } + + // Full SwiftUI render harness for sheet presentation is out of scope; + // the sheet's behavior is covered by LanguageSwitchSheetTests. +} From 2213f41837bb94760e958edfaa695a630400741f Mon Sep 17 00:00:00 2001 From: Yutaka Kondo Date: Mon, 27 Apr 2026 07:03:33 -0700 Subject: [PATCH 6/6] Address PR review: tighten language-switch error handling and naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeView: replace `try? modelContext.save()` with do/catch; on failure, revert the in-memory `profile.l1Identifier` so it doesn't drift from disk, log via OSLog, and leave the sheet open so the user sees the tap didn't take instead of silently losing it on next launch. - LanguageSwitchSheet: rename `simulateSelect/Confirm/Cancel` → `select/confirm/cancel`. They are part of the production public API driven by the toolbar buttons, not test-only helpers — the names should match real user actions. - LanguageAgeState: fix spec reference §6.2 → §7.2 in the `selectedAge` initializer comment (matches the plan's PR 3 section). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../MoraUI/Sources/MoraUI/Home/HomeView.swift | 20 ++++++++++++++++--- .../MoraUI/LanguageAge/LanguageAgeFlow.swift | 2 +- .../LanguageAge/LanguageSwitchSheet.swift | 10 +++++----- .../LanguageSwitchSheetTests.swift | 8 ++++---- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/Packages/MoraUI/Sources/MoraUI/Home/HomeView.swift b/Packages/MoraUI/Sources/MoraUI/Home/HomeView.swift index 84e71c6a..1bdd8a44 100644 --- a/Packages/MoraUI/Sources/MoraUI/Home/HomeView.swift +++ b/Packages/MoraUI/Sources/MoraUI/Home/HomeView.swift @@ -8,6 +8,8 @@ import SwiftUI import UIKit #endif +private let homeViewLog = Logger(subsystem: "tech.reenable.Mora", category: "HomeView") + #if DEBUG private let debugBarLog = Logger(subsystem: "tech.reenable.Mora", category: "DebugBar") #endif @@ -111,12 +113,24 @@ public struct HomeView: View { wordmark Button { guard let profile = profiles.first else { return } + let previousID = profile.l1Identifier languageSheet = LanguageSwitchSheet( - currentIdentifier: profile.l1Identifier, + currentIdentifier: previousID, onCommit: { newID in profile.l1Identifier = newID - try? modelContext.save() - languageSheet = nil + do { + try modelContext.save() + languageSheet = nil + } catch { + // Revert the in-memory change so it doesn't drift + // from disk; leave the sheet open so the user sees + // their tap didn't take rather than silently losing + // it on next launch. + profile.l1Identifier = previousID + homeViewLog.error( + "Failed to persist language switch to \(newID, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + } }, onCancel: { languageSheet = nil diff --git a/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageAgeFlow.swift b/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageAgeFlow.swift index c6bc4989..91ca4106 100644 --- a/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageAgeFlow.swift +++ b/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageAgeFlow.swift @@ -9,7 +9,7 @@ import SwiftUI final class LanguageAgeState { var step: Step = .language var selectedLanguageID: String - var selectedAge: Int? = LanguageAgeFlow.defaultAge // pre-selected per spec §6.2 + var selectedAge: Int? = LanguageAgeFlow.defaultAge // pre-selected per spec §7.2 static let onboardedKey = "tech.reenable.Mora.languageAgeOnboarded" diff --git a/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageSwitchSheet.swift b/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageSwitchSheet.swift index 327a2367..7e922ea8 100644 --- a/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageSwitchSheet.swift +++ b/Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguageSwitchSheet.swift @@ -28,16 +28,16 @@ public final class LanguageSwitchSheet: ObservableObject { pickedID == currentIdentifier } - public func simulateSelect(identifier: String) { + public func select(identifier: String) { pickedID = identifier } - public func simulateConfirm() { + public func confirm() { guard !isConfirmDisabled else { return } onCommit(pickedID) } - public func simulateCancel() { + public func cancel() { onCancel() } } @@ -66,12 +66,12 @@ public struct LanguageSwitchSheetView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button(strings.languageSwitchSheetCancel) { - model.simulateCancel() + model.cancel() } } ToolbarItem(placement: .confirmationAction) { Button(strings.languageSwitchSheetConfirm) { - model.simulateConfirm() + model.confirm() } .disabled(model.isConfirmDisabled) } diff --git a/Packages/MoraUI/Tests/MoraUITests/LanguageSwitchSheetTests.swift b/Packages/MoraUI/Tests/MoraUITests/LanguageSwitchSheetTests.swift index 5c5dde41..157eb129 100644 --- a/Packages/MoraUI/Tests/MoraUITests/LanguageSwitchSheetTests.swift +++ b/Packages/MoraUI/Tests/MoraUITests/LanguageSwitchSheetTests.swift @@ -11,8 +11,8 @@ final class LanguageSwitchSheetTests: XCTestCase { onCommit: { committed = $0 }, onCancel: {} ) - sheet.simulateSelect(identifier: "ko") - sheet.simulateConfirm() + sheet.select(identifier: "ko") + sheet.confirm() XCTAssertEqual(committed, "ko") } @@ -23,7 +23,7 @@ final class LanguageSwitchSheetTests: XCTestCase { onCommit: { _ in }, onCancel: { cancelled = true } ) - sheet.simulateCancel() + sheet.cancel() XCTAssertTrue(cancelled) } @@ -43,7 +43,7 @@ final class LanguageSwitchSheetTests: XCTestCase { onCommit: { _ in }, onCancel: {} ) - sheet.simulateSelect(identifier: "en") + sheet.select(identifier: "en") XCTAssertEqual(sheet.pickedID, "en") XCTAssertFalse(sheet.isConfirmDisabled) }