Add background study timer tracking foreground time per day
Track how long users spend studying by timing foreground sessions. StudyTimerService starts on app active, stops on background, and accumulates seconds into DailyLog.studySeconds (CloudKit-synced). Dashboard shows today/total study time with a 7-day bar chart. Closes #1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
|
||||
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
|
||||
/* 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 */ = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
46
Conjuga/Conjuga/Services/StudyTimerService.swift
Normal file
46
Conjuga/Conjuga/Services/StudyTimerService.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user