// // Stopwatch.swift // MultiChrono // // Created by Beatrice DellacĂ  on 26/01/26. // import Foundation import Combine class Stopwatch: ObservableObject, Identifiable, Codable { struct Lap: Identifiable, Codable { let id: UUID let number: Int var startTime: Date var endTime: Date var duration: TimeInterval } let id: UUID @Published var name: String @Published var elapsedTime: TimeInterval = 0 @Published var isRunning: Bool = false @Published var laps: [Lap] = [] private var timer: AnyCancellable? private var startTime: Date? private var accumulatedTime: TimeInterval = 0 enum CodingKeys: String, CodingKey { case id, name, elapsedTime, isRunning, startTime, accumulatedTime, laps } init(name: String) { self.id = UUID() self.name = name } required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) name = try container.decode(String.self, forKey: .name) elapsedTime = try container.decode(TimeInterval.self, forKey: .elapsedTime) isRunning = try container.decode(Bool.self, forKey: .isRunning) startTime = try container.decodeIfPresent(Date.self, forKey: .startTime) accumulatedTime = try container.decode(TimeInterval.self, forKey: .accumulatedTime) laps = try container.decodeIfPresent([Lap].self, forKey: .laps) ?? [] if isRunning { // Restart the timer if it was running. // We also need to update current elapsedTime based on how much time passed since startTime if let start = startTime { elapsedTime = accumulatedTime + Date().timeIntervalSince(start) } startTimer() } } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(name, forKey: .name) try container.encode(elapsedTime, forKey: .elapsedTime) try container.encode(isRunning, forKey: .isRunning) try container.encode(startTime, forKey: .startTime) try container.encode(accumulatedTime, forKey: .accumulatedTime) try container.encode(laps, forKey: .laps) } deinit { timer?.cancel() } func start() { guard !isRunning else { return } startTime = Date() isRunning = true startTimer() } private func startTimer() { timer = Timer.publish(every: 0.001, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in self?.tick() } } func pause() { guard isRunning else { return } timer?.cancel() timer = nil let now = Date() if let startTime = startTime { let sessionDuration = now.timeIntervalSince(startTime) accumulatedTime += sessionDuration // Record Lap let newLap = Lap( id: UUID(), number: laps.count + 1, startTime: startTime, endTime: now, duration: sessionDuration ) laps.append(newLap) } isRunning = false startTime = nil // Ensure UI shows accurate accumulated time elapsedTime = accumulatedTime } func reset() { // Stop timer without recording a lap? Or should reset clear laps? // Usually reset clears everything. if isRunning { timer?.cancel() timer = nil isRunning = false startTime = nil } accumulatedTime = 0 elapsedTime = 0 laps.removeAll() } private func tick() { guard let startTime = startTime else { return } elapsedTime = accumulatedTime + Date().timeIntervalSince(startTime) } static func format(interval: TimeInterval, format: TimeFormat) -> String { let hours = Int(interval) / 3600 let minutes = Int(interval) / 60 % 60 let seconds = Int(interval) % 60 switch format { case .millis: let millis = Int((interval.truncatingRemainder(dividingBy: 1)) * 1000) if hours > 0 { return String(format: "%02i:%02i:%02i.%03i", hours, minutes, seconds, millis) } else { return String(format: "%02i:%02i.%03i", minutes, seconds, millis) } case .cents: let cents = Int((interval.truncatingRemainder(dividingBy: 1)) * 100) if hours > 0 { return String(format: "%02i:%02i:%02i.%02i", hours, minutes, seconds, cents) } else { return String(format: "%02i:%02i.%02i", minutes, seconds, cents) } case .tenths: let tenths = Int((interval.truncatingRemainder(dividingBy: 1)) * 10) if hours > 0 { return String(format: "%02i:%02i:%02i.%01i", hours, minutes, seconds, tenths) } else { return String(format: "%02i:%02i.%01i", minutes, seconds, tenths) } case .seconds: if hours > 0 { return String(format: "%02i:%02i:%02i", hours, minutes, seconds) } else { return String(format: "%02i:%02i", minutes, seconds) } } } func formattedTime(format: TimeFormat = .cents) -> String { return Stopwatch.format(interval: elapsedTime, format: format) } // Legacy support to avoid breaking existing code immediately var formattedTime: String { formattedTime(format: AppSettings.shared.timeFormat) } var currentRunDuration: TimeInterval? { guard let startTime = startTime else { return nil } return Date().timeIntervalSince(startTime) } var currentRunStartTime: Date? { return startTime } func update(lap: Lap) { guard let index = laps.firstIndex(where: { $0.id == lap.id }) else { return } var updatedLap = lap // Recalculate duration in case start/end times were changed updatedLap.duration = updatedLap.endTime.timeIntervalSince(updatedLap.startTime) laps[index] = updatedLap // Recalculate accumulatedTime from all laps // Note: This assumes accumulatedTime is strictly the sum of all closed laps. // If the stopwatch is running, the current "open" lap is not in `laps` yet (it's implicit). // However, `accumulatedTime` tracks the time *before* the current run started. // If we edited a *past* lap, we should update accumulatedTime to be the sum of all past laps. // Let's assume laps are only recorded when paused or when split. // If we claim `accumulatedTime` is the sum of all stored laps: let totalLapDuration = laps.reduce(0) { $0 + $1.duration } accumulatedTime = totalLapDuration if isRunning { // function tick() will update elapsedTime naturally, but to be immediate: if let start = startTime { elapsedTime = accumulatedTime + Date().timeIntervalSince(start) } } else { elapsedTime = accumulatedTime } } }