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 99dd15c..5140c33 100644
Binary files a/installers/maturita-installer-ios.zip and b/installers/maturita-installer-ios.zip differ
diff --git a/installers/maturita-ios.html b/installers/maturita-ios.html
index fcc5eac..869718c 100644
--- a/installers/maturita-ios.html
+++ b/installers/maturita-ios.html
@@ -28,8 +28,9 @@
- 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)."},