mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
Add a native SwiftUI macOS app that shares the iOS player.
The GTK Mac build stays; CI now also publishes maturita-macos-swift. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
11eb14e136
commit
58e3a75f21
33 changed files with 1415 additions and 128 deletions
|
|
@ -85,93 +85,3 @@ private struct UpdateAccessory: View {
|
|||
return vm.tr(vm.update.messageKey)
|
||||
}
|
||||
}
|
||||
|
||||
struct RouteDestination: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let route: Route
|
||||
|
||||
var body: some View {
|
||||
switch route {
|
||||
case .welcome, .subjects:
|
||||
PracticeHome(vm: vm)
|
||||
case .stats:
|
||||
StatsScreen(vm: vm)
|
||||
case .roadmap:
|
||||
RoadmapScreen(vm: vm)
|
||||
case .unitMap(let id):
|
||||
UnitMapScreen(vm: vm, unitId: id)
|
||||
case .germanEx(let unit, let ex):
|
||||
GermanExercise(vm: vm, unitId: unit, ex: ex)
|
||||
case .vocab(let id):
|
||||
VocabExercise(vm: vm, unitId: id)
|
||||
case .netYears:
|
||||
NetYearsScreen(vm: vm)
|
||||
case .netMap:
|
||||
LessonListScreen(
|
||||
title: vm.tr("net_year1"),
|
||||
subtitle: vm.tr("net_sub"),
|
||||
rows: vm.content.netLessons.map { l in
|
||||
let id = l.int("id")
|
||||
return LessonRow(
|
||||
id: "n\(id)",
|
||||
title: vm.tr(l.str("titleKey")),
|
||||
done: vm.progress.netDone(id),
|
||||
destination: .netLesson(id)
|
||||
)
|
||||
}
|
||||
)
|
||||
case .netLesson(let id):
|
||||
if let l = vm.content.netLesson(id) {
|
||||
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .netEx(id))
|
||||
}
|
||||
case .netEx(let id):
|
||||
NetQuizScreen(vm: vm, id: id)
|
||||
case .hwMap:
|
||||
LessonListScreen(
|
||||
title: vm.tr("Technické vybavení"),
|
||||
subtitle: vm.tr("hw_sub"),
|
||||
rows: vm.content.hw.map { l in
|
||||
let id = l.int("id")
|
||||
return LessonRow(
|
||||
id: "h\(id)",
|
||||
title: vm.tr(l.str("titleKey")),
|
||||
done: vm.progress.hwDone(id),
|
||||
destination: .hwLesson(id)
|
||||
)
|
||||
}
|
||||
)
|
||||
case .hwLesson(let id):
|
||||
if let l = vm.content.hwLesson(id) {
|
||||
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .hwEx(id))
|
||||
}
|
||||
case .hwEx(let id):
|
||||
HwQuizScreen(vm: vm, id: id)
|
||||
case .czechMap:
|
||||
CzechMapScreen(vm: vm)
|
||||
case .mluvnice:
|
||||
LessonListScreen(
|
||||
title: vm.tr("Mluvnice"),
|
||||
subtitle: vm.tr("mluv_sub"),
|
||||
rows: vm.content.mluvnice.map { m in
|
||||
let n = m.int("id")
|
||||
return LessonRow(
|
||||
id: "m\(n)",
|
||||
title: m.str("name"),
|
||||
done: vm.progress.mluvDone(n),
|
||||
destination: .mluvEx(n)
|
||||
)
|
||||
}
|
||||
)
|
||||
case .mluvEx(let n):
|
||||
MluvExercise(vm: vm, n: n)
|
||||
case .readingList:
|
||||
BookListScreen(vm: vm)
|
||||
case .book(let id):
|
||||
BookScreen(vm: vm, id: id)
|
||||
case .bookQuiz(let id):
|
||||
LitQuizScreen(vm: vm, id: id)
|
||||
case .bookPlot(let id):
|
||||
PlotScreen(vm: vm, id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ struct PrimaryButton: View {
|
|||
.font(.body.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
#if os(iOS)
|
||||
.buttonStyle(.glassProminent)
|
||||
#else
|
||||
.buttonStyle(.borderedProminent)
|
||||
#endif
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
|
|
@ -250,17 +254,39 @@ extension View {
|
|||
|
||||
/// Detail screens with their own bottom action hide the tab bar so the
|
||||
/// primary button is never covered by it.
|
||||
@ViewBuilder
|
||||
func detailScreen(_ title: String, subtitle: String? = nil) -> some View {
|
||||
#if os(iOS)
|
||||
navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navSubtitle(subtitle)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
#else
|
||||
navigationTitle(title)
|
||||
.navSubtitle(subtitle)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func sheetDetents() -> some View {
|
||||
#if os(iOS)
|
||||
presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Full-width, leading-aligned content for exercise screens.
|
||||
func exerciseContent() -> some View {
|
||||
#if os(macOS)
|
||||
frame(maxWidth: 720, alignment: .leading)
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#else
|
||||
frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -304,7 +304,7 @@ private struct ComboLine: View {
|
|||
if let word, idx < picks.count { picks[idx] = word }
|
||||
open = false
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.sheetDetents()
|
||||
}
|
||||
if revealed, let meaning, !meaning.isEmpty {
|
||||
Meaning(text: vm.tr(meaning), palette: p, visible: true)
|
||||
|
|
@ -334,8 +334,7 @@ struct WordPicker: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.sheetDetents()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -781,7 +780,7 @@ private struct PrefixMenu: View {
|
|||
if let word { value = word }
|
||||
open = false
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.sheetDetents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ struct PracticeHome: View {
|
|||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(vm.tr("welcome_title"))
|
||||
.font(.title.bold())
|
||||
Text(vm.tr("welcome_body_ios"))
|
||||
Text(vm.tr(Self.welcomeBodyKey))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
ProgressView(value: pct)
|
||||
|
|
@ -50,7 +50,17 @@ struct PracticeHome: View {
|
|||
}
|
||||
}
|
||||
.navigationTitle("Maturita")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
#endif
|
||||
}
|
||||
|
||||
private static var welcomeBodyKey: String {
|
||||
#if os(macOS)
|
||||
"welcome_body_macos"
|
||||
#else
|
||||
"welcome_body_ios"
|
||||
#endif
|
||||
}
|
||||
|
||||
private func subjectLabel(_ s: J) -> some View {
|
||||
|
|
@ -458,32 +468,20 @@ struct SlidesScreen: View {
|
|||
@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)
|
||||
Group {
|
||||
#if os(iOS)
|
||||
TabView(selection: $idx) {
|
||||
ForEach(slides.indices, id: \.self) { i in
|
||||
slidePage(slides[i]).tag(i)
|
||||
}
|
||||
.tag(i)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
#else
|
||||
if slides.indices.contains(idx) {
|
||||
slidePage(slides[idx])
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.detailScreen(title, subtitle: subtitle)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(spacing: 10) {
|
||||
|
|
@ -503,7 +501,11 @@ struct SlidesScreen: View {
|
|||
Image(systemName: "chevron.left")
|
||||
.frame(minWidth: 24)
|
||||
}
|
||||
#if os(iOS)
|
||||
.buttonStyle(.glass)
|
||||
#else
|
||||
.buttonStyle(.bordered)
|
||||
#endif
|
||||
.controlSize(.large)
|
||||
}
|
||||
if idx < slides.count - 1 {
|
||||
|
|
@ -523,6 +525,31 @@ struct SlidesScreen: View {
|
|||
.background(.bar)
|
||||
}
|
||||
}
|
||||
|
||||
private func slidePage(_ slide: J) -> some View {
|
||||
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)
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 720, alignment: .leading)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CzechMapScreen: View {
|
||||
|
|
@ -607,3 +634,93 @@ struct BookScreen: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RouteDestination: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
let route: Route
|
||||
|
||||
var body: some View {
|
||||
switch route {
|
||||
case .welcome, .subjects:
|
||||
PracticeHome(vm: vm)
|
||||
case .stats:
|
||||
StatsScreen(vm: vm)
|
||||
case .roadmap:
|
||||
RoadmapScreen(vm: vm)
|
||||
case .unitMap(let id):
|
||||
UnitMapScreen(vm: vm, unitId: id)
|
||||
case .germanEx(let unit, let ex):
|
||||
GermanExercise(vm: vm, unitId: unit, ex: ex)
|
||||
case .vocab(let id):
|
||||
VocabExercise(vm: vm, unitId: id)
|
||||
case .netYears:
|
||||
NetYearsScreen(vm: vm)
|
||||
case .netMap:
|
||||
LessonListScreen(
|
||||
title: vm.tr("net_year1"),
|
||||
subtitle: vm.tr("net_sub"),
|
||||
rows: vm.content.netLessons.map { l in
|
||||
let id = l.int("id")
|
||||
return LessonRow(
|
||||
id: "n\(id)",
|
||||
title: vm.tr(l.str("titleKey")),
|
||||
done: vm.progress.netDone(id),
|
||||
destination: .netLesson(id)
|
||||
)
|
||||
}
|
||||
)
|
||||
case .netLesson(let id):
|
||||
if let l = vm.content.netLesson(id) {
|
||||
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .netEx(id))
|
||||
}
|
||||
case .netEx(let id):
|
||||
NetQuizScreen(vm: vm, id: id)
|
||||
case .hwMap:
|
||||
LessonListScreen(
|
||||
title: vm.tr("Technické vybavení"),
|
||||
subtitle: vm.tr("hw_sub"),
|
||||
rows: vm.content.hw.map { l in
|
||||
let id = l.int("id")
|
||||
return LessonRow(
|
||||
id: "h\(id)",
|
||||
title: vm.tr(l.str("titleKey")),
|
||||
done: vm.progress.hwDone(id),
|
||||
destination: .hwLesson(id)
|
||||
)
|
||||
}
|
||||
)
|
||||
case .hwLesson(let id):
|
||||
if let l = vm.content.hwLesson(id) {
|
||||
SlidesScreen(vm: vm, title: vm.tr(l.str("titleKey")), subtitle: vm.tr(l.str("subKey")), slides: l.arr("slides"), next: .hwEx(id))
|
||||
}
|
||||
case .hwEx(let id):
|
||||
HwQuizScreen(vm: vm, id: id)
|
||||
case .czechMap:
|
||||
CzechMapScreen(vm: vm)
|
||||
case .mluvnice:
|
||||
LessonListScreen(
|
||||
title: vm.tr("Mluvnice"),
|
||||
subtitle: vm.tr("mluv_sub"),
|
||||
rows: vm.content.mluvnice.map { m in
|
||||
let n = m.int("id")
|
||||
return LessonRow(
|
||||
id: "m\(n)",
|
||||
title: m.str("name"),
|
||||
done: vm.progress.mluvDone(n),
|
||||
destination: .mluvEx(n)
|
||||
)
|
||||
}
|
||||
)
|
||||
case .mluvEx(let n):
|
||||
MluvExercise(vm: vm, n: n)
|
||||
case .readingList:
|
||||
BookListScreen(vm: vm)
|
||||
case .book(let id):
|
||||
BookScreen(vm: vm, id: id)
|
||||
case .bookQuiz(let id):
|
||||
LitQuizScreen(vm: vm, id: id)
|
||||
case .bookPlot(let id):
|
||||
PlotScreen(vm: vm, id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue