diff --git a/MultiChrono/ContentView.swift b/MultiChrono/ContentView.swift index efa531a..3df14cb 100644 --- a/MultiChrono/ContentView.swift +++ b/MultiChrono/ContentView.swift @@ -114,6 +114,11 @@ struct ContentView: View { }, onCancel: { selectedStopwatch = nil + }, + onReset: { + stopwatch.reset() + viewModel.save() + selectedStopwatch = nil } ) } diff --git a/MultiChrono/Stopwatch.swift b/MultiChrono/Stopwatch.swift index e9318e6..99b187a 100644 --- a/MultiChrono/Stopwatch.swift +++ b/MultiChrono/Stopwatch.swift @@ -9,17 +9,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 + case id, name, elapsedTime, isRunning, startTime, accumulatedTime, laps } init(name: String) { @@ -35,6 +44,7 @@ class Stopwatch: ObservableObject, Identifiable, Codable { 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. @@ -54,6 +64,7 @@ class Stopwatch: ObservableObject, Identifiable, Codable { 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 { @@ -82,8 +93,20 @@ class Stopwatch: ObservableObject, Identifiable, Codable { timer?.cancel() timer = nil + let now = Date() if let startTime = startTime { - accumulatedTime += Date().timeIntervalSince(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 @@ -94,9 +117,17 @@ class Stopwatch: ObservableObject, Identifiable, Codable { } func reset() { - pause() + // 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() { @@ -104,28 +135,28 @@ class Stopwatch: ObservableObject, Identifiable, Codable { elapsedTime = accumulatedTime + Date().timeIntervalSince(startTime) } - func formattedTime(format: TimeFormat = .cents) -> String { - let hours = Int(elapsedTime) / 3600 - let minutes = Int(elapsedTime) / 60 % 60 - let seconds = Int(elapsedTime) % 60 + 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((elapsedTime.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) - } + 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((elapsedTime.truncatingRemainder(dividingBy: 1)) * 100) + 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((elapsedTime.truncatingRemainder(dividingBy: 1)) * 10) + let tenths = Int((interval.truncatingRemainder(dividingBy: 1)) * 10) if hours > 0 { return String(format: "%02i:%02i:%02i.%01i", hours, minutes, seconds, tenths) } else { @@ -140,8 +171,21 @@ class Stopwatch: ObservableObject, Identifiable, Codable { } } + 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 + } } diff --git a/MultiChrono/StopwatchDetailView.swift b/MultiChrono/StopwatchDetailView.swift index c22661f..b61499f 100644 --- a/MultiChrono/StopwatchDetailView.swift +++ b/MultiChrono/StopwatchDetailView.swift @@ -8,16 +8,25 @@ import SwiftUI struct StopwatchDetailView: View { - let stopwatch: Stopwatch + @ObservedObject var stopwatch: Stopwatch let onSave: (String) -> Void let onCancel: () -> Void + let onReset: () -> Void @State private var draftName: String + @State private var isShowingResetAlert = false - init(stopwatch: Stopwatch, onSave: @escaping (String) -> Void, onCancel: @escaping () -> Void) { + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd HH:mm:ss" + return formatter + }() + + init(stopwatch: Stopwatch, onSave: @escaping (String) -> Void, onCancel: @escaping () -> Void, onReset: @escaping () -> Void) { self.stopwatch = stopwatch self.onSave = onSave self.onCancel = onCancel + self.onReset = onReset _draftName = State(initialValue: stopwatch.name) } @@ -27,9 +36,91 @@ struct StopwatchDetailView: View { Section(header: Text("Name")) { TextField("Enter name...", text: $draftName) } + + Section(header: Text("Laps")) { + if stopwatch.isRunning, + let currentStart = stopwatch.currentRunStartTime, + let currentDuration = stopwatch.currentRunDuration { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Lap \(stopwatch.laps.count + 1)") + .font(.headline) + .foregroundStyle(.blue) + Spacer() + Text(Stopwatch.format(interval: currentDuration, format: AppSettings.shared.timeFormat)) + .font(.system(.body, design: .monospaced)) + .bold() + .foregroundStyle(.blue) + } + + HStack { + Text("Current") + .font(.caption) + .foregroundStyle(.blue) + Spacer() + VStack(alignment: .trailing) { + Text(dateFormatter.string(from: currentStart)) + Text("Running...") + } + } + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.blue) + } + .padding(.vertical, 2) + } + + if stopwatch.laps.isEmpty && !stopwatch.isRunning { + Text("No laps recorded yet.") + .foregroundStyle(.secondary) + } else { + List { + ForEach(stopwatch.laps) { lap in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Lap \(lap.number)") + .font(.headline) + Spacer() + Text(Stopwatch.format(interval: lap.duration, format: AppSettings.shared.timeFormat)) + .font(.system(.body, design: .monospaced)) + .bold() + } + + HStack { + Spacer() + VStack(alignment: .trailing) { + Text(dateFormatter.string(from: lap.startTime)) + Text(dateFormatter.string(from: lap.endTime)) + } + } + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + } + } + } + + Section { + Button(role: .destructive) { + isShowingResetAlert = true + } label: { + HStack { + Spacer() + Text("Reset Stopwatch") + Spacer() + } + } + } } .navigationTitle("Edit Stopwatch") .navigationBarTitleDisplayMode(.inline) + .alert("Are you sure you want to reset the Stopwatch \(stopwatch.name)?", isPresented: $isShowingResetAlert) { + Button("Reset", role: .destructive) { + onReset() + } + Button("Cancel", role: .cancel) {} + } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { @@ -51,6 +142,7 @@ struct StopwatchDetailView: View { StopwatchDetailView( stopwatch: Stopwatch(name: "Test Timer"), onSave: { _ in }, - onCancel: {} + onCancel: {}, + onReset: {} ) }