mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
Add a SwiftUI iOS app that plays the same maturita content.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3f88e55f33
commit
6c7336c7bb
33 changed files with 4498 additions and 10 deletions
276
ios/Maturita/UI/Components.swift
Normal file
276
ios/Maturita/UI/Components.swift
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import SwiftUI
|
||||
|
||||
struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 6
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
arrange(width: proposal.width ?? .infinity, subviews: subviews).size
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let result = arrange(width: bounds.width, subviews: subviews)
|
||||
for (i, frame) in result.frames.enumerated() {
|
||||
subviews[i].place(
|
||||
at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + frame.minY),
|
||||
proposal: ProposedViewSize(frame.size)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func arrange(width: CGFloat, subviews: Subviews) -> (size: CGSize, frames: [CGRect]) {
|
||||
var frames: [CGRect] = []
|
||||
var x: CGFloat = 0
|
||||
var y: CGFloat = 0
|
||||
var rowH: CGFloat = 0
|
||||
var maxW: CGFloat = 0
|
||||
for view in subviews {
|
||||
let size = view.sizeThatFits(.unspecified)
|
||||
if x > 0 && x + size.width > width {
|
||||
x = 0
|
||||
y += rowH + spacing
|
||||
rowH = 0
|
||||
}
|
||||
frames.append(CGRect(origin: CGPoint(x: x, y: y), size: size))
|
||||
x += size.width + spacing
|
||||
rowH = max(rowH, size.height)
|
||||
maxW = max(maxW, x - spacing)
|
||||
}
|
||||
return (CGSize(width: max(maxW, 0), height: y + rowH), frames)
|
||||
}
|
||||
}
|
||||
|
||||
struct PrimaryButton: View {
|
||||
let label: String
|
||||
let palette: Palette
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(palette.onAccent)
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 12)
|
||||
.background(palette.accent, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct PillButton: View {
|
||||
let label: String
|
||||
let palette: Palette
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(palette.text)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(palette.text.opacity(0.06), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct IconHit<Content: View>: View {
|
||||
var action: () -> Void
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
content()
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct PageTop: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
var back: () -> Void
|
||||
let title: String
|
||||
var subtitle: String?
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
VStack(spacing: 6) {
|
||||
ZStack {
|
||||
Text(title)
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.foregroundStyle(p.text)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 48)
|
||||
HStack {
|
||||
IconHit(action: back) { BackIcon(color: p.text) }
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(p.subtext)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
}
|
||||
|
||||
struct CardBox<Content: View>: View {
|
||||
let palette: Palette
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(palette.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(palette.text.opacity(0.07), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedbackLine: View {
|
||||
let text: String
|
||||
let kind: String
|
||||
let palette: Palette
|
||||
|
||||
var body: some View {
|
||||
if !text.isEmpty {
|
||||
Text(text)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Meaning: View {
|
||||
let text: String
|
||||
let palette: Palette
|
||||
let visible: Bool
|
||||
|
||||
var body: some View {
|
||||
if visible && !text.isEmpty {
|
||||
Text(text)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(palette.subtext)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WordField: View {
|
||||
@Binding var value: String
|
||||
let palette: Palette
|
||||
var placeholder: String
|
||||
var mark: Bool?
|
||||
|
||||
var body: some View {
|
||||
let border: Color = {
|
||||
if mark == true { return palette.success }
|
||||
if mark == false { return palette.error }
|
||||
return palette.text.opacity(0.12)
|
||||
}()
|
||||
ZStack(alignment: .leading) {
|
||||
if value.isEmpty {
|
||||
Text(placeholder).foregroundStyle(palette.overlay).font(.system(size: 15))
|
||||
}
|
||||
TextField("", text: $value)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(palette.text)
|
||||
.tint(palette.accent)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(palette.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(border, lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
struct SegChip: View {
|
||||
let label: String
|
||||
let selected: Bool
|
||||
let palette: Palette
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.text)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(selected ? palette.mantle : Color.clear, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct Hint: View {
|
||||
let text: String
|
||||
let palette: Palette
|
||||
var body: some View {
|
||||
Text(text).font(.system(size: 13)).foregroundStyle(palette.subtext).padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct Prompt: View {
|
||||
let text: String
|
||||
let palette: Palette
|
||||
var body: some View {
|
||||
Text(text).font(.system(size: 16, weight: .medium)).foregroundStyle(palette.text)
|
||||
}
|
||||
}
|
||||
|
||||
struct WordChip: View {
|
||||
let text: String
|
||||
let palette: Palette
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(palette.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(palette.mantle, in: Capsule())
|
||||
.overlay(Capsule().stroke(palette.text.opacity(0.1), lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionChip: View {
|
||||
let text: String
|
||||
let selected: Bool
|
||||
let mark: Bool?
|
||||
let palette: Palette
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let border: Color = {
|
||||
if mark == true { return palette.success }
|
||||
if mark == false { return palette.error }
|
||||
return selected ? palette.accent : palette.text.opacity(0.1)
|
||||
}()
|
||||
Button(action: action) {
|
||||
Text(text)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(palette.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(selected ? palette.surface1 : palette.mantle, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(border, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
608
ios/Maturita/UI/Exercises.swift
Normal file
608
ios/Maturita/UI/Exercises.swift
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ExerciseScaffold<Content: View>: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let action: String
|
||||
var onAction: () -> Void
|
||||
let feedback: String
|
||||
let kind: String
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) { content() }
|
||||
}
|
||||
FeedbackLine(text: feedback, kind: kind, palette: p)
|
||||
PrimaryButton(label: action, palette: p, action: onAction)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
struct GermanExercise: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let unitId: Int
|
||||
let ex: Int
|
||||
|
||||
var body: some View {
|
||||
if let spec = vm.content.germanUnit(unitId)?.exercise(ex) {
|
||||
DispatchExercise(vm: vm, spec: spec, titleFallback: spec.str("title")) {
|
||||
vm.markGerman(unitId, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VocabExercise: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let unitId: Int
|
||||
|
||||
var body: some View {
|
||||
if let spec = vm.content.germanUnit(unitId)?.obj("vocab") {
|
||||
DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr("Vokabeltraining")) {
|
||||
vm.markVocab(unitId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MluvExercise: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let n: Int
|
||||
|
||||
var body: some View {
|
||||
if let spec = vm.content.mluv(n) {
|
||||
DispatchExercise(vm: vm, spec: spec, titleFallback: spec.str("title")) {
|
||||
vm.markMluv(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DispatchExercise: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let spec: J
|
||||
let titleFallback: String
|
||||
var onDone: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let title = spec.str("title").isEmpty ? titleFallback : spec.str("title")
|
||||
let sub = spec.strOrNull("sub").map { vm.tr($0) }
|
||||
switch spec.str("type") {
|
||||
case "choice": ChoiceEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
case "assembly": AssemblyEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
case "typed", "kw": TypedEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
case "free", "profile": FreeEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
case "assign": AssignEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
case "hangman": HangmanEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
case "vocab": VocabEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
case "dialog": DialogEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
case "number", "count", "seq", "verb", "verbclue", "verb_sections", "ordne", "fill":
|
||||
ComboEx(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
case "ex2": Ex2Ex(vm: vm, title: title, sub: sub, spec: spec, onDone: onDone)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChoiceEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
@State private var picks: [Int]
|
||||
@State private var fb = ("", "")
|
||||
@State private var revealed = false
|
||||
|
||||
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
|
||||
self.vm = vm
|
||||
self.title = title
|
||||
self.sub = sub
|
||||
self.spec = spec
|
||||
self.onDone = onDone
|
||||
_picks = State(initialValue: Array(repeating: -1, count: spec.arr("questions").count))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let qs = spec.arr("questions")
|
||||
let meanings = spec.strs("meanings")
|
||||
let expls = spec.strs("expls")
|
||||
let p = vm.palette
|
||||
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
|
||||
let ok = qs.indices.filter { picks[$0] == qs[$0].int("correct") }.count
|
||||
if ok == qs.count { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
|
||||
else { fb = (vm.tr("feedback_retry_short"), "err") }
|
||||
revealed = true
|
||||
}, 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)
|
||||
.padding(.bottom, 6)
|
||||
ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in
|
||||
let sel = picks[i] == o
|
||||
let mark: Bool? = revealed && sel ? picks[i] == qs[i].int("correct") : nil
|
||||
OptionChip(text: opt, selected: sel, mark: mark, palette: p) { picks[i] = o }
|
||||
}
|
||||
if revealed {
|
||||
if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) }
|
||||
if i < expls.count { Meaning(text: expls[i], palette: p, visible: true) }
|
||||
}
|
||||
Spacer().frame(height: 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AssemblyEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
@State private var placed: [[Int]]
|
||||
@State private var pools: [[Int]]
|
||||
@State private var fb = ("", "")
|
||||
@State private var revealed = false
|
||||
|
||||
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
|
||||
self.vm = vm
|
||||
self.title = title
|
||||
self.sub = sub
|
||||
self.spec = spec
|
||||
self.onDone = onDone
|
||||
let items = spec.arr("items")
|
||||
_placed = State(initialValue: items.map { _ in [] })
|
||||
_pools = State(initialValue: items.map { item in
|
||||
Array(0..<item.strs("words").count).shuffled()
|
||||
})
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let items = spec.arr("items")
|
||||
let meanings = spec.strs("meanings")
|
||||
let p = vm.palette
|
||||
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
|
||||
let ok = items.indices.allSatisfy { i in
|
||||
let words = items[i].strs("words")
|
||||
return placed[i].count == words.count && placed[i].map { words[$0] } == words
|
||||
}
|
||||
if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
|
||||
else { fb = (vm.tr("feedback_sentences"), "err") }
|
||||
revealed = true
|
||||
}, feedback: fb.0, kind: fb.1) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
if let prompt = items[i].strOrNull("prompt") { Hint(text: prompt, palette: p) }
|
||||
FlowLayout {
|
||||
if placed[i].isEmpty { Text("…").foregroundStyle(p.overlay) }
|
||||
ForEach(placed[i], id: \.self) { idx in
|
||||
if idx < items[i].strs("words").count {
|
||||
WordChip(text: items[i].strs("words")[idx], palette: p) {
|
||||
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
|
||||
if idx < items[i].strs("words").count {
|
||||
WordChip(text: items[i].strs("words")[idx], palette: p) {
|
||||
pools[i].removeAll { $0 == idx }
|
||||
placed[i].append(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if revealed, i < meanings.count {
|
||||
Meaning(text: vm.tr(meanings[i]), palette: p, visible: true)
|
||||
}
|
||||
Spacer().frame(height: 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TypedEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
@State private var fields: [[String]]
|
||||
@State private var fb = ("", "")
|
||||
@State private var revealed = false
|
||||
|
||||
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
|
||||
self.vm = vm
|
||||
self.title = title
|
||||
self.sub = sub
|
||||
self.spec = spec
|
||||
self.onDone = onDone
|
||||
let items = spec.arr("rows").isEmpty ? spec.arr("items") : spec.arr("rows")
|
||||
_fields = State(initialValue: items.map { q in
|
||||
Array(repeating: "", count: max(1, q.strs("hints").count, q.answerList().count))
|
||||
})
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let items = spec.arr("rows").isEmpty ? spec.arr("items") : spec.arr("rows")
|
||||
let p = vm.palette
|
||||
let umlaut = items.contains { q in
|
||||
q.answerList().contains { hasUmlaut($0) } || hasUmlaut(q.str("prompt"))
|
||||
}
|
||||
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
|
||||
var ok = 0, total = 0, incomplete = false
|
||||
for (i, q) in items.enumerated() {
|
||||
let answers = q.answerList()
|
||||
for (j, value) in fields[i].enumerated() {
|
||||
total += 1
|
||||
let ans = j < answers.count ? answers[j] : ""
|
||||
let norm = normalizeAnswer(value)
|
||||
if answerAccepts(norm, ans) { ok += 1 }
|
||||
else if answerIncomplete(norm, ans) { incomplete = true }
|
||||
}
|
||||
}
|
||||
if total > 0 && ok == total { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
|
||||
else if incomplete { fb = (vm.tr("trans_incomplete"), "warn") }
|
||||
else { fb = (vm.tr("feedback_retry"), "err") }
|
||||
revealed = true
|
||||
}, feedback: fb.0, kind: fb.1) {
|
||||
if let note = spec.strOrNull("note") { Hint(text: note, palette: p) }
|
||||
if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: p) }
|
||||
if let bank = spec.strOrNull("bank") { Hint(text: "\(vm.tr("wordbank")) \(bank)", palette: p) }
|
||||
if umlaut { Hint(text: vm.tr("hint_umlauts"), palette: p) }
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
let q = items[i]
|
||||
Prompt(text: q.str("prompt"), palette: p)
|
||||
let answers = q.answerList()
|
||||
let hints = q.strs("hints")
|
||||
ForEach(fields[i].indices, id: \.self) { j in
|
||||
let ans = j < answers.count ? answers[j] : ""
|
||||
WordField(
|
||||
value: Binding(get: { fields[i][j] }, set: { fields[i][j] = $0 }),
|
||||
palette: p,
|
||||
placeholder: j < hints.count ? hints[j] : vm.tr("your_answer"),
|
||||
mark: revealed ? answerAccepts(normalizeAnswer(fields[i][j]), ans) : nil
|
||||
)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
if revealed {
|
||||
if let m = q.strOrNull("meaning") { Meaning(text: vm.tr(m), palette: p, visible: true) }
|
||||
if let m = q.strOrNull("mean") { Meaning(text: vm.tr(m), palette: p, visible: true) }
|
||||
if let g = q.strOrNull("german") { Meaning(text: g, palette: p, visible: true) }
|
||||
if let s = q.strOrNull("shown") { Meaning(text: s, palette: p, visible: true) }
|
||||
}
|
||||
Spacer().frame(height: 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FreeEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
@State private var shown: [Bool]
|
||||
@State private var values: [String]
|
||||
@State private var fb = ("", "")
|
||||
|
||||
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
|
||||
self.vm = vm
|
||||
self.title = title
|
||||
self.sub = sub
|
||||
self.spec = spec
|
||||
self.onDone = onDone
|
||||
let qs = spec.arr("questions").isEmpty ? spec.arr("rows") : spec.arr("questions")
|
||||
_shown = State(initialValue: Array(repeating: false, count: qs.count))
|
||||
_values = State(initialValue: Array(repeating: "", count: qs.count))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let qs = spec.arr("questions").isEmpty ? spec.arr("rows") : spec.arr("questions")
|
||||
let p = vm.palette
|
||||
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("finish"), onAction: {
|
||||
fb = (vm.tr("feedback_ok"), "ok")
|
||||
shown = shown.map { _ in true }
|
||||
onDone()
|
||||
}, feedback: fb.0, kind: fb.1) {
|
||||
if let tip = spec.strOrNull("tip") { Hint(text: vm.tr(tip), palette: p) }
|
||||
ForEach(qs.indices, id: \.self) { i in
|
||||
let q = qs[i]
|
||||
Prompt(text: q.str("question").isEmpty ? q.str("stem") : q.str("question"), palette: p)
|
||||
WordField(value: Binding(get: { values[i] }, set: { values[i] = $0 }), palette: p, placeholder: vm.tr("your_answer"))
|
||||
PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() }
|
||||
.padding(.vertical, 6)
|
||||
if shown[i] {
|
||||
let sample = q.str("sample")
|
||||
let qcs = q.str("qCs")
|
||||
let acs = q.str("aCs")
|
||||
Meaning(
|
||||
text: qcs.isEmpty ? sample : vm.fmt("sample_fmt", vm.tr(qcs), sample, vm.tr(acs)),
|
||||
palette: p,
|
||||
visible: true
|
||||
)
|
||||
}
|
||||
Spacer().frame(height: 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AssignEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
@State private var active = 0
|
||||
@State private var loc: [Int]
|
||||
@State private var fb = ("", "")
|
||||
@State private var revealed = false
|
||||
|
||||
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
|
||||
self.vm = vm
|
||||
self.title = title
|
||||
self.sub = sub
|
||||
self.spec = spec
|
||||
self.onDone = onDone
|
||||
_loc = State(initialValue: Array(repeating: -1, count: spec.arr("items").count))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let items = spec.arr("items")
|
||||
let groups = spec.strs("groups")
|
||||
let meanings = spec.strs("meanings")
|
||||
let p = vm.palette
|
||||
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
|
||||
let ok = items.indices.allSatisfy { loc[$0] == items[$0].int("group") }
|
||||
if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
|
||||
else { fb = (vm.tr("feedback_retry"), "err") }
|
||||
revealed = true
|
||||
}, feedback: fb.0, kind: fb.1) {
|
||||
Hint(text: vm.tr("assign_hint"), palette: p)
|
||||
FlowLayout(spacing: 8) {
|
||||
ForEach(groups.indices, id: \.self) { i in
|
||||
SegChip(label: groups[i], selected: active == i, palette: p) { active = i }
|
||||
}
|
||||
}
|
||||
Text(vm.tr("wordbank")).font(.system(size: 12)).foregroundStyle(p.subtext).padding(.top, 10)
|
||||
FlowLayout(spacing: 6) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
if loc[i] == -1 {
|
||||
let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ")
|
||||
WordChip(text: label, palette: p) { loc[i] = active }
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(groups.indices, id: \.self) { g in
|
||||
Text(groups[g]).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 10)
|
||||
FlowLayout(spacing: 6) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
if loc[i] == g {
|
||||
let label = [items[i].strOrNull("emoji"), items[i].str("label")].compactMap { $0 }.joined(separator: " ")
|
||||
WordChip(text: label, palette: p) { loc[i] = -1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if revealed {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HangmanEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
@State private var word = 0
|
||||
@State private var misses = 0
|
||||
@State private var guessed: [Bool]
|
||||
@State private var fb = ("", "")
|
||||
@State private var finished = false
|
||||
|
||||
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
|
||||
self.vm = vm
|
||||
self.title = title
|
||||
self.sub = sub
|
||||
self.spec = spec
|
||||
self.onDone = onDone
|
||||
_guessed = State(initialValue: Array(repeating: false, count: spec.strs("letters").count))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let words = spec.strs("words")
|
||||
let tips = spec.strs("tips")
|
||||
let letters = spec.strs("letters")
|
||||
let p = vm.palette
|
||||
let cur = word < words.count ? words[word].uppercased() : ""
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
|
||||
Text(vm.fmt("hm_progress", word + 1, words.count)).foregroundStyle(p.subtext)
|
||||
if word < tips.count { Hint(text: "\(vm.tr("hm_hint")): \(vm.tr(tips[word]))", palette: p) }
|
||||
Canvas { ctx, size in
|
||||
var gallows = Path()
|
||||
gallows.move(to: CGPoint(x: 40, y: size.height - 10))
|
||||
gallows.addLine(to: CGPoint(x: size.width * 0.55, y: size.height - 10))
|
||||
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))
|
||||
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)
|
||||
}
|
||||
if misses > 1 {
|
||||
ctx.stroke(Path(ellipseIn: CGRect(x: x - 16, y: 36, width: 32, height: 32)), with: .color(p.text), 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
.frame(height: 160)
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array(cur.enumerated()), id: \.offset) { _, ch in
|
||||
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)
|
||||
}
|
||||
}
|
||||
ForEach(Array(letters.chunked(10).enumerated()), id: \.offset) { _, row in
|
||||
HStack(spacing: 4) {
|
||||
ForEach(row, id: \.self) { letter in
|
||||
if let i = letters.firstIndex(of: letter) {
|
||||
Button {
|
||||
guessed[i] = true
|
||||
if !cur.localizedCaseInsensitiveContains(letter) {
|
||||
misses += 1
|
||||
if misses >= 6 { fb = (vm.fmt("hm_fail", cur), "err") }
|
||||
} else if cur.allSatisfy({ ch in
|
||||
let idx = letters.firstIndex { $0.compare(String(ch), options: .caseInsensitive) == .orderedSame }
|
||||
return idx.map { guessed[$0] } ?? false
|
||||
}) {
|
||||
if word + 1 >= words.count {
|
||||
finished = true
|
||||
fb = (vm.tr("hm_done"), "ok")
|
||||
onDone()
|
||||
} else {
|
||||
fb = (vm.tr("hm_wrong"), "ok")
|
||||
word += 1
|
||||
misses = 0
|
||||
guessed = guessed.map { _ in false }
|
||||
}
|
||||
}
|
||||
} 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))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(guessed[i] || finished)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
|
||||
if misses >= 6 && !finished {
|
||||
PillButton(label: vm.tr("hm_retry"), palette: p) {
|
||||
misses = 0
|
||||
guessed = guessed.map { _ in false }
|
||||
fb = ("", "")
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
func chunked(_ size: Int) -> [[Element]] {
|
||||
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct VocabEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
@State private var cards: [(String, String, String)]
|
||||
@State private var index = 0
|
||||
@State private var done = 0
|
||||
@State private var value = ""
|
||||
@State private var fb = ("", "")
|
||||
let total: Int
|
||||
|
||||
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
|
||||
self.vm = vm
|
||||
self.title = title
|
||||
self.sub = sub
|
||||
self.spec = spec
|
||||
self.onDone = onDone
|
||||
let list = spec.arr("sections").flatMap { sec in
|
||||
sec.arr("rows").map { (sec.str("header"), $0.str("prompt"), $0.str("answers")) }
|
||||
}
|
||||
_cards = State(initialValue: list)
|
||||
total = list.count
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
|
||||
Text(vm.fmt("trans_progress", done, total)).foregroundStyle(p.subtext)
|
||||
if cards.isEmpty {
|
||||
Text(vm.tr("trans_done")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.success)
|
||||
} else if index < cards.count {
|
||||
let cur = cards[index]
|
||||
Hint(text: cur.0, palette: p)
|
||||
Prompt(text: cur.1, palette: p)
|
||||
if hasUmlaut(cur.2) { Hint(text: vm.tr("hint_umlauts"), palette: p) }
|
||||
WordField(value: $value, palette: p, placeholder: vm.tr("your_answer"))
|
||||
PrimaryButton(label: vm.tr("check"), palette: p) {
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
857
ios/Maturita/UI/ExercisesMore.swift
Normal file
857
ios/Maturita/UI/ExercisesMore.swift
Normal file
|
|
@ -0,0 +1,857 @@
|
|||
import SwiftUI
|
||||
|
||||
struct DialogEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let pool = spec.strs("pool")
|
||||
let meanings = spec.strs("meanings")
|
||||
let lines = dialogLines()
|
||||
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: lines.count, onDone: onDone) { picks, expected, revealed in
|
||||
ForEach(lines.indices, id: \.self) { i in
|
||||
let line = lines[i]
|
||||
if let name = line.name {
|
||||
Text(name)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(vm.palette.subtext)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
ComboLine(
|
||||
vm: vm, pool: pool, picks: picks, expected: expected, idx: i,
|
||||
answer: line.answer,
|
||||
prefix: line.prefix,
|
||||
suffix: line.suffix,
|
||||
meaning: i < meanings.count ? meanings[i] : nil,
|
||||
revealed: revealed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dialogLines() -> [(name: String?, prefix: String, answer: String, suffix: String)] {
|
||||
var out: [(name: String?, prefix: String, answer: String, suffix: String)] = []
|
||||
for d in spec.arr("dialogues") {
|
||||
var header: String? = d.str("name")
|
||||
for row in d.arr("rows") {
|
||||
out.append((
|
||||
header,
|
||||
[row.str("speaker"), row.str("before")].filter { !$0.isEmpty }.joined(separator: " "),
|
||||
row.str("answer"),
|
||||
row.str("after")
|
||||
))
|
||||
header = nil
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
struct ComboEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let pool = spec.strs("pool")
|
||||
let rows = spec.arr("rows")
|
||||
let meanings = spec.strs("meanings")
|
||||
let type = spec.str("type")
|
||||
if type == "verb_sections" {
|
||||
let sections = spec.arr("sections")
|
||||
let allRows = sections.flatMap { $0.arr("rows") }
|
||||
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)
|
||||
}
|
||||
ComboLine(
|
||||
vm: vm, pool: spec.strs("pool"), picks: picks, expected: expected, idx: idx,
|
||||
answer: line.answer, prefix: line.before, suffix: line.after,
|
||||
meaning: line.meaning,
|
||||
revealed: revealed || spec.bool("transUpfront")
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if type == "ordne" {
|
||||
ComboRows(vm: vm, title: title, sub: sub, pool: [], n: rows.count * 2, onDone: onDone) { picks, expected, revealed in
|
||||
ForEach(rows.indices, id: \.self) { i in
|
||||
ComboLine(vm: vm, pool: spec.strs("fwPool"), picks: picks, expected: expected, idx: i * 2, answer: rows[i].str("fw"), prefix: "\(i + 1). ", suffix: rows[i].str("mid"), revealed: revealed)
|
||||
ComboLine(vm: vm, pool: spec.strs("ansPool"), picks: picks, expected: expected, idx: i * 2 + 1, answer: rows[i].str("ans"), prefix: "→ ", meaning: i < meanings.count ? meanings[i] : nil, revealed: revealed)
|
||||
}
|
||||
}
|
||||
} else if type == "fill" {
|
||||
let blanks = rows.reduce(0) { $0 + $1.answerList().count }
|
||||
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in
|
||||
if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: vm.palette) }
|
||||
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)
|
||||
}
|
||||
ForEach(Array(block.parts.enumerated()), id: \.offset) { _, part in
|
||||
switch part {
|
||||
case .text(let s):
|
||||
Text(s).foregroundStyle(vm.palette.text)
|
||||
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)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
if revealed || spec.bool("preMeaning") || spec.bool("showGerman"), let mean = block.rowMean {
|
||||
Meaning(text: vm.tr(mean), palette: vm.palette, visible: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: rows.count, onDone: onDone, revealMean: spec.bool("preMeaning")) { picks, expected, revealed in
|
||||
if let sample = spec.strOrNull("sample") { Hint(text: sample, palette: vm.palette) }
|
||||
ForEach(rows.indices, id: \.self) { i in
|
||||
let r = rows[i]
|
||||
let prefix: String = {
|
||||
switch type {
|
||||
case "number": return "\(r.str("digits")) = "
|
||||
case "count": return "\(Array(repeating: r.str("emoji"), count: r.int("count")).joined(separator: " ")) \(r.str("noun")) "
|
||||
case "seq": return r.str("before")
|
||||
case "verbclue": return "\(r.str("emoji")) \(r.str("before"))"
|
||||
default: return r.str("before")
|
||||
}
|
||||
}()
|
||||
let suffix: String = {
|
||||
switch type {
|
||||
case "seq", "verbclue", "verb": return r.str("after")
|
||||
default: return ""
|
||||
}
|
||||
}()
|
||||
ComboLine(
|
||||
vm: vm, pool: pool, picks: picks, expected: expected, idx: i, answer: r.str("answer"),
|
||||
prefix: prefix, suffix: suffix,
|
||||
meaning: i < meanings.count ? meanings[i] : r.strOrNull("mean"),
|
||||
revealed: revealed || spec.bool("preMeaning")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func verbSectionLines() -> [(title: String?, before: String, answer: String, after: String, meaning: String?)] {
|
||||
var out: [(title: String?, before: String, answer: String, after: String, meaning: String?)] = []
|
||||
for sec in spec.arr("sections") {
|
||||
var header: String? = sec.str("title")
|
||||
let meanings = sec.strs("meanings")
|
||||
for (ri, row) in sec.arr("rows").enumerated() {
|
||||
out.append((
|
||||
header,
|
||||
row.str("before"),
|
||||
row.str("answer"),
|
||||
row.str("after"),
|
||||
ri < meanings.count ? meanings[ri] : nil
|
||||
))
|
||||
header = nil
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private enum FillPart {
|
||||
case text(String)
|
||||
case blank(Int, String)
|
||||
}
|
||||
|
||||
private func fillRows() -> [(num: String?, parts: [FillPart], meaning: String?, rowMean: String?)] {
|
||||
let meanings = spec.strs("meanings")
|
||||
var i = 0
|
||||
return spec.arr("rows").enumerated().map { ri, r in
|
||||
var parts: [FillPart] = []
|
||||
let segs = r.strsOrEmpty("segs")
|
||||
let ans = r.answerList()
|
||||
for si in segs.indices {
|
||||
if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
|
||||
if si < ans.count {
|
||||
parts.append(.blank(i, ans[si]))
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return (
|
||||
r.strOrNull("num"),
|
||||
parts,
|
||||
ri < meanings.count ? meanings[ri] : nil,
|
||||
r.strOrNull("mean")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ComboRows<Body: View>: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let pool: [String]
|
||||
let n: Int
|
||||
var onDone: () -> Void
|
||||
var revealMean = false
|
||||
@ViewBuilder var bodyContent: (Binding<[String]>, Binding<[String]>, Bool) -> Body
|
||||
@State private var picks: [String]
|
||||
@State private var expected: [String]
|
||||
@State private var fb = ("", "")
|
||||
@State private var revealed: Bool
|
||||
|
||||
init(
|
||||
vm: AppModel,
|
||||
title: String,
|
||||
sub: String?,
|
||||
pool: [String],
|
||||
n: Int,
|
||||
onDone: @escaping () -> Void,
|
||||
revealMean: Bool = false,
|
||||
@ViewBuilder bodyContent: @escaping (Binding<[String]>, Binding<[String]>, Bool) -> Body
|
||||
) {
|
||||
self.vm = vm
|
||||
self.title = title
|
||||
self.sub = sub
|
||||
self.pool = pool
|
||||
self.n = n
|
||||
self.onDone = onDone
|
||||
self.revealMean = revealMean
|
||||
self.bodyContent = bodyContent
|
||||
_picks = State(initialValue: Array(repeating: "", count: n))
|
||||
_expected = State(initialValue: Array(repeating: "", count: n))
|
||||
_revealed = State(initialValue: revealMean)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
|
||||
let ok = expected.indices.allSatisfy { i in
|
||||
let exp = expected[i]
|
||||
return exp.isEmpty || answerAccepts(normalizeAnswer(picks[i]), exp)
|
||||
}
|
||||
if ok { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
|
||||
else { fb = (vm.tr("feedback_retry"), "err") }
|
||||
revealed = true
|
||||
}, feedback: fb.0, kind: fb.1) {
|
||||
bodyContent($picks, $expected, revealed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ComboLine: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let pool: [String]
|
||||
@Binding var picks: [String]
|
||||
@Binding var expected: [String]
|
||||
let idx: Int
|
||||
let answer: String
|
||||
var prefix: String = ""
|
||||
var suffix: String = ""
|
||||
var meaning: String?
|
||||
var revealed: Bool
|
||||
var inline = false
|
||||
@State private var open = false
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
let pick = idx < picks.count ? picks[idx] : ""
|
||||
let mark: Bool? = {
|
||||
if revealed && !pick.isEmpty { return answerAccepts(normalizeAnswer(pick), answer) }
|
||||
return nil
|
||||
}()
|
||||
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)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Group {
|
||||
if inline {
|
||||
chip
|
||||
} else {
|
||||
FlowLayout(spacing: 6) {
|
||||
if !prefix.isEmpty { Text(prefix).font(.system(size: 16)).foregroundStyle(p.text) }
|
||||
chip
|
||||
if !suffix.isEmpty { Text(suffix).font(.system(size: 16)).foregroundStyle(p.text) }
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if idx >= 0 && idx < expected.count { expected[idx] = answer }
|
||||
}
|
||||
.onChange(of: answer) { new in
|
||||
if idx >= 0 && idx < expected.count { expected[idx] = new }
|
||||
}
|
||||
.sheet(isPresented: $open) {
|
||||
WordPicker(palette: p, pool: pool) { word in
|
||||
if let word, idx < picks.count { picks[idx] = word }
|
||||
open = false
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
if revealed, let meaning, !meaning.isEmpty {
|
||||
Meaning(text: vm.tr(meaning), palette: p, visible: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WordPicker: View {
|
||||
let palette: Palette
|
||||
let pool: [String]
|
||||
var onPick: (String?) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(pool, id: \.self) { w in
|
||||
Button { onPick(w) } label: {
|
||||
Text(w)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(palette.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.background(palette.base)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(role: .cancel) { onPick(nil) } label: { Text("Zavřít") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Ex2Ex: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let items = spec.arr("items")
|
||||
let pool = spec.strs("pool")
|
||||
let blanks = items.reduce(0) { acc, item in
|
||||
acc + item.arr("rows").reduce(0) { $0 + $1.strs("answers").count }
|
||||
}
|
||||
ComboRows(vm: vm, title: title, sub: sub, pool: pool, n: blanks, onDone: onDone) { picks, expected, revealed in
|
||||
ForEach(Array(ex2Blocks().enumerated()), id: \.offset) { _, block in
|
||||
Meaning(text: block.czech, palette: vm.palette, visible: true)
|
||||
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)
|
||||
}
|
||||
ForEach(Array(row.parts.enumerated()), id: \.offset) { _, part in
|
||||
switch part {
|
||||
case .text(let s):
|
||||
Text(s).foregroundStyle(vm.palette.text)
|
||||
case .blank(let idx, let ans):
|
||||
ComboLine(vm: vm, pool: pool, picks: picks, expected: expected, idx: idx, answer: ans, revealed: revealed, inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum Part {
|
||||
case text(String)
|
||||
case blank(Int, String)
|
||||
}
|
||||
|
||||
private func ex2Blocks() -> [(czech: String, rows: [(num: String?, parts: [Part])])] {
|
||||
var i = 0
|
||||
return spec.arr("items").map { item in
|
||||
let rows = item.arr("rows").map { row -> (num: String?, parts: [Part]) in
|
||||
var parts: [Part] = []
|
||||
let segs = row.strsOrEmpty("segs")
|
||||
let ans = row.strs("answers")
|
||||
for si in segs.indices {
|
||||
if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
|
||||
if si < ans.count {
|
||||
parts.append(.blank(i, ans[si]))
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return (row.strOrNull("num"), parts)
|
||||
}
|
||||
return (item.str("czech"), rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LettersEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
@State private var fields: [String]
|
||||
@State private var fb = ("", "")
|
||||
@State private var revealed = false
|
||||
|
||||
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
|
||||
self.vm = vm
|
||||
self.title = title
|
||||
self.sub = sub
|
||||
self.spec = spec
|
||||
self.onDone = onDone
|
||||
let n = spec.arr("rows").flatMap { $0.strs("answers") }.count
|
||||
_fields = State(initialValue: Array(repeating: "", count: n))
|
||||
}
|
||||
|
||||
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 {
|
||||
for ans in r.strs("answers") {
|
||||
total += 1
|
||||
if i < fields.count && answerAccepts(normalizeAnswer(fields[i]), ans) { ok += 1 }
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
if ok == total { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
|
||||
else { fb = (vm.tr("feedback_retry"), "err") }
|
||||
revealed = true
|
||||
}, feedback: fb.0, kind: fb.1) {
|
||||
if rows.contains(where: { $0.strs("answers").contains { hasUmlaut($0) } }) {
|
||||
Hint(text: vm.tr("hint_umlauts"), palette: p)
|
||||
}
|
||||
LettersRows(vm: vm, rows: rows, fields: $fields, revealed: revealed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LettersRows: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let rows: [J]
|
||||
@Binding var fields: [String]
|
||||
let revealed: Bool
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
let pairs = build()
|
||||
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) }
|
||||
FlowLayout {
|
||||
ForEach(row.parts.indices, id: \.self) { pi in
|
||||
switch row.parts[pi] {
|
||||
case .text(let s):
|
||||
Text(s).foregroundStyle(p.text)
|
||||
case .field(let idx, let ans):
|
||||
WordField(
|
||||
value: Binding(get: { idx < fields.count ? fields[idx] : "" }, set: { if idx < fields.count { fields[idx] = $0 } }),
|
||||
palette: p,
|
||||
placeholder: "",
|
||||
mark: revealed ? answerAccepts(normalizeAnswer(idx < fields.count ? fields[idx] : ""), ans) : nil
|
||||
)
|
||||
.frame(width: 72)
|
||||
}
|
||||
}
|
||||
}
|
||||
if revealed, let czech = row.czech { Meaning(text: czech, palette: p, visible: true) }
|
||||
if revealed, let mean = row.mean { Meaning(text: mean, palette: p, visible: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum Part {
|
||||
case text(String)
|
||||
case field(Int, String)
|
||||
}
|
||||
|
||||
private struct RowModel {
|
||||
var num: String?
|
||||
var parts: [Part]
|
||||
var czech: String?
|
||||
var mean: String?
|
||||
}
|
||||
|
||||
private func build() -> [RowModel] {
|
||||
var i = 0
|
||||
return rows.map { r in
|
||||
var parts: [Part] = []
|
||||
let segs = r.strsOrEmpty("segs")
|
||||
let ans = r.strs("answers")
|
||||
for si in segs.indices {
|
||||
if let seg = segs[si], !seg.isEmpty { parts.append(.text(seg)) }
|
||||
if si < ans.count {
|
||||
parts.append(.field(i, ans[si]))
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return RowModel(num: r.strOrNull("num"), parts: parts, czech: r.strOrNull("czech"), mean: r.strOrNull("mean"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TableEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
@State private var fields: [String]
|
||||
@State private var fb = ("", "")
|
||||
@State private var revealed = false
|
||||
|
||||
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
|
||||
self.vm = vm
|
||||
self.title = title
|
||||
self.sub = sub
|
||||
self.spec = spec
|
||||
self.onDone = onDone
|
||||
_fields = State(initialValue: Array(repeating: "", count: spec.strs("nouns").count * spec.strs("persons").count))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let persons = spec.strs("persons")
|
||||
let nouns = spec.strs("nouns")
|
||||
let answers = spec.intRows("answers")
|
||||
let p = vm.palette
|
||||
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("check"), onAction: {
|
||||
var ok = 0
|
||||
for (r, row) in answers.enumerated() {
|
||||
for (c, ans) in row.enumerated() {
|
||||
let i = r * persons.count + c
|
||||
if i < fields.count && normalizeAnswer(fields[i]) == normalizeAnswer(ans) { ok += 1 }
|
||||
}
|
||||
}
|
||||
if ok == fields.count { fb = (vm.tr("feedback_ok"), "ok"); onDone() }
|
||||
else { fb = (vm.tr("feedback_retry"), "err") }
|
||||
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)
|
||||
ForEach(persons.indices, id: \.self) { c in
|
||||
let i = r * persons.count + c
|
||||
Text(persons[c]).font(.system(size: 12)).foregroundStyle(p.subtext)
|
||||
WordField(value: Binding(get: { i < fields.count ? fields[i] : "" }, set: { if i < fields.count { fields[i] = $0 } }), palette: p, placeholder: "")
|
||||
}
|
||||
Spacer().frame(height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RevealEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
var sub: String?
|
||||
let spec: J
|
||||
var onDone: () -> Void
|
||||
@State private var shown: [Bool]
|
||||
@State private var fb = ("", "")
|
||||
|
||||
init(vm: AppModel, title: String, sub: String?, spec: J, onDone: @escaping () -> Void) {
|
||||
self.vm = vm
|
||||
self.title = title
|
||||
self.sub = sub
|
||||
self.spec = spec
|
||||
self.onDone = onDone
|
||||
_shown = State(initialValue: Array(repeating: false, count: spec.arr("items").count))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let items = spec.arr("items")
|
||||
let p = vm.palette
|
||||
ExerciseScaffold(vm: vm, title: title, sub: sub, action: vm.tr("finish"), onAction: {
|
||||
shown = shown.map { _ in true }
|
||||
fb = (vm.tr("feedback_ok"), "ok")
|
||||
onDone()
|
||||
}, feedback: fb.0, kind: fb.1) {
|
||||
if let note = spec.strOrNull("note") { Hint(text: note, palette: p) }
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
Prompt(text: items[i].str("prompt"), palette: p)
|
||||
PillButton(label: vm.tr("show_sample"), palette: p) { shown[i].toggle() }
|
||||
.padding(.vertical, 6)
|
||||
if shown[i] { Meaning(text: items[i].str("solution"), palette: p, visible: true) }
|
||||
Spacer().frame(height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NetQuizScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let id: Int
|
||||
|
||||
var body: some View {
|
||||
if let lesson = vm.content.netLesson(id) {
|
||||
if id == 1 {
|
||||
VlsmEx(vm: vm, lesson: lesson)
|
||||
} else {
|
||||
let spec = J([
|
||||
"type": "choice",
|
||||
"title": vm.tr(lesson.str("exTitleKey")),
|
||||
"sub": "net_quiz_intro",
|
||||
"questions": lesson.rawArray("quiz"),
|
||||
])
|
||||
DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
|
||||
vm.markNet(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HwQuizScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let id: Int
|
||||
|
||||
var body: some View {
|
||||
if let lesson = vm.content.hwLesson(id) {
|
||||
let spec = J([
|
||||
"type": "choice",
|
||||
"title": vm.tr(lesson.str("exTitleKey")),
|
||||
"sub": lesson.str("quizHeadKey"),
|
||||
"questions": lesson.rawArray("quiz"),
|
||||
])
|
||||
DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(lesson.str("exTitleKey"))) {
|
||||
vm.markHw(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct VlsmRow {
|
||||
var pfx = ""
|
||||
var net = ""
|
||||
var bc = ""
|
||||
var lo = ""
|
||||
var hi = ""
|
||||
}
|
||||
|
||||
private struct VlsmEx: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let lesson: J
|
||||
@State private var done: [Bool]
|
||||
@State private var rows: [[VlsmRow]]
|
||||
@State private var fb = ("", "")
|
||||
|
||||
init(vm: AppModel, lesson: J) {
|
||||
self.vm = vm
|
||||
self.lesson = lesson
|
||||
let answers = vm.content.netAnswers
|
||||
_done = State(initialValue: Array(repeating: false, count: vm.content.netTasks.count))
|
||||
_rows = State(initialValue: answers.map { block in Array(repeating: VlsmRow(), count: block.count) })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let tasks = vm.content.netTasks
|
||||
let answers = vm.content.netAnswers
|
||||
let p = vm.palette
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_ex_title"), subtitle: nil)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(tasks.indices, id: \.self) { ti in
|
||||
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)
|
||||
if infeasible {
|
||||
Hint(text: vm.tr("net_infeasible"), palette: p)
|
||||
} else {
|
||||
ForEach(block.indices, id: \.self) { si in
|
||||
Text("Podsíť \(String(UnicodeScalar(65 + si)!))")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(p.subtext)
|
||||
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 }), palette: p, placeholder: "síť")
|
||||
WordField(value: Binding(get: { rows[ti][si].bc }, set: { rows[ti][si].bc = $0 }), palette: p, placeholder: "broadcast")
|
||||
WordField(value: Binding(get: { rows[ti][si].lo }, set: { rows[ti][si].lo = $0 }), palette: p, placeholder: "první uzel")
|
||||
WordField(value: Binding(get: { rows[ti][si].hi }, set: { rows[ti][si].hi = $0 }), palette: p, placeholder: "poslední uzel")
|
||||
}
|
||||
PillButton(label: vm.tr("check"), palette: p) {
|
||||
let ok = block.indices.allSatisfy { si in
|
||||
let a = block[si]
|
||||
let field = rows[ti][si]
|
||||
return Int(field.pfx.replacingOccurrences(of: "/", with: "")) == a.int("pfx")
|
||||
&& netTxtEq(field.net, a.str("net"))
|
||||
&& netTxtEq(field.bc, a.str("bcast"))
|
||||
&& netTxtEq(field.lo, a.str("lo"))
|
||||
&& netTxtEq(field.hi, a.str("hi"))
|
||||
}
|
||||
if ok {
|
||||
done[ti] = true
|
||||
fb = (vm.tr("feedback_ok"), "ok")
|
||||
maybeFinish()
|
||||
} else {
|
||||
fb = (vm.tr("feedback_retry"), "err")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
PillButton(label: vm.tr("net_solution"), palette: p) {
|
||||
if !infeasible {
|
||||
for si in block.indices {
|
||||
rows[ti][si].pfx = "/\(block[si].int("pfx"))"
|
||||
rows[ti][si].net = block[si].str("net")
|
||||
rows[ti][si].bc = block[si].str("bcast")
|
||||
rows[ti][si].lo = block[si].str("lo")
|
||||
rows[ti][si].hi = block[si].str("hi")
|
||||
}
|
||||
}
|
||||
done[ti] = true
|
||||
fb = ("", "")
|
||||
maybeFinish()
|
||||
}
|
||||
if done[ti] { Meaning(text: task.str("solution"), palette: p, visible: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
FeedbackLine(text: fb.0, kind: fb.1, palette: p)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private func maybeFinish() {
|
||||
if !done.isEmpty && done.allSatisfy({ $0 }) { vm.markNet(1) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct PrefixMenu: View {
|
||||
@Binding var value: String
|
||||
let palette: Palette
|
||||
@State private var open = false
|
||||
|
||||
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))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 4)
|
||||
.sheet(isPresented: $open) {
|
||||
WordPicker(palette: palette, pool: (8...30).map { "/\($0)" }) { word in
|
||||
if let word { value = word }
|
||||
open = false
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LitQuizScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let id: String
|
||||
@State private var idx = 0
|
||||
@State private var score = 0
|
||||
@State private var answered = false
|
||||
@State private var finished = false
|
||||
|
||||
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) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: vm.tr(book.str("quizTitle")), subtitle: vm.tr(book.str("quizSub")))
|
||||
if finished {
|
||||
Text(vm.tr("lit_finished")).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
|
||||
Text(vm.tr("lit_finished_text")).foregroundStyle(p.subtext)
|
||||
Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
|
||||
PrimaryButton(label: vm.tr("lit_again"), palette: p) {
|
||||
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"), palette: p) {
|
||||
if idx + 1 >= qs.count {
|
||||
finished = true
|
||||
vm.markBookQuiz(id)
|
||||
} else {
|
||||
idx += 1
|
||||
answered = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PlotScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let id: String
|
||||
|
||||
var body: some View {
|
||||
if let book = vm.content.book(id) {
|
||||
let spec = J([
|
||||
"type": "assembly",
|
||||
"title": vm.tr(book.str("plotTitle")),
|
||||
"sub": book.str("plotSub"),
|
||||
"items": book.rawArray("plot"),
|
||||
"meanings": book.rawArray("plotMeaning"),
|
||||
])
|
||||
DispatchExercise(vm: vm, spec: spec, titleFallback: vm.tr(book.str("plotTitle"))) {
|
||||
vm.markBookPlot(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
304
ios/Maturita/UI/Icons.swift
Normal file
304
ios/Maturita/UI/Icons.swift
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
||||
struct GermanFlag: View {
|
||||
var size: CGFloat = 48
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
let band = sz.height / 3
|
||||
ctx.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: sz)))
|
||||
ctx.fill(Path(CGRect(x: 0, y: 0, width: sz.width, height: band)), with: .color(Color(red: 0.18, green: 0.18, blue: 0.19)))
|
||||
ctx.fill(Path(CGRect(x: 0, y: band, width: sz.width, height: band)), with: .color(Color(red: 0.72, green: 0.27, blue: 0.24)))
|
||||
ctx.fill(Path(CGRect(x: 0, y: 2 * band, width: sz.width, height: band)), with: .color(Color(red: 0.84, green: 0.70, blue: 0.32)))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct CzechFlag: View {
|
||||
var size: CGFloat = 48
|
||||
var body: some View {
|
||||
Canvas { ctx, sz in
|
||||
ctx.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: sz)))
|
||||
ctx.fill(Path(CGRect(origin: .zero, size: sz)), with: .color(Color(white: 0.98)))
|
||||
ctx.fill(Path(CGRect(x: 0, y: sz.height / 2, width: sz.width, height: sz.height / 2)), with: .color(Color(red: 0.83, green: 0.16, blue: 0.18)))
|
||||
var tri = Path()
|
||||
tri.move(to: .zero)
|
||||
tri.addLine(to: CGPoint(x: sz.width * 0.55, y: sz.height / 2))
|
||||
tri.addLine(to: CGPoint(x: 0, y: sz.height))
|
||||
tri.closeSubpath()
|
||||
ctx.fill(tri, with: .color(Color(red: 0.07, green: 0.24, blue: 0.55)))
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
63
ios/Maturita/UI/PathMap.swift
Normal file
63
ios/Maturita/UI/PathMap.swift
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MapNode {
|
||||
let id: String
|
||||
let label: String
|
||||
let locked: Bool
|
||||
let done: Bool
|
||||
let current: Bool
|
||||
var finish: Bool = false
|
||||
var onClick: (() -> Void)?
|
||||
}
|
||||
|
||||
struct PathMap: View {
|
||||
let nodes: [MapNode]
|
||||
let palette: Palette
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 10) {
|
||||
ForEach(Array(nodes.enumerated()), id: \.element.id) { i, node in
|
||||
Button {
|
||||
if !node.locked { node.onClick?() }
|
||||
} label: {
|
||||
CardBox(palette: palette) {
|
||||
HStack(spacing: 12) {
|
||||
let bg: Color = {
|
||||
if node.locked { return palette.lockedBg }
|
||||
if node.current || node.done { return palette.accent }
|
||||
return palette.node
|
||||
}()
|
||||
let fg: Color = {
|
||||
if node.locked { return palette.overlay }
|
||||
if node.current || node.done { return palette.onAccent }
|
||||
return palette.text
|
||||
}()
|
||||
ZStack {
|
||||
Circle().fill(bg).frame(width: 44, height: 44)
|
||||
if node.locked {
|
||||
LockIcon(color: palette.overlay, size: 16)
|
||||
} else if node.done {
|
||||
CheckIcon(color: palette.onAccent, size: 16)
|
||||
} else {
|
||||
Text("\(i + 1)")
|
||||
.font(.system(size: 16, weight: .heavy))
|
||||
.foregroundStyle(fg)
|
||||
}
|
||||
}
|
||||
Text(node.label)
|
||||
.font(.system(size: 16, weight: node.current ? .bold : .semibold))
|
||||
.foregroundStyle(node.locked ? palette.overlay : palette.text)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(node.locked || node.onClick == nil)
|
||||
.opacity(node.locked ? 0.48 : 1)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
698
ios/Maturita/UI/Screens.swift
Normal file
698
ios/Maturita/UI/Screens.swift
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
import SwiftUI
|
||||
|
||||
struct WelcomeScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("maturita.c")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(p.text)
|
||||
Text("sestavení \(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(p.overlay)
|
||||
.padding(.bottom, 12)
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(vm.tr("welcome_title"))
|
||||
.font(.system(size: 32, weight: .medium))
|
||||
.foregroundStyle(p.text)
|
||||
Text(vm.tr("welcome_body_ios"))
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(p.subtext)
|
||||
PrimaryButton(label: vm.tr("continue"), palette: p) { vm.go(.subjects) }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
VStack {
|
||||
Text(vm.tr("welcome_stat_value"))
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(p.text)
|
||||
Text(vm.tr("welcome_stat_label"))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(p.subtext)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(width: 108, height: 108)
|
||||
.background(p.mantle, in: Circle())
|
||||
.overlay(Circle().stroke(p.text.opacity(0.08), lineWidth: 1))
|
||||
}
|
||||
FeatureCard(vm: vm, t: "welcome_feat1_title", b: "welcome_feat1_body", l: "welcome_feat1_link")
|
||||
.padding(.top, 28)
|
||||
FeatureCard(vm: vm, t: "welcome_feat2_title", b: "welcome_feat2_body", l: "welcome_feat2_link")
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FeatureCard: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let t, b, l: String
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
CardBox(palette: p) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(vm.tr(t)).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text)
|
||||
Text(vm.tr(b)).font(.system(size: 15)).foregroundStyle(p.subtext)
|
||||
PillButton(label: vm.tr(l), palette: p) { vm.go(.subjects) }
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SubjectsScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
VStack(spacing: 0) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("subjects_title"), subtitle: vm.tr("subjects_sub"))
|
||||
.padding(.horizontal, 16)
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(Array(stride(from: 0, to: vm.content.subjects.count, by: 2)), id: \.self) { i in
|
||||
HStack(spacing: 12) {
|
||||
subjectTile(vm.content.subjects[i], p)
|
||||
if i + 1 < vm.content.subjects.count {
|
||||
subjectTile(vm.content.subjects[i + 1], p)
|
||||
} else {
|
||||
Color.clear.frame(maxWidth: .infinity).frame(height: 132)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func subjectTile(_ s: J, _ p: Palette) -> some View {
|
||||
let open = s.bool("open")
|
||||
return Button {
|
||||
if open { vm.goPage(s.str("target")) }
|
||||
} label: {
|
||||
VStack(spacing: 10) {
|
||||
switch s.str("icon") {
|
||||
case "de": GermanFlag(size: 44)
|
||||
case "wifi": WifiIcon(color: p.text, size: 36)
|
||||
case "chip": ChipIcon(color: p.text, size: 36)
|
||||
case "cz": CzechFlag(size: 44)
|
||||
default: LockIcon(color: p.overlay, size: 22)
|
||||
}
|
||||
Text(vm.tr(s.str("key")))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(p.text)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 132)
|
||||
.background(p.mantle, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(p.text.opacity(open ? 0.08 : 0.04), lineWidth: 1)
|
||||
)
|
||||
.opacity(open ? 1 : 0.48)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!open)
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsSheet: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(vm.tr("settings_title"))
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(p.text)
|
||||
Spacer()
|
||||
PillButton(label: vm.tr("back"), palette: p) { vm.settingsOpen = false }
|
||||
}
|
||||
Text(vm.tr("mode")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
|
||||
HStack(spacing: 4) {
|
||||
SegChip(label: vm.tr("mode_dark"), selected: vm.mode == .dark, palette: p) { vm.applyMode(.dark) }
|
||||
SegChip(label: vm.tr("mode_light"), selected: vm.mode == .light, palette: p) { vm.applyMode(.light) }
|
||||
}
|
||||
.padding(3)
|
||||
.background(p.surface1, in: Capsule())
|
||||
Text(vm.tr("theme")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
|
||||
ForEach(Array(themeNames.enumerated()), id: \.offset) { i, name in
|
||||
if let id = ThemeId(rawValue: i) {
|
||||
let sw = themePalette(id, vm.mode)
|
||||
let sel = vm.themeId == id
|
||||
Button { vm.setTheme(id) } label: {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle().fill(sw.base)
|
||||
Circle().fill(sw.accent).frame(width: 10, height: 10).offset(x: -8)
|
||||
Circle().fill(sw.accent3).frame(width: 10, height: 10)
|
||||
Circle().fill(sw.success).frame(width: 10, height: 10).offset(x: 8)
|
||||
if sel { Circle().stroke(p.accent, lineWidth: 2.2) }
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
Text(name).font(.system(size: 16, weight: .medium)).foregroundStyle(p.text)
|
||||
Spacer()
|
||||
}
|
||||
.padding(10)
|
||||
.background(sel ? p.surface1 : p.surface0, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
Text(vm.tr("language")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
|
||||
HStack(spacing: 4) {
|
||||
SegChip(label: "Čeština", selected: vm.lang == .cs, palette: p) { vm.applyLang(.cs) }
|
||||
SegChip(label: "English", selected: vm.lang == .en, palette: p) { vm.applyLang(.en) }
|
||||
}
|
||||
.padding(3)
|
||||
.background(p.surface1, in: Capsule())
|
||||
Text(vm.tr("updates")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext).padding(.top, 8)
|
||||
Text(updateText)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(p.subtext)
|
||||
HStack(spacing: 8) {
|
||||
PillButton(label: vm.tr("update_check"), palette: p) { vm.checkUpdate(true) }
|
||||
if vm.update.canInstall {
|
||||
PrimaryButton(label: vm.tr("update_install"), palette: p) { vm.installUpdate() }
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(p.base)
|
||||
}
|
||||
|
||||
private var updateText: String {
|
||||
if let arg = vm.update.messageArg {
|
||||
return vm.fmt(vm.update.messageKey, arg)
|
||||
}
|
||||
return vm.tr(vm.update.messageKey)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatsScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
let sum = summarize(vm.content, vm.progress)
|
||||
let pct = sum.totalEx == 0 ? 0 : (100 * sum.doneEx / sum.totalEx)
|
||||
VStack(spacing: 0) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("stats_title"), subtitle: vm.tr("stats_sub"))
|
||||
.padding(.horizontal, 16)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
metric(p, vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
|
||||
metric(p, vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", pct))
|
||||
metric(p, vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
|
||||
}
|
||||
ProgressBar(fraction: CGFloat(pct) / 100, palette: p, height: 8)
|
||||
Text(vm.tr("stats_section"))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(p.subtext)
|
||||
.padding(.top, 10)
|
||||
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
|
||||
let open = s.bool("open")
|
||||
let part = subjectSum(vm, s)
|
||||
CardBox(palette: p) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(vm.tr(s.str("key"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
|
||||
Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
|
||||
.font(.system(size: 13)).foregroundStyle(p.subtext)
|
||||
if open && part.totalEx > 0 {
|
||||
ProgressBar(fraction: CGFloat(part.doneEx) / CGFloat(part.totalEx), palette: p, height: 6)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(open ? 1 : 0.55)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func metric(_ p: Palette, _ label: String, _ value: String) -> some View {
|
||||
CardBox(palette: p) {
|
||||
VStack(alignment: .leading) {
|
||||
Text(label).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext)
|
||||
Text(value).font(.system(size: 22, weight: .bold)).foregroundStyle(p.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProgressBar: View {
|
||||
let fraction: CGFloat
|
||||
let palette: Palette
|
||||
var height: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(palette.surface1)
|
||||
Capsule().fill(palette.accent).frame(width: geo.size.width * min(max(fraction, 0), 1))
|
||||
}
|
||||
}
|
||||
.frame(height: height)
|
||||
}
|
||||
}
|
||||
|
||||
private func subjectSum(_ vm: AppModel, _ s: J) -> ProgressSum {
|
||||
var sum = ProgressSum()
|
||||
switch s.str("target") {
|
||||
case "roadmap":
|
||||
for u in vm.content.german where u.bool("unlocked") {
|
||||
let n = u.strs("names").count
|
||||
sum.totalEx += n
|
||||
sum.doneEx += (1...n).filter { vm.progress.germanDone(u.int("id"), $0) }.count
|
||||
}
|
||||
case "netyears":
|
||||
sum.totalEx = vm.content.netLessons.count
|
||||
sum.doneEx = vm.content.netLessons.filter { vm.progress.netDone($0.int("id")) }.count
|
||||
case "hwmap":
|
||||
sum.totalEx = vm.content.hw.count
|
||||
sum.doneEx = vm.content.hw.filter { vm.progress.hwDone($0.int("id")) }.count
|
||||
case "czechmap":
|
||||
sum.totalEx = vm.content.mluvnice.count
|
||||
sum.doneEx = vm.content.mluvnice.filter { vm.progress.mluvDone($0.int("id")) }.count
|
||||
default:
|
||||
break
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
struct SearchOverlay: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
let q = normalizeAnswer(vm.searchQuery)
|
||||
let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) }.prefix(24)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
ZStack(alignment: .leading) {
|
||||
if vm.searchQuery.isEmpty {
|
||||
Text(vm.tr("search_placeholder")).foregroundStyle(p.overlay)
|
||||
}
|
||||
TextField("", text: $vm.searchQuery)
|
||||
.foregroundStyle(p.text)
|
||||
.tint(p.accent)
|
||||
}
|
||||
.padding(14)
|
||||
.background(p.mantle, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
PillButton(label: vm.tr("back"), palette: p) { vm.searchOpen = false }
|
||||
}
|
||||
if hits.isEmpty {
|
||||
Text(q.isEmpty ? vm.tr("search_hint") : vm.tr("search_empty")).foregroundStyle(p.subtext)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(Array(hits), id: \.target) { hit in
|
||||
Button {
|
||||
if !hit.locked {
|
||||
vm.goPage(hit.target)
|
||||
vm.searchOpen = false
|
||||
}
|
||||
} label: {
|
||||
CardBox(palette: p) {
|
||||
VStack(alignment: .leading) {
|
||||
Text(hit.title).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text)
|
||||
Text(hit.sub).font(.system(size: 13)).foregroundStyle(p.subtext)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.opacity(hit.locked ? 0.5 : 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(16)
|
||||
.background(p.base.opacity(0.96))
|
||||
}
|
||||
}
|
||||
|
||||
private struct Hit: Identifiable {
|
||||
var id: String { target + title }
|
||||
let title: String
|
||||
let sub: String
|
||||
let target: String
|
||||
let locked: Bool
|
||||
let hay: String
|
||||
}
|
||||
|
||||
private func buildSearch(_ vm: AppModel) -> [Hit] {
|
||||
var out: [Hit] = []
|
||||
func add(_ title: String, _ sub: String, _ target: String, locked: Bool = false, extra: String = "") {
|
||||
out.append(Hit(title: title, sub: sub, target: target, locked: locked, hay: "\(title) \(sub) \(target) \(extra)"))
|
||||
}
|
||||
add(vm.tr("subjects_title"), vm.tr("search_page"), "subjects", extra: "predmety")
|
||||
add(vm.tr("stats"), vm.tr("search_page"), "stats")
|
||||
add(vm.tr("search_home"), vm.tr("search_page"), "welcome")
|
||||
for s in vm.content.subjects {
|
||||
add(vm.tr(s.str("key")), vm.tr("search_subject"), s.strOrNull("target") ?? "subjects", locked: !s.bool("open"))
|
||||
}
|
||||
for u in vm.content.german {
|
||||
let unlocked = u.bool("unlocked")
|
||||
add(u.str("title"), vm.tr("search_unit"), u.strOrNull("page") ?? "roadmap", locked: !unlocked, extra: "deutsch")
|
||||
if unlocked {
|
||||
for (i, name) in u.strs("names").enumerated() {
|
||||
add(name, u.str("title"), "u\(u.int("id") + 1)e\(i + 1)")
|
||||
}
|
||||
if u.has("branch") { add("Vokabeltraining", u.str("title"), u.str("branch"), extra: "vokabel") }
|
||||
}
|
||||
}
|
||||
for l in vm.content.netLessons {
|
||||
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "netunit\(l.int("id"))", extra: "site")
|
||||
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "netex\(l.int("id"))")
|
||||
}
|
||||
for l in vm.content.hw {
|
||||
add(vm.tr(l.str("titleKey")), vm.tr("search_lesson"), "hwunit\(l.int("id"))", extra: "hardware")
|
||||
add(vm.tr(l.str("titleKey")), vm.tr("search_exercise"), "hwex\(l.int("id"))")
|
||||
}
|
||||
add(vm.tr("Mluvnice"), vm.tr("search_lesson"), "mluvnice")
|
||||
add(vm.tr("Maturitní četba"), vm.tr("search_book"), "readinglist")
|
||||
for b in vm.content.books {
|
||||
add(b.str("title"), vm.tr("search_book"), b.str("page"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
struct RoadmapScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some View {
|
||||
let current = vm.content.german.firstIndex { u in
|
||||
u.bool("unlocked") && u.strs("names").indices.contains { !vm.progress.germanDone(u.int("id"), $0 + 1) }
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("roadmap_title"), subtitle: vm.tr("roadmap_sub"))
|
||||
.padding(.horizontal, 12)
|
||||
PathMap(
|
||||
nodes: vm.content.german.enumerated().map { i, u in
|
||||
let unlocked = u.bool("unlocked")
|
||||
let names = u.strs("names")
|
||||
let done = unlocked && !names.isEmpty && names.indices.allSatisfy { vm.progress.germanDone(u.int("id"), $0 + 1) }
|
||||
return MapNode(
|
||||
id: u.str("page"),
|
||||
label: u.str("title"),
|
||||
locked: !unlocked,
|
||||
done: done,
|
||||
current: i == current,
|
||||
finish: i == vm.content.german.count - 1,
|
||||
onClick: unlocked ? { vm.go(.unitMap(u.int("id"))) } : nil
|
||||
)
|
||||
},
|
||||
palette: vm.palette
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UnitMapScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let unitId: Int
|
||||
|
||||
var body: some View {
|
||||
if let u = vm.content.germanUnit(unitId) {
|
||||
let names = u.strs("names")
|
||||
let next = names.indices.first { !vm.progress.germanDone(unitId, $0 + 1) }
|
||||
VStack(spacing: 0) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: u.str("title"), subtitle: vm.tr(u.str("sub")))
|
||||
.padding(.horizontal, 12)
|
||||
PathMap(
|
||||
nodes: names.enumerated().map { i, name in
|
||||
MapNode(
|
||||
id: "e\(i + 1)",
|
||||
label: name,
|
||||
locked: false,
|
||||
done: vm.progress.germanDone(unitId, i + 1),
|
||||
current: i == next,
|
||||
onClick: { vm.go(.germanEx(unitId, i + 1)) }
|
||||
)
|
||||
},
|
||||
palette: vm.palette
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
if u.has("branch") {
|
||||
PrimaryButton(label: vm.tr("Vokabeltraining"), palette: vm.palette) { vm.go(.vocab(unitId)) }
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NetYearsScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("net_years_title"), subtitle: vm.tr("net_years_sub"))
|
||||
.padding(.horizontal, 16)
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
yearCard(vm.tr("net_year1"), vm.tr("net_sub"), true) { vm.go(.netMap) }
|
||||
ForEach(["net_year2", "net_year3", "net_year4"], id: \.self) { key in
|
||||
yearCard(vm.tr(key), vm.tr("net_year_locked_sub"), false) {}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func yearCard(_ title: String, _ sub: String, _ open: Bool, _ action: @escaping () -> Void) -> some View {
|
||||
let p = vm.palette
|
||||
return Button(action: action) {
|
||||
CardBox(palette: p) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(title).font(.system(size: 18, weight: .bold)).foregroundStyle(p.text)
|
||||
Text(sub).font(.system(size: 14)).foregroundStyle(p.subtext)
|
||||
}
|
||||
Spacer()
|
||||
if !open { LockIcon(color: p.overlay) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!open)
|
||||
.opacity(open ? 1 : 0.48)
|
||||
}
|
||||
}
|
||||
|
||||
struct LessonMapScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
let sub: String
|
||||
let nodes: [MapNode]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
|
||||
.padding(.horizontal, 12)
|
||||
PathMap(nodes: nodes, palette: vm.palette)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SlidesScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let title: String
|
||||
let sub: String
|
||||
let slides: [J]
|
||||
var onExercise: () -> Void
|
||||
@State private var idx = 0
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
if let slide = slides.indices.contains(idx) ? slides[idx] : nil {
|
||||
VStack(spacing: 0) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: title, subtitle: sub)
|
||||
.padding(.horizontal, 16)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(slide.str("kicker")).font(.system(size: 12, weight: .semibold)).foregroundStyle(p.subtext)
|
||||
Text(slide.str("title")).font(.system(size: 24, weight: .bold)).foregroundStyle(p.text)
|
||||
if let tip = slide.strOrNull("tip") {
|
||||
CardBox(palette: p) { Text(tip).foregroundStyle(p.text) }
|
||||
}
|
||||
ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
|
||||
CardBox(palette: p) { Text(line).font(.system(size: 16)).foregroundStyle(p.text) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
HStack {
|
||||
if idx > 0 {
|
||||
PillButton(label: vm.tr("net_slide_prev"), palette: p) { idx -= 1 }
|
||||
}
|
||||
Spacer()
|
||||
if idx < slides.count - 1 {
|
||||
PrimaryButton(label: vm.tr("net_slide_next"), palette: p) { idx += 1 }
|
||||
} else {
|
||||
PrimaryButton(label: vm.tr("net_slide_start"), palette: p, action: onExercise)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CzechMapScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
let items: [(String, String, Route?)] = [
|
||||
("L", vm.tr("Literatura"), nil),
|
||||
("M", vm.tr("Mluvnice"), .mluvnice),
|
||||
("Č", vm.tr("Maturitní četba"), .readingList),
|
||||
]
|
||||
VStack(spacing: 0) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Český jazyk a literatura"), subtitle: vm.tr("czech_sub"))
|
||||
.padding(.horizontal, 16)
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(items, id: \.0) { letter, name, route in
|
||||
Button {
|
||||
if let route { vm.go(route) }
|
||||
} label: {
|
||||
CardBox(palette: p) {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle().fill(route == nil ? p.lockedBg : p.accent).frame(width: 56, height: 56)
|
||||
if route == nil {
|
||||
LockIcon(color: p.overlay)
|
||||
} else {
|
||||
Text(letter).font(.system(size: 20, weight: .heavy)).foregroundStyle(p.onAccent)
|
||||
}
|
||||
}
|
||||
Text(name).font(.system(size: 16, weight: .semibold)).foregroundStyle(p.text)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(route == nil)
|
||||
.opacity(route == nil ? 0.48 : 1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BookListScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
VStack(spacing: 0) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: vm.tr("Maturitní četba"), subtitle: vm.tr("reading_sub"))
|
||||
.padding(.horizontal, 16)
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
|
||||
Button { vm.go(.book(b.str("id"))) } label: {
|
||||
CardBox(palette: p) {
|
||||
HStack(spacing: 12) {
|
||||
BookIcon(color: p.text, size: 28)
|
||||
VStack(alignment: .leading) {
|
||||
Text(b.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
|
||||
Text(vm.tr(b.str("subKey"))).font(.system(size: 13)).foregroundStyle(p.subtext)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BookScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let id: String
|
||||
|
||||
var body: some View {
|
||||
if let b = vm.content.book(id) {
|
||||
let p = vm.palette
|
||||
VStack(spacing: 0) {
|
||||
PageTop(vm: vm, back: { vm.back() }, title: b.str("title"), subtitle: vm.tr(b.str("subKey")))
|
||||
.padding(.horizontal, 16)
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
Button { vm.go(.bookQuiz(id)) } label: {
|
||||
CardBox(palette: p) {
|
||||
HStack(spacing: 10) {
|
||||
QuizIcon(color: p.text)
|
||||
VStack(alignment: .leading) {
|
||||
Text(vm.tr(b.str("quizTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
|
||||
Text(vm.tr(b.str("quizSub"))).font(.system(size: 13)).foregroundStyle(p.subtext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Button { vm.go(.bookPlot(id)) } label: {
|
||||
CardBox(palette: p) {
|
||||
HStack(spacing: 10) {
|
||||
OrderIcon(color: p.text)
|
||||
VStack(alignment: .leading) {
|
||||
Text(vm.tr(b.str("plotTitle"))).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
|
||||
Text(vm.tr(b.str("plotSub"))).font(.system(size: 13)).foregroundStyle(p.subtext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
ForEach(Array(b.arr("notes").enumerated()), id: \.offset) { _, note in
|
||||
CardBox(palette: p) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
noteIcon(note.str("icon"), p.text)
|
||||
Text(note.str("title")).font(.system(size: 16, weight: .bold)).foregroundStyle(p.text)
|
||||
}
|
||||
ForEach(note.strs("lines"), id: \.self) { line in
|
||||
Text("• \(line)").font(.system(size: 15)).foregroundStyle(p.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue