Rework the mobile UIs on native components and fix layout bugs everywhere.

Android moves to Material 3 (navigation bar, app bars, list items,
bottom sheets, pager) with a real back stack. iOS hides the tab bar in
exercises so the bottom action is reachable, aligns content, and uses
system colours so themes and dark mode work. Both pick a readable
accent tint. Desktop keeps the VLSM table inside the window and gives
statistics the same header rhythm as other pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-18 15:37:15 +02:00
commit 0621311b4c
25 changed files with 1965 additions and 1836 deletions

View file

@ -18,7 +18,6 @@
A101000000000000000009 /* Updater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000009 /* Updater.swift */; };
A101000000000000000010 /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000010 /* Components.swift */; };
A101000000000000000011 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000011 /* Icons.swift */; };
A101000000000000000012 /* PathMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000012 /* PathMap.swift */; };
A101000000000000000013 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000013 /* Screens.swift */; };
A101000000000000000014 /* Exercises.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000014 /* Exercises.swift */; };
A101000000000000000015 /* ExercisesMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000000000000000015 /* ExercisesMore.swift */; };
@ -38,7 +37,6 @@
A201000000000000000009 /* Updater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updater.swift; sourceTree = "<group>"; };
A201000000000000000010 /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = "<group>"; };
A201000000000000000011 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = "<group>"; };
A201000000000000000012 /* PathMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathMap.swift; sourceTree = "<group>"; };
A201000000000000000013 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = "<group>"; };
A201000000000000000014 /* Exercises.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exercises.swift; sourceTree = "<group>"; };
A201000000000000000015 /* ExercisesMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExercisesMore.swift; sourceTree = "<group>"; };
@ -108,7 +106,6 @@
children = (
A201000000000000000010 /* Components.swift */,
A201000000000000000011 /* Icons.swift */,
A201000000000000000012 /* PathMap.swift */,
A201000000000000000013 /* Screens.swift */,
A201000000000000000014 /* Exercises.swift */,
A201000000000000000015 /* ExercisesMore.swift */,
@ -230,7 +227,6 @@
A101000000000000000009 /* Updater.swift in Sources */,
A101000000000000000010 /* Components.swift in Sources */,
A101000000000000000011 /* Icons.swift in Sources */,
A101000000000000000012 /* PathMap.swift in Sources */,
A101000000000000000013 /* Screens.swift in Sources */,
A101000000000000000014 /* Exercises.swift in Sources */,
A101000000000000000015 /* ExercisesMore.swift in Sources */,

View file

@ -19,6 +19,18 @@ final class AppModel: ObservableObject {
themeId = progress.themeId
mode = progress.mode
lang = progress.lang
#if DEBUG
// `xcrun simctl launch <udid> org.maturita.maturita -route u1e3` opens a page for screenshots.
if let page = UserDefaults.standard.string(forKey: "route"), let r = routeFromPage(page) {
openFromSearch(r.page)
}
if let theme = UserDefaults.standard.string(forKey: "theme"), let n = Int(theme), let id = ThemeId(rawValue: n) {
themeId = id
}
if let m = UserDefaults.standard.string(forKey: "mode") {
mode = m == "dark" ? .dark : .light
}
#endif
}
var palette: Palette { themePalette(themeId, mode) }

View file

@ -24,6 +24,8 @@ struct Palette {
let success, success2, warning, error: Color
let onAccent, node, lockedBg, lockedBorder, rail: Color
let finishDark, finishLight, finishStroke: Color
/// Accent that stays readable as text and icon tint on the theme's background.
let tint: Color
}
private func hx(_ v: UInt32) -> Color {
@ -34,6 +36,29 @@ private func hx(_ v: UInt32) -> Color {
)
}
private func luminance(_ v: UInt32) -> Double {
func channel(_ c: UInt32) -> Double {
let s = Double(c) / 255
return s <= 0.03928 ? s / 12.92 : pow((s + 0.055) / 1.055, 2.4)
}
return 0.2126 * channel((v >> 16) & 0xFF) + 0.7152 * channel((v >> 8) & 0xFF) + 0.0722 * channel(v & 0xFF)
}
private func contrast(_ a: UInt32, _ b: UInt32) -> Double {
let la = luminance(a), lb = luminance(b)
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
}
/// Pick the first accent with enough contrast against the background; yellow on
/// white is unreadable as tab labels and link text.
private func readableTint(_ c: [UInt32]) -> UInt32 {
let background = c[1]
for candidate in [c[9], c[10], c[11]] where contrast(candidate, background) >= 3.0 {
return candidate
}
return c[7]
}
private func pal(_ c: [UInt32]) -> Palette {
Palette(
crust: hx(c[0]), base: hx(c[1]), mantle: hx(c[2]), surface0: hx(c[3]),
@ -42,7 +67,8 @@ private func pal(_ c: [UInt32]) -> Palette {
success: hx(c[12]), success2: hx(c[13]), warning: hx(c[14]), error: hx(c[15]),
onAccent: hx(c[16]), node: hx(c[17]), lockedBg: hx(c[18]),
lockedBorder: hx(c[19]), rail: hx(c[20]), finishDark: hx(c[21]),
finishLight: hx(c[22]), finishStroke: hx(c[23])
finishLight: hx(c[22]), finishStroke: hx(c[23]),
tint: hx(readableTint(c))
)
}

View file

@ -22,7 +22,7 @@ struct NativeRoot: View {
tabs
}
}
.tint(vm.palette.accent)
.tint(vm.palette.tint)
.preferredColorScheme(vm.mode == .dark ? .dark : .light)
.onAppear { vm.checkUpdate(false) }
}
@ -151,7 +151,7 @@ struct RouteDestination: View {
case .mluvnice:
LessonListScreen(
title: vm.tr("Mluvnice"),
subtitle: "Cvičení z mluvnice",
subtitle: vm.tr("mluv_sub"),
rows: vm.content.mluvnice.map { m in
let n = m.int("id")
return LessonRow(

View file

@ -44,9 +44,13 @@ struct PrimaryButton: View {
var action: () -> Void
var body: some View {
Button(label, action: action)
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button(action: action) {
Text(label)
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity)
}
.buttonStyle(.glassProminent)
.controlSize(.large)
}
}
@ -78,9 +82,13 @@ struct FeedbackLine: View {
var body: some View {
if !text.isEmpty {
Text(text)
.font(.subheadline.weight(.semibold))
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
Label {
Text(text)
} icon: {
Image(systemName: kind == "ok" ? "checkmark.circle.fill" : kind == "warn" ? "exclamationmark.triangle.fill" : "xmark.circle.fill")
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
}
}
}
@ -107,21 +115,28 @@ struct WordField: View {
var palette: Palette?
var body: some View {
TextField(placeholder, text: $value)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(12)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(border, lineWidth: mark == nil ? 0 : 1.5)
)
HStack(spacing: 8) {
TextField(placeholder, text: $value)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if let mark {
Image(systemName: mark ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(mark ? (palette?.success ?? .green) : (palette?.error ?? .red))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(border, lineWidth: mark == nil ? 0.5 : 1.5)
)
}
private var border: Color {
if mark == true { return palette?.success ?? .green }
if mark == false { return palette?.error ?? .red }
return .clear
return Color(.separator)
}
}
@ -158,10 +173,29 @@ struct WordChip: View {
var body: some View {
Button(text, action: action)
.buttonStyle(.bordered)
.tint(.primary)
.controlSize(.small)
}
}
/// Drop zone for word ordering: visible even when empty.
struct WordSlot<Content: View>: View {
let empty: Bool
@ViewBuilder var content: () -> Content
var body: some View {
content()
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.padding(8)
.background(Color(.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(style: StrokeStyle(lineWidth: empty ? 1 : 0, dash: [5, 4]))
.foregroundStyle(.tertiary)
)
}
}
struct OptionChip: View {
let text: String
let selected: Bool
@ -171,15 +205,32 @@ struct OptionChip: View {
var body: some View {
Button(action: action) {
HStack {
Text(text).frame(maxWidth: .infinity, alignment: .leading)
if selected { Image(systemName: "checkmark") }
Image(systemName: symbol)
.foregroundStyle(symbolColor)
Text(text)
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 4)
}
.buttonStyle(.bordered)
.tint(tint)
.padding(.bottom, 4)
}
private var symbol: String {
if mark == true { return "checkmark.circle.fill" }
if mark == false { return "xmark.circle.fill" }
return selected ? "largecircle.fill.circle" : "circle"
}
private var symbolColor: Color {
if mark == true { return .green }
if mark == false { return .red }
return selected ? .accentColor : .secondary
}
private var tint: Color {
if mark == true { return .green }
if mark == false { return .red }
@ -196,6 +247,39 @@ extension View {
self
}
}
/// Detail screens with their own bottom action hide the tab bar so the
/// primary button is never covered by it.
func detailScreen(_ title: String, subtitle: String? = nil) -> some View {
navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.navSubtitle(subtitle)
.toolbar(.hidden, for: .tabBar)
}
/// Full-width, leading-aligned content for exercise screens.
func exerciseContent() -> some View {
frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
/// Bottom action area used by exercises and lesson slides.
struct BottomAction<Extra: View>: View {
let label: String
var action: () -> Void
@ViewBuilder var extra: () -> Extra
var body: some View {
VStack(spacing: 10) {
extra()
PrimaryButton(label: label, action: action)
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 4)
.background(.bar)
}
}
func subjectSymbol(_ icon: String) -> String {
@ -207,3 +291,24 @@ func subjectSymbol(_ icon: String) -> String {
default: return "lock.fill"
}
}
/// Subject glyph for list rows: real flags for the languages, SF Symbols otherwise.
struct SubjectIcon: View {
let icon: String
var open = true
var body: some View {
Group {
switch icon {
case "de": GermanFlag(size: 26)
case "cz": CzechFlag(size: 26)
default:
Image(systemName: subjectSymbol(icon))
.font(.title3)
.foregroundStyle(open ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.tertiary))
}
}
.frame(width: 28, height: 28)
.opacity(open ? 1 : 0.6)
}
}

View file

@ -14,27 +14,19 @@ struct ExerciseScaffold<Content: View>: View {
let p = vm.palette
ScrollView {
VStack(alignment: .leading, spacing: 8) { content() }
.padding()
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.navSubtitle(sub)
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button(action, action: onAction)
.buttonStyle(.borderedProminent)
}
.exerciseContent()
}
.scrollDismissesKeyboard(.interactively)
.detailScreen(title, subtitle: sub)
.safeAreaInset(edge: .bottom) {
if !feedback.isEmpty {
FeedbackLine(text: feedback, kind: kind, palette: p)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.padding(.horizontal)
.padding(.bottom, 4)
BottomAction(label: action, action: onAction) {
if !feedback.isEmpty {
FeedbackLine(text: feedback, kind: kind, palette: p)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.animation(.default, value: feedback)
}
}
@ -102,7 +94,7 @@ struct DispatchExercise: View {
case "letters", "letters_gap": LettersEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "table": TableEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
case "reveal": RevealEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
default: Text("?").foregroundStyle(vm.palette.text)
default: Text("?").foregroundStyle(.primary)
}
}
}
@ -139,8 +131,7 @@ private struct ChoiceEx: View {
}, feedback: fb.0, kind: fb.1) {
ForEach(qs.indices, id: \.self) { i in
Text("\(i + 1). \(qs[i].str("prompt"))")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(p.text)
.font(.body.weight(.semibold))
.padding(.bottom, 6)
ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in
let sel = picks[i] == o
@ -196,20 +187,18 @@ private struct AssemblyEx: View {
}, feedback: fb.0, kind: fb.1) {
ForEach(items.indices, id: \.self) { i in
if let prompt = items[i].strOrNull("prompt") { Hint(text: prompt) }
FlowLayout {
if placed[i].isEmpty { Text("…").foregroundStyle(p.overlay) }
ForEach(placed[i], id: \.self) { idx in
if idx < items[i].strs("words").count {
WordChip(text: items[i].strs("words")[idx]) {
placed[i].removeAll { $0 == idx }
pools[i].append(idx)
WordSlot(empty: placed[i].isEmpty) {
FlowLayout {
ForEach(placed[i], id: \.self) { idx in
if idx < items[i].strs("words").count {
WordChip(text: items[i].strs("words")[idx]) {
placed[i].removeAll { $0 == idx }
pools[i].append(idx)
}
}
}
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.bottom, 6)
FlowLayout(spacing: 6) {
ForEach(pools[i], id: \.self) { idx in
@ -395,7 +384,7 @@ private struct AssignEx: View {
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(.caption).foregroundStyle(.secondary).padding(.top, 10)
FlowLayout(spacing: 6) {
ForEach(items.indices, id: \.self) { i in
if loc[i] == -1 {
@ -405,7 +394,7 @@ private struct AssignEx: View {
}
}
ForEach(groups.indices, id: \.self) { g in
Text(groups[g]).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 10)
Text(groups[g]).font(.caption.weight(.semibold)).foregroundStyle(.secondary).padding(.top, 10)
FlowLayout(spacing: 6) {
ForEach(items.indices, id: \.self) { i in
if loc[i] == g {
@ -451,7 +440,8 @@ private struct HangmanEx: View {
let letters = spec.strs("letters")
let p = vm.palette
let cur = word < words.count ? words[word].uppercased() : ""
VStack(alignment: .leading, spacing: 8) {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(.secondary)
if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))") }
Canvas { ctx, size in
@ -461,30 +451,30 @@ private struct HangmanEx: View {
gallows.move(to: CGPoint(x: 70, y: size.height - 10))
gallows.addLine(to: CGPoint(x: 70, y: 16))
gallows.addLine(to: CGPoint(x: size.width * 0.3 + 40, y: 16))
ctx.stroke(gallows, with: .color(p.text), style: StrokeStyle(lineWidth: 6, lineCap: .round))
ctx.stroke(gallows, with: .color(.primary), style: StrokeStyle(lineWidth: 6, lineCap: .round))
let x = size.width * 0.3 + 40
if misses > 0 {
var rope = Path(); rope.move(to: CGPoint(x: x, y: 16)); rope.addLine(to: CGPoint(x: x, y: 36))
ctx.stroke(rope, with: .color(p.text), lineWidth: 4)
ctx.stroke(rope, with: .color(.primary), lineWidth: 4)
}
if misses > 1 {
ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(p.text), lineWidth: 4)
ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(.primary), lineWidth: 4)
}
if misses > 2 {
var body = Path(); body.move(to: CGPoint(x: x, y: 68)); body.addLine(to: CGPoint(x: x, y: 110))
ctx.stroke(body, with: .color(p.text), lineWidth: 4)
ctx.stroke(body, with: .color(.primary), lineWidth: 4)
}
if misses > 3 {
var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x - 22, y: 100))
ctx.stroke(a, with: .color(p.text), lineWidth: 4)
ctx.stroke(a, with: .color(.primary), lineWidth: 4)
}
if misses > 4 {
var a = Path(); a.move(to: CGPoint(x: x, y: 80)); a.addLine(to: CGPoint(x: x + 22, y: 100))
ctx.stroke(a, with: .color(p.text), lineWidth: 4)
ctx.stroke(a, with: .color(.primary), lineWidth: 4)
}
if misses > 5 {
var a = Path(); a.move(to: CGPoint(x: x, y: 110)); a.addLine(to: CGPoint(x: x - 22, y: 140))
ctx.stroke(a, with: .color(p.text), lineWidth: 4)
ctx.stroke(a, with: .color(.primary), lineWidth: 4)
}
}
.frame(height: 160)
@ -493,13 +483,14 @@ private struct HangmanEx: View {
let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame }
let show = idx.map { guessed[$0] } ?? false
Text(show ? String(ch) : "_")
.font(.system(size: 26, weight: .bold))
.foregroundStyle(p.text)
.font(.system(size: 26, weight: .bold, design: .rounded))
.monospaced()
}
}
ForEach(Array(letters.chunked(10).enumerated()), id: \.offset) { _, row in
HStack(spacing: 4) {
ForEach(row, id: \.self) { letter in
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
FlowLayout(spacing: 6) {
ForEach(letters, id: \.self) { letter in
if let i = letters.firstIndex(of: letter) {
Button {
guessed[i] = true
@ -523,17 +514,14 @@ private struct HangmanEx: View {
}
} label: {
Text(letter)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(p.text)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(guessed[i] ? p.surface1 : p.mantle, in: RoundedRectangle(cornerRadius: 8))
.font(.body.weight(.semibold))
.frame(minWidth: 22)
}
.buttonStyle(.plain)
.buttonStyle(.bordered)
.tint(guessed[i] ? .secondary : .primary)
.disabled(guessed[i] || finished)
}
}
}
}
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
if misses >= 6 && !finished {
@ -543,12 +531,10 @@ private struct HangmanEx: View {
fb = ("", "")
}
}
Spacer()
}
.padding()
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.navSubtitle(sub)
.exerciseContent()
}
.detailScreen(title, subtitle: sub)
}
}
@ -586,40 +572,63 @@ private struct VocabEx: View {
var body: some View {
let p = vm.palette
VStack(alignment: .leading, spacing: 8) {
Text(vm.fmt("trans_progress", done, total)).foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 8) {
ProgressView(value: Double(done), total: Double(max(total, 1)))
Text(vm.fmt("trans_progress", done, total)).font(.footnote).foregroundStyle(.secondary)
if cards.isEmpty {
Text(vm.tr("trans_done")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.success)
Label(vm.tr("trans_done"), systemImage: "checkmark.seal.fill")
.font(.headline)
.foregroundStyle(p.success)
.padding(.top, 8)
} else if index < cards.count {
let cur = cards[index]
Hint(text: cur.0)
Prompt(text: cur.1)
if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts")) }
WordField(value: $value, placeholder: vm.tr("your_answer"), palette: p)
PrimaryButton(label: vm.tr("check")) {
let norm = normalizeAnswer(value)
if answerAccepts(norm, cur.2) {
done += 1
cards.remove(at: index)
if cards.isEmpty { fb = (vm.tr("trans_done"), "ok"); onDone() }
else { if index >= cards.count { index = 0 }; value = ""; fb = ("", "") }
} else if answerIncomplete(norm, cur.2) {
fb = (vm.tr("trans_incomplete"), "warn")
} else {
fb = (vm.fmt("trans_wrong", cur.2.split(separator: "|").first.map(String.init) ?? ""), "err")
let item = cards.remove(at: index)
cards.append(item)
if index >= cards.count { index = 0 }
value = ""
GlassCard {
VStack(alignment: .leading, spacing: 6) {
Text(cur.0).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Text(cur.1).font(.title2.weight(.semibold))
}
}
.padding(.vertical, 8)
if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts")) }
WordField(value: $value, placeholder: vm.tr("your_answer"), palette: p)
.submitLabel(.done)
.onSubmit { check(cur) }
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
}
Spacer()
}
.padding()
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.navSubtitle(sub)
.exerciseContent()
}
.scrollDismissesKeyboard(.interactively)
.detailScreen(title, subtitle: sub)
.safeAreaInset(edge: .bottom) {
if index < cards.count {
BottomAction(label: vm.tr("check"), action: { check(cards[index]) }) { EmptyView() }
}
}
}
private func check(_ cur: (String, String, String)) {
let norm = normalizeAnswer(value)
if answerAccepts(norm, cur.2) {
done += 1
cards.remove(at: index)
if cards.isEmpty {
fb = (vm.tr("trans_done"), "ok")
onDone()
} else {
if index >= cards.count { index = 0 }
value = ""
fb = ("", "")
}
} else if answerIncomplete(norm, cur.2) {
fb = (vm.tr("trans_incomplete"), "warn")
} else {
fb = (vm.fmt("trans_wrong", cur.2.split(separator: "|").first.map(String.init) ?? ""), "err")
let item = cards.remove(at: index)
cards.append(item)
if index >= cards.count { index = 0 }
value = ""
}
}
}

View file

@ -16,8 +16,8 @@ struct DialogEx: View {
let line = lines[i]
if let name = line.name {
Text(name)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(vm.palette.subtext)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.vertical, 6)
}
ComboLine(
@ -68,7 +68,7 @@ struct ComboEx: View {
ComboRows(vm: vm, title: title, sub: sub, pool: spec.strs("pool"), n: allRows.count, onDone: onDone, revealMean: spec.bool("transUpfront")) { picks, expected, revealed in
ForEach(Array(verbSectionLines().enumerated()), id: \.offset) { idx, line in
if let title = line.title {
Text(title).font(.system(size: 15, weight: .semibold)).foregroundStyle(vm.palette.subtext)
Text(title).font(.subheadline.weight(.semibold)).foregroundStyle(.secondary)
}
ComboLine(
vm: vm, pool: spec.strs("pool"), picks: picks, expected: expected, idx: idx,
@ -92,18 +92,18 @@ struct ComboEx: View {
ForEach(Array(fillRows().enumerated()), id: \.offset) { _, block in
FlowLayout(spacing: 4) {
if let num = block.num {
Text(num).font(.system(size: 16, weight: .semibold)).foregroundStyle(vm.palette.text)
Text(num).font(.body.weight(.semibold)).foregroundStyle(.primary)
}
ForEach(Array(block.parts.enumerated()), id: \.offset) { _, part in
switch part {
case .text(let s):
Text(s).foregroundStyle(vm.palette.text)
Text(s).foregroundStyle(.primary)
case .blank(let idx, let ans):
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
}
}
if let mean = block.meaning {
Text(vm.tr(mean)).font(.system(size: 12)).foregroundStyle(vm.palette.subtext)
Text(vm.tr(mean)).font(.caption).foregroundStyle(.secondary)
}
}
.padding(.bottom, 6)
@ -267,35 +267,36 @@ private struct ComboLine: View {
let chip = Button {
open = true
} label: {
Text(pick.isEmpty ? "—" : pick)
.font(.system(size: 15))
.foregroundStyle(pick.isEmpty ? p.overlay : p.text)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(p.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(mark == true ? p.success : mark == false ? p.error : p.text.opacity(0.12), lineWidth: 1)
)
HStack(spacing: 4) {
Text(pick.isEmpty ? "…" : pick)
.foregroundStyle(pick.isEmpty ? Color.secondary : Color.primary)
.frame(minWidth: 36)
Image(systemName: mark == true ? "checkmark.circle.fill" : mark == false ? "xmark.circle.fill" : "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(mark == true ? p.success : mark == false ? p.error : Color.secondary)
}
}
.buttonStyle(.plain)
.buttonStyle(.bordered)
.controlSize(.small)
.tint(mark == true ? p.success : mark == false ? p.error : .secondary)
Group {
if inline {
chip
} else {
FlowLayout(spacing: 6) {
if !prefix.isEmpty { Text(prefix).font(.system(size: 16)).foregroundStyle(p.text) }
if !prefix.isEmpty { Text(prefix) }
chip
if !suffix.isEmpty { Text(suffix).font(.system(size: 16)).foregroundStyle(p.text) }
if !suffix.isEmpty { Text(suffix) }
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 8)
}
}
.onAppear {
if idx >= 0 && idx < expected.count { expected[idx] = answer }
}
.onChange(of: answer) { new in
.onChange(of: answer) { _, new in
if idx >= 0 && idx < expected.count { expected[idx] = new }
}
.sheet(isPresented: $open) {
@ -319,16 +320,22 @@ struct WordPicker: View {
var body: some View {
NavigationStack {
List(Array(pool.enumerated()), id: \.offset) { _, w in
Button(w) { onPick(w) }
Button {
onPick(w)
} label: {
Text(w).foregroundStyle(.primary)
}
}
.navigationTitle("")
.navigationTitle("Vyberte slovo")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Zavřít", role: .cancel) { onPick(nil) }
Button(role: .cancel) { onPick(nil) } label: { Image(systemName: "xmark") }
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
@ -351,12 +358,12 @@ struct Ex2Ex: View {
ForEach(Array(block.rows.enumerated()), id: \.offset) { _, row in
FlowLayout(spacing: 4) {
if let num = row.num {
Text(num).foregroundStyle(vm.palette.text).padding(.trailing, 6)
Text(num).foregroundStyle(.primary).padding(.trailing, 6)
}
ForEach(Array(row.parts.enumerated()), id: \.offset) { _, part in
switch part {
case .text(let s):
Text(s).foregroundStyle(vm.palette.text)
Text(s).foregroundStyle(.primary)
case .blank(let idx, let ans):
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
}
@ -415,7 +422,6 @@ struct LettersEx: View {
var body: some View {
let rows = spec.arr("rows")
let p = vm.palette
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
var i = 0, ok = 0, total = 0
for r in rows {
@ -449,12 +455,12 @@ private struct LettersRows: View {
VStack(alignment: .leading, spacing: 8) {
ForEach(pairs.indices, id: \.self) { ri in
let row = pairs[ri]
if let num = row.num { Text(num).foregroundStyle(p.subtext) }
if let num = row.num { Text(num).foregroundStyle(.secondary) }
FlowLayout {
ForEach(row.parts.indices, id: \.self) { pi in
switch row.parts[pi] {
case .text(let s):
Text(s).foregroundStyle(p.text)
Text(s).foregroundStyle(.primary)
case .field(let idx, let ans):
WordField(
value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }),
@ -539,10 +545,10 @@ struct TableEx: View {
revealed = true
}, feedback: fb.0, kind: fb.1) {
ForEach(nouns.indices, id: \.self) { r in
Text(nouns[r]).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
Text(nouns[r]).font(.body.weight(.bold)).foregroundStyle(.primary)
ForEach(persons.indices, id: \.self) { c in
let i = r * persons.count + c
Text(persons[c]).font(.system(size: 12)).foregroundStyle(p.subtext)
Text(persons[c]).font(.caption).foregroundStyle(.secondary)
WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), placeholder: "", palette: p)
}
Spacer().frame(height: 8)
@ -658,29 +664,41 @@ private struct VlsmEx: View {
let tasks = vm.content.netTasks
let answers = vm.content.netAnswers
let p = vm.palette
VStack(alignment: .leading, spacing: 0) {
ScrollView {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ForEach(tasks.indices, id: \.self) { ti in
let task = tasks[ti]
let block = ti < answers.count ? answers[ti] : []
let infeasible = block.isEmpty || block.allSatisfy { $0.int("pfx") <= 0 }
Text(task.str("prompt")).foregroundStyle(p.text).padding(.vertical, 8)
GlassCard {
Label {
Text(task.str("prompt"))
} icon: {
Image(systemName: done[ti] ? "checkmark.circle.fill" : "\(ti + 1).circle")
.foregroundStyle(done[ti] ? p.success : .secondary)
}
}
.padding(.vertical, 4)
if infeasible {
Hint(text: vm.tr("net_infeasible"))
} else {
ForEach(block.indices, id: \.self) { si in
Text("Podsíť \(String(UnicodeScalar(65 + si)!))")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(p.subtext)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.top, 6)
PrefixMenu(value: Binding(
get: { rows[ti][si].pfx },
set: { rows[ti][si].pfx = $0 }
), palette: p)
WordField(value: Binding(get: { rows[ti][si].net }, set: { rows[ti][si].net = $0 }), placeholder: "síť", palette: p)
.keyboardType(.decimalPad)
WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), placeholder: "broadcast", palette: p)
.keyboardType(.decimalPad)
WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), placeholder: "první uzel", palette: p)
.keyboardType(.decimalPad)
WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), placeholder: "poslední uzel", palette: p)
.keyboardType(.decimalPad)
}
PillButton(label: vm.tr("check")) {
let ok = block.indices.allSatisfy { si in
@ -717,14 +735,22 @@ private struct VlsmEx: View {
maybeFinish()
}
if done[ti] { Meaning(text: task.str("solution"), palette: p, visible: true) }
Divider().padding(.vertical, 8)
}
}
}
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
.exerciseContent()
}
.padding()
.navigationTitle(vm.tr("net_ex_title"))
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
.detailScreen(vm.tr("net_ex_title"))
.safeAreaInset(edge: .bottom) {
if !fb.0.isEmpty {
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.bar)
}
}
.animation(.default, value: fb.0)
}
private func maybeFinish() {
@ -739,15 +765,17 @@ private struct PrefixMenu: View {
var body: some View {
Button { open = true } label: {
Text(value.isEmpty ? "/…" : value)
.font(.system(size: 15))
.foregroundStyle(palette.text)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.mantle, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
HStack {
Text("Prefix").foregroundStyle(.secondary)
Spacer()
Text(value.isEmpty ? "/…" : value)
.foregroundStyle(value.isEmpty ? Color.secondary : Color.primary)
.monospacedDigit()
Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.padding(.vertical, 4)
.buttonStyle(.bordered)
.tint(.secondary)
.sheet(isPresented: $open) {
WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in
if let word { value = word }
@ -765,63 +793,75 @@ struct LitQuizScreen: View {
@State private var score = 0
@State private var answered = false
@State private var finished = false
@State private var picked = -1
var body: some View {
if let book = vm.content.book(id) {
let qs = book.arr("quiz")
let p = vm.palette
let kahoot = [p.accent, p.error, p.warning, p.success]
VStack(alignment: .leading, spacing: 8) {
if finished {
Text(vm.tr("lit_finished")).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
Text(vm.tr("lit_finished_text")).foregroundStyle(p.subtext)
Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
PrimaryButton(label: vm.tr("lit_again")) {
idx = 0; score = 0; answered = false; finished = false
}
} else if idx < qs.count {
let q = qs[idx]
HStack {
Text(vm.fmt("lit_question_fmt", idx + 1, qs.count)).foregroundStyle(p.subtext)
Spacer()
Text(vm.fmt("lit_score_fmt", score, qs.count)).foregroundStyle(p.subtext)
}
Text(q.str("prompt")).font(.system(size: 18, weight: .semibold)).foregroundStyle(p.text).padding(.vertical, 12)
ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in
Button {
answered = true
if i == q.int("correct") { score += 1 }
} label: {
Text(opt)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(p.onAccent)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(kahoot[i % 4], in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(.plain)
.disabled(answered)
.padding(.bottom, 8)
}
if answered {
Meaning(text: q.str("expl"), palette: p, visible: true)
PrimaryButton(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next")) {
if idx + 1 >= qs.count {
finished = true
vm.markBookQuiz(id)
} else {
idx += 1
answered = false
ScrollView {
VStack(alignment: .leading, spacing: 8) {
if finished {
GlassCard {
VStack(alignment: .leading, spacing: 8) {
Label(vm.tr("lit_finished"), systemImage: "rosette")
.font(.title2.weight(.bold))
Text(vm.tr("lit_finished_text")).foregroundStyle(.secondary)
Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.headline)
}
}
} else if idx < qs.count {
let q = qs[idx]
ProgressView(value: Double(idx), total: Double(qs.count))
HStack {
Text(vm.fmt("lit_question_fmt", idx + 1, qs.count))
Spacer()
Text(vm.fmt("lit_score_fmt", score, qs.count))
}
.font(.footnote)
.foregroundStyle(.secondary)
Text(q.str("prompt")).font(.title3.weight(.semibold)).padding(.vertical, 12)
ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in
let correct = i == q.int("correct")
let chosen = answered && picked == i
OptionChip(
text: opt,
selected: chosen,
mark: answered ? (correct ? true : (chosen ? false : nil)) : nil
) {
guard !answered else { return }
picked = i
answered = true
if correct { score += 1 }
}
}
if answered {
Meaning(text: q.str("expl"), palette: p, visible: true)
}
}
}
Spacer()
.exerciseContent()
}
.padding()
.navigationTitle(vm.tr(book.str("quizTitle")))
.navigationBarTitleDisplayMode(.inline)
.navigationSubtitle(vm.tr(book.str("quizSub")))
.detailScreen(vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub")))
.safeAreaInset(edge: .bottom) {
if finished {
BottomAction(label: vm.tr("lit_again"), action: {
idx = 0; score = 0; answered = false; finished = false; picked = -1
}) { EmptyView() }
} else if answered {
BottomAction(label: idx + 1 >= qs.count ? vm.tr("lit_show_result") : vm.tr("lit_next"), action: {
if idx + 1 >= qs.count {
finished = true
vm.markBookQuiz(id)
} else {
idx += 1
answered = false
picked = -1
}
}) { EmptyView() }
}
}
.animation(.default, value: answered)
}
}
}

View file

@ -1,166 +1,6 @@
import SwiftUI
struct BackIcon: View {
var color: Color
var size: CGFloat = 16
var body: some View {
Canvas { ctx, sz in
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.18))
p.addLine(to: CGPoint(x: sz.width * 0.28, y: sz.height * 0.5))
p.addLine(to: CGPoint(x: sz.width * 0.68, y: sz.height * 0.82))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
}
.frame(width: size, height: size)
}
}
struct SearchIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.28
let c = CGPoint(x: sz.width * 0.42, y: sz.height * 0.42)
ctx.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.12)
var handle = Path()
handle.move(to: CGPoint(x: sz.width * 0.62, y: sz.height * 0.62))
handle.addLine(to: CGPoint(x: sz.width * 0.82, y: sz.height * 0.82))
ctx.stroke(handle, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.12, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct StatsIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let w = min(sz.width, sz.height) * 0.16
let bars: [(CGFloat, CGFloat, CGFloat)] = [
(0.18, 0.48, 0.36), (0.42, 0.22, 0.62), (0.66, 0.36, 0.48),
]
for b in bars {
let rect = CGRect(x: sz.width * b.0, y: sz.height * b.1, width: w, height: sz.height * b.2)
ctx.fill(Path(roundedRect: rect, cornerRadius: w), with: .color(color))
}
}
.frame(width: size, height: size)
}
}
struct SettingsIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let cx = sz.width / 2
let cy = sz.height / 2
let s = min(sz.width, sz.height)
let rOuter = s * 0.48
let rInner = s * 0.34
let rHole = s * 0.16
let teeth = 8
let toothHalf = CGFloat.pi / CGFloat(teeth)
var gear = Path()
for i in 0..<teeth {
let a = -CGFloat.pi / 2 + 2 * CGFloat.pi * CGFloat(i) / CGFloat(teeth)
let a0 = a - toothHalf * 0.55
let a1 = a - toothHalf * 0.28
let a2 = a + toothHalf * 0.28
let a3 = a + toothHalf * 0.55
let p0 = CGPoint(x: cx + cos(a0) * rInner, y: cy + sin(a0) * rInner)
if i == 0 { gear.move(to: p0) } else { gear.addLine(to: p0) }
gear.addLine(to: CGPoint(x: cx + cos(a1) * rOuter, y: cy + sin(a1) * rOuter))
gear.addLine(to: CGPoint(x: cx + cos(a2) * rOuter, y: cy + sin(a2) * rOuter))
gear.addLine(to: CGPoint(x: cx + cos(a3) * rInner, y: cy + sin(a3) * rInner))
}
gear.closeSubpath()
gear.addEllipse(in: CGRect(x: cx - rHole, y: cy - rHole, width: rHole * 2, height: rHole * 2))
ctx.fill(gear, with: .color(color), style: FillStyle(eoFill: true))
}
.frame(width: size, height: size)
}
}
struct LockIcon: View {
var color: Color
var size: CGFloat = 18
var body: some View {
Canvas { ctx, sz in
let body = CGRect(x: sz.width * 0.28, y: sz.height * 0.46, width: sz.width * 0.44, height: sz.height * 0.36)
ctx.fill(Path(roundedRect: body, cornerRadius: min(sz.width, sz.height) * 0.08), with: .color(color))
var arc = Path()
arc.addArc(
center: CGPoint(x: sz.width * 0.5, y: sz.height * 0.38),
radius: sz.width * 0.18,
startAngle: .degrees(200),
endAngle: .degrees(340),
clockwise: false
)
ctx.stroke(arc, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.1, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct CheckIcon: View {
var color: Color
var size: CGFloat = 16
var body: some View {
Canvas { ctx, sz in
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.18, y: sz.height * 0.52))
p.addLine(to: CGPoint(x: sz.width * 0.4, y: sz.height * 0.74))
p.addLine(to: CGPoint(x: sz.width * 0.84, y: sz.height * 0.26))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.16, lineCap: .round, lineJoin: .round))
}
.frame(width: size, height: size)
}
}
struct WifiIcon: View {
var color: Color
var size: CGFloat = 28
var body: some View {
Canvas { ctx, sz in
let c = CGPoint(x: sz.width / 2, y: sz.height * 0.72)
ctx.fill(Path(ellipseIn: CGRect(x: c.x - sz.width * 0.06, y: c.y - sz.width * 0.06, width: sz.width * 0.12, height: sz.width * 0.12)), with: .color(color))
for i in 1...3 {
let r = min(sz.width, sz.height) * (0.16 + CGFloat(i) * 0.16)
var p = Path()
p.addArc(center: c, radius: r, startAngle: .degrees(210), endAngle: .degrees(330), clockwise: false)
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
}
.frame(width: size, height: size)
}
}
struct ChipIcon: View {
var color: Color
var size: CGFloat = 28
var body: some View {
Canvas { ctx, sz in
let m = min(sz.width, sz.height) * 0.22
ctx.stroke(
Path(roundedRect: CGRect(x: m, y: m, width: sz.width - 2 * m, height: sz.height - 2 * m), cornerRadius: 6),
with: .color(color),
lineWidth: min(sz.width, sz.height) * 0.08
)
let pin = min(sz.width, sz.height) * 0.08
for i in 0..<3 {
let t = m + (sz.height - 2 * m) * CGFloat(i + 1) / 4
var l = Path(); l.move(to: CGPoint(x: m - pin, y: t)); l.addLine(to: CGPoint(x: m, y: t))
var r = Path(); r.move(to: CGPoint(x: sz.width - m, y: t)); r.addLine(to: CGPoint(x: sz.width - m + pin, y: t))
ctx.stroke(l, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
ctx.stroke(r, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
}
}
.frame(width: size, height: size)
}
}
// Circular flags used as subject glyphs; everything else is an SF Symbol.
struct GermanFlag: View {
var size: CGFloat = 48
@ -193,112 +33,3 @@ struct CzechFlag: View {
.frame(width: size, height: size)
}
}
struct BookIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
ctx.stroke(
Path(roundedRect: CGRect(x: sz.width * 0.22, y: sz.height * 0.18, width: sz.width * 0.56, height: sz.height * 0.64), cornerRadius: 4),
with: .color(color),
lineWidth: min(sz.width, sz.height) * 0.08
)
var spine = Path()
spine.move(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.18))
spine.addLine(to: CGPoint(x: sz.width * 0.38, y: sz.height * 0.82))
ctx.stroke(spine, with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
}
.frame(width: size, height: size)
}
}
struct QuizIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.36
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
let d = min(sz.width, sz.height) * 0.12
ctx.fill(Path(ellipseIn: CGRect(x: sz.width / 2 - d / 2, y: sz.height * 0.68 - d / 2, width: d, height: d)), with: .color(color))
var stem = Path()
stem.move(to: CGPoint(x: sz.width / 2, y: sz.height * 0.32))
stem.addLine(to: CGPoint(x: sz.width / 2, y: sz.height * 0.52))
ctx.stroke(stem, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct OrderIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
for i in 0..<3 {
let y = sz.height * (0.28 + CGFloat(i) * 0.22)
var p = Path()
p.move(to: CGPoint(x: sz.width * 0.22, y: y))
p.addLine(to: CGPoint(x: sz.width * 0.78, y: y))
ctx.stroke(p, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
}
.frame(width: size, height: size)
}
}
struct GlobeIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.34
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height / 2 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width * 0.32, y: sz.height * 0.18, width: sz.width * 0.36, height: sz.height * 0.64)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.06)
}
.frame(width: size, height: size)
}
}
struct PeopleIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.12
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.35 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
ctx.fill(Path(ellipseIn: CGRect(x: sz.width * 0.65 - r, y: sz.height * 0.32 - r, width: r * 2, height: r * 2)), with: .color(color))
var a = Path(); a.addArc(center: CGPoint(x: sz.width * 0.35, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
var b = Path(); b.addArc(center: CGPoint(x: sz.width * 0.65, y: sz.height * 0.62), radius: sz.width * 0.17, startAngle: .degrees(200), endAngle: .degrees(340), clockwise: false)
ctx.stroke(a, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
ctx.stroke(b, with: .color(color), style: StrokeStyle(lineWidth: min(sz.width, sz.height) * 0.08, lineCap: .round))
}
.frame(width: size, height: size)
}
}
struct BulbIcon: View {
var color: Color
var size: CGFloat = 22
var body: some View {
Canvas { ctx, sz in
let r = min(sz.width, sz.height) * 0.22
ctx.stroke(Path(ellipseIn: CGRect(x: sz.width / 2 - r, y: sz.height * 0.4 - r, width: r * 2, height: r * 2)), with: .color(color), lineWidth: min(sz.width, sz.height) * 0.08)
ctx.fill(Path(roundedRect: CGRect(x: sz.width * 0.4, y: sz.height * 0.58, width: sz.width * 0.2, height: sz.height * 0.2), cornerRadius: 3), with: .color(color))
}
.frame(width: size, height: size)
}
}
@ViewBuilder
func noteIcon(_ name: String, _ color: Color) -> some View {
switch name {
case "globe": GlobeIcon(color: color)
case "people": PeopleIcon(color: color)
case "bulb": BulbIcon(color: color)
case "quiz": QuizIcon(color: color)
case "order": OrderIcon(color: color)
default: BookIcon(color: color)
}
}

View file

@ -1,11 +0,0 @@
import SwiftUI
struct MapNode {
let id: String
let label: String
let locked: Bool
let done: Bool
let current: Bool
var finish: Bool = false
var destination: Route?
}

View file

@ -54,7 +54,7 @@ struct PracticeHome: View {
}
}
} icon: {
Image(systemName: subjectSymbol(s.str("icon")))
SubjectIcon(icon: s.str("icon"), open: s.bool("open"))
}
}
}
@ -77,11 +77,11 @@ struct SettingsScreen: View {
vm.setTheme(id)
} label: {
HStack {
Circle().fill(themePalette(id, vm.mode).accent).frame(width: 14, height: 14)
Text(themeNames[id.rawValue])
Circle().fill(themePalette(id, vm.mode).tint).frame(width: 14, height: 14)
Text(themeNames[id.rawValue]).foregroundStyle(.primary)
Spacer()
if vm.themeId == id {
Image(systemName: "checkmark")
Image(systemName: "checkmark").fontWeight(.semibold)
}
}
}
@ -130,28 +130,42 @@ struct StatsScreen: View {
let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
List {
Section {
HStack {
stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100)))
stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
GlassCard {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100)))
stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
}
ProgressView(value: pct)
}
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowBackground(Color.clear)
ProgressView(value: pct)
.listRowSeparator(.hidden)
}
Section(vm.tr("stats_section")) {
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
let open = s.bool("open")
let part = subjectSum(vm, s)
VStack(alignment: .leading, spacing: 6) {
Text(vm.tr(s.str("key")))
Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
.font(.caption)
.foregroundStyle(.secondary)
if open && part.totalEx > 0 {
ProgressView(value: Double(part.doneEx) / Double(part.totalEx))
Label {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(vm.tr(s.str("key")))
Spacer()
Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
if open && part.totalEx > 0 {
ProgressView(value: Double(part.doneEx) / Double(part.totalEx))
}
}
} icon: {
SubjectIcon(icon: s.str("icon"), open: open)
}
.opacity(open ? 1 : 0.5)
.foregroundStyle(open ? .primary : .secondary)
}
}
}
@ -159,9 +173,9 @@ struct StatsScreen: View {
}
private func stat(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.caption).foregroundStyle(.secondary)
Text(value).font(.title3.bold())
Text(value).font(.title3.bold()).monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ -397,29 +411,59 @@ struct SlidesScreen: View {
.foregroundStyle(.secondary)
Text(slide.str("title")).font(.title2.bold())
if let tip = slide.strOrNull("tip") {
GlassCard { Text(tip) }
GlassCard {
Label(tip, systemImage: "lightbulb.fill")
}
}
ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
Text(line).padding(.vertical, 4)
}
}
.padding()
.padding(.bottom, 24)
.frame(maxWidth: .infinity, alignment: .leading)
}
.tag(i)
}
}
.tabViewStyle(.page(indexDisplayMode: .automatic))
.navigationTitle(title)
.navigationSubtitle(subtitle)
.toolbar {
ToolbarItem(placement: .bottomBar) {
if idx < slides.count - 1 {
Button(vm.tr("net_slide_next")) { idx += 1 }
} else {
NavigationLink(vm.tr("net_slide_start"), value: next)
.tabViewStyle(.page(indexDisplayMode: .never))
.detailScreen(title, subtitle: subtitle)
.safeAreaInset(edge: .bottom) {
VStack(spacing: 10) {
HStack(spacing: 6) {
ForEach(slides.indices, id: \.self) { i in
Capsule()
.fill(i == idx ? Color.accentColor : Color(.tertiarySystemFill))
.frame(width: i == idx ? 18 : 6, height: 6)
}
}
.animation(.default, value: idx)
HStack(spacing: 10) {
if idx > 0 {
Button {
withAnimation { idx -= 1 }
} label: {
Image(systemName: "chevron.left")
.frame(minWidth: 24)
}
.buttonStyle(.glass)
.controlSize(.large)
}
if idx < slides.count - 1 {
PrimaryButton(label: vm.tr("net_slide_next")) {
withAnimation { idx += 1 }
}
} else {
PrimaryButton(label: vm.tr("net_slide_start")) {
vm.go(next)
}
}
}
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 4)
.background(.bar)
}
}
}