192 lines
6.1 KiB
Swift
192 lines
6.1 KiB
Swift
//
|
|
// 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
|
|
let startTime: Date
|
|
let endTime: Date
|
|
let 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
|
|
}
|
|
}
|