Files
Spanish/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift
Trey t a3318adf5e Use ViewThatFits for study time and activity cards layout
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>
2026-04-13 16:28:02 -05:00

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