Add background study timer #6
@@ -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