Files
Spanish/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift
Trey t 4b467ec136 Initial commit: Conjuga Spanish conjugation app
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>
2026-04-09 20:58:33 -05:00

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)
}