mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
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:
parent
a1abac51f5
commit
0621311b4c
25 changed files with 1965 additions and 1836 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue