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:
@@ -78,6 +78,7 @@
|
|||||||
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
|
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
|
||||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
|
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
|
||||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -180,6 +181,7 @@
|
|||||||
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -246,6 +248,7 @@
|
|||||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
||||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||||
|
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
|
||||||
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
||||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
||||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
||||||
@@ -589,7 +592,8 @@
|
|||||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||||
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
|
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
|
||||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||||
);
|
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */,
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
217A29BCEDD9D44B6DD85AF6 /* Sources */ = {
|
217A29BCEDD9D44B6DD85AF6 /* Sources */ = {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ struct ConjugaApp: App {
|
|||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var isReady = false
|
@State private var isReady = false
|
||||||
@State private var syncMonitor = SyncStatusMonitor()
|
@State private var syncMonitor = SyncStatusMonitor()
|
||||||
|
@State private var studyTimer = StudyTimerService()
|
||||||
|
|
||||||
let localContainer: ModelContainer
|
let localContainer: ModelContainer
|
||||||
let cloudContainer: ModelContainer
|
let cloudContainer: ModelContainer
|
||||||
@@ -106,6 +107,7 @@ struct ConjugaApp: App {
|
|||||||
.animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast)
|
.animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast)
|
||||||
.environment(syncMonitor)
|
.environment(syncMonitor)
|
||||||
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
|
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
|
||||||
|
.environment(studyTimer)
|
||||||
.task {
|
.task {
|
||||||
if let url = SharedStore.localStoreURL() {
|
if let url = SharedStore.localStoreURL() {
|
||||||
StoreInspector.dump(at: url, label: "before-bootstrap")
|
StoreInspector.dump(at: url, label: "before-bootstrap")
|
||||||
@@ -130,6 +132,15 @@ struct ConjugaApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.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 {
|
if newPhase == .background {
|
||||||
WidgetDataService.update(
|
WidgetDataService.update(
|
||||||
localContainer: localContainer,
|
localContainer: localContainer,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ final class DailyLog {
|
|||||||
var dateString: String = ""
|
var dateString: String = ""
|
||||||
var reviewCount: Int = 0
|
var reviewCount: Int = 0
|
||||||
var correctCount: Int = 0
|
var correctCount: Int = 0
|
||||||
|
var studySeconds: Int = 0
|
||||||
|
|
||||||
var accuracy: Double {
|
var accuracy: Double {
|
||||||
guard reviewCount > 0 else { return 0 }
|
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 {
|
struct DashboardView: View {
|
||||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@Environment(StudyTimerService.self) private var studyTimer
|
||||||
@State private var userProgress: UserProgress?
|
@State private var userProgress: UserProgress?
|
||||||
@State private var dailyLogs: [DailyLog] = []
|
@State private var dailyLogs: [DailyLog] = []
|
||||||
@State private var testResults: [TestResult] = []
|
@State private var testResults: [TestResult] = []
|
||||||
@@ -19,8 +20,11 @@ struct DashboardView: View {
|
|||||||
// Summary stats
|
// Summary stats
|
||||||
statsGrid
|
statsGrid
|
||||||
|
|
||||||
// Streak calendar
|
// Study time + Activity side by side
|
||||||
streakCalendar
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
studyTimeCard
|
||||||
|
streakCalendar
|
||||||
|
}
|
||||||
|
|
||||||
// Accuracy chart
|
// Accuracy chart
|
||||||
accuracyChart
|
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
|
// MARK: - Streak Calendar
|
||||||
|
|
||||||
private var streakCalendar: some View {
|
private var streakCalendar: some View {
|
||||||
@@ -81,6 +136,7 @@ struct DashboardView: View {
|
|||||||
StreakCalendarView(dailyLogs: dailyLogs)
|
StreakCalendarView(dailyLogs: dailyLogs)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +223,48 @@ struct DashboardView: View {
|
|||||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
.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
|
// MARK: - Computed
|
||||||
|
|
||||||
private var recentLogs: [DailyLog] {
|
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 {
|
#Preview {
|
||||||
DashboardView()
|
DashboardView()
|
||||||
|
.environment(StudyTimerService())
|
||||||
.modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true)
|
.modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user