mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
- content/ + scripts/build-content.py produce content.json for every app - stable lesson IDs with one-time progress migration (Swift, Kotlin) - shared answer-check tests (tests/answers.json) for Kotlin and Swift - android/desktop: Compose app with updater, speech and GTK progress import - CI packages AppImage and Windows zip with ProGuard, runs the tests Co-authored-by: Cursor <cursoragent@cursor.com>
300 lines
7.5 KiB
Swift
300 lines
7.5 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
final class AppModel: ObservableObject {
|
|
let content: Content
|
|
let progress: ProgressStore
|
|
let updater = Updater()
|
|
|
|
@Published var themeId: ThemeId
|
|
@Published var mode: ColorMode
|
|
@Published var lang: UiLang
|
|
@Published var tab: AppTab = .practice
|
|
@Published var practicePath = NavigationPath()
|
|
/// Bumps whenever a full path replace is scheduled, so a queued push cannot land afterwards.
|
|
private var pathGeneration = 0
|
|
@Published var searchQuery = ""
|
|
@Published var tick = 0
|
|
@Published var update = UpdateState()
|
|
@Published var showChangelog = false
|
|
|
|
init() {
|
|
let content = Content.load()
|
|
self.content = content
|
|
progress = ProgressStore(content: content)
|
|
themeId = progress.themeId
|
|
mode = progress.mode
|
|
lang = progress.lang
|
|
showChangelog = !content.changelog.isEmpty && !sameBuild(AppConfig.commit, progress.seenCommit)
|
|
#if DEBUG
|
|
// `xcrun simctl launch <udid> com.bukovinafilip.maturita -route u1e3` opens a page for screenshots.
|
|
if let page = UserDefaults.standard.string(forKey: "route"), let r = routeFromPage(page) {
|
|
openFromSearch(r.page)
|
|
}
|
|
if let theme = UserDefaults.standard.string(forKey: "theme"), let n = Int(theme), let id = ThemeId(rawValue: n) {
|
|
themeId = id
|
|
}
|
|
if let m = UserDefaults.standard.string(forKey: "mode") {
|
|
mode = m == "dark" ? .dark : .light
|
|
}
|
|
#endif
|
|
}
|
|
|
|
var palette: Palette { themePalette(themeId, mode) }
|
|
|
|
func tr(_ key: String?) -> String { content.tr(key, lang: lang) }
|
|
|
|
func fmt(_ key: String, _ args: CVarArg...) -> String {
|
|
content.fmt(key, lang: lang, args)
|
|
}
|
|
|
|
func go(_ r: Route) {
|
|
switch r {
|
|
case .stats:
|
|
tab = .progress
|
|
case .welcome, .subjects:
|
|
showPracticeRoot()
|
|
default:
|
|
applyPracticeRoute(r, replace: practicePath.isEmpty)
|
|
}
|
|
}
|
|
|
|
func showPracticeRoot() {
|
|
tab = .practice
|
|
schedulePracticePath(NavigationPath())
|
|
}
|
|
|
|
func goPage(_ page: String?) {
|
|
guard let page, let r = routeFromPage(page) else { return }
|
|
go(r)
|
|
}
|
|
|
|
func openFromSearch(_ page: String) {
|
|
guard let r = routeFromPage(page) else { return }
|
|
switch r {
|
|
case .stats:
|
|
tab = .progress
|
|
case .welcome, .subjects:
|
|
showPracticeRoot()
|
|
default:
|
|
// Switching tabs and assigning a deep NavigationStack path in the
|
|
// same turn crashes SwiftUI on macOS (boundPathChange / unexpectedError).
|
|
applyPracticeRoute(r, replace: true)
|
|
}
|
|
}
|
|
|
|
/// Sets the practice tab path safely, especially when jumping from Search.
|
|
///
|
|
/// The sidebar-style tab view keeps one navigation column for every tab.
|
|
/// A typed `[Route]` path does not match the other tabs, so the column
|
|
/// traps in `boundPathChange` while an exercise is being opened. The path
|
|
/// is therefore type-erased, and a push is applied on the next turn instead
|
|
/// of replacing the whole stack during the click.
|
|
private func applyPracticeRoute(_ r: Route, replace: Bool) {
|
|
let switching = tab != .practice
|
|
tab = .practice
|
|
if switching || replace {
|
|
schedulePracticePath(NavigationPath(r.stack))
|
|
} else {
|
|
let generation = pathGeneration
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, self.pathGeneration == generation else { return }
|
|
self.practicePath.append(r)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func schedulePracticePath(_ path: NavigationPath) {
|
|
pathGeneration += 1
|
|
practicePath = NavigationPath()
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.practicePath = path
|
|
}
|
|
}
|
|
|
|
func setTheme(_ id: ThemeId) {
|
|
withAnimation(Motion.settle) { themeId = id }
|
|
progress.themeId = id
|
|
}
|
|
|
|
func applyMode(_ m: ColorMode) {
|
|
withAnimation(Motion.settle) { mode = m }
|
|
progress.mode = m
|
|
}
|
|
|
|
func applyLang(_ l: UiLang) {
|
|
lang = l
|
|
progress.lang = l
|
|
}
|
|
|
|
func refresh() { tick += 1 }
|
|
|
|
func markGerman(_ unit: Int, _ ex: Int) {
|
|
progress.markGerman(unit, ex)
|
|
refresh()
|
|
}
|
|
|
|
func markVocab(_ unit: Int) {
|
|
progress.markVocab(unit)
|
|
refresh()
|
|
}
|
|
|
|
func markNet(_ id: Int) {
|
|
progress.markNet(id)
|
|
refresh()
|
|
}
|
|
|
|
func markHw(_ id: Int) {
|
|
progress.markHw(id)
|
|
refresh()
|
|
}
|
|
|
|
func markOn(_ id: Int) {
|
|
progress.markOn(id)
|
|
refresh()
|
|
}
|
|
|
|
func markOn2(_ id: Int) {
|
|
progress.markOn2(id)
|
|
refresh()
|
|
}
|
|
|
|
func markOn3(_ id: Int) {
|
|
progress.markOn3(id)
|
|
refresh()
|
|
}
|
|
|
|
func markOn4(_ id: Int) {
|
|
progress.markOn4(id)
|
|
refresh()
|
|
}
|
|
|
|
func markEn(_ year: Int, _ id: Int) {
|
|
progress.markEn(year, id)
|
|
refresh()
|
|
}
|
|
|
|
func markDe(_ year: Int, _ id: Int) {
|
|
progress.markDe(year, id)
|
|
refresh()
|
|
}
|
|
|
|
func markCourse(_ course: String, _ id: Int) {
|
|
progress.markCourse(course, id)
|
|
refresh()
|
|
}
|
|
|
|
func markLit(_ id: Int) {
|
|
progress.markLit(id)
|
|
refresh()
|
|
}
|
|
|
|
func markLit2(_ id: Int) {
|
|
progress.markLit2(id)
|
|
refresh()
|
|
}
|
|
|
|
func markLit3(_ id: Int) {
|
|
progress.markLit3(id)
|
|
refresh()
|
|
}
|
|
|
|
func markLit4(_ id: Int) {
|
|
progress.markLit4(id)
|
|
refresh()
|
|
}
|
|
|
|
func markChem(_ id: Int) {
|
|
progress.markChem(id)
|
|
refresh()
|
|
}
|
|
|
|
func markBio(_ id: Int) {
|
|
progress.markBio(id)
|
|
refresh()
|
|
}
|
|
|
|
func markFyz(_ id: Int) {
|
|
progress.markFyz(id)
|
|
refresh()
|
|
}
|
|
|
|
func markFyz2(_ id: Int) {
|
|
progress.markFyz2(id)
|
|
refresh()
|
|
}
|
|
|
|
func markFyz3(_ id: Int) {
|
|
progress.markFyz3(id)
|
|
refresh()
|
|
}
|
|
|
|
func markFyz4(_ id: Int) {
|
|
progress.markFyz4(id)
|
|
refresh()
|
|
}
|
|
|
|
func markMat0(_ id: Int) {
|
|
progress.markMat0(id)
|
|
refresh()
|
|
}
|
|
|
|
func markMat(_ id: Int) {
|
|
progress.markMat(id)
|
|
refresh()
|
|
}
|
|
|
|
func markMat2(_ id: Int) {
|
|
progress.markMat2(id)
|
|
refresh()
|
|
}
|
|
|
|
func markMat3(_ id: Int) {
|
|
progress.markMat3(id)
|
|
refresh()
|
|
}
|
|
|
|
func markMat4(_ id: Int) {
|
|
progress.markMat4(id)
|
|
refresh()
|
|
}
|
|
|
|
func markMluv(_ n: Int) {
|
|
progress.markMluv(n)
|
|
refresh()
|
|
}
|
|
|
|
func markBookQuiz(_ id: String) {
|
|
progress.markBookQuiz(id)
|
|
refresh()
|
|
}
|
|
|
|
func markBookPlot(_ id: String) {
|
|
progress.markBookPlot(id)
|
|
refresh()
|
|
}
|
|
|
|
func checkUpdate(_ interactive: Bool) {
|
|
updater.check(interactive: interactive) { [weak self] state in
|
|
self?.update = state
|
|
}
|
|
}
|
|
|
|
func installUpdate() {
|
|
updater.install { [weak self] state in
|
|
self?.update = state
|
|
}
|
|
}
|
|
|
|
func dismissChangelog() {
|
|
progress.seenCommit = AppConfig.commit
|
|
withAnimation(Motion.settle) { showChangelog = false }
|
|
}
|
|
}
|
|
|
|
func sameBuild(_ a: String?, _ b: String?) -> Bool {
|
|
guard let a, let b, !a.isEmpty, !b.isEmpty else { return false }
|
|
if a.caseInsensitiveCompare(b) == .orderedSame { return true }
|
|
let n = min(a.count, b.count)
|
|
return n >= 7 && a.prefix(n).caseInsensitiveCompare(b.prefix(n)) == .orderedSame
|
|
}
|