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() } keyboardShortcuts(session: session) bottomBar(session: session) } } .padding(.bottom, 20) .animation(.easeInOut(duration: 0.2), value: session.isFlipped) .animation(.easeInOut(duration: 0.2), value: session.currentIndex) } /// Fixe Höhe, damit der Wechsel zwischen "Antwort anzeigen" und /// `RatingBar` die Card oben nicht stauchen kann — sonst proportioniert /// `.aspectRatio(.fit)` die Card neu und das Layout springt. private func bottomBar(session: StudySession) -> some View { ZStack { if session.isFlipped { RatingBar { rating in Task { await session.grade(rating) } } .transition(.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) .transition(.opacity) } } .frame(height: 52) } private func cardSurface(due: DueReview, isFlipped: Bool) -> some View { CardSurface(size: .hero, elevation: .raised) { CardRenderer( card: due.card, subIndex: due.review.subIndex, isFlipped: isFlipped ) .frame(maxWidth: .infinity, maxHeight: .infinity) } .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) } /// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf /// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating. private func keyboardShortcuts(session: StudySession) -> some View { Group { Button("Flip") { flipHaptic() session.flip() } .keyboardShortcut(.space, modifiers: []) if session.isFlipped { ForEach(Rating.allCases, id: \.self) { rating in Button(rating.label) { Task { await session.grade(rating) } } .keyboardShortcut(KeyEquivalent(Character(rating.shortcut)), modifiers: []) } } } .frame(width: 0, height: 0) .opacity(0) .accessibilityHidden(true) } private func flipHaptic() { #if canImport(UIKit) UIImpactFeedbackGenerator(style: .soft).impactOccurred() #endif } }