mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 07:04: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)
|
||||
enum MacChrome {
|
||||
static let contentMaxWidth: CGFloat = 1080
|
||||
static let exerciseMaxWidth: CGFloat = 900
|
||||
static let cardPadding: CGFloat = 24
|
||||
static let pageInset: CGFloat = 28
|
||||
static let cardPadding: CGFloat = 22
|
||||
static let listRowMinHeight: CGFloat = 52
|
||||
static let homeTileMin: CGFloat = 260
|
||||
static let homeTileHeight: CGFloat = 176
|
||||
static let homeTileMin: CGFloat = 240
|
||||
static let homeTileHeight: CGFloat = 168
|
||||
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
|
||||
|
||||
struct FlowLayout: Layout {
|
||||
|
|
@ -370,14 +507,13 @@ extension View {
|
|||
|
||||
/// Full-width, leading-aligned content for exercise screens.
|
||||
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)
|
||||
#if os(macOS)
|
||||
.padding(.horizontal, MacChrome.pageInset)
|
||||
.padding(.vertical, 20)
|
||||
#else
|
||||
.padding()
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
/// macOS content lists use the inset style so they sit under the floating
|
||||
|
|
@ -422,11 +558,16 @@ struct BottomAction<Extra: View>: View {
|
|||
extra()
|
||||
PrimaryButton(label: label, action: action)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal, {
|
||||
#if os(macOS)
|
||||
MacChrome.pageInset
|
||||
#else
|
||||
16
|
||||
#endif
|
||||
}())
|
||||
#if os(macOS)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 10)
|
||||
.frame(maxWidth: MacChrome.exerciseMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
#else
|
||||
.padding(.top, 8)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,21 @@ struct ExerciseScaffold<Content: View>: View {
|
|||
|
||||
var body: some View {
|
||||
let p = vm.palette
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) { content() }
|
||||
.exerciseContent()
|
||||
Group {
|
||||
#if os(macOS)
|
||||
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)
|
||||
.detailScreen(title, subtitle: sub)
|
||||
|
|
@ -133,11 +145,7 @@ private struct ChoiceEx: View {
|
|||
Text("\(i + 1). \(qs[i].str("prompt"))")
|
||||
.font(.body.weight(.semibold))
|
||||
.padding(.bottom, 6)
|
||||
ForEach(Array(qs[i].strs("options").enumerated()), id: \.offset) { o, opt in
|
||||
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 }
|
||||
}
|
||||
choiceOptions(qs[i], index: i)
|
||||
if revealed {
|
||||
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) }
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -927,20 +927,19 @@ struct LitQuizScreen: View {
|
|||
.font(.title3.weight(.semibold))
|
||||
#endif
|
||||
.padding(.vertical, 12)
|
||||
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 }
|
||||
}
|
||||
#if os(macOS)
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
GridItem(.flexible(), spacing: 12, alignment: .top),
|
||||
GridItem(.flexible(), spacing: 12, alignment: .top),
|
||||
],
|
||||
spacing: 8
|
||||
) {
|
||||
bookQuizOptions(q)
|
||||
}
|
||||
#else
|
||||
bookQuizOptions(q)
|
||||
#endif
|
||||
if answered {
|
||||
Meaning(text: q.str("expl"), palette: p, visible: true)
|
||||
}
|
||||
|
|
@ -970,6 +969,24 @@ struct LitQuizScreen: View {
|
|||
.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 {
|
||||
|
|
|
|||
|
|
@ -199,12 +199,15 @@ struct PathMap: View {
|
|||
.position(x: tip.x, y: tip.y - nodeSize / 2 - labelBand / 2 - 6)
|
||||
}
|
||||
}
|
||||
.frame(width: layout.width + 24, height: canvasH, alignment: .topLeading)
|
||||
.frame(
|
||||
width: max(layout.width + 24, geo.size.width),
|
||||
height: canvasH,
|
||||
alignment: .topLeading
|
||||
minWidth: geo.size.width,
|
||||
minHeight: max(canvasH, geo.size.height),
|
||||
alignment: Alignment(
|
||||
horizontal: layout.width + 24 < geo.size.width ? .center : .leading,
|
||||
vertical: canvasH < geo.size.height ? .center : .top
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import SwiftUI
|
|||
|
||||
struct PracticeHome: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
#if os(macOS)
|
||||
@State private var homeHeader: CGFloat = 280
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
|
|
@ -15,38 +18,62 @@ struct PracticeHome: View {
|
|||
private var macHome: some View {
|
||||
let sum = summarize(vm.content, vm.progress)
|
||||
let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx)
|
||||
return ScrollView {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
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)
|
||||
let subjects = vm.content.subjects
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(vm.tr("subjects_title"))
|
||||
.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)
|
||||
.background {
|
||||
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(MacChrome.pageInset)
|
||||
.frame(maxWidth: .infinity, minHeight: geo.size.height, alignment: .topLeading)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: MacChrome.contentMaxWidth, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.onPreferenceChange(MacHeaderHeight.self) { homeHeader = $0 }
|
||||
}
|
||||
.navigationTitle("Graduately")
|
||||
}
|
||||
|
|
@ -90,7 +117,7 @@ struct PracticeHome: View {
|
|||
}
|
||||
}
|
||||
.padding(22)
|
||||
.frame(maxWidth: .infinity, minHeight: MacChrome.homeTileHeight, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.softSurface(cornerRadius: 22)
|
||||
}
|
||||
#endif
|
||||
|
|
@ -215,10 +242,35 @@ private struct ChangelogLines: View {
|
|||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if index > 0 { Spacer().frame(height: 4) }
|
||||
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||
Text("• \(line)")
|
||||
.font(.subheadline)
|
||||
#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
|
||||
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
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
macSettings
|
||||
#else
|
||||
settingsForm
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private var settingsForm: some View {
|
||||
Form {
|
||||
Section(vm.tr("mode")) {
|
||||
Picker(vm.tr("mode"), selection: modeBinding) {
|
||||
|
|
@ -306,6 +367,88 @@ struct SettingsScreen: View {
|
|||
.navigationTitle(vm.tr("settings_title"))
|
||||
.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> {
|
||||
Binding(get: { vm.mode }, set: { vm.applyMode($0) })
|
||||
|
|
@ -325,11 +468,23 @@ struct SettingsScreen: View {
|
|||
|
||||
struct StatsScreen: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
#if os(macOS)
|
||||
@State private var statsHeader: CGFloat = 160
|
||||
#endif
|
||||
|
||||
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 pct = sum.totalEx == 0 ? 0.0 : Double(sum.doneEx) / Double(sum.totalEx)
|
||||
List {
|
||||
return List {
|
||||
Section {
|
||||
GlassCard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
|
|
@ -386,6 +541,78 @@ struct StatsScreen: View {
|
|||
.navigationTitle(vm.tr("stats_title"))
|
||||
.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 {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
|
|
@ -448,7 +675,58 @@ struct SearchScreen: View {
|
|||
var body: some View {
|
||||
let q = normalizeAnswer(query)
|
||||
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 {
|
||||
ContentUnavailableView.search
|
||||
.listRowBackground(Color.clear)
|
||||
|
|
@ -469,7 +747,6 @@ struct SearchScreen: View {
|
|||
}
|
||||
}
|
||||
.navigationTitle(vm.tr("search"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.safeAreaBar(edge: .top) {
|
||||
searchField
|
||||
|
|
@ -486,13 +763,9 @@ struct SearchScreen: View {
|
|||
.onChange(of: vm.searchQuery) { _, q in
|
||||
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()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
private var searchField: some View {
|
||||
|
|
@ -761,6 +1034,21 @@ struct NetYearsScreen: View {
|
|||
@ObservedObject var vm: AppModel
|
||||
|
||||
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 {
|
||||
NavigationLink(value: Route.netMap) {
|
||||
ListRowLabel(title: vm.tr("net_year1"), subtitle: vm.tr("net_sub"))
|
||||
|
|
@ -777,6 +1065,7 @@ struct NetYearsScreen: View {
|
|||
.navigationTitle(vm.tr("net_years_title"))
|
||||
.navigationSubtitle(vm.tr("net_years_sub"))
|
||||
.appListStyle()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -784,6 +1073,21 @@ struct HwYearsScreen: View {
|
|||
@ObservedObject var vm: AppModel
|
||||
|
||||
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 {
|
||||
NavigationLink(value: Route.hwMap) {
|
||||
ListRowLabel(title: vm.tr("hw_year1"), subtitle: vm.tr("hw_sub"))
|
||||
|
|
@ -800,6 +1104,7 @@ struct HwYearsScreen: View {
|
|||
.navigationTitle(vm.tr("Technické vybavení"))
|
||||
.navigationSubtitle(vm.tr("hw_years_sub"))
|
||||
.appListStyle()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -807,6 +1112,22 @@ struct OnYearsScreen: View {
|
|||
@ObservedObject var vm: AppModel
|
||||
|
||||
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 {
|
||||
NavigationLink(value: Route.onMap) {
|
||||
ListRowLabel(title: vm.tr("on_year1"), subtitle: vm.tr("on_sub"))
|
||||
|
|
@ -824,6 +1145,7 @@ struct OnYearsScreen: View {
|
|||
.navigationTitle(vm.tr("Občanská nauka"))
|
||||
.navigationSubtitle(vm.tr("on_years_sub"))
|
||||
.appListStyle()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -962,42 +1284,84 @@ struct SlidesScreen: 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 {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(slide.str("kicker"))
|
||||
#if os(macOS)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
#else
|
||||
.font(.caption.weight(.semibold))
|
||||
#endif
|
||||
.foregroundStyle(.secondary)
|
||||
Text(slide.str("title"))
|
||||
#if os(macOS)
|
||||
.font(.title.bold())
|
||||
#else
|
||||
.font(.title2.bold())
|
||||
#endif
|
||||
if let tip = slide.strOrNull("tip") {
|
||||
GlassCard {
|
||||
Label(tip, systemImage: "lightbulb.fill")
|
||||
}
|
||||
}
|
||||
ForEach(Array(slide.strs("lines").enumerated()), id: \.offset) { _, line in
|
||||
Text(line)
|
||||
#if os(macOS)
|
||||
.font(.title3)
|
||||
#endif
|
||||
.padding(.vertical, 4)
|
||||
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"))
|
||||
#if os(macOS)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
#else
|
||||
.font(.caption.weight(.semibold))
|
||||
#endif
|
||||
.foregroundStyle(.secondary)
|
||||
Text(slide.str("title"))
|
||||
#if os(macOS)
|
||||
.font(.title.bold())
|
||||
#else
|
||||
.font(.title2.bold())
|
||||
#endif
|
||||
if let tip = slide.strOrNull("tip") {
|
||||
GlassCard {
|
||||
Label(tip, systemImage: "lightbulb.fill")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(.bottom, 24)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: MacChrome.exerciseMaxWidth, alignment: .leading)
|
||||
.padding(12)
|
||||
#endif
|
||||
slideLines(slide.strs("lines"))
|
||||
}
|
||||
.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
|
||||
|
||||
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 {
|
||||
Label {
|
||||
Text(vm.tr("Literatura"))
|
||||
#if os(macOS)
|
||||
.font(.title3)
|
||||
#endif
|
||||
} icon: {
|
||||
Image(systemName: "lock.fill")
|
||||
}
|
||||
|
|
@ -1018,9 +1399,6 @@ struct CzechMapScreen: View {
|
|||
NavigationLink(value: Route.mluvnice) {
|
||||
Label {
|
||||
Text(vm.tr("Mluvnice"))
|
||||
#if os(macOS)
|
||||
.font(.title3)
|
||||
#endif
|
||||
} icon: {
|
||||
Image(systemName: "textformat")
|
||||
}
|
||||
|
|
@ -1028,9 +1406,6 @@ struct CzechMapScreen: View {
|
|||
NavigationLink(value: Route.readingList) {
|
||||
Label {
|
||||
Text(vm.tr("Maturitní četba"))
|
||||
#if os(macOS)
|
||||
.font(.title3)
|
||||
#endif
|
||||
} icon: {
|
||||
Image(systemName: "books.vertical")
|
||||
}
|
||||
|
|
@ -1039,6 +1414,7 @@ struct CzechMapScreen: View {
|
|||
.navigationTitle(vm.tr("Český jazyk a literatura"))
|
||||
.navigationSubtitle(vm.tr("czech_sub"))
|
||||
.appListStyle()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1046,6 +1422,65 @@ struct BookListScreen: View {
|
|||
@ObservedObject var vm: AppModel
|
||||
|
||||
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 {
|
||||
ForEach(Array(vm.content.books.enumerated()), id: \.offset) { _, b in
|
||||
let parts = bookTitleParts(b.str("title"))
|
||||
|
|
@ -1108,6 +1543,7 @@ struct BookListScreen: View {
|
|||
.navigationSubtitle(vm.tr("reading_sub"))
|
||||
.appListStyle()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct BookScreen: View {
|
||||
|
|
@ -1159,6 +1595,34 @@ struct BookScreen: View {
|
|||
#else
|
||||
.font(.title2.weight(.semibold))
|
||||
#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 {
|
||||
NavigationLink(value: Route.bookQuiz(id)) {
|
||||
BookActionCard(
|
||||
|
|
@ -1183,6 +1647,7 @@ struct BookScreen: View {
|
|||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1194,15 +1659,22 @@ struct BookScreen: View {
|
|||
#else
|
||||
.font(.title2.weight(.semibold))
|
||||
#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
|
||||
BookNoteCard(note: note)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(28)
|
||||
.frame(maxWidth: MacChrome.exerciseMaxWidth, alignment: .leading)
|
||||
.padding(MacChrome.pageInset)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
#else
|
||||
.padding()
|
||||
|
|
@ -1270,7 +1742,7 @@ private struct BookActionCard: View {
|
|||
#else
|
||||
.padding(16)
|
||||
#endif
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.softSurface(cornerRadius: 18)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue