diff --git a/ios/Graduately/AppModel.swift b/ios/Graduately/AppModel.swift index 37beae3..34bb644 100644 --- a/ios/Graduately/AppModel.swift +++ b/ios/Graduately/AppModel.swift @@ -10,7 +10,9 @@ final class AppModel: ObservableObject { @Published var mode: ColorMode @Published var lang: UiLang @Published var tab: AppTab = .practice - @Published var practicePath: [Route] = [] + @Published var practicePath = NavigationPath() + /// Bumps whenever a full path replace is scheduled, so a queued push cannot land afterwards. + private var pathGeneration = 0 @Published var searchQuery = "" @Published var tick = 0 @Published var update = UpdateState() @@ -48,13 +50,17 @@ final class AppModel: ObservableObject { case .stats: tab = .progress case .welcome, .subjects: - tab = .practice - practicePath = [] + showPracticeRoot() default: applyPracticeRoute(r, replace: practicePath.isEmpty) } } + func showPracticeRoot() { + tab = .practice + schedulePracticePath(NavigationPath()) + } + func goPage(_ page: String?) { guard let page, let r = routeFromPage(page) else { return } go(r) @@ -66,8 +72,7 @@ final class AppModel: ObservableObject { case .stats: tab = .progress case .welcome, .subjects: - tab = .practice - practicePath = [] + showPracticeRoot() default: // Switching tabs and assigning a deep NavigationStack path in the // same turn crashes SwiftUI on macOS (boundPathChange / unexpectedError). @@ -76,18 +81,31 @@ final class AppModel: ObservableObject { } /// Sets the practice tab path safely, especially when jumping from Search. + /// + /// The sidebar-style tab view keeps one navigation column for every tab. + /// A typed `[Route]` path does not match the other tabs, so the column + /// traps in `boundPathChange` while an exercise is being opened. The path + /// is therefore type-erased, and a push is applied on the next turn instead + /// of replacing the whole stack during the click. private func applyPracticeRoute(_ r: Route, replace: Bool) { - let stack = replace ? r.stack : (practicePath + [r]) - if !replace, practicePath.last == r { return } let switching = tab != .practice tab = .practice if switching || replace { - practicePath = [] - DispatchQueue.main.async { [weak self] in - self?.practicePath = stack - } + schedulePracticePath(NavigationPath(r.stack)) } else { - practicePath = stack + let generation = pathGeneration + DispatchQueue.main.async { [weak self] in + guard let self, self.pathGeneration == generation else { return } + self.practicePath.append(r) + } + } + } + + private func schedulePracticePath(_ path: NavigationPath) { + pathGeneration += 1 + practicePath = NavigationPath() + DispatchQueue.main.async { [weak self] in + self?.practicePath = path } } diff --git a/ios/Graduately/GraduatelyApp.swift b/ios/Graduately/GraduatelyApp.swift index d622722..366e93c 100644 --- a/ios/Graduately/GraduatelyApp.swift +++ b/ios/Graduately/GraduatelyApp.swift @@ -17,6 +17,9 @@ struct GraduatelyApp: App { struct NativeRoot: View { @ObservedObject var vm: AppModel + @State private var progressPath = NavigationPath() + @State private var settingsPath = NavigationPath() + @State private var searchPath = NavigationPath() var body: some View { Group { @@ -49,17 +52,17 @@ struct NativeRoot: View { } } Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: .progress) { - NavigationStack { + NavigationStack(path: $progressPath) { StatsScreen(vm: vm) } } Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: .settings) { - NavigationStack { + NavigationStack(path: $settingsPath) { SettingsScreen(vm: vm) } } Tab(vm.tr("search"), systemImage: "magnifyingglass", value: .search) { - NavigationStack { + NavigationStack(path: $searchPath) { SearchScreen(vm: vm) } } diff --git a/macos/Graduately/GraduatelyApp.swift b/macos/Graduately/GraduatelyApp.swift index a65f366..143b584 100644 --- a/macos/Graduately/GraduatelyApp.swift +++ b/macos/Graduately/GraduatelyApp.swift @@ -16,8 +16,7 @@ struct GraduatelyApp: App { CommandGroup(replacing: .newItem) {} CommandGroup(after: .sidebar) { Button(vm.tr("search_home")) { - vm.tab = .practice - vm.practicePath = [] + vm.showPracticeRoot() } .keyboardShortcut("1", modifiers: [.command]) Button(vm.tr("stats")) { @@ -47,6 +46,9 @@ struct GraduatelyApp: App { struct MacRoot: View { @ObservedObject var vm: AppModel + @State private var progressPath = NavigationPath() + @State private var settingsPath = NavigationPath() + @State private var searchPath = NavigationPath() var body: some View { tabs @@ -77,17 +79,17 @@ struct MacRoot: View { } } Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: AppTab.progress) { - NavigationStack { + NavigationStack(path: $progressPath) { StatsScreen(vm: vm) } } Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: AppTab.settings) { - NavigationStack { + NavigationStack(path: $settingsPath) { SettingsScreen(vm: vm) } } Tab(vm.tr("search"), systemImage: "magnifyingglass", value: AppTab.search, role: .search) { - NavigationStack { + NavigationStack(path: $searchPath) { SearchScreen(vm: vm) } }