mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
Adapt the iOS app to native iOS 26 navigation and Liquid Glass.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3407af1b85
commit
939ac2294e
13 changed files with 631 additions and 838 deletions
|
|
@ -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** |
|
| 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 |
|
| 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) |
|
| 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),
|
Kam se to nainstaluje: `/Applications` nebo `~/Applications` (macOS),
|
||||||
`%LOCALAPPDATA%\Programs\Maturita` (Windows), `~/Applications` (Linux).
|
`%LOCALAPPDATA%\Programs\Maturita` (Windows), `~/Applications` (Linux).
|
||||||
|
|
@ -160,7 +160,7 @@ cd android
|
||||||
|
|
||||||
nebo z kořene `make android`.
|
nebo z kořene `make android`.
|
||||||
|
|
||||||
iOS ze zdroje (Xcode 16, iOS 16+):
|
iOS ze zdroje (Xcode 26, iOS 26+ / Liquid Glass):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/extract-android-content.py
|
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** |
|
| 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 |
|
| 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) |
|
| 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),
|
Install locations: `/Applications` or `~/Applications` (macOS),
|
||||||
`%LOCALAPPDATA%\Programs\Maturita` (Windows), `~/Applications` (Linux).
|
`%LOCALAPPDATA%\Programs\Maturita` (Windows), `~/Applications` (Linux).
|
||||||
|
|
@ -328,7 +328,7 @@ cd android
|
||||||
|
|
||||||
or `make android` from the repo root.
|
or `make android` from the repo root.
|
||||||
|
|
||||||
iOS from source (Xcode 16, iOS 16+):
|
iOS from source (Xcode 26, iOS 26+ / Liquid Glass):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/extract-android-content.py
|
python3 scripts/extract-android-content.py
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -28,8 +28,9 @@
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>maturita.C pro iPhone</h1>
|
<h1>maturita.C pro iPhone (iOS 26)</h1>
|
||||||
<p>
|
<p>
|
||||||
|
Nativní aplikace pro iOS 26+ s Liquid Glass. Není 1:1 kopie desktopu.
|
||||||
Apple nepovolí instalaci jako Android APK. Nejjednodušší cesta je Mac
|
Apple nepovolí instalaci jako Android APK. Nejjednodušší cesta je Mac
|
||||||
s Xcode a vlastní iPhone (bezplatný Apple účet stačí na 7 dní).
|
s Xcode a vlastní iPhone (bezplatný Apple účet stačí na 7 dní).
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
GCC_OPTIMIZATION_LEVEL = 0;
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
|
@ -269,7 +269,7 @@
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
|
@ -296,7 +296,7 @@
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|
@ -329,7 +329,7 @@
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,11 @@ final class AppModel: ObservableObject {
|
||||||
@Published var themeId: ThemeId
|
@Published var themeId: ThemeId
|
||||||
@Published var mode: ColorMode
|
@Published var mode: ColorMode
|
||||||
@Published var lang: UiLang
|
@Published var lang: UiLang
|
||||||
@Published var settingsOpen = false
|
@Published var tab: AppTab = .practice
|
||||||
@Published var searchOpen = false
|
@Published var practicePath: [Route] = []
|
||||||
@Published var searchQuery = ""
|
@Published var searchQuery = ""
|
||||||
@Published var tick = 0
|
@Published var tick = 0
|
||||||
@Published var update = UpdateState()
|
@Published var update = UpdateState()
|
||||||
@Published var stack: [Route] = [.welcome]
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
themeId = progress.themeId
|
themeId = progress.themeId
|
||||||
|
|
@ -22,8 +21,6 @@ final class AppModel: ObservableObject {
|
||||||
lang = progress.lang
|
lang = progress.lang
|
||||||
}
|
}
|
||||||
|
|
||||||
var route: Route { stack.last ?? .welcome }
|
|
||||||
var canGoBack: Bool { stack.count > 1 }
|
|
||||||
var palette: Palette { themePalette(themeId, mode) }
|
var palette: Palette { themePalette(themeId, mode) }
|
||||||
|
|
||||||
func tr(_ key: String?) -> String { content.tr(key, lang: lang) }
|
func tr(_ key: String?) -> String { content.tr(key, lang: lang) }
|
||||||
|
|
@ -33,9 +30,21 @@ final class AppModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func go(_ r: Route) {
|
func go(_ r: Route) {
|
||||||
if stack.last == r { return }
|
switch r {
|
||||||
stack.append(r)
|
case .stats:
|
||||||
searchOpen = false
|
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?) {
|
func goPage(_ page: String?) {
|
||||||
|
|
@ -43,8 +52,18 @@ final class AppModel: ObservableObject {
|
||||||
go(r)
|
go(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func back() {
|
func openFromSearch(_ page: String) {
|
||||||
if stack.count > 1 { stack.removeLast() }
|
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) {
|
func setTheme(_ id: ThemeId) {
|
||||||
|
|
|
||||||
|
|
@ -6,149 +6,172 @@ struct MaturitaApp: App {
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
MaturitaRoot(vm: vm)
|
NativeRoot(vm: vm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MaturitaRoot: View {
|
struct NativeRoot: View {
|
||||||
@ObservedObject var vm: AppModel
|
@ObservedObject var vm: AppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let p = vm.palette
|
Group {
|
||||||
ZStack {
|
if showsUpdateAccessory {
|
||||||
p.base.ignoresSafeArea()
|
tabs.tabViewBottomAccessory { UpdateAccessory(vm: vm) }
|
||||||
VStack(spacing: 0) {
|
} else {
|
||||||
HStack {
|
tabs
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.tint(vm.palette.accent)
|
||||||
.preferredColorScheme(vm.mode == .dark ? .dark : .light)
|
.preferredColorScheme(vm.mode == .dark ? .dark : .light)
|
||||||
.onAppear { vm.checkUpdate(false) }
|
.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 {
|
private var bannerText: String {
|
||||||
if let arg = vm.update.messageArg {
|
if let arg = vm.update.messageArg {
|
||||||
return vm.fmt(vm.update.messageKey, arg)
|
return vm.fmt(vm.update.messageKey, arg)
|
||||||
}
|
}
|
||||||
return vm.tr(vm.update.messageKey)
|
return vm.tr(vm.update.messageKey)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
struct RouteDestination: View {
|
||||||
private var routeView: some View {
|
@ObservedObject var vm: AppModel
|
||||||
switch vm.route {
|
let route: Route
|
||||||
case .welcome: WelcomeScreen(vm: vm)
|
|
||||||
case .subjects: SubjectsScreen(vm: vm)
|
var body: some View {
|
||||||
case .stats: StatsScreen(vm: vm)
|
switch route {
|
||||||
case .roadmap: RoadmapScreen(vm: vm)
|
case .welcome, .subjects:
|
||||||
case .unitMap(let id): UnitMapScreen(vm: vm, unitId: id)
|
PracticeHome(vm: vm)
|
||||||
case .germanEx(let unit, let ex): GermanExercise(vm: vm, unitId: unit, ex: ex)
|
case .stats:
|
||||||
case .vocab(let id): VocabExercise(vm: vm, unitId: id)
|
StatsScreen(vm: vm)
|
||||||
case .netYears: NetYearsScreen(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:
|
case .netMap:
|
||||||
LessonMapScreen(
|
LessonListScreen(
|
||||||
vm: vm,
|
|
||||||
title: vm.tr("net_year1"),
|
title: vm.tr("net_year1"),
|
||||||
sub: vm.tr("net_sub"),
|
subtitle: vm.tr("net_sub"),
|
||||||
nodes: vm.content.netLessons.map { l in
|
rows: vm.content.netLessons.map { l in
|
||||||
let id = l.int("id")
|
let id = l.int("id")
|
||||||
return MapNode(
|
return LessonRow(
|
||||||
id: "n\(id)",
|
id: "n\(id)",
|
||||||
label: vm.tr(l.str("titleKey")),
|
title: vm.tr(l.str("titleKey")),
|
||||||
locked: false,
|
|
||||||
done: vm.progress.netDone(id),
|
done: vm.progress.netDone(id),
|
||||||
current: !vm.progress.netDone(id),
|
destination: .netLesson(id)
|
||||||
onClick: { vm.go(.netLesson(id)) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
case .netLesson(let id):
|
case .netLesson(let id):
|
||||||
if let l = vm.content.netLesson(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")) {
|
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .netEx(id))
|
||||||
vm.go(.netEx(id))
|
|
||||||
}
|
}
|
||||||
}
|
case .netEx(let id):
|
||||||
case .netEx(let id): NetQuizScreen(vm: vm, id: id)
|
NetQuizScreen(vm: vm, id: id)
|
||||||
case .hwMap:
|
case .hwMap:
|
||||||
LessonMapScreen(
|
LessonListScreen(
|
||||||
vm: vm,
|
|
||||||
title: vm.tr("Technické vybavení"),
|
title: vm.tr("Technické vybavení"),
|
||||||
sub: vm.tr("hw_sub"),
|
subtitle: vm.tr("hw_sub"),
|
||||||
nodes: vm.content.hw.map { l in
|
rows: vm.content.hw.map { l in
|
||||||
let id = l.int("id")
|
let id = l.int("id")
|
||||||
return MapNode(
|
return LessonRow(
|
||||||
id: "h\(id)",
|
id: "h\(id)",
|
||||||
label: vm.tr(l.str("titleKey")),
|
title: vm.tr(l.str("titleKey")),
|
||||||
locked: false,
|
|
||||||
done: vm.progress.hwDone(id),
|
done: vm.progress.hwDone(id),
|
||||||
current: !vm.progress.hwDone(id),
|
destination: .hwLesson(id)
|
||||||
onClick: { vm.go(.hwLesson(id)) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
case .hwLesson(let id):
|
case .hwLesson(let id):
|
||||||
if let l = vm.content.hwLesson(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")) {
|
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .hwEx(id))
|
||||||
vm.go(.hwEx(id))
|
|
||||||
}
|
}
|
||||||
}
|
case .hwEx(let id):
|
||||||
case .hwEx(let id): HwQuizScreen(vm: vm, id: id)
|
HwQuizScreen(vm: vm, id: id)
|
||||||
case .czechMap: CzechMapScreen(vm: vm)
|
case .czechMap:
|
||||||
|
CzechMapScreen(vm: vm)
|
||||||
case .mluvnice:
|
case .mluvnice:
|
||||||
LessonMapScreen(
|
LessonListScreen(
|
||||||
vm: vm,
|
|
||||||
title: vm.tr("Mluvnice"),
|
title: vm.tr("Mluvnice"),
|
||||||
sub: "Cvičení z mluvnice – styl maturita / přijímačky z ČJL",
|
subtitle: "Cvičení z mluvnice",
|
||||||
nodes: vm.content.mluvnice.map { m in
|
rows: vm.content.mluvnice.map { m in
|
||||||
let n = m.int("id")
|
let n = m.int("id")
|
||||||
return MapNode(
|
return LessonRow(
|
||||||
id: "m\(n)",
|
id: "m\(n)",
|
||||||
label: m.str("name"),
|
title: m.str("name"),
|
||||||
locked: false,
|
|
||||||
done: vm.progress.mluvDone(n),
|
done: vm.progress.mluvDone(n),
|
||||||
current: !vm.progress.mluvDone(n),
|
destination: .mluvEx(n)
|
||||||
onClick: { vm.go(.mluvEx(n)) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
case .mluvEx(let n): MluvExercise(vm: vm, n: n)
|
case .mluvEx(let n):
|
||||||
case .readingList: BookListScreen(vm: vm)
|
MluvExercise(vm: vm, n: n)
|
||||||
case .book(let id): BookScreen(vm: vm, id: id)
|
case .readingList:
|
||||||
case .bookQuiz(let id): LitQuizScreen(vm: vm, id: id)
|
BookListScreen(vm: vm)
|
||||||
case .bookPlot(let id): PlotScreen(vm: vm, id: id)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum Route: Equatable {
|
enum AppTab: Hashable {
|
||||||
|
case practice
|
||||||
|
case progress
|
||||||
|
case search
|
||||||
|
case settings
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Route: Hashable {
|
||||||
case welcome
|
case welcome
|
||||||
case subjects
|
case subjects
|
||||||
case stats
|
case stats
|
||||||
|
|
@ -48,6 +55,50 @@ enum Route: Equatable {
|
||||||
case .bookPlot(let id): return id == "1984" ? "cetba1984dej" : "cetbaFuksDej"
|
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? {
|
func routeFromPage(_ page: String) -> Route? {
|
||||||
|
|
|
||||||
|
|
@ -41,98 +41,33 @@ struct FlowLayout: Layout {
|
||||||
|
|
||||||
struct PrimaryButton: View {
|
struct PrimaryButton: View {
|
||||||
let label: String
|
let label: String
|
||||||
let palette: Palette
|
|
||||||
var action: () -> Void
|
var action: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(label, action: action)
|
||||||
Text(label)
|
.buttonStyle(.borderedProminent)
|
||||||
.font(.system(size: 15, weight: .medium))
|
.controlSize(.large)
|
||||||
.foregroundStyle(palette.onAccent)
|
|
||||||
.padding(.horizontal, 22)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(palette.accent, in: Capsule())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PillButton: View {
|
struct PillButton: View {
|
||||||
let label: String
|
let label: String
|
||||||
let palette: Palette
|
|
||||||
var action: () -> Void
|
var action: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(label, action: action)
|
||||||
Text(label)
|
.buttonStyle(.bordered)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct IconHit<Content: View>: View {
|
struct GlassCard<Content: View>: 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<Content: View>: View {
|
|
||||||
let palette: Palette
|
|
||||||
@ViewBuilder var content: () -> Content
|
@ViewBuilder var content: () -> Content
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content()
|
content()
|
||||||
.padding(20)
|
.padding(16)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(palette.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
||||||
.stroke(palette.text.opacity(0.07), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,9 +79,8 @@ struct FeedbackLine: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if !text.isEmpty {
|
if !text.isEmpty {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
|
.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 {
|
var body: some View {
|
||||||
if visible && !text.isEmpty {
|
if visible && !text.isEmpty {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 13))
|
.font(.footnote)
|
||||||
.foregroundStyle(palette.subtext)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WordField: View {
|
struct WordField: View {
|
||||||
@Binding var value: String
|
@Binding var value: String
|
||||||
let palette: Palette
|
|
||||||
var placeholder: String
|
var placeholder: String
|
||||||
var mark: Bool?
|
var mark: Bool?
|
||||||
|
var palette: Palette?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let border: Color = {
|
TextField(placeholder, text: $value)
|
||||||
if mark == true { return palette.success }
|
.textInputAutocapitalization(.never)
|
||||||
if mark == false { return palette.error }
|
.autocorrectionDisabled()
|
||||||
return palette.text.opacity(0.12)
|
.padding(12)
|
||||||
}()
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
ZStack(alignment: .leading) {
|
.overlay(
|
||||||
if value.isEmpty {
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
Text(placeholder).foregroundStyle(palette.overlay).font(.system(size: 15))
|
.stroke(border, lineWidth: mark == nil ? 0 : 1.5)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
TextField("", text: $value)
|
|
||||||
.font(.system(size: 15))
|
private var border: Color {
|
||||||
.foregroundStyle(palette.text)
|
if mark == true { return palette?.success ?? .green }
|
||||||
.tint(palette.accent)
|
if mark == false { return palette?.error ?? .red }
|
||||||
}
|
return .clear
|
||||||
.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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SegChip: View {
|
struct SegChip: View {
|
||||||
let label: String
|
let label: String
|
||||||
let selected: Bool
|
let selected: Bool
|
||||||
let palette: Palette
|
|
||||||
var action: () -> Void
|
var action: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(label, action: action)
|
||||||
Text(label)
|
.buttonStyle(.bordered)
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.tint(selected ? .accentColor : .secondary)
|
||||||
.foregroundStyle(palette.text)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(selected ? palette.mantle : Color.clear, in: Capsule())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Hint: View {
|
struct Hint: View {
|
||||||
let text: String
|
let text: String
|
||||||
let palette: Palette
|
|
||||||
var body: some View {
|
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 {
|
struct Prompt: View {
|
||||||
let text: String
|
let text: String
|
||||||
let palette: Palette
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(text).font(.system(size: 16, weight: .medium)).foregroundStyle(palette.text)
|
Text(text).font(.body.weight(.medium))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WordChip: View {
|
struct WordChip: View {
|
||||||
let text: String
|
let text: String
|
||||||
let palette: Palette
|
|
||||||
var action: () -> Void
|
var action: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(text, action: action)
|
||||||
Text(text)
|
.buttonStyle(.bordered)
|
||||||
.font(.system(size: 15, weight: .medium))
|
.controlSize(.small)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,25 +166,44 @@ struct OptionChip: View {
|
||||||
let text: String
|
let text: String
|
||||||
let selected: Bool
|
let selected: Bool
|
||||||
let mark: Bool?
|
let mark: Bool?
|
||||||
let palette: Palette
|
|
||||||
var action: () -> Void
|
var action: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
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) {
|
Button(action: action) {
|
||||||
Text(text)
|
HStack {
|
||||||
.font(.system(size: 15))
|
Text(text).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.foregroundStyle(palette.text)
|
if selected { Image(systemName: "checkmark") }
|
||||||
.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))
|
|
||||||
}
|
}
|
||||||
.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,29 @@ struct ExerciseScaffold<Content: View>: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let p = vm.palette
|
let p = vm.palette
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 0) { content() }
|
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)
|
FeedbackLine(text: feedback, kind: kind, palette: p)
|
||||||
PrimaryButton(label: action, palette: p, action: onAction)
|
.padding()
|
||||||
.padding(.vertical, 12)
|
.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
|
ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in
|
||||||
let sel = picks[i] == o
|
let sel = picks[i] == o
|
||||||
let mark: Bool? = revealed && sel ? picks[i] == qs[i].int("correct") : nil
|
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 revealed {
|
||||||
if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) }
|
if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) }
|
||||||
|
|
@ -182,12 +195,12 @@ private struct AssemblyEx: View {
|
||||||
revealed = true
|
revealed = true
|
||||||
}, feedback: fb.0, kind: fb.1) {
|
}, feedback: fb.0, kind: fb.1) {
|
||||||
ForEach(items.indices, id: \.self) { i in
|
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 {
|
FlowLayout {
|
||||||
if placed[i].isEmpty { Text("…").foregroundStyle(p.overlay) }
|
if placed[i].isEmpty { Text("…").foregroundStyle(p.overlay) }
|
||||||
ForEach(placed[i], id: \.self) { idx in
|
ForEach(placed[i], id: \.self) { idx in
|
||||||
if idx < items[i].strs("words").count {
|
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 }
|
placed[i].removeAll { $0 == idx }
|
||||||
pools[i].append(idx)
|
pools[i].append(idx)
|
||||||
}
|
}
|
||||||
|
|
@ -201,7 +214,7 @@ private struct AssemblyEx: View {
|
||||||
FlowLayout(spacing: 6) {
|
FlowLayout(spacing: 6) {
|
||||||
ForEach(pools[i], id: \.self) { idx in
|
ForEach(pools[i], id: \.self) { idx in
|
||||||
if idx < items[i].strs("words").count {
|
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 }
|
pools[i].removeAll { $0 == idx }
|
||||||
placed[i].append(idx)
|
placed[i].append(idx)
|
||||||
}
|
}
|
||||||
|
|
@ -262,22 +275,22 @@ private struct TypedEx: View {
|
||||||
else { fb = (vm.tr("feedback_retry"), "err") }
|
else { fb = (vm.tr("feedback_retry"), "err") }
|
||||||
revealed = true
|
revealed = true
|
||||||
}, feedback: fb.0, kind: fb.1) {
|
}, 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) }
|
||||||
if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: p) }
|
if let sample = spec.strOrNull("sample") { Hint(text: sample) }
|
||||||
if let bank = spec.strOrNull("bank") { Hint(text: "\(vm.tr("wordbank")) \(bank)", palette: p) }
|
if let bank = spec.strOrNull("bank") { Hint(text: "\(vm.tr("wordbank")) \(bank)") }
|
||||||
if umlaut { Hint(text: vm.tr("hint_umlauts"), palette: p) }
|
if umlaut { Hint(text: vm.tr("hint_umlauts")) }
|
||||||
ForEach(items.indices, id: \.self) { i in
|
ForEach(items.indices, id: \.self) { i in
|
||||||
let q = items[i]
|
let q = items[i]
|
||||||
Prompt(text: q.str("prompt"), palette: p)
|
Prompt(text: q.str("prompt"))
|
||||||
let answers = q.answerList()
|
let answers = q.answerList()
|
||||||
let hints = q.strs("hints")
|
let hints = q.strs("hints")
|
||||||
ForEach(fields[i].indices, id: \.self) { j in
|
ForEach(fields[i].indices, id: \.self) { j in
|
||||||
let ans = j < answers.count ? answers[j] : ""
|
let ans = j < answers.count ? answers[j] : ""
|
||||||
WordField(
|
WordField(
|
||||||
value: Binding(get: { fields[i][j] }, set: { fields[i][j] = $0 }),
|
value: Binding(get: { fields[i][j] }, set: { fields[i][j] = $0 }),
|
||||||
palette: p,
|
|
||||||
placeholder: j < hints.count ? hints[j] : vm.tr("your_answer"),
|
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)
|
.padding(.bottom, 4)
|
||||||
}
|
}
|
||||||
|
|
@ -322,12 +335,12 @@ private struct FreeEx: View {
|
||||||
shown = shown.map { _ in true }
|
shown = shown.map { _ in true }
|
||||||
onDone()
|
onDone()
|
||||||
}, feedback: fb.0, kind: fb.1) {
|
}, 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
|
ForEach(qs.indices, id: \.self) { i in
|
||||||
let q = qs[i]
|
let q = qs[i]
|
||||||
Prompt(text: q.str("question").isEmpty ? q.str("stem") : q.str("question"), palette: p)
|
Prompt(text: q.str("question").isEmpty ? q.str("stem") : q.str("question"))
|
||||||
WordField(value: Binding(get: { values[i] }, set: { values[i] = $0 }), palette: p, placeholder: vm.tr("your_answer"))
|
WordField(value: Binding(get: { values[i] }, set: { values[i] = $0 }), placeholder: vm.tr("your_answer"), palette: p)
|
||||||
PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() }
|
PillButton(label: vm.tr("show_sample")) { shown[i].toggle() }
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
if shown[i] {
|
if shown[i] {
|
||||||
let sample = q.str("sample")
|
let sample = q.str("sample")
|
||||||
|
|
@ -376,10 +389,10 @@ private struct AssignEx: View {
|
||||||
else { fb = (vm.tr("feedback_retry"), "err") }
|
else { fb = (vm.tr("feedback_retry"), "err") }
|
||||||
revealed = true
|
revealed = true
|
||||||
}, feedback: fb.0, kind: fb.1) {
|
}, feedback: fb.0, kind: fb.1) {
|
||||||
Hint(text: vm.tr("assign_hint"), palette: p)
|
Hint(text: vm.tr("assign_hint"))
|
||||||
FlowLayout(spacing: 8) {
|
FlowLayout(spacing: 8) {
|
||||||
ForEach(groups.indices, id: \.self) { i in
|
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)
|
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
|
ForEach(items.indices, id: \.self) { i in
|
||||||
if loc[i] == -1 {
|
if loc[i] == -1 {
|
||||||
let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ")
|
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
|
ForEach(items.indices, id: \.self) { i in
|
||||||
if loc[i] == g {
|
if loc[i] == g {
|
||||||
let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ")
|
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 p = vm.palette
|
||||||
let cur = word < words.count ? words[word].uppercased() : ""
|
let cur = word < words.count ? words[word].uppercased() : ""
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
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(.secondary)
|
||||||
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]))") }
|
||||||
if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))", palette: p) }
|
|
||||||
Canvas { ctx, size in
|
Canvas { ctx, size in
|
||||||
var gallows = Path()
|
var gallows = Path()
|
||||||
gallows.move(to: CGPoint(x: 40, y: size.height - 10))
|
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)
|
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
|
||||||
if misses >= 6 && !finished {
|
if misses >= 6 && !finished {
|
||||||
PillButton(label: vm.tr("hm_retry"), palette: p) {
|
PillButton(label: vm.tr("hm_retry")) {
|
||||||
misses = 0
|
misses = 0
|
||||||
guessed = guessed.map { _ in false }
|
guessed = guessed.map { _ in false }
|
||||||
fb = ("", "")
|
fb = ("", "")
|
||||||
|
|
@ -533,7 +545,10 @@ private struct HangmanEx: View {
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding()
|
||||||
|
.navigationTitle(title)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navSubtitle(sub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -572,17 +587,16 @@ private struct VocabEx: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let p = vm.palette
|
let p = vm.palette
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
|
Text(vm.fmt("trans_progress", done, total)).foregroundStyle(.secondary)
|
||||||
Text(vm.fmt("trans_progress", done, total)).foregroundStyle(p.subtext)
|
|
||||||
if cards.isEmpty {
|
if cards.isEmpty {
|
||||||
Text(vm.tr("trans_done")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.success)
|
Text(vm.tr("trans_done")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.success)
|
||||||
} else if index < cards.count {
|
} else if index < cards.count {
|
||||||
let cur = cards[index]
|
let cur = cards[index]
|
||||||
Hint(text: cur.0, palette: p)
|
Hint(text: cur.0)
|
||||||
Prompt(text: cur.1, palette: p)
|
Prompt(text: cur.1)
|
||||||
if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts"), palette: p) }
|
if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts")) }
|
||||||
WordField(value: $value, palette: p, placeholder: vm.tr("your_answer"))
|
WordField(value: $value, placeholder: vm.tr("your_answer"), palette: p)
|
||||||
PrimaryButton(label: vm.tr("check"), palette: p) {
|
PrimaryButton(label: vm.tr("check")) {
|
||||||
let norm = normalizeAnswer(value)
|
let norm = normalizeAnswer(value)
|
||||||
if answerAccepts(norm, cur.2) {
|
if answerAccepts(norm, cur.2) {
|
||||||
done += 1
|
done += 1
|
||||||
|
|
@ -603,6 +617,9 @@ private struct VocabEx: View {
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding()
|
||||||
|
.navigationTitle(title)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navSubtitle(sub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ struct ComboEx: View {
|
||||||
} else if type == "fill" {
|
} else if type == "fill" {
|
||||||
let blanks = rows.reduce(0) { $0 + $1.answerList().count }
|
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
|
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
|
ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in
|
||||||
FlowLayout(spacing: 4) {
|
FlowLayout(spacing: 4) {
|
||||||
if let num = block.num {
|
if let num = block.num {
|
||||||
|
|
@ -114,7 +114,7 @@ struct ComboEx: View {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: rows.count, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in
|
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
|
ForEach(rows.indices, id: \.self) { i in
|
||||||
let r = rows[i]
|
let r = rows[i]
|
||||||
let prefix: String = {
|
let prefix: String = {
|
||||||
|
|
@ -318,29 +318,17 @@ struct WordPicker: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
List(Array(pool.enumerated()), id: \.offset) { _, w in
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
Button(w) { onPick(w) }
|
||||||
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)
|
.navigationTitle("")
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
}
|
|
||||||
.background(palette.base)
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
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
|
revealed = true
|
||||||
}, feedback: fb.0, kind: fb.1) {
|
}, feedback: fb.0, kind: fb.1) {
|
||||||
if rows.contains(where: { $0.strs("answers").contains { hasUmlaut($0) } }) {
|
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)
|
LettersRows(vm: vm, rows: rows, fields: $fields, revealed: revealed)
|
||||||
}
|
}
|
||||||
|
|
@ -470,9 +458,9 @@ private struct LettersRows: View {
|
||||||
case .field(let idx, let ans):
|
case .field(let idx, let ans):
|
||||||
WordField(
|
WordField(
|
||||||
value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }),
|
value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }),
|
||||||
palette: p,
|
|
||||||
placeholder: "",
|
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)
|
.frame(width: 72)
|
||||||
}
|
}
|
||||||
|
|
@ -555,7 +543,7 @@ struct TableEx: View {
|
||||||
ForEach(persons.indices, id: \.self) { c in
|
ForEach(persons.indices, id: \.self) { c in
|
||||||
let i = r * persons.count + c
|
let i = r * persons.count + c
|
||||||
Text(persons[c]).font(.system(size: 12)).foregroundStyle(p.subtext)
|
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)
|
Spacer().frame(height: 8)
|
||||||
}
|
}
|
||||||
|
|
@ -589,10 +577,10 @@ struct RevealEx: View {
|
||||||
fb = (vm.tr("feedback_ok"), "ok")
|
fb = (vm.tr("feedback_ok"), "ok")
|
||||||
onDone()
|
onDone()
|
||||||
}, feedback: fb.0, kind: fb.1) {
|
}, 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
|
ForEach(items.indices, id: \.self) { i in
|
||||||
Prompt(text: items[i].str("prompt"), palette: p)
|
Prompt(text: items[i].str("prompt"))
|
||||||
PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() }
|
PillButton(label: vm.tr("show_sample")) { shown[i].toggle() }
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
if shown[i] { Meaning(text: items[i].str("solution"), palette: p, visible: true) }
|
if shown[i] { Meaning(text: items[i].str("solution"), palette: p, visible: true) }
|
||||||
Spacer().frame(height: 8)
|
Spacer().frame(height: 8)
|
||||||
|
|
@ -671,7 +659,6 @@ private struct VlsmEx: View {
|
||||||
let answers = vm.content.netAnswers
|
let answers = vm.content.netAnswers
|
||||||
let p = vm.palette
|
let p = vm.palette
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_ex_title"), subtitle: nil)
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
ForEach(tasks.indices, id: \.self) { ti in
|
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 }
|
let infeasible = block.isEmpty || block.allSatisfy { $0.int("pfx") <= 0 }
|
||||||
Text(task.str("prompt")).foregroundStyle(p.text).padding(.vertical, 8)
|
Text(task.str("prompt")).foregroundStyle(p.text).padding(.vertical, 8)
|
||||||
if infeasible {
|
if infeasible {
|
||||||
Hint(text: vm.tr("net_infeasible"), palette: p)
|
Hint(text: vm.tr("net_infeasible"))
|
||||||
} else {
|
} else {
|
||||||
ForEach(block.indices, id: \.self) { si in
|
ForEach(block.indices, id: \.self) { si in
|
||||||
Text("Podsíť \(String(UnicodeScalar(65 + si)!))")
|
Text("Podsíť \(String(UnicodeScalar(65 + si)!))")
|
||||||
|
|
@ -690,12 +677,12 @@ private struct VlsmEx: View {
|
||||||
get: { rows[ti][si].pfx },
|
get: { rows[ti][si].pfx },
|
||||||
set: { rows[ti][si].pfx = $0 }
|
set: { rows[ti][si].pfx = $0 }
|
||||||
), palette: p)
|
), 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].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 }), palette: p, placeholder: "broadcast")
|
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 }), palette: p, placeholder: "první uzel")
|
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 }), palette: p, placeholder: "poslední uzel")
|
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 ok = block.indices.allSatisfy { si in
|
||||||
let a = block[si]
|
let a = block[si]
|
||||||
let field = rows[ti][si]
|
let field = rows[ti][si]
|
||||||
|
|
@ -715,7 +702,7 @@ private struct VlsmEx: View {
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
PillButton(label: vm.tr("net_solution"), palette: p) {
|
PillButton(label: vm.tr("net_solution")) {
|
||||||
if !infeasible {
|
if !infeasible {
|
||||||
for si in block.indices {
|
for si in block.indices {
|
||||||
rows[ti][si].pfx = "/\(block[si].int("pfx"))"
|
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)
|
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding()
|
||||||
|
.navigationTitle(vm.tr("net_ex_title"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func maybeFinish() {
|
private func maybeFinish() {
|
||||||
|
|
@ -783,12 +772,11 @@ struct LitQuizScreen: View {
|
||||||
let p = vm.palette
|
let p = vm.palette
|
||||||
let kahoot = [p.accent, p.error, p.warning, p.success]
|
let kahoot = [p.accent, p.error, p.warning, p.success]
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
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 {
|
if finished {
|
||||||
Text(vm.tr("lit_finished")).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
|
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.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)
|
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
|
idx = 0; score = 0; answered = false; finished = false
|
||||||
}
|
}
|
||||||
} else if idx < qs.count {
|
} else if idx < qs.count {
|
||||||
|
|
@ -817,7 +805,7 @@ struct LitQuizScreen: View {
|
||||||
}
|
}
|
||||||
if answered {
|
if answered {
|
||||||
Meaning(text: q.str("expl"), palette: p, visible: true)
|
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 {
|
if idx + 1 >= qs.count {
|
||||||
finished = true
|
finished = true
|
||||||
vm.markBookQuiz(id)
|
vm.markBookQuiz(id)
|
||||||
|
|
@ -830,7 +818,10 @@ struct LitQuizScreen: View {
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding()
|
||||||
|
.navigationTitle(vm.tr(book.str("quizTitle")))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationSubtitle(vm.tr(book.str("quizSub")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,57 +7,5 @@ struct MapNode {
|
||||||
let done: Bool
|
let done: Bool
|
||||||
let current: Bool
|
let current: Bool
|
||||||
var finish: Bool = false
|
var finish: Bool = false
|
||||||
var onClick: (() -> Void)?
|
var destination: Route?
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,197 +1,117 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct WelcomeScreen: View {
|
struct PracticeHome: View {
|
||||||
@ObservedObject var vm: AppModel
|
@ObservedObject var vm: AppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let p = vm.palette
|
let sum = summarize(vm.content, vm.progress)
|
||||||
ScrollView {
|
let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx)
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
List {
|
||||||
Text("maturita.c")
|
Section {
|
||||||
.font(.system(size: 15, weight: .medium))
|
GlassCard {
|
||||||
.foregroundStyle(p.text)
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
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) {
|
|
||||||
Text(vm.tr("welcome_title"))
|
Text(vm.tr("welcome_title"))
|
||||||
.font(.system(size: 32, weight: .medium))
|
.font(.title.bold())
|
||||||
.foregroundStyle(p.text)
|
|
||||||
Text(vm.tr("welcome_body_ios"))
|
Text(vm.tr("welcome_body_ios"))
|
||||||
.font(.system(size: 16))
|
.font(.subheadline)
|
||||||
.foregroundStyle(p.subtext)
|
.foregroundStyle(.secondary)
|
||||||
PrimaryButton(label: vm.tr("continue"), palette: p) { vm.go(.subjects) }
|
ProgressView(value: pct)
|
||||||
}
|
Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
|
||||||
Spacer(minLength: 0)
|
.font(.footnote)
|
||||||
VStack {
|
.foregroundStyle(.secondary)
|
||||||
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)
|
|
||||||
}
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.bottom, 24)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func subjectTile(_ s: J, _ p: Palette) -> some View {
|
Section(vm.tr("subjects_title")) {
|
||||||
|
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
|
||||||
let open = s.bool("open")
|
let open = s.bool("open")
|
||||||
return Button {
|
let dest = s.strOrNull("target").flatMap(routeFromPage)
|
||||||
if open { vm.goPage(s.str("target")) }
|
if open, let dest, dest != .subjects, dest != .welcome {
|
||||||
} label: {
|
NavigationLink(value: dest) {
|
||||||
VStack(spacing: 10) {
|
subjectLabel(s)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
subjectLabel(s)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Maturita")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subjectLabel(_ s: J) -> some View {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(vm.tr(s.str("key")))
|
Text(vm.tr(s.str("key")))
|
||||||
.font(.system(size: 13, weight: .semibold))
|
if !s.bool("open") {
|
||||||
.foregroundStyle(p.text)
|
Text(vm.tr("stats_locked")).font(.caption).foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
}
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
} icon: {
|
||||||
.disabled(!open)
|
Image(systemName: subjectSymbol(s.str("icon")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingsSheet: View {
|
struct SettingsScreen: View {
|
||||||
@ObservedObject var vm: AppModel
|
@ObservedObject var vm: AppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let p = vm.palette
|
Form {
|
||||||
ScrollView {
|
Section(vm.tr("mode")) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
Picker(vm.tr("mode"), selection: modeBinding) {
|
||||||
|
Text(vm.tr("mode_dark")).tag(ColorMode.dark)
|
||||||
|
Text(vm.tr("mode_light")).tag(ColorMode.light)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
Section(vm.tr("theme")) {
|
||||||
|
ForEach(Array(ThemeId.allCases), id: \.self) { id in
|
||||||
|
Button {
|
||||||
|
vm.setTheme(id)
|
||||||
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(vm.tr("settings_title"))
|
Circle().fill(themePalette(id, vm.mode).accent).frame(width: 14, height: 14)
|
||||||
.font(.system(size: 22, weight: .bold))
|
Text(themeNames[id.rawValue])
|
||||||
.foregroundStyle(p.text)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
PillButton(label: vm.tr("back"), palette: p) { vm.settingsOpen = false }
|
if vm.themeId == id {
|
||||||
}
|
Image(systemName: "checkmark")
|
||||||
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)
|
Section(vm.tr("language")) {
|
||||||
Text(updateText)
|
Picker(vm.tr("language"), selection: langBinding) {
|
||||||
.font(.system(size: 14))
|
Text("Čeština").tag(UiLang.cs)
|
||||||
.foregroundStyle(p.subtext)
|
Text("English").tag(UiLang.en)
|
||||||
HStack(spacing: 8) {
|
}
|
||||||
PillButton(label: vm.tr("update_check"), palette: p) { vm.checkUpdate(true) }
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
Section(vm.tr("updates")) {
|
||||||
|
Text(updateText).foregroundStyle(.secondary)
|
||||||
|
Button(vm.tr("update_check")) { vm.checkUpdate(true) }
|
||||||
if vm.update.canInstall {
|
if vm.update.canInstall {
|
||||||
PrimaryButton(label: vm.tr("update_install"), palette: p) { vm.installUpdate() }
|
Button(vm.tr("update_install")) { vm.installUpdate() }
|
||||||
|
}
|
||||||
|
LabeledContent("Build", value: "\(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 24)
|
.navigationTitle(vm.tr("settings_title"))
|
||||||
}
|
}
|
||||||
.padding(20)
|
|
||||||
|
private var modeBinding: Binding<ColorMode> {
|
||||||
|
Binding(get: { vm.mode }, set: { vm.applyMode($0) })
|
||||||
}
|
}
|
||||||
.background(p.base)
|
|
||||||
|
private var langBinding: Binding<UiLang> {
|
||||||
|
Binding(get: { vm.lang }, set: { vm.applyLang($0) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private var updateText: String {
|
private var updateText: String {
|
||||||
|
|
@ -206,70 +126,44 @@ struct StatsScreen: View {
|
||||||
@ObservedObject var vm: AppModel
|
@ObservedObject var vm: AppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let p = vm.palette
|
|
||||||
let sum = summarize(vm.content, vm.progress)
|
let sum = summarize(vm.content, vm.progress)
|
||||||
let pct = sum.totalEx == 0 ? 0 : (100 * sum.doneEx / sum.totalEx)
|
let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
|
||||||
VStack(spacing: 0) {
|
List {
|
||||||
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("stats_title"), subtitle: vm.tr("stats_sub"))
|
Section {
|
||||||
.padding(.horizontal, 16)
|
HStack {
|
||||||
ScrollView {
|
stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100)))
|
||||||
HStack(spacing: 10) {
|
stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
|
||||||
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)
|
.listRowBackground(Color.clear)
|
||||||
Text(vm.tr("stats_section"))
|
ProgressView(value: pct)
|
||||||
.font(.system(size: 12, weight: .semibold))
|
}
|
||||||
.foregroundStyle(p.subtext)
|
Section(vm.tr("stats_section")) {
|
||||||
.padding(.top, 10)
|
|
||||||
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
|
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
|
||||||
let open = s.bool("open")
|
let open = s.bool("open")
|
||||||
let part = subjectSum(vm, s)
|
let part = subjectSum(vm, s)
|
||||||
CardBox(palette: p) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Text(vm.tr(s.str("key")))
|
||||||
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"))
|
Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
|
||||||
.font(.system(size: 13)).foregroundStyle(p.subtext)
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
if open && part.totalEx > 0 {
|
if open && part.totalEx > 0 {
|
||||||
ProgressBar(fraction: CGFloat(part.doneEx) / CGFloat(part.totalEx), palette: p, height: 6)
|
ProgressView(value: Double(part.doneEx) / Double(part.totalEx))
|
||||||
.padding(.top, 4)
|
}
|
||||||
|
}
|
||||||
|
.opacity(open ? 1 : 0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.opacity(open ? 1 : 0.55)
|
.navigationTitle(vm.tr("stats_title"))
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.bottom, 24)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func metric(_ p: Palette, _ label: String, _ value: String) -> some View {
|
private func stat(_ label: String, _ value: String) -> some View {
|
||||||
CardBox(palette: p) {
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(label).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext)
|
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||||
Text(value).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
|
Text(value).font(.title3.bold())
|
||||||
}
|
}
|
||||||
}
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,61 +191,37 @@ private func subjectSum(_ vm: AppModel, _ s: J) -> ProgressSum {
|
||||||
return sum
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SearchOverlay: View {
|
struct SearchScreen: View {
|
||||||
@ObservedObject var vm: AppModel
|
@ObservedObject var vm: AppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let p = vm.palette
|
|
||||||
let q = normalizeAnswer(vm.searchQuery)
|
let q = normalizeAnswer(vm.searchQuery)
|
||||||
let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) }.prefix(24)
|
let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) }
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
List {
|
||||||
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 }
|
|
||||||
}
|
|
||||||
if hits.isEmpty {
|
if hits.isEmpty {
|
||||||
Text(q.isEmpty ? vm.tr("search_hint") : vm.tr("search_empty")).foregroundStyle(p.subtext)
|
ContentUnavailableView.search
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ForEach(hits) { hit in
|
||||||
VStack(spacing: 8) {
|
|
||||||
ForEach(Array(hits), id: \.target) { hit in
|
|
||||||
Button {
|
Button {
|
||||||
if !hit.locked {
|
if !hit.locked { vm.openFromSearch(hit.target) }
|
||||||
vm.goPage(hit.target)
|
|
||||||
vm.searchOpen = false
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
CardBox(palette: p) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
VStack(alignment: .leading) {
|
Text(hit.title)
|
||||||
Text(hit.title).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text)
|
Text(hit.sub).font(.caption).foregroundStyle(.secondary)
|
||||||
Text(hit.sub).font(.system(size: 13)).foregroundStyle(p.subtext)
|
}
|
||||||
|
}
|
||||||
|
.disabled(hit.locked)
|
||||||
|
.foregroundStyle(hit.locked ? .secondary : .primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.navigationTitle(vm.tr("search_placeholder"))
|
||||||
.opacity(hit.locked ? 0.5 : 1)
|
.searchable(text: $vm.searchQuery, prompt: vm.tr("search_placeholder"))
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.background(p.base.opacity(0.96))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct Hit: Identifiable {
|
private struct Hit: Identifiable {
|
||||||
var id: String { target + title }
|
var id: String { target + "|" + title }
|
||||||
let title: String
|
let title: String
|
||||||
let sub: String
|
let sub: String
|
||||||
let target: String
|
let target: String
|
||||||
|
|
@ -400,32 +270,28 @@ struct RoadmapScreen: View {
|
||||||
@ObservedObject var vm: AppModel
|
@ObservedObject var vm: AppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let current = vm.content.german.firstIndex { u in
|
List {
|
||||||
u.bool("unlocked") && u.strs("names").indices.contains { !vm.progress.germanDone(u.int("id"), $0 + 1) }
|
ForEach(Array(vm.content.german.enumerated()), id: \.offset) { _, u in
|
||||||
}
|
|
||||||
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 unlocked = u.bool("unlocked")
|
||||||
let names = u.strs("names")
|
let names = u.strs("names")
|
||||||
let done = unlocked && !names.isEmpty && names.indices.allSatisfy { vm.progress.germanDone(u.int("id"), $0 + 1) }
|
let done = unlocked && !names.isEmpty && names.indices.allSatisfy { vm.progress.germanDone(u.int("id"), $0 + 1) }
|
||||||
return MapNode(
|
if unlocked {
|
||||||
id: u.str("page"),
|
NavigationLink(value: Route.unitMap(u.int("id"))) {
|
||||||
label: u.str("title"),
|
Label {
|
||||||
locked: !unlocked,
|
Text(u.str("title"))
|
||||||
done: done,
|
} icon: {
|
||||||
current: i == current,
|
Image(systemName: done ? "checkmark.circle.fill" : "circle")
|
||||||
finish: i == vm.content.german.count - 1,
|
|
||||||
onClick: unlocked ? { vm.go(.unitMap(u.int("id"))) } : nil
|
|
||||||
)
|
|
||||||
},
|
|
||||||
palette: vm.palette
|
|
||||||
)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Label(u.str("title"), systemImage: "lock.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(vm.tr("roadmap_title"))
|
||||||
|
.navigationSubtitle(vm.tr("roadmap_sub"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UnitMapScreen: View {
|
struct UnitMapScreen: View {
|
||||||
|
|
@ -435,30 +301,25 @@ struct UnitMapScreen: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let u = vm.content.germanUnit(unitId) {
|
if let u = vm.content.germanUnit(unitId) {
|
||||||
let names = u.strs("names")
|
let names = u.strs("names")
|
||||||
let next = names.indices.first { !vm.progress.germanDone(unitId, $0 + 1) }
|
List {
|
||||||
VStack(spacing: 0) {
|
ForEach(names.indices, id: \.self) { i in
|
||||||
PageTop(vm: vm, back: { vm.back() }, title: u.str("title"), subtitle: vm.tr(u.str("sub")))
|
NavigationLink(value: Route.germanEx(unitId, i + 1)) {
|
||||||
.padding(.horizontal, 12)
|
Label {
|
||||||
PathMap(
|
Text(names[i])
|
||||||
nodes: names.enumerated().map { i, name in
|
} icon: {
|
||||||
MapNode(
|
Image(systemName: vm.progress.germanDone(unitId, i + 1) ? "checkmark.circle.fill" : "circle")
|
||||||
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)
|
|
||||||
if u.has("branch") {
|
if u.has("branch") {
|
||||||
PrimaryButton(label: vm.tr("Vokabeltraining"), palette: vm.palette) { vm.go(.vocab(unitId)) }
|
NavigationLink(value: Route.vocab(unitId)) {
|
||||||
.padding(.bottom, 16)
|
Label(vm.tr("Vokabeltraining"), systemImage: "character.book.closed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationTitle(u.str("title"))
|
||||||
|
.navigationSubtitle(vm.tr(u.str("sub")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -466,98 +327,99 @@ struct NetYearsScreen: View {
|
||||||
@ObservedObject var vm: AppModel
|
@ObservedObject var vm: AppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
List {
|
||||||
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_years_title"), subtitle: vm.tr("net_years_sub"))
|
NavigationLink(value: Route.netMap) {
|
||||||
.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) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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 {
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(title).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text)
|
Text(vm.tr("net_year1"))
|
||||||
Text(sub).font(.system(size: 14)).foregroundStyle(p.subtext)
|
Text(vm.tr("net_sub")).font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
if !open { LockIcon(color: p.overlay) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ForEach(["net_year2", "net_year3", "net_year4"], id: \.self) { key in
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(vm.tr(key))
|
||||||
|
Text(vm.tr("net_year_locked_sub")).font(.caption)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
} icon: {
|
||||||
.disabled(!open)
|
Image(systemName: "lock.fill")
|
||||||
.opacity(open ? 1 : 0.48)
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(vm.tr("net_years_title"))
|
||||||
|
.navigationSubtitle(vm.tr("net_years_sub"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LessonMapScreen: View {
|
struct LessonRow: Identifiable {
|
||||||
@ObservedObject var vm: AppModel
|
let id: String
|
||||||
let title: String
|
let title: String
|
||||||
let sub: String
|
let done: Bool
|
||||||
let nodes: [MapNode]
|
let destination: Route
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LessonListScreen: View {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let rows: [LessonRow]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
List(rows) { row in
|
||||||
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
|
NavigationLink(value: row.destination) {
|
||||||
.padding(.horizontal, 12)
|
Label {
|
||||||
PathMap(nodes: nodes, palette: vm.palette)
|
Text(row.title)
|
||||||
.padding(.horizontal, 12)
|
} icon: {
|
||||||
|
Image(systemName: row.done ? "checkmark.circle.fill" : "circle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(title)
|
||||||
|
.navigationSubtitle(subtitle)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SlidesScreen: View {
|
struct SlidesScreen: View {
|
||||||
@ObservedObject var vm: AppModel
|
@ObservedObject var vm: AppModel
|
||||||
let title: String
|
let title: String
|
||||||
let sub: String
|
let subtitle: String
|
||||||
let slides: [J]
|
let slides: [J]
|
||||||
var onExercise: () -> Void
|
let next: Route
|
||||||
@State private var idx = 0
|
@State private var idx = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let p = vm.palette
|
TabView(selection: $idx) {
|
||||||
if let slide = slides.indices.contains(idx) ? slides[idx] : nil {
|
ForEach(slides.indices, id: \.self) { i in
|
||||||
VStack(spacing: 0) {
|
let slide = slides[i]
|
||||||
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text(slide.str("kicker")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext)
|
Text(slide.str("kicker"))
|
||||||
Text(slide.str("title")).font(.system(size: 24, weight: .bold)).foregroundStyle(p.text)
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(slide.str("title")).font(.title2.bold())
|
||||||
if let tip = slide.strOrNull("tip") {
|
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
|
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 {
|
.tag(i)
|
||||||
if idx > 0 {
|
|
||||||
PillButton(label: vm.tr("net_slide_prev"), palette: p) { idx -= 1 }
|
|
||||||
}
|
}
|
||||||
Spacer()
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .automatic))
|
||||||
|
.navigationTitle(title)
|
||||||
|
.navigationSubtitle(subtitle)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .bottomBar) {
|
||||||
if idx < slides.count - 1 {
|
if idx < slides.count - 1 {
|
||||||
PrimaryButton(label: vm.tr("net_slide_next"), palette: p) { idx += 1 }
|
Button(vm.tr("net_slide_next")) { idx += 1 }
|
||||||
} else {
|
} else {
|
||||||
PrimaryButton(label: vm.tr("net_slide_start"), palette: p, action: onExercise)
|
NavigationLink(vm.tr("net_slide_start"), value: next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(12)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -566,44 +428,18 @@ struct CzechMapScreen: View {
|
||||||
@ObservedObject var vm: AppModel
|
@ObservedObject var vm: AppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let p = vm.palette
|
List {
|
||||||
let items: [(String, String, Route?)] = [
|
Label(vm.tr("Literatura"), systemImage: "lock.fill")
|
||||||
("L", vm.tr("Literatura"), nil),
|
.foregroundStyle(.secondary)
|
||||||
("M", vm.tr("Mluvnice"), .mluvnice),
|
NavigationLink(value: Route.mluvnice) {
|
||||||
("Č", vm.tr("Maturitní četba"), .readingList),
|
Label(vm.tr("Mluvnice"), systemImage: "textformat")
|
||||||
]
|
}
|
||||||
VStack(spacing: 0) {
|
NavigationLink(value: Route.readingList) {
|
||||||
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Český jazyk a literatura"), subtitle: vm.tr("czech_sub"))
|
Label(vm.tr("Maturitní četba"), systemImage: "books.vertical")
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationTitle(vm.tr("Český jazyk a literatura"))
|
||||||
|
.navigationSubtitle(vm.tr("czech_sub"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -611,31 +447,22 @@ struct BookListScreen: View {
|
||||||
@ObservedObject var vm: AppModel
|
@ObservedObject var vm: AppModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let p = vm.palette
|
List {
|
||||||
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
|
ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
|
||||||
Button { vm.go(.book(b.str("id"))) } label: {
|
NavigationLink(value: Route.book(b.str("id"))) {
|
||||||
CardBox(palette: p) {
|
Label {
|
||||||
HStack(spacing: 12) {
|
|
||||||
BookIcon(color: p.text, size: 28)
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(b.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
|
Text(b.str("title"))
|
||||||
Text(vm.tr(b.str("subKey"))).font(.system(size: 13)).foregroundStyle(p.subtext)
|
Text(vm.tr(b.str("subKey"))).font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
} icon: {
|
||||||
|
Image(systemName: "book.fill")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.navigationTitle(vm.tr("Maturitní četba"))
|
||||||
|
.navigationSubtitle(vm.tr("reading_sub"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -645,54 +472,37 @@ struct BookScreen: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let b = vm.content.book(id) {
|
if let b = vm.content.book(id) {
|
||||||
let p = vm.palette
|
List {
|
||||||
VStack(spacing: 0) {
|
NavigationLink(value: Route.bookQuiz(id)) {
|
||||||
PageTop(vm: vm, back: { vm.back() }, title: b.str("title"), subtitle: vm.tr(b.str("subKey")))
|
Label {
|
||||||
.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) {
|
VStack(alignment: .leading) {
|
||||||
Text(vm.tr(b.str("quizTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
|
Text(vm.tr(b.str("quizTitle")))
|
||||||
Text(vm.tr(b.str("quizSub"))).font(.system(size: 13)).foregroundStyle(p.subtext)
|
Text(vm.tr(b.str("quizSub"))).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "questionmark.circle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
NavigationLink(value: Route.bookPlot(id)) {
|
||||||
}
|
Label {
|
||||||
.buttonStyle(.plain)
|
|
||||||
Button { vm.go(.bookPlot(id)) } label: {
|
|
||||||
CardBox(palette: p) {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
OrderIcon(color: p.text)
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(vm.tr(b.str("plotTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
|
Text(vm.tr(b.str("plotTitle")))
|
||||||
Text(vm.tr(b.str("plotSub"))).font(.system(size: 13)).foregroundStyle(p.subtext)
|
Text(vm.tr(b.str("plotSub"))).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "list.number")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
ForEach(Array(b.arr("notes").enumerated()), id: \.offset) { _, note in
|
ForEach(Array(b.arr("notes").enumerated()), id: \.offset) { _, note in
|
||||||
CardBox(palette: p) {
|
Section(note.str("title")) {
|
||||||
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
|
ForEach(note.strs("lines"), id: \.self) { line in
|
||||||
Text("• \(line)").font(.system(size: 15)).foregroundStyle(p.text)
|
Text(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.navigationTitle(b.str("title"))
|
||||||
.padding(.horizontal, 16)
|
.navigationSubtitle(vm.tr(b.str("subKey")))
|
||||||
.padding(.bottom, 20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,10 @@ const TrEntry tr_ui[] = {
|
||||||
"Exam practice on your phone. Czech and English, "
|
"Exam practice on your phone. Czech and English, "
|
||||||
"the same content as on the computer."},
|
"the same content as on the computer."},
|
||||||
{"welcome_body_ios",
|
{"welcome_body_ios",
|
||||||
"Procvičování k maturitě v iPhonu. Česky i anglicky, "
|
"Nativní procvičování na iPhonu. Česky i anglicky, "
|
||||||
"stejný obsah jako na počítači.",
|
"stejný obsah, rozhraní podle iOS.",
|
||||||
"Exam practice on iPhone. Czech and English, "
|
"Native exam practice on iPhone. Czech and English, "
|
||||||
"the same content as on the computer."},
|
"the same content, adapted to iOS."},
|
||||||
{"update_ios_sideload",
|
{"update_ios_sideload",
|
||||||
"Nový build je na GitHubu. Nainstalujte ho znovu přes Xcode nebo AltStore (%s).",
|
"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)."},
|
"A newer build is on GitHub. Reinstall it with Xcode or AltStore (%s)."},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue