dce2cc1f51
Full Table (issue from chat): drop the level filter — Full Table tests regular conjugation patterns, not vocabulary recognition, so restricting to Basic-level verbs collapsed the eligible pool to two combos (vivir present, ir future). Pool now draws from all 1,750 verbs. Random sampling first; if 40 attempts fail we fall through to a deterministic shuffled scan that guarantees finding any eligible (verb, tense) combo when one exists. Returning nil now happens only when the user's filters genuinely produce zero eligible prompts. The view replaces its silent blank screen with a ContentUnavailableView pointing at the settings that need adjusting. FeatureReferenceView documents the level exception. Streak (issue #31 follow-up): activity recording was scoped to flashcard and Full Table reviews only, so spending an hour on textbook work, guides, videos, or AI chat could break a "streak" that the dashboard kept displaying as if it were intact. Three fixes: 1. Extract ReviewStore.recordActivity(context:) — a streak-only entry point that any user-initiated learning action can call. 2. Add UserProgress.validateStreakIfStale(today:context:) — resets a broken currentStreak to 0 immediately, called from app launch and dashboard appear so the displayed number is never a lie. 3. DailyLog formatter pins POSIX locale + current timezone so the yyyy-MM-dd strings can't drift across locales. Wired recordActivity into every previously-silent learning action: chat send, story-quiz completion, textbook exercise submit, grammar exercise completion, course-deck study finish, week test / checkpoint save, listening + pronunciation check, cloze quiz completion, lyrics word lookup, video stream / play / download success, sentence-builder check, and course-vocab SRS rate (which was bypassing ReviewStore entirely). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
343 lines
12 KiB
Swift
343 lines
12 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
import Charts
|
|
|
|
struct DashboardView: View {
|
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
|
@Environment(StudyTimerService.self) private var studyTimer
|
|
@State private var userProgress: UserProgress?
|
|
@State private var dailyLogs: [DailyLog] = []
|
|
@State private var testResults: [TestResult] = []
|
|
@State private var reviewCards: [ReviewCard] = []
|
|
@State private var showingSettings = false
|
|
|
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
// Summary stats
|
|
statsGrid
|
|
|
|
// Study time + Activity — side by side on iPad, stacked on iPhone
|
|
ViewThatFits(in: .horizontal) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
studyTimeCard
|
|
streakCalendar
|
|
}
|
|
VStack(spacing: 12) {
|
|
studyTimeCard
|
|
streakCalendar
|
|
}
|
|
}
|
|
|
|
// Accuracy chart
|
|
accuracyChart
|
|
|
|
// Test scores
|
|
if !testResults.isEmpty {
|
|
testScoresSection
|
|
}
|
|
}
|
|
.padding()
|
|
.adaptiveContainer(maxWidth: 800)
|
|
}
|
|
.navigationTitle("Dashboard")
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
showingSettings = true
|
|
} label: {
|
|
Image(systemName: "gearshape")
|
|
}
|
|
.accessibilityLabel("Settings")
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingSettings) {
|
|
SettingsView()
|
|
}
|
|
.onAppear(perform: loadData)
|
|
}
|
|
}
|
|
|
|
// MARK: - Stats Grid
|
|
|
|
private var statsGrid: some View {
|
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
|
StatCard(title: "Total Reviews", value: "\(userProgress?.totalReviewed ?? 0)", icon: "checkmark.circle", color: .blue)
|
|
|
|
StatCard(title: "Current Streak", value: "\(userProgress?.currentStreak ?? 0)d", icon: "flame.fill", color: .orange)
|
|
|
|
StatCard(title: "Longest Streak", value: "\(userProgress?.longestStreak ?? 0)d", icon: "trophy.fill", color: .yellow)
|
|
|
|
let mastered = reviewCards.filter { $0.interval >= 21 }.count
|
|
StatCard(title: "Verbs Mastered", value: "\(mastered)", icon: "star.fill", color: .green)
|
|
|
|
let avgAccuracy = averageAccuracy
|
|
StatCard(title: "Avg Accuracy", value: "\(avgAccuracy)%", icon: "target", color: .purple)
|
|
|
|
StatCard(title: "Tests Taken", value: "\(testResults.count)", icon: "pencil.and.list.clipboard", color: .indigo)
|
|
}
|
|
}
|
|
|
|
// MARK: - Study Time Card
|
|
|
|
private var studyTimeCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Study Time")
|
|
.font(.headline)
|
|
|
|
let todaySeconds = todayStudySeconds + studyTimer.currentSessionSeconds
|
|
HStack(spacing: 0) {
|
|
VStack(spacing: 4) {
|
|
Text(formatStudyTime(todaySeconds))
|
|
.font(.title3.bold().monospacedDigit())
|
|
Text("Today")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
VStack(spacing: 4) {
|
|
Text(formatStudyTime(totalStudySeconds))
|
|
.font(.title3.bold().monospacedDigit())
|
|
Text("Total")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
if weeklyStudyData.allSatisfy({ $0.minutes == 0 }) {
|
|
Text("Start studying to see your time")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, minHeight: 80)
|
|
} else {
|
|
Chart(weeklyStudyData) { day in
|
|
BarMark(
|
|
x: .value("Day", day.label),
|
|
y: .value("Minutes", day.minutes)
|
|
)
|
|
.foregroundStyle(.mint.gradient)
|
|
.cornerRadius(4)
|
|
}
|
|
.chartYAxis(.hidden)
|
|
.frame(height: 80)
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
|
|
// MARK: - Streak Calendar
|
|
|
|
private var streakCalendar: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Activity")
|
|
.font(.headline)
|
|
|
|
StreakCalendarView(dailyLogs: dailyLogs)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
|
|
// MARK: - Accuracy Chart
|
|
|
|
private var accuracyChart: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Accuracy (Last 30 Days)")
|
|
.font(.headline)
|
|
|
|
if recentLogs.isEmpty {
|
|
Text("Start practicing to see your accuracy trend")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.frame(height: 150)
|
|
} else {
|
|
Chart(recentLogs, id: \.dateString) { log in
|
|
LineMark(
|
|
x: .value("Date", DailyLog.date(from: log.dateString) ?? Date()),
|
|
y: .value("Accuracy", log.accuracy * 100)
|
|
)
|
|
.foregroundStyle(.blue)
|
|
.interpolationMethod(.catmullRom)
|
|
|
|
AreaMark(
|
|
x: .value("Date", DailyLog.date(from: log.dateString) ?? Date()),
|
|
y: .value("Accuracy", log.accuracy * 100)
|
|
)
|
|
.foregroundStyle(.blue.opacity(0.1))
|
|
.interpolationMethod(.catmullRom)
|
|
}
|
|
.chartYScale(domain: 0...100)
|
|
.chartYAxis {
|
|
AxisMarks(values: [0, 25, 50, 75, 100]) { value in
|
|
AxisValueLabel {
|
|
Text("\(value.as(Int.self) ?? 0)%")
|
|
.font(.caption2)
|
|
}
|
|
AxisGridLine()
|
|
}
|
|
}
|
|
.frame(height: 180)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
|
|
// MARK: - Test Scores
|
|
|
|
private var testScoresSection: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Text("Recent Tests")
|
|
.font(.headline)
|
|
Spacer()
|
|
if let best = testResults.map(\.scorePercent).max() {
|
|
Text("Best: \(best)%")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.green)
|
|
}
|
|
}
|
|
|
|
ForEach(Array(testResults.sorted { $0.dateTaken > $1.dateTaken }.prefix(5).enumerated()), id: \.offset) { _, result in
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Week \(result.weekNumber)")
|
|
.font(.subheadline.weight(.medium))
|
|
Text(result.dateTaken.formatted(date: .abbreviated, time: .omitted))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text("\(result.scorePercent)%")
|
|
.font(.title3.weight(.bold))
|
|
.foregroundStyle(result.scorePercent >= 90 ? .green : result.scorePercent >= 70 ? .orange : .red)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
|
|
// MARK: - Study Time Computed
|
|
|
|
private var todayStudySeconds: Int {
|
|
let today = DailyLog.todayString()
|
|
return dailyLogs.first { $0.dateString == today }?.studySeconds ?? 0
|
|
}
|
|
|
|
private var totalStudySeconds: Int {
|
|
dailyLogs.reduce(0) { $0 + $1.studySeconds } + studyTimer.currentSessionSeconds
|
|
}
|
|
|
|
private var weeklyStudySeconds: Int {
|
|
weeklyStudyData.reduce(0) { $0 + $1.minutes } * 60
|
|
}
|
|
|
|
private var weeklyStudyData: [StudyDay] {
|
|
let calendar = Calendar.current
|
|
let today = calendar.startOfDay(for: Date())
|
|
return (0..<7).reversed().map { daysAgo in
|
|
let date = calendar.date(byAdding: .day, value: -daysAgo, to: today)!
|
|
let dateStr = DailyLog.dateString(from: date)
|
|
let seconds = dailyLogs.first { $0.dateString == dateStr }?.studySeconds ?? 0
|
|
let dayLabel = daysAgo == 0 ? "Today" : Self.shortDayFormatter.string(from: date)
|
|
return StudyDay(label: dayLabel, minutes: seconds / 60)
|
|
}
|
|
}
|
|
|
|
private static let shortDayFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "EEE"
|
|
return f
|
|
}()
|
|
|
|
private func formatStudyTime(_ totalSeconds: Int) -> String {
|
|
let hours = totalSeconds / 3600
|
|
let minutes = (totalSeconds % 3600) / 60
|
|
if hours > 0 {
|
|
return "\(hours)h \(minutes)m"
|
|
}
|
|
return "\(minutes)m"
|
|
}
|
|
|
|
// MARK: - Computed
|
|
|
|
private var recentLogs: [DailyLog] {
|
|
return dailyLogs
|
|
.filter { $0.reviewCount > 0 }
|
|
.suffix(30)
|
|
.reversed()
|
|
}
|
|
|
|
private var averageAccuracy: Int {
|
|
let logsWithData = dailyLogs.filter { $0.reviewCount > 0 }
|
|
guard !logsWithData.isEmpty else { return 0 }
|
|
let total = logsWithData.reduce(0.0) { $0 + $1.accuracy }
|
|
return Int(total / Double(logsWithData.count) * 100)
|
|
}
|
|
|
|
private func loadData() {
|
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
|
// Reset a stale streak before rendering so the dashboard never lies.
|
|
progress.validateStreakIfStale(context: cloudModelContext)
|
|
userProgress = progress
|
|
let dailyDescriptor = FetchDescriptor<DailyLog>(
|
|
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
|
|
)
|
|
dailyLogs = (try? cloudModelContext.fetch(dailyDescriptor)) ?? []
|
|
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
|
|
reviewCards = (try? cloudModelContext.fetch(FetchDescriptor<ReviewCard>())) ?? []
|
|
try? cloudModelContext.save()
|
|
}
|
|
}
|
|
|
|
// MARK: - Stat Card
|
|
|
|
private struct StatCard: View {
|
|
let title: String
|
|
let value: String
|
|
let icon: String
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: icon)
|
|
.font(.title2)
|
|
.foregroundStyle(color)
|
|
|
|
Text(value)
|
|
.font(.title2.bold().monospacedDigit())
|
|
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 16)
|
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 14))
|
|
}
|
|
}
|
|
|
|
private struct StudyDay: Identifiable {
|
|
let label: String
|
|
let minutes: Int
|
|
var id: String { label }
|
|
}
|
|
|
|
#Preview {
|
|
DashboardView()
|
|
.environment(StudyTimerService())
|
|
.modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true)
|
|
}
|