implement laps, reset

This commit is contained in:
2026-01-28 23:09:21 +01:00
parent 750a096999
commit a607e33ce9
3 changed files with 159 additions and 18 deletions

View File

@@ -114,6 +114,11 @@ struct ContentView: View {
}, },
onCancel: { onCancel: {
selectedStopwatch = nil selectedStopwatch = nil
},
onReset: {
stopwatch.reset()
viewModel.save()
selectedStopwatch = nil
} }
) )
} }

View File

@@ -9,17 +9,26 @@ import Foundation
import Combine import Combine
class Stopwatch: ObservableObject, Identifiable, Codable { 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 let id: UUID
@Published var name: String @Published var name: String
@Published var elapsedTime: TimeInterval = 0 @Published var elapsedTime: TimeInterval = 0
@Published var isRunning: Bool = false @Published var isRunning: Bool = false
@Published var laps: [Lap] = []
private var timer: AnyCancellable? private var timer: AnyCancellable?
private var startTime: Date? private var startTime: Date?
private var accumulatedTime: TimeInterval = 0 private var accumulatedTime: TimeInterval = 0
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, name, elapsedTime, isRunning, startTime, accumulatedTime case id, name, elapsedTime, isRunning, startTime, accumulatedTime, laps
} }
init(name: String) { init(name: String) {
@@ -35,6 +44,7 @@ class Stopwatch: ObservableObject, Identifiable, Codable {
isRunning = try container.decode(Bool.self, forKey: .isRunning) isRunning = try container.decode(Bool.self, forKey: .isRunning)
startTime = try container.decodeIfPresent(Date.self, forKey: .startTime) startTime = try container.decodeIfPresent(Date.self, forKey: .startTime)
accumulatedTime = try container.decode(TimeInterval.self, forKey: .accumulatedTime) accumulatedTime = try container.decode(TimeInterval.self, forKey: .accumulatedTime)
laps = try container.decodeIfPresent([Lap].self, forKey: .laps) ?? []
if isRunning { if isRunning {
// Restart the timer if it was running. // 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(isRunning, forKey: .isRunning)
try container.encode(startTime, forKey: .startTime) try container.encode(startTime, forKey: .startTime)
try container.encode(accumulatedTime, forKey: .accumulatedTime) try container.encode(accumulatedTime, forKey: .accumulatedTime)
try container.encode(laps, forKey: .laps)
} }
deinit { deinit {
@@ -82,8 +93,20 @@ class Stopwatch: ObservableObject, Identifiable, Codable {
timer?.cancel() timer?.cancel()
timer = nil timer = nil
let now = Date()
if let startTime = startTime { 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 isRunning = false
@@ -94,9 +117,17 @@ class Stopwatch: ObservableObject, Identifiable, Codable {
} }
func reset() { 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 accumulatedTime = 0
elapsedTime = 0 elapsedTime = 0
laps.removeAll()
} }
private func tick() { private func tick() {
@@ -104,28 +135,28 @@ class Stopwatch: ObservableObject, Identifiable, Codable {
elapsedTime = accumulatedTime + Date().timeIntervalSince(startTime) elapsedTime = accumulatedTime + Date().timeIntervalSince(startTime)
} }
func formattedTime(format: TimeFormat = .cents) -> String { static func format(interval: TimeInterval, format: TimeFormat) -> String {
let hours = Int(elapsedTime) / 3600 let hours = Int(interval) / 3600
let minutes = Int(elapsedTime) / 60 % 60 let minutes = Int(interval) / 60 % 60
let seconds = Int(elapsedTime) % 60 let seconds = Int(interval) % 60
switch format { switch format {
case .millis: case .millis:
let millis = Int((elapsedTime.truncatingRemainder(dividingBy: 1)) * 1000) let millis = Int((interval.truncatingRemainder(dividingBy: 1)) * 1000)
if hours > 0 { if hours > 0 {
return String(format: "%02i:%02i:%02i.%03i", hours, minutes, seconds, millis) return String(format: "%02i:%02i:%02i.%03i", hours, minutes, seconds, millis)
} else { } else {
return String(format: "%02i:%02i.%03i", minutes, seconds, millis) return String(format: "%02i:%02i.%03i", minutes, seconds, millis)
} }
case .cents: case .cents:
let cents = Int((elapsedTime.truncatingRemainder(dividingBy: 1)) * 100) let cents = Int((interval.truncatingRemainder(dividingBy: 1)) * 100)
if hours > 0 { if hours > 0 {
return String(format: "%02i:%02i:%02i.%02i", hours, minutes, seconds, cents) return String(format: "%02i:%02i:%02i.%02i", hours, minutes, seconds, cents)
} else { } else {
return String(format: "%02i:%02i.%02i", minutes, seconds, cents) return String(format: "%02i:%02i.%02i", minutes, seconds, cents)
} }
case .tenths: case .tenths:
let tenths = Int((elapsedTime.truncatingRemainder(dividingBy: 1)) * 10) let tenths = Int((interval.truncatingRemainder(dividingBy: 1)) * 10)
if hours > 0 { if hours > 0 {
return String(format: "%02i:%02i:%02i.%01i", hours, minutes, seconds, tenths) return String(format: "%02i:%02i:%02i.%01i", hours, minutes, seconds, tenths)
} else { } 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 // Legacy support to avoid breaking existing code immediately
var formattedTime: String { var formattedTime: String {
formattedTime(format: AppSettings.shared.timeFormat) formattedTime(format: AppSettings.shared.timeFormat)
} }
var currentRunDuration: TimeInterval? {
guard let startTime = startTime else { return nil }
return Date().timeIntervalSince(startTime)
}
var currentRunStartTime: Date? {
return startTime
}
} }

View File

@@ -8,16 +8,25 @@
import SwiftUI import SwiftUI
struct StopwatchDetailView: View { struct StopwatchDetailView: View {
let stopwatch: Stopwatch @ObservedObject var stopwatch: Stopwatch
let onSave: (String) -> Void let onSave: (String) -> Void
let onCancel: () -> Void let onCancel: () -> Void
let onReset: () -> Void
@State private var draftName: String @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.stopwatch = stopwatch
self.onSave = onSave self.onSave = onSave
self.onCancel = onCancel self.onCancel = onCancel
self.onReset = onReset
_draftName = State(initialValue: stopwatch.name) _draftName = State(initialValue: stopwatch.name)
} }
@@ -27,9 +36,91 @@ struct StopwatchDetailView: View {
Section(header: Text("Name")) { Section(header: Text("Name")) {
TextField("Enter name...", text: $draftName) 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") .navigationTitle("Edit Stopwatch")
.navigationBarTitleDisplayMode(.inline) .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 { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { Button("Cancel") {
@@ -51,6 +142,7 @@ struct StopwatchDetailView: View {
StopwatchDetailView( StopwatchDetailView(
stopwatch: Stopwatch(name: "Test Timer"), stopwatch: Stopwatch(name: "Test Timer"),
onSave: { _ in }, onSave: { _ in },
onCancel: {} onCancel: {},
onReset: {}
) )
} }