diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index 604d7b8..7cbe7fe 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -78,6 +78,7 @@ F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; }; F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; }; FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; }; + DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -180,6 +181,7 @@ E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = ""; }; E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = ""; }; + 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -246,6 +248,7 @@ 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */, 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */, 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */, + 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */, 777C696A841803D5B775B678 /* ReferenceStore.swift */, CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */, 49E3AD244327CBF24B7A2752 /* SpeechService.swift */, @@ -589,7 +592,8 @@ 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */, 968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, - ); + DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */, +); runOnlyForDeploymentPostprocessing = 0; }; 217A29BCEDD9D44B6DD85AF6 /* Sources */ = { diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index ff8883e..78e61cd 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -38,6 +38,7 @@ struct ConjugaApp: App { @Environment(\.scenePhase) private var scenePhase @State private var isReady = false @State private var syncMonitor = SyncStatusMonitor() + @State private var studyTimer = StudyTimerService() let localContainer: ModelContainer let cloudContainer: ModelContainer @@ -106,6 +107,7 @@ struct ConjugaApp: App { .animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast) .environment(syncMonitor) .environment(\.cloudModelContextProvider, { cloudContainer.mainContext }) + .environment(studyTimer) .task { if let url = SharedStore.localStoreURL() { StoreInspector.dump(at: url, label: "before-bootstrap") @@ -130,6 +132,15 @@ struct ConjugaApp: App { } } .onChange(of: scenePhase) { _, newPhase in + switch newPhase { + case .active: + studyTimer.start() + case .inactive, .background: + studyTimer.stop(context: cloudContainer.mainContext) + @unknown default: + break + } + if newPhase == .background { WidgetDataService.update( localContainer: localContainer, diff --git a/Conjuga/Conjuga/Models/DailyLog.swift b/Conjuga/Conjuga/Models/DailyLog.swift index 4554d41..c5e2d02 100644 --- a/Conjuga/Conjuga/Models/DailyLog.swift +++ b/Conjuga/Conjuga/Models/DailyLog.swift @@ -7,6 +7,7 @@ final class DailyLog { var dateString: String = "" var reviewCount: Int = 0 var correctCount: Int = 0 + var studySeconds: Int = 0 var accuracy: Double { guard reviewCount > 0 else { return 0 } diff --git a/Conjuga/Conjuga/Services/StudyTimerService.swift b/Conjuga/Conjuga/Services/StudyTimerService.swift new file mode 100644 index 0000000..00583cd --- /dev/null +++ b/Conjuga/Conjuga/Services/StudyTimerService.swift @@ -0,0 +1,46 @@ +import Foundation +import SwiftData +import Observation + +@MainActor +@Observable +final class StudyTimerService { + private(set) var sessionStart: Date? + private(set) var tick: Int = 0 + private var timer: Timer? + + var isRunning: Bool { sessionStart != nil } + + /// Seconds elapsed in the current live session. + var currentSessionSeconds: Int { + // Access `tick` so SwiftUI re-evaluates each second. + _ = tick + guard let start = sessionStart else { return 0 } + return max(0, Int(Date().timeIntervalSince(start))) + } + + func start() { + guard sessionStart == nil else { return } + sessionStart = Date() + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.tick += 1 + } + } + } + + func stop(context: ModelContext) { + guard let start = sessionStart else { return } + let elapsed = max(0, Int(Date().timeIntervalSince(start))) + sessionStart = nil + timer?.invalidate() + timer = nil + + guard elapsed > 0 else { return } + + let todayString = DailyLog.todayString() + let log = ReviewStore.fetchOrCreateDailyLog(dateString: todayString, context: context) + log.studySeconds += elapsed + try? context.save() + } +} diff --git a/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift b/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift index 2d810c4..5f5a525 100644 --- a/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift +++ b/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift @@ -4,6 +4,7 @@ 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] = [] @@ -19,8 +20,11 @@ struct DashboardView: View { // Summary stats statsGrid - // Streak calendar - streakCalendar + // Study time + Activity side by side + HStack(alignment: .top, spacing: 12) { + studyTimeCard + streakCalendar + } // Accuracy chart accuracyChart @@ -71,6 +75,57 @@ struct DashboardView: View { } } + // 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 { @@ -81,6 +136,7 @@ struct DashboardView: View { StreakCalendarView(dailyLogs: dailyLogs) } .padding() + .frame(maxWidth: .infinity) .background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16)) } @@ -167,6 +223,48 @@ struct DashboardView: View { .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] { @@ -222,7 +320,14 @@ private struct StatCard: View { } } +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) }