mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
698 lines
29 KiB
Swift
698 lines
29 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|