Fill the macOS window so cards, lessons, and quizzes use the free space.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-03 15:28:21 +02:00
commit 630142b57d
5 changed files with 786 additions and 118 deletions

View file

@ -2,14 +2,151 @@ import SwiftUI
#if os(macOS) #if os(macOS)
enum MacChrome { enum MacChrome {
static let contentMaxWidth: CGFloat = 1080 static let pageInset: CGFloat = 28
static let exerciseMaxWidth: CGFloat = 900 static let cardPadding: CGFloat = 22
static let cardPadding: CGFloat = 24
static let listRowMinHeight: CGFloat = 52 static let listRowMinHeight: CGFloat = 52
static let homeTileMin: CGFloat = 260 static let homeTileMin: CGFloat = 240
static let homeTileHeight: CGFloat = 176 static let homeTileHeight: CGFloat = 168
static let pathNode: CGFloat = 84 static let pathNode: CGFloat = 84
} }
enum MacGrid {
/// Picks a column count that fills the width and keeps tiles wider than they are tall.
static func columns(width: CGFloat, height: CGFloat, count: Int, minWidth: CGFloat, minHeight: CGFloat, spacing: CGFloat) -> Int {
guard count > 0, width > 0 else { return 1 }
let maxCols = max(1, min(count, Int((width + spacing) / (minWidth + spacing))))
var best = maxCols
var bestScore = CGFloat.greatestFiniteMagnitude
let offeredH = height > 1 ? height : minHeight * CGFloat(count)
for cols in 1...maxCols {
let rows = Int(ceil(Double(count) / Double(cols)))
let tileW = (width - spacing * CGFloat(cols - 1)) / CGFloat(cols)
if tileW + 0.5 < minWidth { continue }
let needed = CGFloat(rows) * minHeight + spacing * CGFloat(max(rows - 1, 0))
let tileH = max(minHeight, (offeredH - spacing * CGFloat(max(rows - 1, 0))) / CGFloat(rows))
let aspect = tileH / max(tileW, 1)
let rem = count % cols
let ragged = rem == 0 ? 0 : CGFloat(1.5)
let lonely = rem == 1 ? CGFloat(1.4) : 0
let tooTall = max(0, aspect - 1.05) * 3
let overflow = max(0, needed - offeredH)
let score = abs(aspect - 0.78) * 2 + ragged + lonely + tooTall + overflow / 180
if score < bestScore {
bestScore = score
best = cols
}
}
return best
}
static func contentHeight(width: CGFloat, height: CGFloat, count: Int, minWidth: CGFloat, minHeight: CGFloat, spacing: CGFloat) -> CGFloat {
let cols = columns(width: width, height: height, count: count, minWidth: minWidth, minHeight: minHeight, spacing: spacing)
let rows = max(1, Int(ceil(Double(max(count, 1)) / Double(cols))))
return CGFloat(rows) * minHeight + spacing * CGFloat(max(rows - 1, 0))
}
}
struct MacHeaderHeight: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
/// Card grid that grows into the space it is given and scrolls only when the
/// minimum tile size no longer fits.
struct MacFillGrid<Content: View>: View {
var count: Int
var minWidth: CGFloat = 240
var minHeight: CGFloat = 148
var spacing: CGFloat = 16
var inset: CGFloat = 28
@ViewBuilder var content: (Int) -> Content
var body: some View {
GeometryReader { geo in
let innerW = max(geo.size.width - inset * 2, minWidth)
let available = max(geo.size.height - inset * 2, minHeight)
let cols = MacGrid.columns(width: innerW, height: available, count: count, minWidth: minWidth, minHeight: minHeight, spacing: spacing)
let rows = max(1, Int(ceil(Double(max(count, 1)) / Double(cols))))
let floorH = CGFloat(rows) * minHeight + spacing * CGFloat(max(rows - 1, 0))
let gridH = max(available, floorH)
let tileH = max(minHeight, (gridH - spacing * CGFloat(max(rows - 1, 0))) / CGFloat(rows))
let tileW = (innerW - spacing * CGFloat(max(cols - 1, 0))) / CGFloat(max(cols, 1))
let lastCount = count - (rows - 1) * cols
ScrollView {
VStack(spacing: spacing) {
ForEach(0..<rows, id: \.self) { row in
let stretch = row < rows - 1 || lastCount > 1
HStack(spacing: spacing) {
ForEach(0..<cols, id: \.self) { col in
let index = row * cols + col
if index < count {
content(index)
.frame(
maxWidth: stretch ? .infinity : tileW,
minHeight: tileH,
maxHeight: tileH,
alignment: .topLeading
)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(height: gridH, alignment: .top)
.padding(inset)
.frame(maxWidth: .infinity, minHeight: geo.size.height, alignment: .top)
}
.scrollDisabled(floorH + inset * 2 <= geo.size.height + 1)
}
}
}
struct MacLinkCard: View {
let title: String
var subtitle: String? = nil
var badge: String? = nil
var systemImage: String? = nil
var locked = false
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center) {
if let badge {
Text(badge)
.font(.title.weight(.bold))
.foregroundStyle(locked ? Color.secondary : Color.accentColor)
}
if let systemImage {
Image(systemName: systemImage)
.font(.title2.weight(.semibold))
.foregroundStyle(locked ? Color.secondary : Color.accentColor)
}
Spacer(minLength: 0)
if locked {
Image(systemName: "lock.fill")
.foregroundStyle(.secondary)
}
}
Text(title)
.font(.title2.weight(.semibold))
.foregroundStyle(locked ? .secondary : .primary)
.fixedSize(horizontal: false, vertical: true)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.padding(22)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.softSurface(cornerRadius: 22)
}
}
#endif #endif
struct FlowLayout: Layout { struct FlowLayout: Layout {
@ -370,12 +507,11 @@ extension View {
/// Full-width, leading-aligned content for exercise screens. /// Full-width, leading-aligned content for exercise screens.
func exerciseContent() -> some View { func exerciseContent() -> some View {
#if os(macOS)
frame(maxWidth: MacChrome.exerciseMaxWidth, alignment: .leading)
.padding(28)
.frame(maxWidth: .infinity, alignment: .leading)
#else
frame(maxWidth: .infinity, alignment: .leading) frame(maxWidth: .infinity, alignment: .leading)
#if os(macOS)
.padding(.horizontal, MacChrome.pageInset)
.padding(.vertical, 20)
#else
.padding() .padding()
#endif #endif
} }
@ -422,11 +558,16 @@ struct BottomAction<Extra: View>: View {
extra() extra()
PrimaryButton(label: label, action: action) PrimaryButton(label: label, action: action)
} }
.padding(.horizontal) .padding(.horizontal, {
#if os(macOS)
MacChrome.pageInset
#else
16
#endif
}())
#if os(macOS) #if os(macOS)
.padding(.top, 12) .padding(.top, 12)
.padding(.bottom, 10) .padding(.bottom, 10)
.frame(maxWidth: MacChrome.exerciseMaxWidth)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
#else #else
.padding(.top, 8) .padding(.top, 8)

View file

@ -12,9 +12,21 @@ struct ExerciseScaffold<Content: View>: View {
var body: some View { var body: some View {
let p = vm.palette let p = vm.palette
Group {
#if os(macOS)
GeometryReader { geo in
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 8) { content() } VStack(alignment: .leading, spacing: 8) { content() }
.exerciseContent() .exerciseContent()
.frame(maxWidth: .infinity, minHeight: geo.size.height, alignment: .center)
}
}
#else
ScrollView {
VStack(alignment: .leading, spacing: 8) { content() }
.exerciseContent()
}
#endif
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.detailScreen(title, subtitle: sub) .detailScreen(title, subtitle: sub)
@ -133,11 +145,7 @@ private struct ChoiceEx: View {
Text("\(i + 1). \(qs[i].str("prompt"))") Text("\(i + 1). \(qs[i].str("prompt"))")
.font(.body.weight(.semibold)) .font(.body.weight(.semibold))
.padding(.bottom, 6) .padding(.bottom, 6)
ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in choiceOptions(qs[i], index: i)
let sel = picks[i] == o
let mark: Bool? = revealed && sel ? picks[i] == qs[i].int("correct") : nil
OptionChip(text: opt, selected: sel, mark: mark) { picks[i] = o }
}
if revealed { if revealed {
if i < meanings.count { Meaning(text: vm.tr(meanings[i]), palette: p, visible: true) } 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) } if i < expls.count { Meaning(text: expls[i], palette: p, visible: true) }
@ -146,6 +154,33 @@ private struct ChoiceEx: View {
} }
} }
} }
@ViewBuilder
private func choiceOptions(_ question: J, index: Int) -> some View {
let options = question.strs("options")
#if os(macOS)
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 12, alignment: .top),
GridItem(.flexible(), spacing: 12, alignment: .top),
],
spacing: 8
) {
optionChips(options, index: index, correct: question.int("correct"))
}
#else
optionChips(options, index: index, correct: question.int("correct"))
#endif
}
@ViewBuilder
private func optionChips(_ options: [String], index: Int, correct: Int) -> some View {
ForEach(Array(options.enumerated()), id: \.offset) { o, opt in
let sel = picks[index] == o
let mark: Bool? = revealed && sel ? picks[index] == correct : nil
OptionChip(text: opt, selected: sel, mark: mark) { picks[index] = o }
}
}
} }
private struct AssemblyEx: View { private struct AssemblyEx: View {

View file

@ -927,20 +927,19 @@ struct LitQuizScreen: View {
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
#endif #endif
.padding(.vertical, 12) .padding(.vertical, 12)
ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in #if os(macOS)
let correct = i == q.int("correct") LazyVGrid(
let chosen = answered && picked == i columns: [
OptionChip( GridItem(.flexible(), spacing: 12, alignment: .top),
text: opt, GridItem(.flexible(), spacing: 12, alignment: .top),
selected: chosen, ],
mark: answered ? (correct ? true : (chosen ? false : nil)) : nil spacing: 8
) { ) {
guard !answered else { return } bookQuizOptions(q)
picked = i
answered = true
if correct { score += 1 }
}
} }
#else
bookQuizOptions(q)
#endif
if answered { if answered {
Meaning(text: q.str("expl"), palette: p, visible: true) Meaning(text: q.str("expl"), palette: p, visible: true)
} }
@ -970,6 +969,24 @@ struct LitQuizScreen: View {
.animation(.default, value: answered) .animation(.default, value: answered)
} }
} }
@ViewBuilder
private func bookQuizOptions(_ q: J) -> some View {
ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in
let correct = i == q.int("correct")
let chosen = answered && picked == i
OptionChip(
text: opt,
selected: chosen,
mark: answered ? (correct ? true : (chosen ? false : nil)) : nil
) {
guard !answered else { return }
picked = i
answered = true
if correct { score += 1 }
}
}
}
} }
struct PlotScreen: View { struct PlotScreen: View {

View file

@ -199,12 +199,15 @@ struct PathMap: View {
.position(x: tip.x, y: tip.y - nodeSize / 2 - labelBand / 2 - 6) .position(x: tip.x, y: tip.y - nodeSize / 2 - labelBand / 2 - 6)
} }
} }
.frame(width: layout.width + 24, height: canvasH, alignment: .topLeading)
.frame( .frame(
width: max(layout.width + 24, geo.size.width), minWidth: geo.size.width,
height: canvasH, minHeight: max(canvasH, geo.size.height),
alignment: .topLeading alignment: Alignment(
horizontal: layout.width + 24 < geo.size.width ? .center : .leading,
vertical: canvasH < geo.size.height ? .center : .top
)
) )
.padding(.horizontal, 12)
} }
} }
#if os(macOS) #if os(macOS)

View file

@ -2,6 +2,9 @@ import SwiftUI
struct PracticeHome: View { struct PracticeHome: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
#if os(macOS)
@State private var homeHeader: CGFloat = 280
#endif
var body: some View { var body: some View {
#if os(macOS) #if os(macOS)
@ -15,8 +18,22 @@ struct PracticeHome: View {
private var macHome: some View { private var macHome: some View {
let sum = summarize(vm.content, vm.progress) let sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx) let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx)
return ScrollView { let subjects = vm.content.subjects
VStack(alignment: .leading, spacing: 28) { return GeometryReader { geo in
let width = max(geo.size.width - MacChrome.pageInset * 2, MacChrome.homeTileMin)
let offered = max(geo.size.height - homeHeader - 20 - MacChrome.pageInset * 2, MacChrome.homeTileHeight)
let minGrid = MacGrid.contentHeight(
width: width,
height: offered,
count: subjects.count,
minWidth: MacChrome.homeTileMin,
minHeight: MacChrome.homeTileHeight,
spacing: 16
)
let gridH = max(minGrid, geo.size.height - homeHeader - 20 - MacChrome.pageInset * 2)
ScrollView {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 16) {
if vm.showChangelog { if vm.showChangelog {
ChangelogCard(vm: vm) ChangelogCard(vm: vm)
} }
@ -34,19 +51,29 @@ struct PracticeHome: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
VStack(alignment: .leading, spacing: 16) {
Text(vm.tr("subjects_title")) Text(vm.tr("subjects_title"))
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
LazyVGrid(columns: [GridItem(.adaptive(minimum: MacChrome.homeTileMin), spacing: 18)], spacing: 18) { }
ForEach(Array(vm.content.subjects.enumerated()), id: \.offset) { _, s in .background {
subjectTile(s) GeometryReader { proxy in
Color.clear.preference(key: MacHeaderHeight.self, value: proxy.size.height)
} }
} }
MacFillGrid(
count: subjects.count,
minWidth: MacChrome.homeTileMin,
minHeight: MacChrome.homeTileHeight,
spacing: 16,
inset: 0
) { index in
subjectTile(subjects[index])
} }
.frame(height: gridH)
} }
.padding(32) .padding(MacChrome.pageInset)
.frame(maxWidth: MacChrome.contentMaxWidth, alignment: .leading) .frame(maxWidth: .infinity, minHeight: geo.size.height, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading) }
.onPreferenceChange(MacHeaderHeight.self) { homeHeader = $0 }
} }
.navigationTitle("Graduately") .navigationTitle("Graduately")
} }
@ -90,7 +117,7 @@ struct PracticeHome: View {
} }
} }
.padding(22) .padding(22)
.frame(maxWidth: .infinity, minHeight: MacChrome.homeTileHeight, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.softSurface(cornerRadius: 22) .softSurface(cornerRadius: 22)
} }
#endif #endif
@ -215,10 +242,35 @@ private struct ChangelogLines: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
if index > 0 { Spacer().frame(height: 4) } if index > 0 { Spacer().frame(height: 4) }
#if os(macOS)
if lines.count >= 4 {
LazyVGrid(
columns: [
GridItem(.flexible(minimum: 240), spacing: 22, alignment: .topLeading),
GridItem(.flexible(minimum: 240), spacing: 22, alignment: .topLeading),
],
alignment: .leading,
spacing: 6
) {
lineList
}
} else {
VStack(alignment: .leading, spacing: 4) { lineList }
}
#else
VStack(alignment: .leading, spacing: 4) { lineList }
#endif
}
}
@ViewBuilder
private var lineList: some View {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text("• \(line)") Text("• \(line)")
.font(.subheadline) .font(.subheadline)
} .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
} }
} }
} }
@ -227,6 +279,15 @@ struct SettingsScreen: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
var body: some View { var body: some View {
#if os(macOS)
macSettings
#else
settingsForm
#endif
}
#if os(iOS)
private var settingsForm: some View {
Form { Form {
Section(vm.tr("mode")) { Section(vm.tr("mode")) {
Picker(vm.tr("mode"), selection: modeBinding) { Picker(vm.tr("mode"), selection: modeBinding) {
@ -306,6 +367,88 @@ struct SettingsScreen: View {
.navigationTitle(vm.tr("settings_title")) .navigationTitle(vm.tr("settings_title"))
.appFormStyle() .appFormStyle()
} }
#endif
#if os(macOS)
private var macSettings: some View {
let themes = Array(ThemeId.allCases)
return VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .top, spacing: 16) {
settingsCard(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)
.labelsHidden()
.controlSize(.large)
}
settingsCard(vm.tr("language")) {
Picker(vm.tr("language"), selection: langBinding) {
Text("Čeština").tag(UiLang.cs)
Text("English").tag(UiLang.en)
}
.pickerStyle(.segmented)
.labelsHidden()
.controlSize(.large)
}
settingsCard(vm.tr("updates")) {
Text(updateText)
.font(.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button(vm.tr("update_check")) { vm.checkUpdate(true) }
.controlSize(.large)
if vm.update.canInstall {
Button(vm.tr("update_install")) { vm.installUpdate() }
.controlSize(.large)
}
LabeledContent("Build", value: "\(AppConfig.versionName) · \(String(AppConfig.commit.prefix(7)))")
}
}
.layoutPriority(1)
Text(vm.tr("theme"))
.font(.title2.weight(.semibold))
.layoutPriority(1)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 280), spacing: 12)], spacing: 12) {
ForEach(themes, id: \.self) { id in
Button {
vm.setTheme(id)
} label: {
HStack(spacing: 12) {
Circle().fill(themePalette(id, vm.mode).tint)
.frame(width: 18, height: 18)
Text(themeNames[id.rawValue])
.font(.title3)
.foregroundStyle(.primary)
Spacer(minLength: 0)
if vm.themeId == id {
Image(systemName: "checkmark").fontWeight(.semibold)
}
}
.padding(16)
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
.softSurface(cornerRadius: 16)
}
.buttonStyle(.plain)
}
}
}
.padding(MacChrome.pageInset)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.navigationTitle(vm.tr("settings_title"))
}
private func settingsCard<Content: View>(_ title: String, @ViewBuilder content: @escaping () -> Content) -> some View {
GlassCard {
VStack(alignment: .leading, spacing: 12) {
Text(title).font(.headline)
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
#endif
private var modeBinding: Binding<ColorMode> { private var modeBinding: Binding<ColorMode> {
Binding(get: { vm.mode }, set: { vm.applyMode($0) }) Binding(get: { vm.mode }, set: { vm.applyMode($0) })
@ -325,11 +468,23 @@ struct SettingsScreen: View {
struct StatsScreen: View { struct StatsScreen: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
#if os(macOS)
@State private var statsHeader: CGFloat = 160
#endif
var body: some View { var body: some View {
#if os(macOS)
macStats
#else
iosStats
#endif
}
#if os(iOS)
private var iosStats: some View {
let sum = summarize(vm.content, vm.progress) let sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx) let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
List { return List {
Section { Section {
GlassCard { GlassCard {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
@ -386,6 +541,78 @@ struct StatsScreen: View {
.navigationTitle(vm.tr("stats_title")) .navigationTitle(vm.tr("stats_title"))
.appListStyle() .appListStyle()
} }
#endif
#if os(macOS)
private var macStats: some View {
let sum = summarize(vm.content, vm.progress)
let pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
let subjects = vm.content.subjects
return GeometryReader { geo in
let width = max(geo.size.width - MacChrome.pageInset * 2, 240)
let offered = max(geo.size.height - statsHeader - 20 - MacChrome.pageInset * 2, 150)
let minGrid = MacGrid.contentHeight(width: width, height: offered, count: subjects.count, minWidth: 240, minHeight: 150, spacing: 16)
let gridH = max(minGrid, geo.size.height - statsHeader - 20 - MacChrome.pageInset * 2)
ScrollView {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 16) {
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)
}
}
Text(vm.tr("stats_section"))
.font(.title2.weight(.semibold))
}
.background {
GeometryReader { proxy in
Color.clear.preference(key: MacHeaderHeight.self, value: proxy.size.height)
}
}
MacFillGrid(count: subjects.count, minWidth: 240, minHeight: 150, spacing: 16, inset: 0) { index in
statSubject(subjects[index])
}
.frame(height: gridH)
}
.padding(MacChrome.pageInset)
.frame(maxWidth: .infinity, minHeight: geo.size.height, alignment: .topLeading)
}
.onPreferenceChange(MacHeaderHeight.self) { statsHeader = $0 }
}
.navigationTitle(vm.tr("stats_title"))
}
private func statSubject(_ s: J) -> some View {
let open = s.bool("open")
let part = subjectSum(vm, s)
return VStack(alignment: .leading, spacing: 12) {
SubjectIcon(icon: s.str("icon"), open: open, size: 36)
Text(vm.tr(s.str("key")))
.font(.title3.weight(.semibold))
.foregroundStyle(open ? .primary : .secondary)
Spacer(minLength: 0)
if open && part.totalEx > 0 {
ProgressView(value: Double(part.doneEx) / Double(part.totalEx))
Text(vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx))
.font(.subheadline)
.foregroundStyle(.secondary)
.monospacedDigit()
} else {
Text(vm.tr("stats_locked"))
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.softSurface(cornerRadius: 22)
}
#endif
private func stat(_ label: String, _ value: String) -> some View { private func stat(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@ -448,7 +675,58 @@ struct SearchScreen: View {
var body: some View { var body: some View {
let q = normalizeAnswer(query) let q = normalizeAnswer(query)
let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) } let hits = buildSearch(vm).filter { q.isEmpty || normalizeAnswer($0.hay).contains(q) }
return List { #if os(macOS)
return macSearch(hits)
#else
return iosSearch(hits)
#endif
}
#if os(macOS)
private func macSearch(_ hits: [Hit]) -> some View {
ScrollView {
if hits.isEmpty {
ContentUnavailableView.search
.frame(maxWidth: .infinity, minHeight: 360)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 340), spacing: 12)], spacing: 12) {
ForEach(hits) { hit in
Button {
if !hit.locked { vm.openFromSearch(hit.target) }
} label: {
VStack(alignment: .leading, spacing: 6) {
Text(hit.title)
.font(.title3.weight(.semibold))
.foregroundStyle(hit.locked ? .secondary : .primary)
.multilineTextAlignment(.leading)
if !hit.sub.isEmpty {
Text(hit.sub)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
}
.padding(16)
.frame(maxWidth: .infinity, minHeight: 84, alignment: .leading)
.softSurface(cornerRadius: 16)
}
.buttonStyle(.plain)
.disabled(hit.locked)
}
}
.padding(MacChrome.pageInset)
}
}
.navigationTitle(vm.tr("search"))
.searchable(text: $vm.searchQuery, prompt: vm.tr("search_placeholder"))
.onAppear { query = vm.searchQuery }
.onChange(of: vm.searchQuery) { _, q in query = q }
}
#endif
#if os(iOS)
private func iosSearch(_ hits: [Hit]) -> some View {
List {
if hits.isEmpty { if hits.isEmpty {
ContentUnavailableView.search ContentUnavailableView.search
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
@ -469,7 +747,6 @@ struct SearchScreen: View {
} }
} }
.navigationTitle(vm.tr("search")) .navigationTitle(vm.tr("search"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.safeAreaBar(edge: .top) { .safeAreaBar(edge: .top) {
searchField searchField
@ -486,13 +763,9 @@ struct SearchScreen: View {
.onChange(of: vm.searchQuery) { _, q in .onChange(of: vm.searchQuery) { _, q in
if q != query { query = q } if q != query { query = q }
} }
#else
.searchable(text: $vm.searchQuery, prompt: vm.tr("search_placeholder"))
.onAppear { query = vm.searchQuery }
.onChange(of: vm.searchQuery) { _, q in query = q }
#endif
.appListStyle() .appListStyle()
} }
#endif
#if os(iOS) #if os(iOS)
private var searchField: some View { private var searchField: some View {
@ -761,6 +1034,21 @@ struct NetYearsScreen: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
var body: some View { var body: some View {
#if os(macOS)
let keys = ["net_year1", "net_year2", "net_year3", "net_year4"]
MacFillGrid(count: keys.count, minWidth: 260, minHeight: 180) { i in
if i == 0 {
NavigationLink(value: Route.netMap) {
MacLinkCard(title: vm.tr(keys[i]), subtitle: vm.tr("net_sub"), badge: "\(i + 1).")
}
.buttonStyle(.plain)
} else {
MacLinkCard(title: vm.tr(keys[i]), subtitle: vm.tr("net_year_locked_sub"), badge: "\(i + 1).", locked: true)
}
}
.navigationTitle(vm.tr("net_years_title"))
.navigationSubtitle(vm.tr("net_years_sub"))
#else
List { List {
NavigationLink(value: Route.netMap) { NavigationLink(value: Route.netMap) {
ListRowLabel(title: vm.tr("net_year1"), subtitle: vm.tr("net_sub")) ListRowLabel(title: vm.tr("net_year1"), subtitle: vm.tr("net_sub"))
@ -777,6 +1065,7 @@ struct NetYearsScreen: View {
.navigationTitle(vm.tr("net_years_title")) .navigationTitle(vm.tr("net_years_title"))
.navigationSubtitle(vm.tr("net_years_sub")) .navigationSubtitle(vm.tr("net_years_sub"))
.appListStyle() .appListStyle()
#endif
} }
} }
@ -784,6 +1073,21 @@ struct HwYearsScreen: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
var body: some View { var body: some View {
#if os(macOS)
let keys = ["hw_year1", "hw_year2", "hw_year3", "hw_year4"]
MacFillGrid(count: keys.count, minWidth: 260, minHeight: 180) { i in
if i == 0 {
NavigationLink(value: Route.hwMap) {
MacLinkCard(title: vm.tr(keys[i]), subtitle: vm.tr("hw_sub"), badge: "\(i + 1).")
}
.buttonStyle(.plain)
} else {
MacLinkCard(title: vm.tr(keys[i]), subtitle: vm.tr("hw_year_locked_sub"), badge: "\(i + 1).", locked: true)
}
}
.navigationTitle(vm.tr("Technické vybavení"))
.navigationSubtitle(vm.tr("hw_years_sub"))
#else
List { List {
NavigationLink(value: Route.hwMap) { NavigationLink(value: Route.hwMap) {
ListRowLabel(title: vm.tr("hw_year1"), subtitle: vm.tr("hw_sub")) ListRowLabel(title: vm.tr("hw_year1"), subtitle: vm.tr("hw_sub"))
@ -800,6 +1104,7 @@ struct HwYearsScreen: View {
.navigationTitle(vm.tr("Technické vybavení")) .navigationTitle(vm.tr("Technické vybavení"))
.navigationSubtitle(vm.tr("hw_years_sub")) .navigationSubtitle(vm.tr("hw_years_sub"))
.appListStyle() .appListStyle()
#endif
} }
} }
@ -807,6 +1112,22 @@ struct OnYearsScreen: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
var body: some View { var body: some View {
#if os(macOS)
let years: [(String, String, Route)] = [
("on_year1", "on_sub", .onMap),
("on_year2", "on2_sub", .on2Map),
("on_year3", "on3_sub", .on3Map),
("on_year4", "on4_sub", .on4Map),
]
MacFillGrid(count: years.count, minWidth: 260, minHeight: 180) { i in
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
}
.navigationTitle(vm.tr("Občanská nauka"))
.navigationSubtitle(vm.tr("on_years_sub"))
#else
List { List {
NavigationLink(value: Route.onMap) { NavigationLink(value: Route.onMap) {
ListRowLabel(title: vm.tr("on_year1"), subtitle: vm.tr("on_sub")) ListRowLabel(title: vm.tr("on_year1"), subtitle: vm.tr("on_sub"))
@ -824,6 +1145,7 @@ struct OnYearsScreen: View {
.navigationTitle(vm.tr("Občanská nauka")) .navigationTitle(vm.tr("Občanská nauka"))
.navigationSubtitle(vm.tr("on_years_sub")) .navigationSubtitle(vm.tr("on_years_sub"))
.appListStyle() .appListStyle()
#endif
} }
} }
@ -962,8 +1284,27 @@ struct SlidesScreen: View {
} }
private func slidePage(_ slide: J) -> some View { private func slidePage(_ slide: J) -> some View {
#if os(macOS)
GeometryReader { geo in
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 14) { slideStack(slide)
.padding(.horizontal, MacChrome.pageInset)
.padding(.vertical, 28)
.frame(maxWidth: .infinity, minHeight: geo.size.height, alignment: .center)
}
}
#else
ScrollView {
slideStack(slide)
.padding()
.padding(.bottom, 24)
.frame(maxWidth: .infinity, alignment: .leading)
}
#endif
}
private func slideStack(_ slide: J) -> some View {
VStack(alignment: .leading, spacing: 16) {
Text(slide.str("kicker")) Text(slide.str("kicker"))
#if os(macOS) #if os(macOS)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
@ -982,35 +1323,75 @@ struct SlidesScreen: View {
Label(tip, systemImage: "lightbulb.fill") Label(tip, systemImage: "lightbulb.fill")
} }
} }
ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in slideLines(slide.strs("lines"))
Text(line) }
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func slideLines(_ lines: [String]) -> some View {
#if os(macOS) #if os(macOS)
if lines.count >= 3 {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 28, alignment: .topLeading),
GridItem(.flexible(), spacing: 28, alignment: .topLeading),
],
alignment: .leading,
spacing: 12
) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.title3) .font(.title3)
#endif .fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
} else {
VStack(alignment: .leading, spacing: 12) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.title3)
.fixedSize(horizontal: false, vertical: true)
}
}
}
#else
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(line)
.padding(.vertical, 4) .padding(.vertical, 4)
} }
}
.padding()
.padding(.bottom, 24)
.frame(maxWidth: .infinity, alignment: .leading)
#if os(macOS)
.frame(maxWidth: MacChrome.exerciseMaxWidth, alignment: .leading)
.padding(12)
#endif #endif
} }
}
} }
struct CzechMapScreen: View { struct CzechMapScreen: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
var body: some View { var body: some View {
#if os(macOS)
MacFillGrid(count: 3, minWidth: 260, minHeight: 180) { i in
switch i {
case 1:
NavigationLink(value: Route.mluvnice) {
MacLinkCard(title: vm.tr("Mluvnice"), systemImage: "textformat")
}
.buttonStyle(.plain)
case 2:
NavigationLink(value: Route.readingList) {
MacLinkCard(title: vm.tr("Maturitní četba"), systemImage: "books.vertical")
}
.buttonStyle(.plain)
default:
MacLinkCard(title: vm.tr("Literatura"), systemImage: "book.closed", locked: true)
}
}
.navigationTitle(vm.tr("Český jazyk a literatura"))
.navigationSubtitle(vm.tr("czech_sub"))
#else
List { List {
Label { Label {
Text(vm.tr("Literatura")) Text(vm.tr("Literatura"))
#if os(macOS)
.font(.title3)
#endif
} icon: { } icon: {
Image(systemName: "lock.fill") Image(systemName: "lock.fill")
} }
@ -1018,9 +1399,6 @@ struct CzechMapScreen: View {
NavigationLink(value: Route.mluvnice) { NavigationLink(value: Route.mluvnice) {
Label { Label {
Text(vm.tr("Mluvnice")) Text(vm.tr("Mluvnice"))
#if os(macOS)
.font(.title3)
#endif
} icon: { } icon: {
Image(systemName: "textformat") Image(systemName: "textformat")
} }
@ -1028,9 +1406,6 @@ struct CzechMapScreen: View {
NavigationLink(value: Route.readingList) { NavigationLink(value: Route.readingList) {
Label { Label {
Text(vm.tr("Maturitní četba")) Text(vm.tr("Maturitní četba"))
#if os(macOS)
.font(.title3)
#endif
} icon: { } icon: {
Image(systemName: "books.vertical") Image(systemName: "books.vertical")
} }
@ -1039,6 +1414,7 @@ struct CzechMapScreen: View {
.navigationTitle(vm.tr("Český jazyk a literatura")) .navigationTitle(vm.tr("Český jazyk a literatura"))
.navigationSubtitle(vm.tr("czech_sub")) .navigationSubtitle(vm.tr("czech_sub"))
.appListStyle() .appListStyle()
#endif
} }
} }
@ -1046,6 +1422,65 @@ struct BookListScreen: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
var body: some View { var body: some View {
#if os(macOS)
macBooks
#else
iosBooks
#endif
}
#if os(macOS)
private var macBooks: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 300), spacing: 16)], spacing: 16) {
ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
let parts = bookTitleParts(b.str("title"))
let quizDone = !b.arr("quiz").isEmpty && vm.progress.bookQuiz(b.str("id"))
let plotDone = !b.arr("plot").isEmpty && vm.progress.bookPlot(b.str("id"))
NavigationLink(value: Route.book(b.str("id"))) {
VStack(alignment: .leading, spacing: 8) {
Image(systemName: "book.fill")
.font(.title2)
.foregroundStyle(.tint)
Text(parts.title)
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
if let author = parts.author {
Text(author)
.font(.body)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
HStack(spacing: 8) {
let genre = b.str("genre")
Text(genre.isEmpty ? vm.tr(b.str("subKey")) : genre)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
if quizDone || plotDone {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.accessibilityLabel(vm.tr("book_done"))
}
}
}
.padding(18)
.frame(maxWidth: .infinity, minHeight: 168, alignment: .topLeading)
.softSurface(cornerRadius: 18)
}
.buttonStyle(.plain)
}
}
.padding(MacChrome.pageInset)
}
.navigationTitle(vm.tr("Maturitní četba"))
.navigationSubtitle(vm.tr("reading_sub"))
}
#endif
#if os(iOS)
private var iosBooks: some View {
List { List {
ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
let parts = bookTitleParts(b.str("title")) let parts = bookTitleParts(b.str("title"))
@ -1108,6 +1543,7 @@ struct BookListScreen: View {
.navigationSubtitle(vm.tr("reading_sub")) .navigationSubtitle(vm.tr("reading_sub"))
.appListStyle() .appListStyle()
} }
#endif
} }
struct BookScreen: View { struct BookScreen: View {
@ -1159,6 +1595,8 @@ struct BookScreen: View {
#else #else
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
#endif #endif
#if os(macOS)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 320), spacing: 16)], spacing: 16) {
if hasQuiz { if hasQuiz {
NavigationLink(value: Route.bookQuiz(id)) { NavigationLink(value: Route.bookQuiz(id)) {
BookActionCard( BookActionCard(
@ -1184,6 +1622,33 @@ struct BookScreen: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
#else
if hasQuiz {
NavigationLink(value: Route.bookQuiz(id)) {
BookActionCard(
title: vm.tr("book_quiz_heading"),
subtitle: vm.tr("lit_quiz_sub"),
systemImage: "questionmark.circle.fill",
done: quizDone,
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(.plain)
}
if hasPlot {
NavigationLink(value: Route.bookPlot(id)) {
BookActionCard(
title: vm.tr("lit_plot_title"),
subtitle: vm.tr("lit_plot_sub"),
systemImage: "list.number",
done: plotDone,
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(.plain)
}
#endif
}
} }
if !notes.isEmpty { if !notes.isEmpty {
@ -1194,15 +1659,22 @@ struct BookScreen: View {
#else #else
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
#endif #endif
#if os(macOS)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 360), spacing: 16)], alignment: .leading, spacing: 16) {
ForEach(Array(notes.enumerated()), id: \.offset) { _, note in ForEach(Array(notes.enumerated()), id: \.offset) { _, note in
BookNoteCard(note: note) BookNoteCard(note: note)
} }
} }
#else
ForEach(Array(notes.enumerated()), id: \.offset) { _, note in
BookNoteCard(note: note)
}
#endif
}
} }
} }
#if os(macOS) #if os(macOS)
.padding(28) .padding(MacChrome.pageInset)
.frame(maxWidth: MacChrome.exerciseMaxWidth, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
#else #else
.padding() .padding()
@ -1270,7 +1742,7 @@ private struct BookActionCard: View {
#else #else
.padding(16) .padding(16)
#endif #endif
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.softSurface(cornerRadius: 18) .softSurface(cornerRadius: 18)
} }
} }