From 939ac2294e6d094549e0e4e619578d14cd3a7f3f Mon Sep 17 00:00:00 2001 From: pepaskopec-blip Date: Fri, 18 Sep 2026 14:18:43 +0200 Subject: [PATCH] Adapt the iOS app to native iOS 26 navigation and Liquid Glass. Co-authored-by: Cursor --- README.md | 8 +- installers/maturita-installer-ios.zip | Bin 1271 -> 1323 bytes installers/maturita-ios.html | 3 +- ios/Maturita.xcodeproj/project.pbxproj | 8 +- ios/Maturita/AppModel.swift | 39 +- ios/Maturita/MaturitaApp.swift | 209 +++---- ios/Maturita/Route.swift | 53 +- ios/Maturita/UI/Components.swift | 207 +++---- ios/Maturita/UI/Exercises.swift | 99 ++-- ios/Maturita/UI/ExercisesMore.swift | 69 +-- ios/Maturita/UI/PathMap.swift | 54 +- ios/Maturita/UI/Screens.swift | 734 +++++++++---------------- src/i18n.c | 8 +- 13 files changed, 642 insertions(+), 849 deletions(-) diff --git a/README.md b/README.md index a45ce90..534b791 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ větve. | Windows (x64) | [maturita-installer-windows.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-windows.zip) | Rozbalit a dvojklik; u SmartScreenu **Další informace → Přesto spustit** | | Linux (x86_64) | [maturita-installer-linux.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-linux.zip) | Rozbalit a dvojklik na instalátor | | Android (8+) | [maturita-installer-android.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-android.zip) | Rozbalit, otevřít **Stahnout maturita.C.html** a nainstalovat APK (neznámé zdroje) | -| iOS (16+) | [maturita-installer-ios.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-ios.zip) | Rozbalit, otevřít **Jak nainstalovat maturita.C.html**. Nejjednodušší: Xcode na Macu, nebo IPA přes AltStore | +| iOS (26+) | [maturita-installer-ios.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-ios.zip) | Rozbalit, otevřít **Jak nainstalovat maturita.C.html**. Nejjednodušší: Xcode na Macu, nebo IPA přes AltStore | Kam se to nainstaluje: `/Applications` nebo `~/Applications` (macOS), `%LOCALAPPDATA%\Programs\Maturita` (Windows), `~/Applications` (Linux). @@ -160,7 +160,7 @@ cd android nebo z kořene `make android`. -iOS ze zdroje (Xcode 16, iOS 16+): +iOS ze zdroje (Xcode 26, iOS 26+ / Liquid Glass): ```bash python3 scripts/extract-android-content.py @@ -198,7 +198,7 @@ branch-named file. | Windows (x64) | [maturita-installer-windows.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-windows.zip) | Unzip and double-click; if SmartScreen appears, **More info → Run anyway** | | Linux (x86_64) | [maturita-installer-linux.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-linux.zip) | Unzip and double-click the installer | | Android (8+) | [maturita-installer-android.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-android.zip) | Unzip, open **Stahnout maturita.C.html**, then install the APK (unknown sources) | -| iOS (16+) | [maturita-installer-ios.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-ios.zip) | Unzip, open **Jak nainstalovat maturita.C.html**. Easiest: Xcode on a Mac, or the IPA via AltStore | +| iOS (26+) | [maturita-installer-ios.zip](https://github.com/pepaskopec-blip/maturita.c/raw/main/installers/maturita-installer-ios.zip) | Unzip, open **Jak nainstalovat maturita.C.html**. Easiest: Xcode on a Mac, or the IPA via AltStore | Install locations: `/Applications` or `~/Applications` (macOS), `%LOCALAPPDATA%\Programs\Maturita` (Windows), `~/Applications` (Linux). @@ -328,7 +328,7 @@ cd android or `make android` from the repo root. -iOS from source (Xcode 16, iOS 16+): +iOS from source (Xcode 26, iOS 26+ / Liquid Glass): ```bash python3 scripts/extract-android-content.py diff --git a/installers/maturita-installer-ios.zip b/installers/maturita-installer-ios.zip index 99dd15c097b94527cd294452db59211cc9c81451..5140c331ff2cd487b8d24c6f40cf4ce02d8869c5 100644 GIT binary patch delta 1216 zcmV;x1V8)t39AYhP)h>@6aWAK2mm8;GF>{R>UmWJ007{T7c76lE3In5E3ImEcnbgl z1oZ&`00a~O005m<+in{-5Pi>AOt(NF!Pc(jDrqdQ1h{EX1Zixe4vO}XB1c+ETyjft zWy^s-As-O*$sbVky+He7>%ThOi!8-xQ`82m6*+U}oH;|T(=Wa~d-?9?^H-4hT#cWk z;R6)Yvr)ud6c&GwiSZMFG)K>XXUsbEqp0-L-izpAL$e%5(Nf}NVXTjU8|_h3;H4Bk z8wp%Wj=k#3K1eOSWUA*lQ#k6^FkX6v>^F-oe<;vs(u2P7_ox) z)g!=^s`aKUmufWxw{jlyUMcs%G3|N|t(+b=kvY4nSqy*S@P)Q0&2CVd^ z-6)t4QqP8P0R8xID}x(l>=3@}v+!H5-c)~CJn?$NO-NTLM!Y7<@I{;L@yhp@k~6Bw zsZnU#oeATMnKh*rpMXDRCFa z9xV0UZ@>yA7mQ;&dlb9}4!)Palv2QVia8gL;3u_4L_a;gf$)>YBdgwAw6E;p0!rXwLd0ehPN=O6$ zu{17u+eTl7CF<}zO{&e^#2?{;P6e3`%hd!exaIoyIlM!b@5Y@IdST{mKNty@ZlNXo zQ0g`^O=`WZ(XY=>;2M_qzxpLM%qFE&!Zm+PU{1Z(EVFmF9wZd&kLch;`3rAsFm+;x zoIqumP^${DQ))+aQP8<1yUBsYJ`~1N=A@kC`WE!vt@9){7~-1b9ZMQ+WC9NZHj3!> zh(L-_BvjO#$WQ#S>@u!BD(QA>?%L1Tl@Nlf&WcNF(VNX4*%QlxMdPO2g}=Mav?+gm z=vd8;41Ub{jaaS!0wWge8Zt#fY3>FGgMBcy`>nA5F@v2yNAD)X%RG$E4E-yG;qjZ*Ytadl*NuZ~Fxp7~_zyFu`?belI)w^KdQyPz>NP3pG*-SE}_Gk*h6O928N0~7!N00<5MBXTlb eI;QG*RRjP4;FFXDIs(Bflh6bq2CoDF0001xxkyX^ delta 1163 zcmV;61a$kW3ik;YP)h>@6aWAK2mm&3GF{L2xT_%q004`T7c74`C#`BYC#`CAcnbgl z1oZ&`00a~O005m<&2Aev5WeRrrduG85Np>;oi>(N0^Br*AV_S~LD3$$ccPRuOat^nmkFv z7bvD@gDESdT8+T1oX28N$pdgqyMaS1rzfpt!LAwQ5GDUT?7p=}N^2SEdYKblx7X{D3Jrqnw-y zg|<7%7+=h+skC?y{66J$I(-Ch7=Z61%#0Oi2bKw0xe**6AD35q`2})gnU{t@Xru9D zx15(KXSshL!MBHpyW~pvxW#dFbaVnrY8>RP@i0Dmanf3HBan>iU`}2ml`uYjLGaph zqrMP|BDM4e3a9l}O|SM(leQVsq-oJKbVS`H`S4L2o##Z4EQhs0*|gAUStZ5>GLX!xhawoOXe1)L_WxrER$^!cywP7o+fOPCKPiU{LJHW^I1X~ z@K1lFaml;R`YLQu1?Oo}AMQK;1Q*o6qz^1t8Cr15&F^#gfUMZJJE!QSnRoSIL|nRs zmhfYxJIgd__;%vFIX{JKSl<2Wm)Ii9Dyf8Pk-(g4tyykw*B&I4o6qRrRQU^UY!Edw z1WuqbOsG{Iu~%tFa8b||Be_X|#Q~JYlV^Wc&2h5^eY%1pPi;YC!q_G=E zbkQS_VjKw-wU_6CK9*g^jYbs>qIN8PL9T=%xVo)f(vaRB4oIF@mMogI=`Q^3I@hN1 zp<)d>68JIZw_>&V3yfH>YseK5rM^2lJURf=sNWI$AKjIBi`W8LMIk$(g%Od z1JJI^9`fl_HNsb7x8#mnS^6eOZ(3;{#ufDX+xfa=7A)Oi7(?_gH()#4(DS^vCCEw{ z?~n|EToS>vCgd}S2v<=^-w6@*JDDN&IqDrqmN1bUWz=Bfxih-gr&;yS4&t6fc*!Yk z!PBQ8Vwbc1Gx`}ZyedNL%+SA97?xSzuil7W=)8U$RD -

maturita.C pro iPhone

+

maturita.C pro iPhone (iOS 26)

+ Nativní aplikace pro iOS 26+ s Liquid Glass. Není 1:1 kopie desktopu. Apple nepovolí instalaci jako Android APK. Nejjednodušší cesta je Mac s Xcode a vlastní iPhone (bezplatný Apple účet stačí na 7 dní).

diff --git a/ios/Maturita.xcodeproj/project.pbxproj b/ios/Maturita.xcodeproj/project.pbxproj index da44a2c..0c9b434 100644 --- a/ios/Maturita.xcodeproj/project.pbxproj +++ b/ios/Maturita.xcodeproj/project.pbxproj @@ -251,7 +251,7 @@ ENABLE_TESTABILITY = YES; GCC_DYNAMIC_NO_PIC = NO; GCC_OPTIMIZATION_LEVEL = 0; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -269,7 +269,7 @@ CLANG_ENABLE_OBJC_ARC = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; @@ -296,7 +296,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -329,7 +329,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Maturita/AppModel.swift b/ios/Maturita/AppModel.swift index 46af406..e010583 100644 --- a/ios/Maturita/AppModel.swift +++ b/ios/Maturita/AppModel.swift @@ -9,12 +9,11 @@ final class AppModel: ObservableObject { @Published var themeId: ThemeId @Published var mode: ColorMode @Published var lang: UiLang - @Published var settingsOpen = false - @Published var searchOpen = false + @Published var tab: AppTab = .practice + @Published var practicePath: [Route] = [] @Published var searchQuery = "" @Published var tick = 0 @Published var update = UpdateState() - @Published var stack: [Route] = [.welcome] init() { themeId = progress.themeId @@ -22,8 +21,6 @@ final class AppModel: ObservableObject { lang = progress.lang } - var route: Route { stack.last ?? .welcome } - var canGoBack: Bool { stack.count > 1 } var palette: Palette { themePalette(themeId, mode) } func tr(_ key: String?) -> String { content.tr(key, lang: lang) } @@ -33,9 +30,21 @@ final class AppModel: ObservableObject { } func go(_ r: Route) { - if stack.last == r { return } - stack.append(r) - searchOpen = false + switch r { + case .stats: + tab = .progress + case .welcome, .subjects: + tab = .practice + practicePath = [] + default: + tab = .practice + if practicePath.last == r { return } + if practicePath.isEmpty { + practicePath = r.stack + } else { + practicePath.append(r) + } + } } func goPage(_ page: String?) { @@ -43,8 +52,18 @@ final class AppModel: ObservableObject { go(r) } - func back() { - if stack.count > 1 { stack.removeLast() } + func openFromSearch(_ page: String) { + guard let r = routeFromPage(page) else { return } + switch r { + case .stats: + tab = .progress + case .welcome, .subjects: + tab = .practice + practicePath = [] + default: + tab = .practice + practicePath = r.stack + } } func setTheme(_ id: ThemeId) { diff --git a/ios/Maturita/MaturitaApp.swift b/ios/Maturita/MaturitaApp.swift index c40c214..a037523 100644 --- a/ios/Maturita/MaturitaApp.swift +++ b/ios/Maturita/MaturitaApp.swift @@ -6,149 +6,172 @@ struct MaturitaApp: App { var body: some Scene { WindowGroup { - MaturitaRoot(vm: vm) + NativeRoot(vm: vm) } } } -struct MaturitaRoot: View { +struct NativeRoot: View { @ObservedObject var vm: AppModel var body: some View { - let p = vm.palette - ZStack { - p.base.ignoresSafeArea() - VStack(spacing: 0) { - HStack { - Text("maturita.c") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(p.text) - Spacer() - IconHit(action: { vm.searchOpen = true }) { SearchIcon(color: p.text) } - IconHit(action: { vm.go(.stats) }) { StatsIcon(color: p.text) } - IconHit(action: { vm.settingsOpen = true }) { SettingsIcon(color: p.text) } - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - if vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged" { - HStack { - Text(bannerText) - .font(.system(size: 13)) - .foregroundStyle(p.text) - Spacer() - if vm.update.canInstall { - PillButton(label: vm.tr("update_install"), palette: p) { vm.installUpdate() } - } - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(p.surface1, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .padding(.horizontal, 12) - } - routeView - .id(vm.tick) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - if vm.searchOpen { - SearchOverlay(vm: vm).ignoresSafeArea(edges: .bottom) - } - if vm.settingsOpen { - SettingsSheet(vm: vm).ignoresSafeArea(edges: .bottom) + Group { + if showsUpdateAccessory { + tabs.tabViewBottomAccessory { UpdateAccessory(vm: vm) } + } else { + tabs } } + .tint(vm.palette.accent) .preferredColorScheme(vm.mode == .dark ? .dark : .light) .onAppear { vm.checkUpdate(false) } } + private var showsUpdateAccessory: Bool { + vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged" + } + + private var tabs: some View { + TabView(selection: $vm.tab) { + Tab(vm.tr("subjects_title"), systemImage: "square.grid.2x2.fill", value: .practice) { + NavigationStack(path: $vm.practicePath) { + PracticeHome(vm: vm) + .navigationDestination(for: Route.self) { route in + RouteDestination(vm: vm, route: route) + } + } + } + Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: .progress) { + NavigationStack { + StatsScreen(vm: vm) + } + } + Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: .settings) { + NavigationStack { + SettingsScreen(vm: vm) + } + } + Tab(vm.tr("search_placeholder"), systemImage: "magnifyingglass", value: .search, role: .search) { + NavigationStack { + SearchScreen(vm: vm) + } + } + } + .tabViewStyle(.sidebarAdaptable) + .tabBarMinimizeBehavior(.onScrollDown) + } +} + +private struct UpdateAccessory: View { + @ObservedObject var vm: AppModel + + var body: some View { + HStack { + Text(bannerText) + .font(.subheadline) + Spacer() + if vm.update.canInstall { + Button(vm.tr("update_install")) { vm.installUpdate() } + .buttonStyle(.borderedProminent) + } + } + .padding(.horizontal, 4) + } + private var bannerText: String { if let arg = vm.update.messageArg { return vm.fmt(vm.update.messageKey, arg) } return vm.tr(vm.update.messageKey) } +} - @ViewBuilder - private var routeView: some View { - switch vm.route { - case .welcome: WelcomeScreen(vm: vm) - case .subjects: SubjectsScreen(vm: vm) - case .stats: StatsScreen(vm: vm) - case .roadmap: RoadmapScreen(vm: vm) - case .unitMap(let id): UnitMapScreen(vm: vm, unitId: id) - case .germanEx(let unit, let ex): GermanExercise(vm: vm, unitId: unit, ex: ex) - case .vocab(let id): VocabExercise(vm: vm, unitId: id) - case .netYears: NetYearsScreen(vm: vm) +struct RouteDestination: View { + @ObservedObject var vm: AppModel + let route: Route + + var body: some View { + switch route { + case .welcome, .subjects: + PracticeHome(vm: vm) + case .stats: + StatsScreen(vm: vm) + case .roadmap: + RoadmapScreen(vm: vm) + case .unitMap(let id): + UnitMapScreen(vm: vm, unitId: id) + case .germanEx(let unit, let ex): + GermanExercise(vm: vm, unitId: unit, ex: ex) + case .vocab(let id): + VocabExercise(vm: vm, unitId: id) + case .netYears: + NetYearsScreen(vm: vm) case .netMap: - LessonMapScreen( - vm: vm, + LessonListScreen( title: vm.tr("net_year1"), - sub: vm.tr("net_sub"), - nodes: vm.content.netLessons.map { l in + subtitle: vm.tr("net_sub"), + rows: vm.content.netLessons.map { l in let id = l.int("id") - return MapNode( + return LessonRow( id: "n\(id)", - label: vm.tr(l.str("titleKey")), - locked: false, + title: vm.tr(l.str("titleKey")), done: vm.progress.netDone(id), - current: !vm.progress.netDone(id), - onClick: { vm.go(.netLesson(id)) } + destination: .netLesson(id) ) } ) case .netLesson(let id): if let l = vm.content.netLesson(id) { - SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), sub: vm.tr(l.str("subKey")), slides: l.arr("slides")) { - vm.go(.netEx(id)) - } + SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .netEx(id)) } - case .netEx(let id): NetQuizScreen(vm: vm, id: id) + case .netEx(let id): + NetQuizScreen(vm: vm, id: id) case .hwMap: - LessonMapScreen( - vm: vm, + LessonListScreen( title: vm.tr("Technické vybavení"), - sub: vm.tr("hw_sub"), - nodes: vm.content.hw.map { l in + subtitle: vm.tr("hw_sub"), + rows: vm.content.hw.map { l in let id = l.int("id") - return MapNode( + return LessonRow( id: "h\(id)", - label: vm.tr(l.str("titleKey")), - locked: false, + title: vm.tr(l.str("titleKey")), done: vm.progress.hwDone(id), - current: !vm.progress.hwDone(id), - onClick: { vm.go(.hwLesson(id)) } + destination: .hwLesson(id) ) } ) case .hwLesson(let id): if let l = vm.content.hwLesson(id) { - SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), sub: vm.tr(l.str("subKey")), slides: l.arr("slides")) { - vm.go(.hwEx(id)) - } + SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .hwEx(id)) } - case .hwEx(let id): HwQuizScreen(vm: vm, id: id) - case .czechMap: CzechMapScreen(vm: vm) + case .hwEx(let id): + HwQuizScreen(vm: vm, id: id) + case .czechMap: + CzechMapScreen(vm: vm) case .mluvnice: - LessonMapScreen( - vm: vm, + LessonListScreen( title: vm.tr("Mluvnice"), - sub: "Cvičení z mluvnice – styl maturita / přijímačky z ČJL", - nodes: vm.content.mluvnice.map { m in + subtitle: "Cvičení z mluvnice", + rows: vm.content.mluvnice.map { m in let n = m.int("id") - return MapNode( + return LessonRow( id: "m\(n)", - label: m.str("name"), - locked: false, + title: m.str("name"), done: vm.progress.mluvDone(n), - current: !vm.progress.mluvDone(n), - onClick: { vm.go(.mluvEx(n)) } + destination: .mluvEx(n) ) } ) - case .mluvEx(let n): MluvExercise(vm: vm, n: n) - case .readingList: BookListScreen(vm: vm) - case .book(let id): BookScreen(vm: vm, id: id) - case .bookQuiz(let id): LitQuizScreen(vm: vm, id: id) - case .bookPlot(let id): PlotScreen(vm: vm, id: id) + case .mluvEx(let n): + MluvExercise(vm: vm, n: n) + case .readingList: + BookListScreen(vm: vm) + case .book(let id): + BookScreen(vm: vm, id: id) + case .bookQuiz(let id): + LitQuizScreen(vm: vm, id: id) + case .bookPlot(let id): + PlotScreen(vm: vm, id: id) } } } diff --git a/ios/Maturita/Route.swift b/ios/Maturita/Route.swift index 5fd8a78..f7800eb 100644 --- a/ios/Maturita/Route.swift +++ b/ios/Maturita/Route.swift @@ -1,6 +1,13 @@ import Foundation -enum Route: Equatable { +enum AppTab: Hashable { + case practice + case progress + case search + case settings +} + +enum Route: Hashable { case welcome case subjects case stats @@ -48,6 +55,50 @@ enum Route: Equatable { case .bookPlot(let id): return id == "1984" ? "cetba1984dej" : "cetbaFuksDej" } } + + /// Stack of destinations on top of the practice root. + var stack: [Route] { + switch self { + case .welcome, .subjects, .stats: + return [] + case .roadmap: + return [.roadmap] + case .unitMap(let id): + return [.roadmap, .unitMap(id)] + case .germanEx(let unit, let ex): + return [.roadmap, .unitMap(unit), .germanEx(unit, ex)] + case .vocab(let unit): + return [.roadmap, .unitMap(unit), .vocab(unit)] + case .netYears: + return [.netYears] + case .netMap: + return [.netYears, .netMap] + case .netLesson(let id): + return [.netYears, .netMap, .netLesson(id)] + case .netEx(let id): + return [.netYears, .netMap, .netLesson(id), .netEx(id)] + case .hwMap: + return [.hwMap] + case .hwLesson(let id): + return [.hwMap, .hwLesson(id)] + case .hwEx(let id): + return [.hwMap, .hwLesson(id), .hwEx(id)] + case .czechMap: + return [.czechMap] + case .mluvnice: + return [.czechMap, .mluvnice] + case .mluvEx(let n): + return [.czechMap, .mluvnice, .mluvEx(n)] + case .readingList: + return [.czechMap, .readingList] + case .book(let id): + return [.czechMap, .readingList, .book(id)] + case .bookQuiz(let id): + return [.czechMap, .readingList, .book(id), .bookQuiz(id)] + case .bookPlot(let id): + return [.czechMap, .readingList, .book(id), .bookPlot(id)] + } + } } func routeFromPage(_ page: String) -> Route? { diff --git a/ios/Maturita/UI/Components.swift b/ios/Maturita/UI/Components.swift index d020074..63af591 100644 --- a/ios/Maturita/UI/Components.swift +++ b/ios/Maturita/UI/Components.swift @@ -41,98 +41,33 @@ struct FlowLayout: Layout { struct PrimaryButton: View { let label: String - let palette: Palette var action: () -> Void var body: some View { - Button(action: action) { - Text(label) - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(palette.onAccent) - .padding(.horizontal, 22) - .padding(.vertical, 12) - .background(palette.accent, in: Capsule()) - } - .buttonStyle(.plain) + Button(label, action: action) + .buttonStyle(.borderedProminent) + .controlSize(.large) } } struct PillButton: View { let label: String - let palette: Palette var action: () -> Void var body: some View { - Button(action: action) { - Text(label) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(palette.text) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(palette.text.opacity(0.06), in: Capsule()) - } - .buttonStyle(.plain) + Button(label, action: action) + .buttonStyle(.bordered) } } -struct IconHit: View { - var action: () -> Void - @ViewBuilder var content: () -> Content - - var body: some View { - Button(action: action) { - content() - .frame(width: 40, height: 40) - } - .buttonStyle(.plain) - } -} - -struct PageTop: View { - @ObservedObject var vm: AppModel - var back: () -> Void - let title: String - var subtitle: String? - - var body: some View { - let p = vm.palette - VStack(spacing: 6) { - ZStack { - Text(title) - .font(.system(size: 22, weight: .medium)) - .foregroundStyle(p.text) - .multilineTextAlignment(.center) - .padding(.horizontal, 48) - HStack { - IconHit(action: back) { BackIcon(color: p.text) } - Spacer() - } - } - if let subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(.system(size: 14)) - .foregroundStyle(p.subtext) - .multilineTextAlignment(.center) - } - } - .padding(.top, 4) - .padding(.bottom, 14) - } -} - -struct CardBox: View { - let palette: Palette +struct GlassCard: View { @ViewBuilder var content: () -> Content var body: some View { content() - .padding(20) + .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .background(palette.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .stroke(palette.text.opacity(0.07), lineWidth: 1) - ) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) } } @@ -144,9 +79,8 @@ struct FeedbackLine: View { var body: some View { if !text.isEmpty { Text(text) - .font(.system(size: 15, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error) - .padding(.top, 8) } } } @@ -159,92 +93,72 @@ struct Meaning: View { var body: some View { if visible && !text.isEmpty { Text(text) - .font(.system(size: 13)) - .foregroundStyle(palette.subtext) - .padding(.bottom, 8) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.bottom, 4) } } } struct WordField: View { @Binding var value: String - let palette: Palette var placeholder: String var mark: Bool? + var palette: Palette? var body: some View { - let border: Color = { - if mark == true { return palette.success } - if mark == false { return palette.error } - return palette.text.opacity(0.12) - }() - ZStack(alignment: .leading) { - if value.isEmpty { - Text(placeholder).foregroundStyle(palette.overlay).font(.system(size: 15)) - } - TextField("", text: $value) - .font(.system(size: 15)) - .foregroundStyle(palette.text) - .tint(palette.accent) - } - .padding(.horizontal, 14) - .padding(.vertical, 12) - .background(palette.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(border, lineWidth: 1)) + TextField(placeholder, text: $value) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .padding(12) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(border, lineWidth: mark == nil ? 0 : 1.5) + ) + } + + private var border: Color { + if mark == true { return palette?.success ?? .green } + if mark == false { return palette?.error ?? .red } + return .clear } } struct SegChip: View { let label: String let selected: Bool - let palette: Palette var action: () -> Void var body: some View { - Button(action: action) { - Text(label) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(palette.text) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(selected ? palette.mantle : Color.clear, in: Capsule()) - } - .buttonStyle(.plain) + Button(label, action: action) + .buttonStyle(.bordered) + .tint(selected ? .accentColor : .secondary) } } struct Hint: View { let text: String - let palette: Palette var body: some View { - Text(text).font(.system(size: 13)).foregroundStyle(palette.subtext).padding(.bottom, 8) + Text(text).font(.footnote).foregroundStyle(.secondary).padding(.bottom, 6) } } struct Prompt: View { let text: String - let palette: Palette var body: some View { - Text(text).font(.system(size: 16, weight: .medium)).foregroundStyle(palette.text) + Text(text).font(.body.weight(.medium)) } } struct WordChip: View { let text: String - let palette: Palette var action: () -> Void var body: some View { - Button(action: action) { - Text(text) - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(palette.text) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(palette.mantle, in: Capsule()) - .overlay(Capsule().stroke(palette.text.opacity(0.1), lineWidth: 1)) - } - .buttonStyle(.plain) + Button(text, action: action) + .buttonStyle(.bordered) + .controlSize(.small) } } @@ -252,25 +166,44 @@ struct OptionChip: View { let text: String let selected: Bool let mark: Bool? - let palette: Palette var action: () -> Void var body: some View { - let border: Color = { - if mark == true { return palette.success } - if mark == false { return palette.error } - return selected ? palette.accent : palette.text.opacity(0.1) - }() Button(action: action) { - Text(text) - .font(.system(size: 15)) - .foregroundStyle(palette.text) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(selected ? palette.surface1 : palette.mantle, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(border, lineWidth: 1)) + HStack { + Text(text).frame(maxWidth: .infinity, alignment: .leading) + if selected { Image(systemName: "checkmark") } + } } - .buttonStyle(.plain) - .padding(.bottom, 6) + .buttonStyle(.bordered) + .tint(tint) + .padding(.bottom, 4) + } + + private var tint: Color { + if mark == true { return .green } + if mark == false { return .red } + return selected ? .accentColor : .secondary + } +} + +extension View { + @ViewBuilder + func navSubtitle(_ text: String?) -> some View { + if let text, !text.isEmpty { + navigationSubtitle(text) + } else { + self + } + } +} + +func subjectSymbol(_ icon: String) -> String { + switch icon { + case "de": return "character.book.closed.fill" + case "wifi": return "wifi" + case "chip": return "cpu" + case "cz": return "text.book.closed.fill" + default: return "lock.fill" } } diff --git a/ios/Maturita/UI/Exercises.swift b/ios/Maturita/UI/Exercises.swift index 300102c..33d1344 100644 --- a/ios/Maturita/UI/Exercises.swift +++ b/ios/Maturita/UI/Exercises.swift @@ -12,16 +12,29 @@ struct ExerciseScaffold: View { var body: some View { let p = vm.palette - VStack(alignment: .leading, spacing: 0) { - PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub) - ScrollView { - VStack(alignment: .leading, spacing: 0) { content() } - } - FeedbackLine(text: feedback, kind: kind, palette: p) - PrimaryButton(label: action, palette: p, action: onAction) - .padding(.vertical, 12) + ScrollView { + VStack(alignment: .leading, spacing: 8) { content() } + .padding() + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .navSubtitle(sub) + .toolbar { + ToolbarItem(placement: .bottomBar) { + Button(action, action: onAction) + .buttonStyle(.borderedProminent) + } + } + .safeAreaInset(edge: .bottom) { + if !feedback.isEmpty { + FeedbackLine(text: feedback, kind: kind, palette: p) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .padding(.horizontal) + .padding(.bottom, 4) + } } - .padding(.horizontal, 16) } } @@ -132,7 +145,7 @@ private struct ChoiceEx: View { ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in let sel = picks[i] == o let mark: Bool? = revealed && sel ? picks[i] == qs[i].int("correct") : nil - OptionChip(text: opt, selected: sel, mark: mark, palette: p) { picks[i] = o } + OptionChip(text: opt, selected: sel, mark: mark) { picks[i] = o } } if revealed { if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) } @@ -182,12 +195,12 @@ private struct AssemblyEx: View { revealed = true }, feedback: fb.0, kind: fb.1) { ForEach(items.indices, id: \.self) { i in - if let prompt = items[i].strOrNull("prompt") { Hint(text: prompt, palette: p) } + if let prompt = items[i].strOrNull("prompt") { Hint(text: prompt) } FlowLayout { if placed[i].isEmpty { Text("…").foregroundStyle(p.overlay) } ForEach(placed[i], id: \.self) { idx in if idx < items[i].strs("words").count { - WordChip(text: items[i].strs("words")[idx], palette: p) { + WordChip(text: items[i].strs("words")[idx]) { placed[i].removeAll { $0 == idx } pools[i].append(idx) } @@ -201,7 +214,7 @@ private struct AssemblyEx: View { FlowLayout(spacing: 6) { ForEach(pools[i], id: \.self) { idx in if idx < items[i].strs("words").count { - WordChip(text: items[i].strs("words")[idx], palette: p) { + WordChip(text: items[i].strs("words")[idx]) { pools[i].removeAll { $0 == idx } placed[i].append(idx) } @@ -262,22 +275,22 @@ private struct TypedEx: View { else { fb = (vm.tr("feedback_retry"), "err") } revealed = true }, feedback: fb.0, kind: fb.1) { - if let note = spec.strOrNull("note") { Hint(text: note, palette: p) } - if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: p) } - if let bank = spec.strOrNull("bank") { Hint(text: "\(vm.tr("wordbank")) \(bank)", palette: p) } - if umlaut { Hint(text: vm.tr("hint_umlauts"), palette: p) } + if let note = spec.strOrNull("note") { Hint(text: note) } + if let sample = spec.strOrNull("sample") { Hint(text: sample) } + if let bank = spec.strOrNull("bank") { Hint(text: "\(vm.tr("wordbank")) \(bank)") } + if umlaut { Hint(text: vm.tr("hint_umlauts")) } ForEach(items.indices, id: \.self) { i in let q = items[i] - Prompt(text: q.str("prompt"), palette: p) + Prompt(text: q.str("prompt")) let answers = q.answerList() let hints = q.strs("hints") ForEach(fields[i].indices, id: \.self) { j in let ans = j < answers.count ? answers[j] : "" WordField( value: Binding(get: { fields[i][j] }, set: { fields[i][j] = $0 }), - palette: p, placeholder: j < hints.count ? hints[j] : vm.tr("your_answer"), - mark: revealed ? answerAccepts(normalizeAnswer(fields[i][j]), ans) : nil + mark: revealed ? answerAccepts(normalizeAnswer(fields[i][j]), ans) : nil, + palette: p ) .padding(.bottom, 4) } @@ -322,12 +335,12 @@ private struct FreeEx: View { shown = shown.map { _ in true } onDone() }, feedback: fb.0, kind: fb.1) { - if let tip = spec.strOrNull("tip") { Hint(text: vm.tr(tip), palette: p) } + if let tip = spec.strOrNull("tip") { Hint(text: vm.tr(tip)) } ForEach(qs.indices, id: \.self) { i in let q = qs[i] - Prompt(text: q.str("question").isEmpty ? q.str("stem") : q.str("question"), palette: p) - WordField(value: Binding(get: { values[i] }, set: { values[i] = $0 }), palette: p, placeholder: vm.tr("your_answer")) - PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() } + Prompt(text: q.str("question").isEmpty ? q.str("stem") : q.str("question")) + WordField(value: Binding(get: { values[i] }, set: { values[i] = $0 }), placeholder: vm.tr("your_answer"), palette: p) + PillButton(label: vm.tr("show_sample")) { shown[i].toggle() } .padding(.vertical, 6) if shown[i] { let sample = q.str("sample") @@ -376,10 +389,10 @@ private struct AssignEx: View { else { fb = (vm.tr("feedback_retry"), "err") } revealed = true }, feedback: fb.0, kind: fb.1) { - Hint(text: vm.tr("assign_hint"), palette: p) + Hint(text: vm.tr("assign_hint")) FlowLayout(spacing: 8) { ForEach(groups.indices, id: \.self) { i in - SegChip(label: groups[i], selected: active == i, palette: p) { active = i } + SegChip(label: groups[i], selected: active == i) { active = i } } } Text(vm.tr("wordbank")).font(.system(size: 12)).foregroundStyle(p.subtext).padding(.top, 10) @@ -387,7 +400,7 @@ private struct AssignEx: View { ForEach(items.indices, id: \.self) { i in if loc[i] == -1 { let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ") - WordChip(text: label, palette: p) { loc[i] = active } + WordChip(text: label) { loc[i] = active } } } } @@ -397,7 +410,7 @@ private struct AssignEx: View { ForEach(items.indices, id: \.self) { i in if loc[i] == g { let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ") - WordChip(text: label, palette: p) { loc[i] = -1 } + WordChip(text: label) { loc[i] = -1 } } } } @@ -439,9 +452,8 @@ private struct HangmanEx: View { let p = vm.palette let cur = word < words.count ? words[word].uppercased() : "" VStack(alignment: .leading, spacing: 8) { - PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub) - Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(p.subtext) - if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))", palette: p) } + Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(.secondary) + if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))") } Canvas { ctx, size in var gallows = Path() gallows.move(to: CGPoint(x: 40, y: size.height - 10)) @@ -525,7 +537,7 @@ private struct HangmanEx: View { } FeedbackLine(text: fb.0, kind: fb.1, palette: p) if misses >= 6 && !finished { - PillButton(label: vm.tr("hm_retry"), palette: p) { + PillButton(label: vm.tr("hm_retry")) { misses = 0 guessed = guessed.map { _ in false } fb = ("", "") @@ -533,7 +545,10 @@ private struct HangmanEx: View { } Spacer() } - .padding(.horizontal, 16) + .padding() + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .navSubtitle(sub) } } @@ -572,17 +587,16 @@ private struct VocabEx: View { var body: some View { let p = vm.palette VStack(alignment: .leading, spacing: 8) { - PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub) - Text(vm.fmt("trans_progress", done, total)).foregroundStyle(p.subtext) + Text(vm.fmt("trans_progress", done, total)).foregroundStyle(.secondary) if cards.isEmpty { Text(vm.tr("trans_done")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.success) } else if index < cards.count { let cur = cards[index] - Hint(text: cur.0, palette: p) - Prompt(text: cur.1, palette: p) - if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts"), palette: p) } - WordField(value: $value, palette: p, placeholder: vm.tr("your_answer")) - PrimaryButton(label: vm.tr("check"), palette: p) { + Hint(text: cur.0) + Prompt(text: cur.1) + if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts")) } + WordField(value: $value, placeholder: vm.tr("your_answer"), palette: p) + PrimaryButton(label: vm.tr("check")) { let norm = normalizeAnswer(value) if answerAccepts(norm, cur.2) { done += 1 @@ -603,6 +617,9 @@ private struct VocabEx: View { } Spacer() } - .padding(.horizontal, 16) + .padding() + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .navSubtitle(sub) } } diff --git a/ios/Maturita/UI/ExercisesMore.swift b/ios/Maturita/UI/ExercisesMore.swift index 9e49d74..873e717 100644 --- a/ios/Maturita/UI/ExercisesMore.swift +++ b/ios/Maturita/UI/ExercisesMore.swift @@ -88,7 +88,7 @@ struct ComboEx: View { } else if type == "fill" { let blanks = rows.reduce(0) { $0 + $1.answerList().count } ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in - if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: vm.palette) } + if let sample = spec.strOrNull("sample") { Hint(text: sample) } ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in FlowLayout(spacing: 4) { if let num = block.num { @@ -114,7 +114,7 @@ struct ComboEx: View { } } else { ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: rows.count, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in - if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: vm.palette) } + if let sample = spec.strOrNull("sample") { Hint(text: sample) } ForEach(rows.indices, id: \.self) { i in let r = rows[i] let prefix: String = { @@ -318,29 +318,17 @@ struct WordPicker: View { var body: some View { NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - ForEach(pool, id: \.self) { w in - Button { onPick(w) } label: { - Text(w) - .font(.system(size: 16)) - .foregroundStyle(palette.text) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 14) - .padding(.vertical, 12) - } - .buttonStyle(.plain) - } - } - .padding(10) + List(Array(pool.enumerated()), id: \.offset) { _, w in + Button(w) { onPick(w) } } - .background(palette.base) + .navigationTitle("") .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(role: .cancel) { onPick(nil) } label: { Text("Zavřít") } + Button("Zavřít", role: .cancel) { onPick(nil) } } } } + .presentationDetents([.medium, .large]) } } @@ -442,7 +430,7 @@ struct LettersEx: View { revealed = true }, feedback: fb.0, kind: fb.1) { if rows.contains(where: { $0.strs("answers").contains { hasUmlaut($0) } }) { - Hint(text: vm.tr("hint_umlauts"), palette: p) + Hint(text: vm.tr("hint_umlauts")) } LettersRows(vm: vm, rows: rows, fields: $fields, revealed: revealed) } @@ -470,9 +458,9 @@ private struct LettersRows: View { case .field(let idx, let ans): WordField( value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }), - palette: p, placeholder: "", - mark: revealed ? answerAccepts(normalizeAnswer(idx < fields.count ? fields[idx] : ""), ans) : nil + mark: revealed ? answerAccepts(normalizeAnswer(idx < fields.count ? fields[idx] : ""), ans) : nil, + palette: p ) .frame(width: 72) } @@ -555,7 +543,7 @@ struct TableEx: View { ForEach(persons.indices, id: \.self) { c in let i = r * persons.count + c Text(persons[c]).font(.system(size: 12)).foregroundStyle(p.subtext) - WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), palette: p, placeholder: "") + WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), placeholder: "", palette: p) } Spacer().frame(height: 8) } @@ -589,10 +577,10 @@ struct RevealEx: View { fb = (vm.tr("feedback_ok"), "ok") onDone() }, feedback: fb.0, kind: fb.1) { - if let note = spec.strOrNull("note") { Hint(text: note, palette: p) } + if let note = spec.strOrNull("note") { Hint(text: note) } ForEach(items.indices, id: \.self) { i in - Prompt(text: items[i].str("prompt"), palette: p) - PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() } + Prompt(text: items[i].str("prompt")) + PillButton(label: vm.tr("show_sample")) { shown[i].toggle() } .padding(.vertical, 6) if shown[i] { Meaning(text: items[i].str("solution"), palette: p, visible: true) } Spacer().frame(height: 8) @@ -671,7 +659,6 @@ private struct VlsmEx: View { let answers = vm.content.netAnswers let p = vm.palette VStack(alignment: .leading, spacing: 0) { - PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_ex_title"), subtitle: nil) ScrollView { VStack(alignment: .leading, spacing: 12) { ForEach(tasks.indices, id: \.self) { ti in @@ -680,7 +667,7 @@ private struct VlsmEx: View { let infeasible = block.isEmpty || block.allSatisfy { $0.int("pfx") <= 0 } Text(task.str("prompt")).foregroundStyle(p.text).padding(.vertical, 8) if infeasible { - Hint(text: vm.tr("net_infeasible"), palette: p) + Hint(text: vm.tr("net_infeasible")) } else { ForEach(block.indices, id: \.self) { si in Text("Podsíť \(String(UnicodeScalar(65 + si)!))") @@ -690,12 +677,12 @@ private struct VlsmEx: View { get: { rows[ti][si].pfx }, set: { rows[ti][si].pfx = $0 } ), palette: p) - WordField(value: Binding(get: { rows[ti][si].net }, set: { rows[ti][si].net = $0 }), palette: p, placeholder: "síť") - WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), palette: p, placeholder: "broadcast") - WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), palette: p, placeholder: "první uzel") - WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), palette: p, placeholder: "poslední uzel") + WordField(value: Binding(get: { rows[ti][si].net }, set: { rows[ti][si].net = $0 }), placeholder: "síť", palette: p) + WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), placeholder: "broadcast", palette: p) + WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), placeholder: "první uzel", palette: p) + WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), placeholder: "poslední uzel", palette: p) } - PillButton(label: vm.tr("check"), palette: p) { + PillButton(label: vm.tr("check")) { let ok = block.indices.allSatisfy { si in let a = block[si] let field = rows[ti][si] @@ -715,7 +702,7 @@ private struct VlsmEx: View { } .padding(.vertical, 6) } - PillButton(label: vm.tr("net_solution"), palette: p) { + PillButton(label: vm.tr("net_solution")) { if !infeasible { for si in block.indices { rows[ti][si].pfx = "/\(block[si].int("pfx"))" @@ -735,7 +722,9 @@ private struct VlsmEx: View { } FeedbackLine(text: fb.0, kind: fb.1, palette: p) } - .padding(.horizontal, 16) + .padding() + .navigationTitle(vm.tr("net_ex_title")) + .navigationBarTitleDisplayMode(.inline) } private func maybeFinish() { @@ -783,12 +772,11 @@ struct LitQuizScreen: View { let p = vm.palette let kahoot = [p.accent, p.error, p.warning, p.success] VStack(alignment: .leading, spacing: 8) { - PageTop(vm: vm, back: { vm.back() }, title: vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub"))) if finished { Text(vm.tr("lit_finished")).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text) Text(vm.tr("lit_finished_text")).foregroundStyle(p.subtext) Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) - PrimaryButton(label: vm.tr("lit_again"), palette: p) { + PrimaryButton(label: vm.tr("lit_again")) { idx = 0; score = 0; answered = false; finished = false } } else if idx < qs.count { @@ -817,7 +805,7 @@ struct LitQuizScreen: View { } if answered { Meaning(text: q.str("expl"), palette: p, visible: true) - PrimaryButton(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next"), palette: p) { + PrimaryButton(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next")) { if idx + 1 >= qs.count { finished = true vm.markBookQuiz(id) @@ -830,7 +818,10 @@ struct LitQuizScreen: View { } Spacer() } - .padding(16) + .padding() + .navigationTitle(vm.tr(book.str("quizTitle"))) + .navigationBarTitleDisplayMode(.inline) + .navigationSubtitle(vm.tr(book.str("quizSub"))) } } } diff --git a/ios/Maturita/UI/PathMap.swift b/ios/Maturita/UI/PathMap.swift index d7796d1..54a1139 100644 --- a/ios/Maturita/UI/PathMap.swift +++ b/ios/Maturita/UI/PathMap.swift @@ -7,57 +7,5 @@ struct MapNode { let done: Bool let current: Bool var finish: Bool = false - var onClick: (() -> Void)? -} - -struct PathMap: View { - let nodes: [MapNode] - let palette: Palette - - var body: some View { - ScrollView { - LazyVStack(spacing: 10) { - ForEach(Array(nodes.enumerated()), id: \.element.id) { i, node in - Button { - if !node.locked { node.onClick?() } - } label: { - CardBox(palette: palette) { - HStack(spacing: 12) { - let bg: Color = { - if node.locked { return palette.lockedBg } - if node.current || node.done { return palette.accent } - return palette.node - }() - let fg: Color = { - if node.locked { return palette.overlay } - if node.current || node.done { return palette.onAccent } - return palette.text - }() - ZStack { - Circle().fill(bg).frame(width: 44, height: 44) - if node.locked { - LockIcon(color: palette.overlay, size: 16) - } else if node.done { - CheckIcon(color: palette.onAccent, size: 16) - } else { - Text("\(i + 1)") - .font(.system(size: 16, weight: .heavy)) - .foregroundStyle(fg) - } - } - Text(node.label) - .font(.system(size: 16, weight: node.current ? .bold : .semibold)) - .foregroundStyle(node.locked ? palette.overlay : palette.text) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - .buttonStyle(.plain) - .disabled(node.locked || node.onClick == nil) - .opacity(node.locked ? 0.48 : 1) - } - } - .padding(.bottom, 24) - } - } + var destination: Route? } diff --git a/ios/Maturita/UI/Screens.swift b/ios/Maturita/UI/Screens.swift index 419a36b..7050cf0 100644 --- a/ios/Maturita/UI/Screens.swift +++ b/ios/Maturita/UI/Screens.swift @@ -1,197 +1,117 @@ import SwiftUI -struct WelcomeScreen: View { +struct PracticeHome: View { @ObservedObject var vm: AppModel var body: some View { - let p = vm.palette - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Text("maturita.c") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(p.text) - Text("sestavení \(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))") - .font(.system(size: 12)) - .foregroundStyle(p.overlay) - .padding(.bottom, 12) - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 12) { + let sum = summarize(vm.content, vm.progress) + let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx) + List { + Section { + GlassCard { + VStack(alignment: .leading, spacing: 10) { Text(vm.tr("welcome_title")) - .font(.system(size: 32, weight: .medium)) - .foregroundStyle(p.text) + .font(.title.bold()) Text(vm.tr("welcome_body_ios")) - .font(.system(size: 16)) - .foregroundStyle(p.subtext) - PrimaryButton(label: vm.tr("continue"), palette: p) { vm.go(.subjects) } + .font(.subheadline) + .foregroundStyle(.secondary) + ProgressView(value: pct) + Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx)) + .font(.footnote) + .foregroundStyle(.secondary) } - Spacer(minLength: 0) - VStack { - Text(vm.tr("welcome_stat_value")) - .font(.system(size: 28, weight: .bold)) - .foregroundStyle(p.text) - Text(vm.tr("welcome_stat_label")) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(p.subtext) - .multilineTextAlignment(.center) - } - .frame(width: 108, height: 108) - .background(p.mantle, in: Circle()) - .overlay(Circle().stroke(p.text.opacity(0.08), lineWidth: 1)) } - FeatureCard(vm: vm, t: "welcome_feat1_title", b: "welcome_feat1_body", l: "welcome_feat1_link") - .padding(.top, 28) - FeatureCard(vm: vm, t: "welcome_feat2_title", b: "welcome_feat2_body", l: "welcome_feat2_link") - .padding(.top, 12) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } - .padding(.horizontal, 24) - .padding(.vertical, 16) - } - } -} -private struct FeatureCard: View { - @ObservedObject var vm: AppModel - let t, b, l: String - - var body: some View { - let p = vm.palette - CardBox(palette: p) { - VStack(alignment: .leading, spacing: 6) { - Text(vm.tr(t)).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text) - Text(vm.tr(b)).font(.system(size: 15)).foregroundStyle(p.subtext) - PillButton(label: vm.tr(l), palette: p) { vm.go(.subjects) } - .padding(.top, 4) - } - } - } -} - -struct SubjectsScreen: View { - @ObservedObject var vm: AppModel - - var body: some View { - let p = vm.palette - VStack(spacing: 0) { - PageTop(vm: vm, back: { vm.back() }, title: vm.tr("subjects_title"), subtitle: vm.tr("subjects_sub")) - .padding(.horizontal, 16) - ScrollView { - VStack(spacing: 12) { - ForEach(Array(stride(from: 0, to: vm.content.subjects.count, by: 2)), id: \.self) { i in - HStack(spacing: 12) { - subjectTile(vm.content.subjects[i], p) - if i + 1 < vm.content.subjects.count { - subjectTile(vm.content.subjects[i + 1], p) - } else { - Color.clear.frame(maxWidth: .infinity).frame(height: 132) - } + Section(vm.tr("subjects_title")) { + ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in + let open = s.bool("open") + let dest = s.strOrNull("target").flatMap(routeFromPage) + if open, let dest, dest != .subjects, dest != .welcome { + NavigationLink(value: dest) { + subjectLabel(s) } + } else { + subjectLabel(s) + .foregroundStyle(.secondary) } } - .padding(.horizontal, 16) - .padding(.bottom, 24) } } + .navigationTitle("Maturita") + .navigationBarTitleDisplayMode(.large) } - private func subjectTile(_ s: J, _ p: Palette) -> some View { - let open = s.bool("open") - return Button { - if open { vm.goPage(s.str("target")) } - } label: { - VStack(spacing: 10) { - switch s.str("icon") { - case "de": GermanFlag(size: 44) - case "wifi": WifiIcon(color: p.text, size: 36) - case "chip": ChipIcon(color: p.text, size: 36) - case "cz": CzechFlag(size: 44) - default: LockIcon(color: p.overlay, size: 22) - } + private func subjectLabel(_ s: J) -> some View { + Label { + VStack(alignment: .leading, spacing: 2) { Text(vm.tr(s.str("key"))) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(p.text) - .multilineTextAlignment(.center) + if !s.bool("open") { + Text(vm.tr("stats_locked")).font(.caption).foregroundStyle(.secondary) + } } - .frame(maxWidth: .infinity) - .frame(height: 132) - .background(p.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .stroke(p.text.opacity(open ? 0.08 : 0.04), lineWidth: 1) - ) - .opacity(open ? 1 : 0.48) + } icon: { + Image(systemName: subjectSymbol(s.str("icon"))) } - .buttonStyle(.plain) - .disabled(!open) } } -struct SettingsSheet: View { +struct SettingsScreen: View { @ObservedObject var vm: AppModel var body: some View { - let p = vm.palette - ScrollView { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(vm.tr("settings_title")) - .font(.system(size: 22, weight: .bold)) - .foregroundStyle(p.text) - Spacer() - PillButton(label: vm.tr("back"), palette: p) { vm.settingsOpen = false } + Form { + Section(vm.tr("mode")) { + Picker(vm.tr("mode"), selection: modeBinding) { + Text(vm.tr("mode_dark")).tag(ColorMode.dark) + Text(vm.tr("mode_light")).tag(ColorMode.light) } - Text(vm.tr("mode")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8) - HStack(spacing: 4) { - SegChip(label: vm.tr("mode_dark"), selected: vm.mode == .dark, palette: p) { vm.applyMode(.dark) } - SegChip(label: vm.tr("mode_light"), selected: vm.mode == .light, palette: p) { vm.applyMode(.light) } - } - .padding(3) - .background(p.surface1, in: Capsule()) - Text(vm.tr("theme")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8) - ForEach(Array(themeNames.enumerated()), id: \.offset) { i, name in - if let id = ThemeId(rawValue: i) { - let sw = themePalette(id, vm.mode) - let sel = vm.themeId == id - Button { vm.setTheme(id) } label: { - HStack(spacing: 10) { - ZStack { - Circle().fill(sw.base) - Circle().fill(sw.accent).frame(width: 10, height: 10).offset(x: -8) - Circle().fill(sw.accent3).frame(width: 10, height: 10) - Circle().fill(sw.success).frame(width: 10, height: 10).offset(x: 8) - if sel { Circle().stroke(p.accent, lineWidth: 2.2) } - } - .frame(width: 36, height: 36) - Text(name).font(.system(size: 16, weight: .medium)).foregroundStyle(p.text) - Spacer() - } - .padding(10) - .background(sel ? p.surface1 : p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - } - .buttonStyle(.plain) - } - } - Text(vm.tr("language")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8) - HStack(spacing: 4) { - SegChip(label: "Čeština", selected: vm.lang == .cs, palette: p) { vm.applyLang(.cs) } - SegChip(label: "English", selected: vm.lang == .en, palette: p) { vm.applyLang(.en) } - } - .padding(3) - .background(p.surface1, in: Capsule()) - Text(vm.tr("updates")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8) - Text(updateText) - .font(.system(size: 14)) - .foregroundStyle(p.subtext) - HStack(spacing: 8) { - PillButton(label: vm.tr("update_check"), palette: p) { vm.checkUpdate(true) } - if vm.update.canInstall { - PrimaryButton(label: vm.tr("update_install"), palette: p) { vm.installUpdate() } - } - } - .padding(.bottom, 24) + .pickerStyle(.segmented) + } + Section(vm.tr("theme")) { + ForEach(Array(ThemeId.allCases), id: \.self) { id in + Button { + vm.setTheme(id) + } label: { + HStack { + Circle().fill(themePalette(id, vm.mode).accent).frame(width: 14, height: 14) + Text(themeNames[id.rawValue]) + Spacer() + if vm.themeId == id { + Image(systemName: "checkmark") + } + } + } + } + } + Section(vm.tr("language")) { + Picker(vm.tr("language"), selection: langBinding) { + Text("Čeština").tag(UiLang.cs) + Text("English").tag(UiLang.en) + } + .pickerStyle(.segmented) + } + Section(vm.tr("updates")) { + Text(updateText).foregroundStyle(.secondary) + Button(vm.tr("update_check")) { vm.checkUpdate(true) } + if vm.update.canInstall { + Button(vm.tr("update_install")) { vm.installUpdate() } + } + LabeledContent("Build", value: "\(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))") } - .padding(20) } - .background(p.base) + .navigationTitle(vm.tr("settings_title")) + } + + private var modeBinding: Binding { + Binding(get: { vm.mode }, set: { vm.applyMode($0) }) + } + + private var langBinding: Binding { + Binding(get: { vm.lang }, set: { vm.applyLang($0) }) } private var updateText: String { @@ -206,70 +126,44 @@ struct StatsScreen: View { @ObservedObject var vm: AppModel var body: some View { - let p = vm.palette let sum = summarize(vm.content, vm.progress) - let pct = sum.totalEx == 0 ? 0 : (100 * sum.doneEx / sum.totalEx) - VStack(spacing: 0) { - PageTop(vm: vm, back: { vm.back() }, title: vm.tr("stats_title"), subtitle: vm.tr("stats_sub")) - .padding(.horizontal, 16) - ScrollView { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 10) { - metric(p, vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx)) - metric(p, vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", pct)) - metric(p, vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits)) - } - ProgressBar(fraction: CGFloat(pct) / 100, palette: p, height: 8) - Text(vm.tr("stats_section")) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(p.subtext) - .padding(.top, 10) - ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in - let open = s.bool("open") - let part = subjectSum(vm, s) - CardBox(palette: p) { - VStack(alignment: .leading, spacing: 4) { - Text(vm.tr(s.str("key"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) - Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked")) - .font(.system(size: 13)).foregroundStyle(p.subtext) - if open && part.totalEx > 0 { - ProgressBar(fraction: CGFloat(part.doneEx) / CGFloat(part.totalEx), palette: p, height: 6) - .padding(.top, 4) - } - } - } - .opacity(open ? 1 : 0.55) - } + let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx) + List { + Section { + HStack { + stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx)) + stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100))) + stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits)) + } + .listRowBackground(Color.clear) + ProgressView(value: pct) + } + Section(vm.tr("stats_section")) { + ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in + let open = s.bool("open") + let part = subjectSum(vm, s) + VStack(alignment: .leading, spacing: 6) { + Text(vm.tr(s.str("key"))) + Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked")) + .font(.caption) + .foregroundStyle(.secondary) + if open && part.totalEx > 0 { + ProgressView(value: Double(part.doneEx) / Double(part.totalEx)) + } + } + .opacity(open ? 1 : 0.5) } - .padding(.horizontal, 16) - .padding(.bottom, 24) } } + .navigationTitle(vm.tr("stats_title")) } - private func metric(_ p: Palette, _ label: String, _ value: String) -> some View { - CardBox(palette: p) { - VStack(alignment: .leading) { - Text(label).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext) - Text(value).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text) - } + private func stat(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading) { + Text(label).font(.caption).foregroundStyle(.secondary) + Text(value).font(.title3.bold()) } - } -} - -struct ProgressBar: View { - let fraction: CGFloat - let palette: Palette - var height: CGFloat = 8 - - var body: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(palette.surface1) - Capsule().fill(palette.accent).frame(width: geo.size.width * min(max(fraction, 0), 1)) - } - } - .frame(height: height) + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -297,61 +191,37 @@ private func subjectSum(_ vm: AppModel, _ s: J) -> ProgressSum { return sum } -struct SearchOverlay: View { +struct SearchScreen: View { @ObservedObject var vm: AppModel var body: some View { - let p = vm.palette let q = normalizeAnswer(vm.searchQuery) - let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) }.prefix(24) - VStack(alignment: .leading, spacing: 12) { - HStack { - ZStack(alignment: .leading) { - if vm.searchQuery.isEmpty { - Text(vm.tr("search_placeholder")).foregroundStyle(p.overlay) - } - TextField("", text: $vm.searchQuery) - .foregroundStyle(p.text) - .tint(p.accent) - } - .padding(14) - .background(p.mantle, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - PillButton(label: vm.tr("back"), palette: p) { vm.searchOpen = false } - } + let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) } + List { if hits.isEmpty { - Text(q.isEmpty ? vm.tr("search_hint") : vm.tr("search_empty")).foregroundStyle(p.subtext) + ContentUnavailableView.search } else { - ScrollView { - VStack(spacing: 8) { - ForEach(Array(hits), id: \.target) { hit in - Button { - if !hit.locked { - vm.goPage(hit.target) - vm.searchOpen = false - } - } label: { - CardBox(palette: p) { - VStack(alignment: .leading) { - Text(hit.title).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text) - Text(hit.sub).font(.system(size: 13)).foregroundStyle(p.subtext) - } - } - } - .buttonStyle(.plain) - .opacity(hit.locked ? 0.5 : 1) + ForEach(hits) { hit in + Button { + if !hit.locked { vm.openFromSearch(hit.target) } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(hit.title) + Text(hit.sub).font(.caption).foregroundStyle(.secondary) } } + .disabled(hit.locked) + .foregroundStyle(hit.locked ? .secondary : .primary) } } - Spacer(minLength: 0) } - .padding(16) - .background(p.base.opacity(0.96)) + .navigationTitle(vm.tr("search_placeholder")) + .searchable(text: $vm.searchQuery, prompt: vm.tr("search_placeholder")) } } private struct Hit: Identifiable { - var id: String { target + title } + var id: String { target + "|" + title } let title: String let sub: String let target: String @@ -400,31 +270,27 @@ struct RoadmapScreen: View { @ObservedObject var vm: AppModel var body: some View { - let current = vm.content.german.firstIndex { u in - u.bool("unlocked") && u.strs("names").indices.contains { !vm.progress.germanDone(u.int("id"), $0 + 1) } - } - VStack(spacing: 0) { - PageTop(vm: vm, back: { vm.back() }, title: vm.tr("roadmap_title"), subtitle: vm.tr("roadmap_sub")) - .padding(.horizontal, 12) - PathMap( - nodes: vm.content.german.enumerated().map { i, u in - let unlocked = u.bool("unlocked") - let names = u.strs("names") - let done = unlocked && !names.isEmpty && names.indices.allSatisfy { vm.progress.germanDone(u.int("id"), $0 + 1) } - return MapNode( - id: u.str("page"), - label: u.str("title"), - locked: !unlocked, - done: done, - current: i == current, - finish: i == vm.content.german.count - 1, - onClick: unlocked ? { vm.go(.unitMap(u.int("id"))) } : nil - ) - }, - palette: vm.palette - ) - .padding(.horizontal, 12) + List { + ForEach(Array(vm.content.german.enumerated()), id: \.offset) { _, u in + let unlocked = u.bool("unlocked") + let names = u.strs("names") + let done = unlocked && !names.isEmpty && names.indices.allSatisfy { vm.progress.germanDone(u.int("id"), $0 + 1) } + if unlocked { + NavigationLink(value: Route.unitMap(u.int("id"))) { + Label { + Text(u.str("title")) + } icon: { + Image(systemName: done ? "checkmark.circle.fill" : "circle") + } + } + } else { + Label(u.str("title"), systemImage: "lock.fill") + .foregroundStyle(.secondary) + } + } } + .navigationTitle(vm.tr("roadmap_title")) + .navigationSubtitle(vm.tr("roadmap_sub")) } } @@ -435,29 +301,24 @@ struct UnitMapScreen: View { var body: some View { if let u = vm.content.germanUnit(unitId) { let names = u.strs("names") - let next = names.indices.first { !vm.progress.germanDone(unitId, $0 + 1) } - VStack(spacing: 0) { - PageTop(vm: vm, back: { vm.back() }, title: u.str("title"), subtitle: vm.tr(u.str("sub"))) - .padding(.horizontal, 12) - PathMap( - nodes: names.enumerated().map { i, name in - MapNode( - id: "e\(i + 1)", - label: name, - locked: false, - done: vm.progress.germanDone(unitId, i + 1), - current: i == next, - onClick: { vm.go(.germanEx(unitId, i + 1)) } - ) - }, - palette: vm.palette - ) - .padding(.horizontal, 12) + List { + ForEach(names.indices, id: \.self) { i in + NavigationLink(value: Route.germanEx(unitId, i + 1)) { + Label { + Text(names[i]) + } icon: { + Image(systemName: vm.progress.germanDone(unitId, i + 1) ? "checkmark.circle.fill" : "circle") + } + } + } if u.has("branch") { - PrimaryButton(label: vm.tr("Vokabeltraining"), palette: vm.palette) { vm.go(.vocab(unitId)) } - .padding(.bottom, 16) + NavigationLink(value: Route.vocab(unitId)) { + Label(vm.tr("Vokabeltraining"), systemImage: "character.book.closed") + } } } + .navigationTitle(u.str("title")) + .navigationSubtitle(vm.tr(u.str("sub"))) } } } @@ -466,97 +327,98 @@ struct NetYearsScreen: View { @ObservedObject var vm: AppModel var body: some View { - VStack(spacing: 0) { - PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_years_title"), subtitle: vm.tr("net_years_sub")) - .padding(.horizontal, 16) - ScrollView { - VStack(spacing: 12) { - yearCard(vm.tr("net_year1"), vm.tr("net_sub"), true) { vm.go(.netMap) } - ForEach(["net_year2", "net_year3", "net_year4"], id: \.self) { key in - yearCard(vm.tr(key), vm.tr("net_year_locked_sub"), false) {} - } + List { + NavigationLink(value: Route.netMap) { + VStack(alignment: .leading) { + Text(vm.tr("net_year1")) + Text(vm.tr("net_sub")).font(.caption).foregroundStyle(.secondary) } - .padding(.horizontal, 16) - .padding(.bottom, 16) } - } - } - - private func yearCard(_ title: String, _ sub: String, _ open: Bool, _ action: @escaping () -> Void) -> some View { - let p = vm.palette - return Button(action: action) { - CardBox(palette: p) { - HStack { + ForEach(["net_year2", "net_year3", "net_year4"], id: \.self) { key in + Label { VStack(alignment: .leading) { - Text(title).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text) - Text(sub).font(.system(size: 14)).foregroundStyle(p.subtext) + Text(vm.tr(key)) + Text(vm.tr("net_year_locked_sub")).font(.caption) } - Spacer() - if !open { LockIcon(color: p.overlay) } + } icon: { + Image(systemName: "lock.fill") } + .foregroundStyle(.secondary) } } - .buttonStyle(.plain) - .disabled(!open) - .opacity(open ? 1 : 0.48) + .navigationTitle(vm.tr("net_years_title")) + .navigationSubtitle(vm.tr("net_years_sub")) } } -struct LessonMapScreen: View { - @ObservedObject var vm: AppModel +struct LessonRow: Identifiable { + let id: String let title: String - let sub: String - let nodes: [MapNode] + let done: Bool + let destination: Route +} + +struct LessonListScreen: View { + let title: String + let subtitle: String + let rows: [LessonRow] var body: some View { - VStack(spacing: 0) { - PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub) - .padding(.horizontal, 12) - PathMap(nodes: nodes, palette: vm.palette) - .padding(.horizontal, 12) + List(rows) { row in + NavigationLink(value: row.destination) { + Label { + Text(row.title) + } icon: { + Image(systemName: row.done ? "checkmark.circle.fill" : "circle") + } + } } + .navigationTitle(title) + .navigationSubtitle(subtitle) } } struct SlidesScreen: View { @ObservedObject var vm: AppModel let title: String - let sub: String + let subtitle: String let slides: [J] - var onExercise: () -> Void + let next: Route @State private var idx = 0 var body: some View { - let p = vm.palette - if let slide = slides.indices.contains(idx) ? slides[idx] : nil { - VStack(spacing: 0) { - PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub) - .padding(.horizontal, 16) + TabView(selection: $idx) { + ForEach(slides.indices, id: \.self) { i in + let slide = slides[i] ScrollView { - VStack(alignment: .leading, spacing: 8) { - Text(slide.str("kicker")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext) - Text(slide.str("title")).font(.system(size: 24, weight: .bold)).foregroundStyle(p.text) + VStack(alignment: .leading, spacing: 12) { + Text(slide.str("kicker")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(slide.str("title")).font(.title2.bold()) if let tip = slide.strOrNull("tip") { - CardBox(palette: p) { Text(tip).foregroundStyle(p.text) } + GlassCard { Text(tip) } } ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in - CardBox(palette: p) { Text(line).font(.system(size: 16)).foregroundStyle(p.text) } + Text(line).padding(.vertical, 4) } } - .padding(.horizontal, 16) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) } - HStack { - if idx > 0 { - PillButton(label: vm.tr("net_slide_prev"), palette: p) { idx -= 1 } - } - Spacer() - if idx < slides.count - 1 { - PrimaryButton(label: vm.tr("net_slide_next"), palette: p) { idx += 1 } - } else { - PrimaryButton(label: vm.tr("net_slide_start"), palette: p, action: onExercise) - } + .tag(i) + } + } + .tabViewStyle(.page(indexDisplayMode: .automatic)) + .navigationTitle(title) + .navigationSubtitle(subtitle) + .toolbar { + ToolbarItem(placement: .bottomBar) { + if idx < slides.count - 1 { + Button(vm.tr("net_slide_next")) { idx += 1 } + } else { + NavigationLink(vm.tr("net_slide_start"), value: next) } - .padding(12) } } } @@ -566,44 +428,18 @@ struct CzechMapScreen: View { @ObservedObject var vm: AppModel var body: some View { - let p = vm.palette - let items: [(String, String, Route?)] = [ - ("L", vm.tr("Literatura"), nil), - ("M", vm.tr("Mluvnice"), .mluvnice), - ("Č", vm.tr("Maturitní četba"), .readingList), - ] - VStack(spacing: 0) { - PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Český jazyk a literatura"), subtitle: vm.tr("czech_sub")) - .padding(.horizontal, 16) - ScrollView { - VStack(spacing: 12) { - ForEach(items, id: \.0) { letter, name, route in - Button { - if let route { vm.go(route) } - } label: { - CardBox(palette: p) { - HStack(spacing: 14) { - ZStack { - Circle().fill(route == nil ? p.lockedBg : p.accent).frame(width: 56, height: 56) - if route == nil { - LockIcon(color: p.overlay) - } else { - Text(letter).font(.system(size: 20, weight: .heavy)).foregroundStyle(p.onAccent) - } - } - Text(name).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text) - Spacer() - } - } - } - .buttonStyle(.plain) - .disabled(route == nil) - .opacity(route == nil ? 0.48 : 1) - } - } - .padding(.horizontal, 16) + List { + Label(vm.tr("Literatura"), systemImage: "lock.fill") + .foregroundStyle(.secondary) + NavigationLink(value: Route.mluvnice) { + Label(vm.tr("Mluvnice"), systemImage: "textformat") + } + NavigationLink(value: Route.readingList) { + Label(vm.tr("Maturitní četba"), systemImage: "books.vertical") } } + .navigationTitle(vm.tr("Český jazyk a literatura")) + .navigationSubtitle(vm.tr("czech_sub")) } } @@ -611,31 +447,22 @@ struct BookListScreen: View { @ObservedObject var vm: AppModel var body: some View { - let p = vm.palette - VStack(spacing: 0) { - PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Maturitní četba"), subtitle: vm.tr("reading_sub")) - .padding(.horizontal, 16) - ScrollView { - VStack(spacing: 12) { - ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in - Button { vm.go(.book(b.str("id"))) } label: { - CardBox(palette: p) { - HStack(spacing: 12) { - BookIcon(color: p.text, size: 28) - VStack(alignment: .leading) { - Text(b.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) - Text(vm.tr(b.str("subKey"))).font(.system(size: 13)).foregroundStyle(p.subtext) - } - Spacer() - } - } + List { + ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in + NavigationLink(value: Route.book(b.str("id"))) { + Label { + VStack(alignment: .leading) { + Text(b.str("title")) + Text(vm.tr(b.str("subKey"))).font(.caption).foregroundStyle(.secondary) } - .buttonStyle(.plain) + } icon: { + Image(systemName: "book.fill") } } - .padding(.horizontal, 16) } } + .navigationTitle(vm.tr("Maturitní četba")) + .navigationSubtitle(vm.tr("reading_sub")) } } @@ -645,54 +472,37 @@ struct BookScreen: View { var body: some View { if let b = vm.content.book(id) { - let p = vm.palette - VStack(spacing: 0) { - PageTop(vm: vm, back: { vm.back() }, title: b.str("title"), subtitle: vm.tr(b.str("subKey"))) - .padding(.horizontal, 16) - ScrollView { - VStack(spacing: 12) { - Button { vm.go(.bookQuiz(id)) } label: { - CardBox(palette: p) { - HStack(spacing: 10) { - QuizIcon(color: p.text) - VStack(alignment: .leading) { - Text(vm.tr(b.str("quizTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) - Text(vm.tr(b.str("quizSub"))).font(.system(size: 13)).foregroundStyle(p.subtext) - } - } - } + List { + NavigationLink(value: Route.bookQuiz(id)) { + Label { + VStack(alignment: .leading) { + Text(vm.tr(b.str("quizTitle"))) + Text(vm.tr(b.str("quizSub"))).font(.caption).foregroundStyle(.secondary) } - .buttonStyle(.plain) - Button { vm.go(.bookPlot(id)) } label: { - CardBox(palette: p) { - HStack(spacing: 10) { - OrderIcon(color: p.text) - VStack(alignment: .leading) { - Text(vm.tr(b.str("plotTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) - Text(vm.tr(b.str("plotSub"))).font(.system(size: 13)).foregroundStyle(p.subtext) - } - } - } + } icon: { + Image(systemName: "questionmark.circle") + } + } + NavigationLink(value: Route.bookPlot(id)) { + Label { + VStack(alignment: .leading) { + Text(vm.tr(b.str("plotTitle"))) + Text(vm.tr(b.str("plotSub"))).font(.caption).foregroundStyle(.secondary) } - .buttonStyle(.plain) - ForEach(Array(b.arr("notes").enumerated()), id: \.offset) { _, note in - CardBox(palette: p) { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - noteIcon(note.str("icon"), p.text) - Text(note.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text) - } - ForEach(note.strs("lines"), id: \.self) { line in - Text("• \(line)").font(.system(size: 15)).foregroundStyle(p.text) - } - } - } + } icon: { + Image(systemName: "list.number") + } + } + ForEach(Array(b.arr("notes").enumerated()), id: \.offset) { _, note in + Section(note.str("title")) { + ForEach(note.strs("lines"), id: \.self) { line in + Text(line) } } - .padding(.horizontal, 16) - .padding(.bottom, 20) } } + .navigationTitle(b.str("title")) + .navigationSubtitle(vm.tr(b.str("subKey"))) } } } diff --git a/src/i18n.c b/src/i18n.c index e2a160e..9851250 100644 --- a/src/i18n.c +++ b/src/i18n.c @@ -62,10 +62,10 @@ const TrEntry tr_ui[] = { "Exam practice on your phone. Czech and English, " "the same content as on the computer."}, {"welcome_body_ios", - "Procvičování k maturitě v iPhonu. Česky i anglicky, " - "stejný obsah jako na počítači.", - "Exam practice on iPhone. Czech and English, " - "the same content as on the computer."}, + "Nativní procvičování na iPhonu. Česky i anglicky, " + "stejný obsah, rozhraní podle iOS.", + "Native exam practice on iPhone. Czech and English, " + "the same content, adapted to iOS."}, {"update_ios_sideload", "Nový build je na GitHubu. Nainstalujte ho znovu přes Xcode nebo AltStore (%s).", "A newer build is on GitHub. Reinstall it with Xcode or AltStore (%s)."},