v0.3.0 — Phase β-2 Study-Loop
Voller Lern-Flow mit Web-Parität: fällige Karten via /reviews/due laden, flip + rate (4 Buttons + Haptic), Grades via Offline-Queue ans Server-FSRS schicken. - Card/Review/DueReview DTOs mit snake_case + camelCase-deckId- Sonderfall im embedded card-Subobjekt - CardType-Enum (alle 7 Typen), Rating-Enum mit deutschen Labels - Cloze-Helper 1:1-Port aus cards-domain (extractClusterIds, subIndexCount, clusterId, renderPrompt/Answer, hint) - CardsAPI.dueReviews(deckId:) + gradeReview(cardId,subIndex,rating,reviewedAt) - PendingGrade SwiftData-Model + GradeQueue (FIFO-Drain, originaler Timestamp bleibt, bei Netzfehler in Queue, Retry beim nächsten Drain) - StudySession @Observable State-Machine - CardRenderer für basic, basic-reverse, cloze; Placeholder für image-occlusion/audio-front/typing/multiple-choice (β-3/β-4) - RatingBar mit UIImpactFeedbackGenerator (medium/heavy) - StudySessionView per NavigationLink aus DeckListView - 9 neue Tests (Cloze: 8, Review-Decoding: 3), insgesamt 17 grün Server-authoritative FSRS bleibt — kein ts-fsrs-Port. Endurance-Test auf realem Gerät steht aus (siehe PLAN.md). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f664a00b64
commit
3b861af3fb
15 changed files with 1013 additions and 23 deletions
|
|
@ -19,6 +19,11 @@ struct DeckListView: View {
|
|||
content
|
||||
}
|
||||
.navigationTitle("Decks")
|
||||
.navigationDestination(for: String.self) { deckId in
|
||||
if let deck = decks.first(where: { $0.id == deckId }) {
|
||||
StudySessionView(deckId: deck.id, deckName: deck.name)
|
||||
}
|
||||
}
|
||||
.toolbar { toolbar }
|
||||
.refreshable {
|
||||
await store?.refresh()
|
||||
|
|
@ -112,10 +117,13 @@ struct DeckListView: View {
|
|||
private var ownDecksSection: some View {
|
||||
Section {
|
||||
ForEach(decks) { deck in
|
||||
DeckRow(deck: deck)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||
NavigationLink(value: deck.id) {
|
||||
DeckRow(deck: deck)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
88
Sources/Features/Study/CardRenderer.swift
Normal file
88
Sources/Features/Study/CardRenderer.swift
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Rendert die Karten-Inhalte je nach `CardType`. Front-/Back-Seite
|
||||
/// werden über `isFlipped` gesteuert.
|
||||
///
|
||||
/// β-2 deckt `basic`, `basic-reverse`, `cloze` ab. Restliche Typen
|
||||
/// zeigen einen Placeholder mit Hinweis auf die kommende Phase.
|
||||
struct CardRenderer: View {
|
||||
let card: ReviewCard
|
||||
let subIndex: Int
|
||||
let isFlipped: Bool
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch card.type {
|
||||
case .basic:
|
||||
basicView(front: "front", back: "back")
|
||||
case .basicReverse:
|
||||
// sub_index 0 = front→back, sub_index 1 = back→front
|
||||
if subIndex == 0 {
|
||||
basicView(front: "front", back: "back")
|
||||
} else {
|
||||
basicView(front: "back", back: "front")
|
||||
}
|
||||
case .cloze:
|
||||
clozeView
|
||||
case .imageOcclusion, .audioFront, .typing, .multipleChoice:
|
||||
placeholderView
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func basicView(front frontKey: String, back backKey: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
text(card.fields[frontKey] ?? "")
|
||||
.font(.title2)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
if isFlipped {
|
||||
Divider().background(CardsTheme.border)
|
||||
text(card.fields[backKey] ?? "")
|
||||
.font(.title3)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var clozeView: some View {
|
||||
let raw = card.fields["text"] ?? ""
|
||||
let clusterId = Cloze.clusterId(for: raw, subIndex: subIndex) ?? 1
|
||||
let rendered = isFlipped
|
||||
? Cloze.renderAnswer(raw, activeClusterId: clusterId)
|
||||
: Cloze.renderPrompt(raw, activeClusterId: clusterId)
|
||||
VStack(spacing: 12) {
|
||||
text(rendered)
|
||||
.font(.title3)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var placeholderView: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "questionmark.square.dashed")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
Text("Card-Type »\(card.type.rawValue)« kommt in einer späteren Phase")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
|
||||
/// Markdown-Bold (`**...**`) wird auf SwiftUI's AttributedString gemappt.
|
||||
private func text(_ markdown: String) -> some View {
|
||||
let attributed = (try? AttributedString(
|
||||
markdown: markdown,
|
||||
options: AttributedString.MarkdownParsingOptions(
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace
|
||||
)
|
||||
)) ?? AttributedString(markdown)
|
||||
return Text(attributed)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
63
Sources/Features/Study/RatingBar.swift
Normal file
63
Sources/Features/Study/RatingBar.swift
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Vier Rating-Buttons unten am Bildschirm. Tap → onRate(rating)
|
||||
/// plus Haptic-Feedback.
|
||||
struct RatingBar: View {
|
||||
let onRate: (Rating) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Rating.allCases, id: \.self) { rating in
|
||||
Button {
|
||||
triggerHaptic(for: rating)
|
||||
onRate(rating)
|
||||
} label: {
|
||||
VStack(spacing: 2) {
|
||||
Text(rating.label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(rating.shortcut)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(background(for: rating), in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(foreground(for: rating))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private func background(for rating: Rating) -> Color {
|
||||
switch rating {
|
||||
case .again: CardsTheme.error.opacity(0.12)
|
||||
case .hard: CardsTheme.warning.opacity(0.12)
|
||||
case .good: CardsTheme.primary.opacity(0.12)
|
||||
case .easy: CardsTheme.success.opacity(0.12)
|
||||
}
|
||||
}
|
||||
|
||||
private func foreground(for rating: Rating) -> Color {
|
||||
switch rating {
|
||||
case .again: CardsTheme.error
|
||||
case .hard: CardsTheme.warning
|
||||
case .good: CardsTheme.primary
|
||||
case .easy: CardsTheme.success
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerHaptic(for rating: Rating) {
|
||||
#if canImport(UIKit)
|
||||
let generator = UIImpactFeedbackGenerator(
|
||||
style: rating == .easy ? .heavy : .medium
|
||||
)
|
||||
generator.impactOccurred()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
92
Sources/Features/Study/StudySession.swift
Normal file
92
Sources/Features/Study/StudySession.swift
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
import Observation
|
||||
import SwiftData
|
||||
|
||||
/// State-Machine für eine Lern-Session. Lädt Due-Reviews beim Start,
|
||||
/// rendert eine Karte nach der anderen, schickt Grades via GradeQueue ab.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class StudySession {
|
||||
enum Phase: Sendable {
|
||||
case loading
|
||||
case studying
|
||||
case finished
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .loading
|
||||
private(set) var queue: [DueReview] = []
|
||||
private(set) var currentIndex: Int = 0
|
||||
private(set) var isFlipped: Bool = false
|
||||
private(set) var totalGraded: Int = 0
|
||||
|
||||
let deckId: String
|
||||
let deckName: String
|
||||
|
||||
private let api: CardsAPI
|
||||
private let gradeQueue: GradeQueue
|
||||
|
||||
init(deckId: String, deckName: String, auth: AuthClient, context: ModelContext) {
|
||||
self.deckId = deckId
|
||||
self.deckName = deckName
|
||||
api = CardsAPI(auth: auth)
|
||||
gradeQueue = GradeQueue(api: api, context: context)
|
||||
}
|
||||
|
||||
var current: DueReview? {
|
||||
guard queue.indices.contains(currentIndex) else { return nil }
|
||||
return queue[currentIndex]
|
||||
}
|
||||
|
||||
var remaining: Int {
|
||||
max(0, queue.count - currentIndex)
|
||||
}
|
||||
|
||||
func start() async {
|
||||
phase = .loading
|
||||
do {
|
||||
queue = try await api.dueReviews(deckId: deckId, limit: 500)
|
||||
currentIndex = 0
|
||||
isFlipped = false
|
||||
totalGraded = 0
|
||||
if queue.isEmpty {
|
||||
phase = .finished
|
||||
} else {
|
||||
phase = .studying
|
||||
}
|
||||
Log.study.info("Session start — \(self.queue.count, privacy: .public) due in deck \(self.deckId, privacy: .public)")
|
||||
} catch {
|
||||
let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||
phase = .failed(msg)
|
||||
Log.study.error("Session start failed: \(msg, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func flip() {
|
||||
guard case .studying = phase else { return }
|
||||
isFlipped.toggle()
|
||||
}
|
||||
|
||||
func grade(_ rating: Rating) async {
|
||||
guard case .studying = phase, let card = current else { return }
|
||||
let reviewedAt = Date.now
|
||||
await gradeQueue.submit(
|
||||
cardId: card.review.cardId,
|
||||
subIndex: card.review.subIndex,
|
||||
rating: rating,
|
||||
reviewedAt: reviewedAt
|
||||
)
|
||||
totalGraded += 1
|
||||
advance()
|
||||
}
|
||||
|
||||
private func advance() {
|
||||
currentIndex += 1
|
||||
isFlipped = false
|
||||
if currentIndex >= queue.count {
|
||||
phase = .finished
|
||||
Log.study.info("Session finished — graded \(self.totalGraded, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
165
Sources/Features/Study/StudySessionView.swift
Normal file
165
Sources/Features/Study/StudySessionView.swift
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import ManaCore
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet.
|
||||
struct StudySessionView: View {
|
||||
let deckId: String
|
||||
let deckName: String
|
||||
|
||||
@Environment(AuthClient.self) private var auth
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var session: StudySession?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CardsTheme.background.ignoresSafeArea()
|
||||
content
|
||||
}
|
||||
.navigationTitle(deckName)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if let session, case .studying = session.phase {
|
||||
Text("\(session.remaining)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.accessibilityLabel("\(session.remaining) Karten übrig")
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if session == nil {
|
||||
let s = StudySession(deckId: deckId, deckName: deckName, auth: auth, context: context)
|
||||
session = s
|
||||
await s.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let session {
|
||||
switch session.phase {
|
||||
case .loading:
|
||||
ProgressView("Karten werden geladen …")
|
||||
.tint(CardsTheme.primary)
|
||||
case .studying:
|
||||
studyingView(session: session)
|
||||
case .finished:
|
||||
finishedView(session: session)
|
||||
case let .failed(message):
|
||||
failedView(message: message, session: session)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
.tint(CardsTheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
private func studyingView(session: StudySession) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
if let due = session.current {
|
||||
cardSurface(due: due, isFlipped: session.isFlipped)
|
||||
.onTapGesture {
|
||||
flipHaptic()
|
||||
session.flip()
|
||||
}
|
||||
if session.isFlipped {
|
||||
RatingBar { rating in
|
||||
Task { await session.grade(rating) }
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
} else {
|
||||
Button {
|
||||
flipHaptic()
|
||||
session.flip()
|
||||
} label: {
|
||||
Text("Antwort anzeigen")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(CardsTheme.primaryForeground)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
.animation(.easeInOut(duration: 0.2), value: session.isFlipped)
|
||||
.animation(.easeInOut(duration: 0.2), value: session.currentIndex)
|
||||
}
|
||||
|
||||
private func cardSurface(due: DueReview, isFlipped: Bool) -> some View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(CardsTheme.surface)
|
||||
.overlay(
|
||||
CardRenderer(
|
||||
card: due.card,
|
||||
subIndex: due.review.subIndex,
|
||||
isFlipped: isFlipped
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(CardsTheme.border, lineWidth: 1)
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
|
||||
private func finishedView(session: StudySession) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(CardsTheme.success)
|
||||
Text(session.totalGraded == 0 ? "Keine Karten fällig" : "Fertig!")
|
||||
.font(.title.bold())
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
if session.totalGraded > 0 {
|
||||
Text("\(session.totalGraded) Karten gelernt")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
Button("Zurück") { dismiss() }
|
||||
.padding(.top, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func failedView(message: String, session: StudySession) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "wifi.exclamationmark")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
Text("Karten konnten nicht geladen werden")
|
||||
.font(.headline)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.padding(.horizontal, 32)
|
||||
Button("Erneut versuchen") {
|
||||
Task { await session.start() }
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func flipHaptic() {
|
||||
#if canImport(UIKit)
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue