Graduately/ios/Maturita/UI/Screens.swift
pepaskopec-blip 0621311b4c Rework the mobile UIs on native components and fix layout bugs everywhere.
Android moves to Material 3 (navigation bar, app bars, list items,
bottom sheets, pager) with a real back stack. iOS hides the tab bar in
exercises so the bottom action is reachable, aligns content, and uses
system colours so themes and dark mode work. Both pick a readable
accent tint. Desktop keeps the VLSM table inside the window and gives
statistics the same header rhythm as other pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 15:37:15 +02:00

552 lines
20 KiB
Swift

import SwiftUI
struct PracticeHome: View {
@ObservedObject var vm: AppModel
var body: some View {
let sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx)
List {
Section {
GlassCard {
VStack(alignment: .leading, spacing: 10) {
Text(vm.tr("welcome_title"))
.font(.title.bold())
Text(vm.tr("welcome_body_ios"))
.font(.subheadline)
.foregroundStyle(.secondary)
ProgressView(value: pct)
Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
Section(vm.tr("subjects_title")) {
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
let open = s.bool("open")
let dest = s.strOrNull("target").flatMap(routeFromPage)
if open, let dest, dest != .subjects, dest != .welcome {
NavigationLink(value: dest) {
subjectLabel(s)
}
} else {
subjectLabel(s)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Maturita")
.navigationBarTitleDisplayMode(.large)
}
private func subjectLabel(_ s: J) -> some View {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(vm.tr(s.str("key")))
if !s.bool("open") {
Text(vm.tr("stats_locked")).font(.caption).foregroundStyle(.secondary)
}
}
} icon: {
SubjectIcon(icon: s.str("icon"), open: s.bool("open"))
}
}
}
struct SettingsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
Form {
Section(vm.tr("mode")) {
Picker(vm.tr("mode"), selection: modeBinding) {
Text(vm.tr("mode_dark")).tag(ColorMode.dark)
Text(vm.tr("mode_light")).tag(ColorMode.light)
}
.pickerStyle(.segmented)
}
Section(vm.tr("theme")) {
ForEach(Array(ThemeId.allCases), id: \.self) { id in
Button {
vm.setTheme(id)
} label: {
HStack {
Circle().fill(themePalette(id, vm.mode).tint).frame(width: 14, height: 14)
Text(themeNames[id.rawValue]).foregroundStyle(.primary)
Spacer()
if vm.themeId == id {
Image(systemName: "checkmark").fontWeight(.semibold)
}
}
}
}
}
Section(vm.tr("language")) {
Picker(vm.tr("language"), selection: langBinding) {
Text("Čeština").tag(UiLang.cs)
Text("English").tag(UiLang.en)
}
.pickerStyle(.segmented)
}
Section(vm.tr("updates")) {
Text(updateText).foregroundStyle(.secondary)
Button(vm.tr("update_check")) { vm.checkUpdate(true) }
if vm.update.canInstall {
Button(vm.tr("update_install")) { vm.installUpdate() }
}
LabeledContent("Build", value: "\(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))")
}
}
.navigationTitle(vm.tr("settings_title"))
}
private var modeBinding: Binding<ColorMode> {
Binding(get: { vm.mode }, set: { vm.applyMode($0) })
}
private var langBinding: Binding<UiLang> {
Binding(get: { vm.lang }, set: { vm.applyLang($0) })
}
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 sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
List {
Section {
GlassCard {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
stat(vm.tr("stats_ex_label"), vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
stat(vm.tr("stats_pct_label"), vm.fmt("stats_pct_fmt", Int(pct * 100)))
stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
}
ProgressView(value: pct)
}
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
Section(vm.tr("stats_section")) {
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in
let open = s.bool("open")
let part = subjectSum(vm, s)
Label {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(vm.tr(s.str("key")))
Spacer()
Text(open ? vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx) : vm.tr("stats_locked"))
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
if open && part.totalEx > 0 {
ProgressView(value: Double(part.doneEx) / Double(part.totalEx))
}
}
} icon: {
SubjectIcon(icon: s.str("icon"), open: open)
}
.foregroundStyle(open ? .primary : .secondary)
}
}
}
.navigationTitle(vm.tr("stats_title"))
}
private func stat(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.caption).foregroundStyle(.secondary)
Text(value).font(.title3.bold()).monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
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 SearchScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
let q = normalizeAnswer(vm.searchQuery)
let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) }
List {
if hits.isEmpty {
ContentUnavailableView.search
} else {
ForEach(hits) { hit in
Button {
if !hit.locked { vm.openFromSearch(hit.target) }
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(hit.title)
Text(hit.sub).font(.caption).foregroundStyle(.secondary)
}
}
.disabled(hit.locked)
.foregroundStyle(hit.locked ? .secondary : .primary)
}
}
}
.navigationTitle(vm.tr("search_placeholder"))
.searchable(text: $vm.searchQuery, prompt: vm.tr("search_placeholder"))
}
}
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 {
List {
ForEach(Array(vm.content.german.enumerated()), id: \.offset) { _, 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) }
if unlocked {
NavigationLink(value: Route.unitMap(u.int("id"))) {
Label {
Text(u.str("title"))
} icon: {
Image(systemName: done ? "checkmark.circle.fill" : "circle")
}
}
} else {
Label(u.str("title"), systemImage: "lock.fill")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle(vm.tr("roadmap_title"))
.navigationSubtitle(vm.tr("roadmap_sub"))
}
}
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")
List {
ForEach(names.indices, id: \.self) { i in
NavigationLink(value: Route.germanEx(unitId, i + 1)) {
Label {
Text(names[i])
} icon: {
Image(systemName: vm.progress.germanDone(unitId, i + 1) ? "checkmark.circle.fill" : "circle")
}
}
}
if u.has("branch") {
NavigationLink(value: Route.vocab(unitId)) {
Label(vm.tr("Vokabeltraining"), systemImage: "character.book.closed")
}
}
}
.navigationTitle(u.str("title"))
.navigationSubtitle(vm.tr(u.str("sub")))
}
}
}
struct NetYearsScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
List {
NavigationLink(value: Route.netMap) {
VStack(alignment: .leading) {
Text(vm.tr("net_year1"))
Text(vm.tr("net_sub")).font(.caption).foregroundStyle(.secondary)
}
}
ForEach(["net_year2", "net_year3", "net_year4"], id: \.self) { key in
Label {
VStack(alignment: .leading) {
Text(vm.tr(key))
Text(vm.tr("net_year_locked_sub")).font(.caption)
}
} icon: {
Image(systemName: "lock.fill")
}
.foregroundStyle(.secondary)
}
}
.navigationTitle(vm.tr("net_years_title"))
.navigationSubtitle(vm.tr("net_years_sub"))
}
}
struct LessonRow: Identifiable {
let id: String
let title: String
let done: Bool
let destination: Route
}
struct LessonListScreen: View {
let title: String
let subtitle: String
let rows: [LessonRow]
var body: some View {
List(rows) { row in
NavigationLink(value: row.destination) {
Label {
Text(row.title)
} icon: {
Image(systemName: row.done ? "checkmark.circle.fill" : "circle")
}
}
}
.navigationTitle(title)
.navigationSubtitle(subtitle)
}
}
struct SlidesScreen: View {
@ObservedObject var vm: AppModel
let title: String
let subtitle: String
let slides: [J]
let next: Route
@State private var idx = 0
var body: some View {
TabView(selection: $idx) {
ForEach(slides.indices, id: \.self) { i in
let slide = slides[i]
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text(slide.str("kicker"))
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(slide.str("title")).font(.title2.bold())
if let tip = slide.strOrNull("tip") {
GlassCard {
Label(tip, systemImage: "lightbulb.fill")
}
}
ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
Text(line).padding(.vertical, 4)
}
}
.padding()
.padding(.bottom, 24)
.frame(maxWidth: .infinity, alignment: .leading)
}
.tag(i)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.detailScreen(title, subtitle: subtitle)
.safeAreaInset(edge: .bottom) {
VStack(spacing: 10) {
HStack(spacing: 6) {
ForEach(slides.indices, id: \.self) { i in
Capsule()
.fill(i == idx ? Color.accentColor : Color(.tertiarySystemFill))
.frame(width: i == idx ? 18 : 6, height: 6)
}
}
.animation(.default, value: idx)
HStack(spacing: 10) {
if idx > 0 {
Button {
withAnimation { idx -= 1 }
} label: {
Image(systemName: "chevron.left")
.frame(minWidth: 24)
}
.buttonStyle(.glass)
.controlSize(.large)
}
if idx < slides.count - 1 {
PrimaryButton(label: vm.tr("net_slide_next")) {
withAnimation { idx += 1 }
}
} else {
PrimaryButton(label: vm.tr("net_slide_start")) {
vm.go(next)
}
}
}
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 4)
.background(.bar)
}
}
}
struct CzechMapScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
List {
Label(vm.tr("Literatura"), systemImage: "lock.fill")
.foregroundStyle(.secondary)
NavigationLink(value: Route.mluvnice) {
Label(vm.tr("Mluvnice"), systemImage: "textformat")
}
NavigationLink(value: Route.readingList) {
Label(vm.tr("Maturitní četba"), systemImage: "books.vertical")
}
}
.navigationTitle(vm.tr("Český jazyk a literatura"))
.navigationSubtitle(vm.tr("czech_sub"))
}
}
struct BookListScreen: View {
@ObservedObject var vm: AppModel
var body: some View {
List {
ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
NavigationLink(value: Route.book(b.str("id"))) {
Label {
VStack(alignment: .leading) {
Text(b.str("title"))
Text(vm.tr(b.str("subKey"))).font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "book.fill")
}
}
}
}
.navigationTitle(vm.tr("Maturitní četba"))
.navigationSubtitle(vm.tr("reading_sub"))
}
}
struct BookScreen: View {
@ObservedObject var vm: AppModel
let id: String
var body: some View {
if let b = vm.content.book(id) {
List {
NavigationLink(value: Route.bookQuiz(id)) {
Label {
VStack(alignment: .leading) {
Text(vm.tr(b.str("quizTitle")))
Text(vm.tr(b.str("quizSub"))).font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "questionmark.circle")
}
}
NavigationLink(value: Route.bookPlot(id)) {
Label {
VStack(alignment: .leading) {
Text(vm.tr(b.str("plotTitle")))
Text(vm.tr(b.str("plotSub"))).font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "list.number")
}
}
ForEach(Array(b.arr("notes").enumerated()), id: \.offset) { _, note in
Section(note.str("title")) {
ForEach(note.strs("lines"), id: \.self) { line in
Text(line)
}
}
}
}
.navigationTitle(b.str("title"))
.navigationSubtitle(vm.tr(b.str("subKey")))
}
}
}