Includes SwiftData dual-store architecture (local reference + CloudKit user data), JSON-based data seeding, 20 tense guides, 20 grammar notes, SRS review system, course vocabulary, and widget support. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
202 lines
6.7 KiB
Swift
202 lines
6.7 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
import Charts
|
|
|
|
struct DashboardView: View {
|
|
@Query private var progress: [UserProgress]
|
|
@Query(sort: \DailyLog.dateString, order: .reverse) private var dailyLogs: [DailyLog]
|
|
@Query private var testResults: [TestResult]
|
|
@Query private var reviewCards: [ReviewCard]
|
|
|
|
private var userProgress: UserProgress? { progress.first }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
// Summary stats
|
|
statsGrid
|
|
|
|
// Streak calendar
|
|
streakCalendar
|
|
|
|
// Accuracy chart
|
|
accuracyChart
|
|
|
|
// Test scores
|
|
if !testResults.isEmpty {
|
|
testScoresSection
|
|
}
|
|
}
|
|
.padding()
|
|
.adaptiveContainer(maxWidth: 800)
|
|
}
|
|
.navigationTitle("Dashboard")
|
|
}
|
|
}
|
|
|
|
// 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: - Streak Calendar
|
|
|
|
private var streakCalendar: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Activity")
|
|
.font(.headline)
|
|
|
|
StreakCalendarView(dailyLogs: dailyLogs)
|
|
}
|
|
.padding()
|
|
.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: - 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)
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
DashboardView()
|
|
.modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true)
|
|
}
|