Clarify book study flow with notes-first cards and practice actions.

Make reading-list pages show how to learn: readable note cards, then quiz/plot, with clearer book list metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-22 14:06:16 +02:00
commit f6043c33c9
3 changed files with 274 additions and 37 deletions

View file

@ -799,27 +799,50 @@ struct LitQuizScreen: View {
let qs = book.arr("quiz") let qs = book.arr("quiz")
let p = vm.palette let p = vm.palette
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 14) {
if finished { if finished {
GlassCard { GlassCard {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 10) {
Label(vm.tr("lit_finished"), systemImage: "rosette") Label(vm.tr("lit_finished"), systemImage: "rosette")
#if os(macOS)
.font(.largeTitle.weight(.bold))
#else
.font(.title2.weight(.bold)) .font(.title2.weight(.bold))
Text(vm.tr("lit_finished_text")).foregroundStyle(.secondary) #endif
Text(vm.fmt("lit_score_fmt", score, qs.count)).font(.headline) Text(vm.tr("lit_finished_text"))
#if os(macOS)
.font(.title3)
#endif
.foregroundStyle(.secondary)
Text(vm.fmt("lit_score_fmt", score, qs.count))
#if os(macOS)
.font(.title2.weight(.semibold))
#else
.font(.headline)
#endif
} }
} }
} else if idx < qs.count { } else if idx < qs.count {
let q = qs[idx] let q = qs[idx]
ProgressView(value: Double(idx), total: Double(qs.count)) ProgressView(value: Double(idx + (answered ? 1 : 0)), total: Double(qs.count))
HStack { HStack {
Text(vm.fmt("lit_question_fmt", idx + 1, qs.count)) Text(vm.fmt("lit_question_fmt", idx + 1, qs.count))
Spacer() Spacer()
Text(vm.fmt("lit_score_fmt", score, qs.count)) Text(vm.fmt("lit_score_fmt", score, qs.count))
} }
#if os(macOS)
.font(.body.weight(.semibold))
#else
.font(.footnote) .font(.footnote)
#endif
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(q.str("prompt")).font(.title3.weight(.semibold)).padding(.vertical, 12) Text(q.str("prompt"))
#if os(macOS)
.font(.title.weight(.semibold))
#else
.font(.title3.weight(.semibold))
#endif
.padding(.vertical, 12)
ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in ForEach(Array(q.strs("options").enumerated()), id: \.offset) { i, opt in
let correct = i == q.int("correct") let correct = i == q.int("correct")
let chosen = answered && picked == i let chosen = answered && picked == i

View file

@ -971,21 +971,59 @@ struct BookListScreen: View {
var body: some View { var body: 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 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"))) { NavigationLink(value: Route.book(b.str("id"))) {
Label { HStack(alignment: .top, spacing: 12) {
ListRowLabel(
title: b.str("title"),
subtitle: {
let genre = b.str("genre")
return genre.isEmpty ? vm.tr(b.str("subKey")) : genre
}()
)
} icon: {
Image(systemName: "book.fill") Image(systemName: "book.fill")
#if os(macOS) #if os(macOS)
.font(.title2)
#else
.font(.title3) .font(.title3)
#endif #endif
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text(parts.title)
#if os(macOS)
.font(.title3.weight(.semibold))
#else
.font(.body.weight(.semibold))
#endif
if let author = parts.author {
Text(author)
#if os(macOS)
.font(.body)
#else
.font(.subheadline)
#endif
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
let genre = b.str("genre")
Text(genre.isEmpty ? vm.tr(b.str("subKey")) : genre)
#if os(macOS)
.font(.subheadline)
#else
.font(.caption)
#endif
.foregroundStyle(.secondary)
if quizDone || plotDone {
Label(vm.tr("book_done"), systemImage: "checkmark.circle.fill")
#if os(macOS)
.font(.subheadline.weight(.semibold))
#else
.font(.caption.weight(.semibold))
#endif
.foregroundStyle(.green)
}
}
}
} }
#if os(macOS)
.padding(.vertical, 4)
#endif
} }
} }
} }
@ -1003,50 +1041,219 @@ struct BookScreen: View {
if let b = vm.content.book(id) { if let b = vm.content.book(id) {
let hasQuiz = !b.arr("quiz").isEmpty let hasQuiz = !b.arr("quiz").isEmpty
let hasPlot = !b.arr("plot").isEmpty let hasPlot = !b.arr("plot").isEmpty
List { let notes = b.arr("notes")
if hasQuiz { let quizDone = vm.progress.bookQuiz(id)
NavigationLink(value: Route.bookQuiz(id)) { let plotDone = vm.progress.bookPlot(id)
Label { let parts = bookTitleParts(b.str("title"))
ListRowLabel(title: vm.tr(b.str("quizTitle")), subtitle: vm.tr(b.str("quizSub"))) ScrollView {
} icon: { VStack(alignment: .leading, spacing: 28) {
Image(systemName: "questionmark.circle") GlassCard {
VStack(alignment: .leading, spacing: 10) {
if let author = parts.author {
Text(author)
#if os(macOS)
.font(.title3)
#else
.font(.subheadline)
#endif
.foregroundStyle(.secondary)
}
if !b.str("genre").isEmpty {
Text(b.str("genre"))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.tint)
}
Text(vm.tr("book_study_hint"))
#if os(macOS) #if os(macOS)
.font(.title3) .font(.title3)
#else
.font(.body)
#endif #endif
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
if !notes.isEmpty {
VStack(alignment: .leading, spacing: 14) {
Text(vm.tr("book_notes"))
#if os(macOS)
.font(.title.weight(.semibold))
#else
.font(.title2.weight(.semibold))
#endif
ForEach(Array(notes.enumerated()), id: \.offset) { _, note in
BookNoteCard(note: note)
}
}
}
if hasQuiz || hasPlot {
VStack(alignment: .leading, spacing: 14) {
Text(vm.tr("book_practice"))
#if os(macOS)
.font(.title.weight(.semibold))
#else
.font(.title2.weight(.semibold))
#endif
if hasQuiz {
NavigationLink(value: Route.bookQuiz(id)) {
BookActionCard(
title: vm.tr(b.str("quizTitle")),
subtitle: vm.tr(b.str("quizSub")),
systemImage: "questionmark.circle.fill",
done: quizDone,
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(.plain)
}
if hasPlot {
NavigationLink(value: Route.bookPlot(id)) {
BookActionCard(
title: vm.tr(b.str("plotTitle")),
subtitle: vm.tr(b.str("plotSub")),
systemImage: "list.number",
done: plotDone,
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(.plain)
}
} }
} }
} }
if hasPlot { #if os(macOS)
NavigationLink(value: Route.bookPlot(id)) { .padding(28)
Label { .frame(maxWidth: MacChrome.exerciseMaxWidth, alignment: .leading)
ListRowLabel(title: vm.tr(b.str("plotTitle")), subtitle: vm.tr(b.str("plotSub"))) .frame(maxWidth: .infinity, alignment: .leading)
} icon: { #else
Image(systemName: "list.number") .padding()
#if os(macOS) .padding(.bottom, 24)
.font(.title3) #endif
#endif }
} .navigationTitle(parts.title)
} .navigationSubtitle(parts.author ?? vm.tr(b.str("subKey")))
} }
ForEach(Array(b.arr("notes").enumerated()), id: \.offset) { _, note in }
Section(note.str("title")) { }
ForEach(Array(note.strs("lines").enumerated()), id: \.offset) { _, line in
private struct BookActionCard: View {
let title: String
let subtitle: String
let systemImage: String
let done: Bool
let doneLabel: String
var body: some View {
HStack(alignment: .center, spacing: 16) {
Image(systemName: systemImage)
#if os(macOS)
.font(.largeTitle)
#else
.font(.title)
#endif
.foregroundStyle(.tint)
.frame(width: 44)
VStack(alignment: .leading, spacing: 4) {
Text(title)
#if os(macOS)
.font(.title3.weight(.semibold))
#else
.font(.headline)
#endif
.foregroundStyle(.primary)
Text(subtitle)
#if os(macOS)
.font(.body)
#else
.font(.subheadline)
#endif
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
if done {
Image(systemName: "checkmark.circle.fill")
#if os(macOS)
.font(.title2)
#else
.font(.title3)
#endif
.foregroundStyle(.green)
.accessibilityLabel(doneLabel)
} else {
Image(systemName: "chevron.right")
.font(.body.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
#if os(macOS)
.padding(20)
#else
.padding(16)
#endif
.frame(maxWidth: .infinity, alignment: .leading)
.softSurface(cornerRadius: 18)
}
}
private struct BookNoteCard: View {
let note: J
var body: some View {
GlassCard {
VStack(alignment: .leading, spacing: 12) {
Label(note.str("title"), systemImage: noteSymbol(note.str("icon")))
#if os(macOS)
.font(.title2.weight(.semibold))
#else
.font(.headline)
#endif
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(note.strs("lines").enumerated()), id: \.offset) { _, line in
HStack(alignment: .top, spacing: 10) {
Text("•")
.foregroundStyle(.secondary)
Text(line) Text(line)
#if os(macOS) #if os(macOS)
.font(.title3) .font(.title3)
.padding(.vertical, 4) #else
.font(.body)
#endif #endif
.fixedSize(horizontal: false, vertical: true)
} }
} }
} }
} }
.navigationTitle(b.str("title"))
.navigationSubtitle(vm.tr(b.str("subKey")))
.appListStyle()
} }
} }
} }
private func bookTitleParts(_ title: String) -> (author: String?, title: String) {
let separators = [" – ", " — ", " - "]
for sep in separators {
if let range = title.range(of: sep) {
let author = String(title[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
let name = String(title[range.upperBound...]).trimmingCharacters(in: .whitespaces)
if !author.isEmpty && !name.isEmpty {
return (author, name)
}
}
}
return (nil, title)
}
private func noteSymbol(_ icon: String) -> String {
switch icon {
case "book": return "book.fill"
case "globe": return "globe.europe.africa.fill"
case "people": return "person.2.fill"
case "bulb": return "lightbulb.fill"
default: return "text.alignleft"
}
}
struct RouteDestination: View { struct RouteDestination: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
let route: Route let route: Route

View file

@ -549,6 +549,13 @@ const TrEntry tr_ui[] = {
{"Maturitní četba", "Maturitní četba", "Maturita reading list"}, {"Maturitní četba", "Maturitní četba", "Maturita reading list"},
{"reading_sub", "Vyberte knihu, kterou chcete studovat.", {"reading_sub", "Vyberte knihu, kterou chcete studovat.",
"Pick a book to study."}, "Pick a book to study."},
{"book_study_hint",
"Nejdřív si projděte zápisky. Pak si látku ověřte v kvízu a seřaďte děj.",
"Read the study notes first. Then check yourself with the quiz and order the plot."},
{"book_notes", "Zápisky k maturitě", "Study notes"},
{"book_practice", "Procvičení", "Practice"},
{"book_done", "Hotovo", "Done"},
{"book_open", "Otevřít", "Open"},
{"book_1984_sub", "Antiutopický román • 1949", "Dystopian novel • 1949"}, {"book_1984_sub", "Antiutopický román • 1949", "Dystopian novel • 1949"},
{"cetba1984_sub", "Přehled knihy, klíčové pojmy a cvičení.", {"cetba1984_sub", "Přehled knihy, klíčové pojmy a cvičení.",
"Book overview, key terms and exercises."}, "Book overview, key terms and exercises."},