Side by side on iPad, stacked vertically on iPhone. Fixes calendar grid overflowing on narrow screens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
340 lines
12 KiB
Swift
340 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() {
|
|
userProgress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
|
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)
|
|
}
|