Merge pull request 'Add background study timer' (#6) from feature/background-study-timer into main

This commit was merged in pull request #6.
This commit is contained in:
2026-04-13 09:45:07 -05:00
5 changed files with 170 additions and 3 deletions

View File

@@ -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 */ = {

View File

@@ -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,

View File

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

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

View File

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