Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Packages/MoraCore/Sources/MoraCore/EnglishL1Profile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
}
18 changes: 15 additions & 3 deletions Packages/MoraCore/Sources/MoraCore/JapaneseL1Profile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -554,6 +562,10 @@ public struct JapaneseL1Profile: L1Profile {
completionComeBack: "明日も またね",
a11yCloseSession: "クエストを おわる",
a11yMicButton: "マイク",
a11yStreakChip: { days in "\(days)日 れんぞく" }
a11yStreakChip: { days in "\(days)日 れんぞく" },
homeChangeLanguageButton: "ことばを かえる",
languageSwitchSheetTitle: "ことばを えらぶ",
languageSwitchSheetCancel: "キャンセル",
languageSwitchSheetConfirm: "OK"
)
}
6 changes: 5 additions & 1 deletion Packages/MoraCore/Sources/MoraCore/KoreanL1Profile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ public struct KoreanL1Profile: L1Profile {
completionComeBack: "내일 또 만나요",
a11yCloseSession: "퀘스트를 끝내기",
a11yMicButton: "마이크",
a11yStreakChip: { days in "\(days)일 연속" }
a11yStreakChip: { days in "\(days)일 연속" },
homeChangeLanguageButton: "언어 바꾸기",
languageSwitchSheetTitle: "언어 선택",
languageSwitchSheetCancel: "취소",
languageSwitchSheetConfirm: "확인"
)
}
16 changes: 15 additions & 1 deletion Packages/MoraCore/Sources/MoraCore/MoraStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]
}
57 changes: 47 additions & 10 deletions Packages/MoraUI/Sources/MoraUI/Home/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,11 +62,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() {}

Expand Down Expand Up @@ -98,6 +99,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
Expand All @@ -106,6 +111,38 @@ public struct HomeView: View {
private var header: some View {
HStack {
wordmark
Button {
guard let profile = profiles.first else { return }
let previousID = profile.l1Identifier
languageSheet = LanguageSwitchSheet(
currentIdentifier: previousID,
onCommit: { newID in
profile.l1Identifier = newID
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)"
)
}
},
Comment on lines +119 to +134
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onCommit updates profile.l1Identifier but swallows persistence errors via try? modelContext.save(). If save() throws, the sheet still dismisses and the in-memory change may be lost on next launch. Handle the error (e.g., do/catch with assertionFailure/logging and revert profile.l1Identifier or keep the sheet open) instead of ignoring it.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 2213f41. Replaced try? modelContext.save() with do/catch; on failure I revert profile.l1Identifier to its previous value, log via Logger (tech.reenable.Mora / HomeView category), and leave the sheet open so the user sees the change didn't take. Sheet still dismisses on success.

onCancel: {
languageSheet = nil
}
)
} label: {
Image(systemName: "globe")
.foregroundStyle(MoraTheme.Ink.muted)
}
.buttonStyle(.plain)
.accessibilityLabel(strings.homeChangeLanguageButton)

Spacer()
StreakChip(count: streaks.first?.currentCount ?? 0)
}
Expand Down Expand Up @@ -390,7 +427,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
Expand Down Expand Up @@ -430,15 +467,15 @@ 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)
}

let nextID = ladder.skills.lazy.compactMap(\.yokaiID).first(where: {
!befriendedIDs.contains($0)
})
if let nextID {
debugContext.insert(
modelContext.insert(
YokaiEncounterEntity(
yokaiID: nextID,
weekStart: now,
Expand All @@ -460,9 +497,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)")
}
Expand All @@ -479,7 +516,7 @@ public struct HomeView: View {

private func persistDebugChanges() {
do {
try debugContext.save()
try modelContext.save()
} catch {
assertionFailure("Failed to persist debug change: \(error)")
}
Expand Down
18 changes: 4 additions & 14 deletions Packages/MoraUI/Sources/MoraUI/LanguageAge/AgePickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 §7.2

static let onboardedKey = "tech.reenable.Mora.languageAgeOnboarded"

Expand Down Expand Up @@ -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"]

Expand Down
99 changes: 99 additions & 0 deletions Packages/MoraUI/Sources/MoraUI/LanguageAge/LanguagePicker.swift
Original file line number Diff line number Diff line change
@@ -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<String>) {
_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 : [])
}
}
Loading
Loading