import Foundation import Observation import SwiftData #if os(iOS) import ActivityKit #endif @MainActor @Observable final class TaskViewModel { private(set) var tasks: [TaskItem] = [] private(set) var activeTimerStartDates: [UUID: Date] = [:] private(set) var elapsedSeconds: [UUID: Int] = [:] private var context: ModelContext? private var timerTasks: [UUID: Task] = [:] #if os(iOS) private var liveActivities: [UUID: Activity] = [:] #endif func setup(context: ModelContext) { self.context = context fetchTasks() } func fetchTasks() { guard let context else { return } let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.name)]) tasks = (try? context.fetch(descriptor)) ?? [] } func addTask(name: String, icon: String, type: TaskType, category: Category? = nil) { guard let context else { return } let item = TaskItem(name: name, icon: icon, type: type, category: category) context.insert(item) try? context.save() fetchTasks() } func deleteTask(_ task: TaskItem) { stopTimer(for: task) guard let context else { return } context.delete(task) try? context.save() fetchTasks() } func addCount(to task: TaskItem) { guard let context else { return } let log = TaskLog(date: .now, duration: 0, count: 1) log.task = task context.insert(log) try? context.save() } func toggleTimer(for task: TaskItem) { if activeTimerStartDates[task.id] != nil { stopTimer(for: task) } else { startTimer(for: task) } } func isTimerRunning(for taskID: UUID) -> Bool { activeTimerStartDates[taskID] != nil } func formattedElapsed(for taskID: UUID) -> String { let s = elapsedSeconds[taskID] ?? 0 let h = s / 3600 let m = (s % 3600) / 60 let sec = s % 60 return h > 0 ? String(format: "%d:%02d:%02d", h, m, sec) : String(format: "%02d:%02d", m, sec) } func todayCount(for task: TaskItem) -> Int { task.logs .filter { Calendar.current.isDateInToday($0.date) } .reduce(0) { $0 + $1.count } } // MARK: - Timer private func startTimer(for task: TaskItem) { let startDate = Date.now activeTimerStartDates[task.id] = startDate elapsedSeconds[task.id] = 0 startLiveActivity(for: task, startDate: startDate) let taskID = task.id timerTasks[taskID] = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(1)) guard !Task.isCancelled, let self else { break } let elapsed = Int(Date.now.timeIntervalSince(startDate)) self.elapsedSeconds[taskID] = elapsed await self.updateLiveActivity(taskID: taskID, elapsed: elapsed, startDate: startDate) } } } private func stopTimer(for task: TaskItem) { guard let startDate = activeTimerStartDates[task.id] else { return } timerTasks[task.id]?.cancel() timerTasks.removeValue(forKey: task.id) let duration = Date.now.timeIntervalSince(startDate) activeTimerStartDates.removeValue(forKey: task.id) elapsedSeconds.removeValue(forKey: task.id) endLiveActivity(for: task) guard let context else { return } let log = TaskLog(date: .now, duration: duration, count: 0) log.task = task context.insert(log) try? context.save() } // MARK: - ActivityKit private func startLiveActivity(for task: TaskItem, startDate: Date) { #if os(iOS) guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } let attrs = TimerActivityAttributes(taskName: task.name, taskIcon: task.icon) let state = TimerActivityAttributes.ContentState(startDate: startDate, elapsedSeconds: 0) let content = ActivityContent(state: state, staleDate: nil) do { let activity = try Activity.request(attributes: attrs, content: content) liveActivities[task.id] = activity } catch { // Live Activity unavailable (simulator, denied, or OS < 16.2) } #endif } private func updateLiveActivity(taskID: UUID, elapsed: Int, startDate: Date) async { #if os(iOS) guard let activity = liveActivities[taskID] else { return } let state = TimerActivityAttributes.ContentState(startDate: startDate, elapsedSeconds: elapsed) await activity.update(ActivityContent(state: state, staleDate: nil)) #endif } private func endLiveActivity(for task: TaskItem) { #if os(iOS) guard let activity = liveActivities[task.id] else { return } liveActivities.removeValue(forKey: task.id) Task { await activity.end(nil, dismissalPolicy: .immediate) } #endif } }