Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions App/AppContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ final class AppContainer {
let backupFolder: any BackupFolder
let notificationScheduler: any NotificationScheduling
let examRepository: any ExamRepository
let studySessionRepository: any StudySessionRepository
/// Einstellungen und Lesefortschritt; im Speicher für Tests und Previews, sonst in `UserDefaults`.
let preferences: any StudySettingsStore & ReadingProgressStore

Expand All @@ -44,6 +45,7 @@ final class AppContainer {
notificationScheduler = UserNotificationScheduler()
examRepository = SwiftDataExamRepository(context: context)
announcementRepository = SwiftDataAnnouncementRepository(context: context)
studySessionRepository = SwiftDataStudySessionRepository(context: context)
calendarFeedLoader = URLSessionCalendarFeedLoader()
calendarEventParser = ICalendarFeedParser()
pdfInspector = PDFKitInspector()
Expand Down Expand Up @@ -100,6 +102,7 @@ final class AppContainer {
calendar: studyCalendarRepository,
courseFiles: courseFileRepository,
exams: examRepository,
studySessions: studySessionRepository,
files: fileStorage
)
}
Expand Down Expand Up @@ -138,6 +141,10 @@ final class AppContainer {
AnnouncementsViewModel(repository: announcementRepository, modules: moduleRepository)
}

func makeStudyTimerViewModel() -> StudyTimerViewModel {
StudyTimerViewModel(repository: studySessionRepository, settings: preferences, scheduler: notificationScheduler)
}

func makeAssignmentsViewModel() -> AssignmentsViewModel {
AssignmentsViewModel(repository: assignmentRepository)
}
Expand Down
5 changes: 5 additions & 0 deletions App/StudyAppApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ struct StudyAppApp: App {
@State private var notificationsViewModel: NotificationsViewModel
@State private var examsViewModel: ExamsViewModel
@State private var announcementsViewModel: AnnouncementsViewModel
@State private var studyTimerViewModel: StudyTimerViewModel
@State private var updater = AppUpdater()
@AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard

Expand Down Expand Up @@ -65,6 +66,7 @@ struct StudyAppApp: App {
}
responder.install()
notificationResponder = responder
let studyTimer = container.makeStudyTimerViewModel()
let backup = container.makeBackupViewModel()
// After a restore every screen shows the restored data.
backup.onRestored = {
Expand All @@ -74,11 +76,13 @@ struct StudyAppApp: App {
assignments.load()
exams.load()
announcements.load()
studyTimer.load()
Task { await notifications.refresh() }
}
_backupViewModel = State(initialValue: backup)
_examsViewModel = State(initialValue: exams)
_announcementsViewModel = State(initialValue: announcements)
_studyTimerViewModel = State(initialValue: studyTimer)
} catch {
fatalError("Datenbank konnte nicht geöffnet werden: \(error)")
}
Expand All @@ -96,6 +100,7 @@ struct StudyAppApp: App {
backupViewModel: backupViewModel,
examsViewModel: examsViewModel,
announcementsViewModel: announcementsViewModel,
studyTimerViewModel: studyTimerViewModel,
makeSlidesViewModel: { moduleID in
container.makeSlidesViewModel(moduleID: moduleID)
},
Expand Down
10 changes: 10 additions & 0 deletions Data/Preferences/UserDefaultsStudyPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ final class UserDefaultsStudyPreferences: StudySettingsStore, ReadingProgressSto
static let lastAutoOpenedLecture = "autopilot.lastHandledLecture"
static let automaticBackups = "backup.automatic"
static let notificationSettings = "notifications.settings"
static let studyTimerSettings = "studyTimer.settings"
static func lastPage(_ documentID: UUID) -> String { "reading.page.\(documentID.uuidString)" }
static func lastDocument(_ moduleID: UUID) -> String { "reading.document.\(moduleID.uuidString)" }
}
Expand Down Expand Up @@ -69,6 +70,15 @@ final class UserDefaultsStudyPreferences: StudySettingsStore, ReadingProgressSto
set { defaults.set(try? JSONEncoder().encode(newValue), forKey: Key.notificationSettings) }
}

/// Stored as JSON; unreadable or missing data falls back to the defaults.
var studyTimerSettings: StudyTimerSettings {
get {
defaults.data(forKey: Key.studyTimerSettings)
.flatMap { try? JSONDecoder().decode(StudyTimerSettings.self, from: $0) } ?? StudyTimerSettings()
}
set { defaults.set(try? JSONEncoder().encode(newValue), forKey: Key.studyTimerSettings) }
}

func lastPage(for documentID: UUID) -> Int? {
let page = defaults.integer(forKey: Key.lastPage(documentID))
return page > 0 ? page : nil
Expand Down
30 changes: 30 additions & 0 deletions Data/Repositories/SwiftDataStudySessionRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation
import SwiftData

@MainActor
final class SwiftDataStudySessionRepository: StudySessionRepository {
private let context: ModelContext
init(context: ModelContext) { self.context = context }

func all() throws -> [StudySession] {
let descriptor = FetchDescriptor<StudySessionRecord>(sortBy: [SortDescriptor(\.startedAt)])
return try context.fetch(descriptor).map { $0.toDomain() }
}

func add(_ session: StudySession) throws {
context.insert(StudySessionRecord(session))
try context.save()
}

func deleteSessions(forModule moduleID: UUID) throws {
let records = try context.fetch(FetchDescriptor<StudySessionRecord>()).filter { $0.moduleID == moduleID }
guard !records.isEmpty else { return }
for record in records { context.delete(record) }
try context.save()
}

func deleteAll() throws {
try context.delete(model: StudySessionRecord.self)
try context.save()
}
}
1 change: 1 addition & 0 deletions Data/SwiftData/Persistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ enum Persistence {
CourseFileRecord.self,
ExamRecord.self,
AnnouncementRecord.self,
StudySessionRecord.self,
])

static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
Expand Down
22 changes: 22 additions & 0 deletions Data/SwiftData/StudySessionRecord.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation
import SwiftData

/// A completed focus phase of the study timer.
@Model
final class StudySessionRecord {
@Attribute(.unique) var id: UUID
var moduleID: UUID?
var startedAt: Date
var duration: TimeInterval

init(_ session: StudySession) {
id = session.id
moduleID = session.moduleID
startedAt = session.startedAt
duration = session.duration
}

func toDomain() -> StudySession {
StudySession(id: id, moduleID: moduleID, startedAt: startedAt, duration: duration)
}
}
2 changes: 2 additions & 0 deletions Domain/Entities/Backup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ struct BackupSnapshot: Codable, Equatable, Sendable {
var courseFiles: [CourseFile] = []
/// Manual exams plus the stored state (hidden, study plan progress) of detected ones.
var exams: [Exam] = []
/// Completed focus phases of the study timer.
var studySessions: [StudySession] = []

/// Names of the stored files (`FileStorage`) the snapshot refers to: slide PDFs and downloaded course files.
var referencedFiles: [String] {
Expand Down
2 changes: 2 additions & 0 deletions Domain/Entities/StudyNotification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ struct StudyNotification: Equatable, Sendable {

/// Every identifier StudyApp schedules starts with this prefix, so foreign requests are never touched.
static let identifierPrefix = "studyapp."
/// The study timer's „phase is over“ notification. Not part of the plan, so the plan sync keeps it.
static let studyTimerIdentifier = "studyapp.timer"
}

/// Which notifications the user wants. Stored as one value so new options do not need new storage keys.
Expand Down
42 changes: 42 additions & 0 deletions Domain/Entities/StudySession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation

/// A completed focus phase of the study timer.
struct StudySession: Identifiable, Hashable, Codable, Sendable {
let id: UUID
/// `nil` when the timer ran without a module.
var moduleID: UUID?
var startedAt: Date
var duration: TimeInterval

var endedAt: Date { startedAt.addingTimeInterval(duration) }
}

/// Lengths of the study timer's phases (Pomodoro technique).
struct StudyTimerSettings: Codable, Equatable, Sendable {
var focusMinutes = 25
var shortBreakMinutes = 5
var longBreakMinutes = 15
/// A long break follows every this many focus phases.
var focusPhasesPerLongBreak = 4

static let minuteRange = 1...120
}

extension StudyTimerSettings {
private enum CodingKeys: String, CodingKey {
case focusMinutes, shortBreakMinutes, longBreakMinutes, focusPhasesPerLongBreak
}

/// Missing keys fall back to the defaults, so stored settings survive new options.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let defaults = StudyTimerSettings()
self.init(
focusMinutes: try container.decodeIfPresent(Int.self, forKey: .focusMinutes) ?? defaults.focusMinutes,
shortBreakMinutes: try container.decodeIfPresent(Int.self, forKey: .shortBreakMinutes) ?? defaults.shortBreakMinutes,
longBreakMinutes: try container.decodeIfPresent(Int.self, forKey: .longBreakMinutes) ?? defaults.longBreakMinutes,
focusPhasesPerLongBreak: try container.decodeIfPresent(Int.self, forKey: .focusPhasesPerLongBreak)
?? defaults.focusPhasesPerLongBreak
)
}
}
1 change: 1 addition & 0 deletions Domain/Repositories/BackupArchiving.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,6 @@ struct StudyDataRepositories {
let calendar: any StudyCalendarRepository
let courseFiles: any CourseFileRepository
let exams: any ExamRepository
let studySessions: any StudySessionRepository
let files: any FileStorage
}
2 changes: 2 additions & 0 deletions Domain/Repositories/StudyPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ protocol StudySettingsStore: AnyObject {
/// Create a weekly backup in the app's backup folder.
var automaticBackups: Bool { get set }
var notificationSettings: NotificationSettings { get set }
var studyTimerSettings: StudyTimerSettings { get set }
}

extension StudySettingsStore {
Expand All @@ -45,6 +46,7 @@ final class InMemoryStudyPreferences: StudySettingsStore, ReadingProgressStore {
var lastAutoOpenedLecture: String?
var automaticBackups = true
var notificationSettings = NotificationSettings()
var studyTimerSettings = StudyTimerSettings()
private var pages: [UUID: Int] = [:]
private var documents: [UUID: UUID] = [:]

Expand Down
10 changes: 10 additions & 0 deletions Domain/Repositories/StudySessionRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation

/// Stores the study timer's completed focus phases.
@MainActor
protocol StudySessionRepository {
func all() throws -> [StudySession]
func add(_ session: StudySession) throws
func deleteSessions(forModule moduleID: UUID) throws
func deleteAll() throws
}
3 changes: 3 additions & 0 deletions Domain/UseCases/BackupUseCases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct MakeBackupSnapshotUseCase {
snapshot.calendarSources = try repositories.calendar.sources()
snapshot.calendarEvents = try repositories.calendar.events()
snapshot.exams = try repositories.exams.all()
snapshot.studySessions = try repositories.studySessions.all()
return snapshot
}
}
Expand Down Expand Up @@ -138,6 +139,7 @@ struct RestoreBackupUseCase {
for exam in try repositories.exams.all() {
try repositories.exams.delete(id: exam.id)
}
try repositories.studySessions.deleteAll()
}

private func insert(_ snapshot: BackupSnapshot) throws {
Expand All @@ -155,6 +157,7 @@ struct RestoreBackupUseCase {
)
}
for exam in snapshot.exams { try repositories.exams.save(exam) }
for session in snapshot.studySessions { try repositories.studySessions.add(session) }
for (moduleID, files) in Dictionary(grouping: snapshot.courseFiles, by: \.moduleID) {
try repositories.courseFiles.replaceCatalog(forModule: moduleID, with: files)
}
Expand Down
1 change: 1 addition & 0 deletions Domain/UseCases/NotificationUseCases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ struct SyncNotificationsUseCase {
let wanted = Set(planned.map(\.id))
let stale = await scheduler.pendingIdentifiers().filter {
$0.hasPrefix(StudyNotification.identifierPrefix) && !wanted.contains($0)
&& $0 != StudyNotification.studyTimerIdentifier
}
scheduler.cancel(identifiers: stale)
await scheduler.schedule(planned)
Expand Down
66 changes: 66 additions & 0 deletions Domain/UseCases/StudyStatistics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Foundation

/// Study time figures for the statistics view, computed from recorded sessions.
struct StudyStatistics: Equatable, Sendable {
struct Day: Equatable, Sendable {
let date: Date
let minutes: Int
}

struct ModuleTime: Equatable, Sendable {
let moduleID: UUID?
let minutes: Int
}

/// The last seven days, oldest first, including today.
let lastSevenDays: [Day]
/// Minutes in the current calendar week.
let minutesThisWeek: Int
/// Minutes per module in the current calendar week, most first.
let modulesThisWeek: [ModuleTime]
/// Consecutive days with at least one session, ending today (or yesterday, if today has none yet).
let streakDays: Int

init(sessions: [StudySession], now: Date, calendar: Calendar = .current) {
let today = calendar.startOfDay(for: now)
var minutesPerDay: [Date: TimeInterval] = [:]
for session in sessions {
minutesPerDay[calendar.startOfDay(for: session.startedAt), default: 0] += session.duration
}
func minutes(_ seconds: TimeInterval) -> Int { Int((seconds / 60).rounded()) }

lastSevenDays = (0..<7).reversed().compactMap { offset in
calendar.date(byAdding: .day, value: -offset, to: today).map { Day(date: $0, minutes: minutes(minutesPerDay[$0] ?? 0)) }
}

let week = calendar.dateInterval(of: .weekOfYear, for: now)
let thisWeek = sessions.filter { week?.contains($0.startedAt) ?? false }
minutesThisWeek = minutes(thisWeek.reduce(0) { $0 + $1.duration })
var perModule: [UUID?: TimeInterval] = [:]
for session in thisWeek { perModule[session.moduleID, default: 0] += session.duration }
modulesThisWeek = perModule
.map { ModuleTime(moduleID: $0.key, minutes: minutes($0.value)) }
.sorted { $0.minutes > $1.minutes }

var streak = 0
var day = minutesPerDay[today] == nil ? calendar.date(byAdding: .day, value: -1, to: today) : today
while let current = day, minutesPerDay[current] != nil {
streak += 1
day = calendar.date(byAdding: .day, value: -1, to: current)
}
streakDays = streak
}
}

/// Stores a completed focus phase. Phases shorter than a minute are not worth recording.
@MainActor
struct RecordStudySessionUseCase {
static let minimumDuration: TimeInterval = 60

let repository: any StudySessionRepository

func callAsFunction(moduleID: UUID?, startedAt: Date, duration: TimeInterval) throws {
guard duration >= Self.minimumDuration else { return }
try repository.add(StudySession(id: UUID(), moduleID: moduleID, startedAt: startedAt, duration: duration))
}
}
Loading
Loading