mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
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:
parent
50a23c334b
commit
630142b57d
5 changed files with 786 additions and 118 deletions
|
|
@ -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,14 +507,13 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// macOS content lists use the inset style so they sit under the floating
|
/// macOS content lists use the inset style so they sit under the floating
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
ScrollView {
|
Group {
|
||||||
VStack(alignment: .leading, spacing: 8) { content() }
|
#if os(macOS)
|
||||||
.exerciseContent()
|
GeometryReader { geo in
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 8) { content() }
|
||||||
|
.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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,38 +18,62 @@ 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
|
||||||
if vm.showChangelog {
|
let width = max(geo.size.width - MacChrome.pageInset * 2, MacChrome.homeTileMin)
|
||||||
ChangelogCard(vm: vm)
|
let offered = max(geo.size.height - homeHeader - 20 - MacChrome.pageInset * 2, MacChrome.homeTileHeight)
|
||||||
}
|
let minGrid = MacGrid.contentHeight(
|
||||||
GlassCard {
|
width: width,
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
height: offered,
|
||||||
Text(vm.tr("welcome_title"))
|
count: subjects.count,
|
||||||
.font(.largeTitle.bold())
|
minWidth: MacChrome.homeTileMin,
|
||||||
Text(vm.tr(Self.welcomeBodyKey))
|
minHeight: MacChrome.homeTileHeight,
|
||||||
.font(.title3)
|
spacing: 16
|
||||||
.foregroundStyle(.secondary)
|
)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
let gridH = max(minGrid, geo.size.height - homeHeader - 20 - MacChrome.pageInset * 2)
|
||||||
ProgressView(value: pct)
|
ScrollView {
|
||||||
Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
.font(.subheadline)
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
.foregroundStyle(.secondary)
|
if vm.showChangelog {
|
||||||
|
ChangelogCard(vm: vm)
|
||||||
|
}
|
||||||
|
GlassCard {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(vm.tr("welcome_title"))
|
||||||
|
.font(.largeTitle.bold())
|
||||||
|
Text(vm.tr(Self.welcomeBodyKey))
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
ProgressView(value: pct)
|
||||||
|
Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(vm.tr("subjects_title"))
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
}
|
}
|
||||||
}
|
.background {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
GeometryReader { proxy in
|
||||||
Text(vm.tr("subjects_title"))
|
Color.clear.preference(key: MacHeaderHeight.self, value: proxy.size.height)
|
||||||
.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
|
|
||||||
subjectTile(s)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MacFillGrid(
|
||||||
|
count: subjects.count,
|
||||||
|
minWidth: MacChrome.homeTileMin,
|
||||||
|
minHeight: MacChrome.homeTileHeight,
|
||||||
|
spacing: 16,
|
||||||
|
inset: 0
|
||||||
|
) { index in
|
||||||
|
subjectTile(subjects[index])
|
||||||
|
}
|
||||||
|
.frame(height: gridH)
|
||||||
}
|
}
|
||||||
|
.padding(MacChrome.pageInset)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: geo.size.height, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
.padding(32)
|
.onPreferenceChange(MacHeaderHeight.self) { homeHeader = $0 }
|
||||||
.frame(maxWidth: MacChrome.contentMaxWidth, alignment: .leading)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
}
|
||||||
.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) }
|
||||||
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
#if os(macOS)
|
||||||
Text("• \(line)")
|
if lines.count >= 4 {
|
||||||
.font(.subheadline)
|
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
|
||||||
|
Text("• \(line)")
|
||||||
|
.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,42 +1284,84 @@ struct SlidesScreen: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func slidePage(_ slide: J) -> some View {
|
private func slidePage(_ slide: J) -> some View {
|
||||||
|
#if os(macOS)
|
||||||
|
GeometryReader { geo in
|
||||||
|
ScrollView {
|
||||||
|
slideStack(slide)
|
||||||
|
.padding(.horizontal, MacChrome.pageInset)
|
||||||
|
.padding(.vertical, 28)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: geo.size.height, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
slideStack(slide)
|
||||||
Text(slide.str("kicker"))
|
.padding()
|
||||||
#if os(macOS)
|
.padding(.bottom, 24)
|
||||||
.font(.subheadline.weight(.semibold))
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
#else
|
}
|
||||||
.font(.caption.weight(.semibold))
|
#endif
|
||||||
#endif
|
}
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(slide.str("title"))
|
private func slideStack(_ slide: J) -> some View {
|
||||||
#if os(macOS)
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
.font(.title.bold())
|
Text(slide.str("kicker"))
|
||||||
#else
|
#if os(macOS)
|
||||||
.font(.title2.bold())
|
.font(.subheadline.weight(.semibold))
|
||||||
#endif
|
#else
|
||||||
if let tip = slide.strOrNull("tip") {
|
.font(.caption.weight(.semibold))
|
||||||
GlassCard {
|
#endif
|
||||||
Label(tip, systemImage: "lightbulb.fill")
|
.foregroundStyle(.secondary)
|
||||||
}
|
Text(slide.str("title"))
|
||||||
}
|
#if os(macOS)
|
||||||
ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
|
.font(.title.bold())
|
||||||
Text(line)
|
#else
|
||||||
#if os(macOS)
|
.font(.title2.bold())
|
||||||
.font(.title3)
|
#endif
|
||||||
#endif
|
if let tip = slide.strOrNull("tip") {
|
||||||
.padding(.vertical, 4)
|
GlassCard {
|
||||||
|
Label(tip, systemImage: "lightbulb.fill")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
slideLines(slide.strs("lines"))
|
||||||
.padding(.bottom, 24)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
#if os(macOS)
|
|
||||||
.frame(maxWidth: MacChrome.exerciseMaxWidth, alignment: .leading)
|
|
||||||
.padding(12)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func slideLines(_ lines: [String]) -> some View {
|
||||||
|
#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)
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1005,12 +1369,29 @@ 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,34 @@ 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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
if hasQuiz {
|
if hasQuiz {
|
||||||
NavigationLink(value: Route.bookQuiz(id)) {
|
NavigationLink(value: Route.bookQuiz(id)) {
|
||||||
BookActionCard(
|
BookActionCard(
|
||||||
|
|
@ -1183,6 +1647,7 @@ struct BookScreen: View {
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
BookNoteCard(note: note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
ForEach(Array(notes.enumerated()), id: \.offset) { _, note in
|
ForEach(Array(notes.enumerated()), id: \.offset) { _, note in
|
||||||
BookNoteCard(note: note)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue