Compare commits

...

3 Commits

Author SHA1 Message Date
190cfc4071 implement lap edit 2026-02-05 00:54:58 +01:00
93cf6f8483 implement alerts, delete 2026-01-28 23:43:50 +01:00
a607e33ce9 implement laps, reset 2026-01-28 23:09:21 +01:00
5 changed files with 410 additions and 26 deletions

View File

@@ -62,6 +62,7 @@ struct ContentView: View {
@State private var isShowingSettings = false
@State private var selectedStopwatch: Stopwatch?
@State private var draggingStopwatch: Stopwatch?
@State private var stopwatchToDelete: Stopwatch?
@Environment(\.scenePhase) private var scenePhase
var body: some View {
@@ -73,7 +74,8 @@ struct ContentView: View {
stopwatch: stopwatch,
viewModel: viewModel,
selectedStopwatch: $selectedStopwatch,
draggingStopwatch: $draggingStopwatch
draggingStopwatch: $draggingStopwatch,
stopwatchToDelete: $stopwatchToDelete
)
}
}
@@ -114,9 +116,31 @@ struct ContentView: View {
},
onCancel: {
selectedStopwatch = nil
},
onReset: {
stopwatch.reset()
viewModel.save()
selectedStopwatch = nil
},
onDelete: {
viewModel.deleteStopwatch(id: stopwatch.id)
selectedStopwatch = nil
}
)
}
.alert("Are you sure you want to delete the Stopwatch \(stopwatchToDelete?.name ?? "")?", isPresented: Binding(
get: { stopwatchToDelete != nil },
set: { if !$0 { stopwatchToDelete = nil } }
)) {
if let stopwatch = stopwatchToDelete {
Button("Delete", role: .destructive) {
withAnimation {
viewModel.deleteStopwatch(id: stopwatch.id)
}
}
}
Button("Cancel", role: .cancel) {}
}
.overlay {
if viewModel.stopwatches.isEmpty {
ContentUnavailableView(
@@ -140,15 +164,14 @@ struct StopwatchListItem: View {
@ObservedObject var viewModel: ContentViewModel
@Binding var selectedStopwatch: Stopwatch?
@Binding var draggingStopwatch: Stopwatch?
@Binding var stopwatchToDelete: Stopwatch?
var body: some View {
Button {
selectedStopwatch = stopwatch
} label: {
StopwatchRow(stopwatch: stopwatch, onDelete: {
withAnimation {
viewModel.deleteStopwatch(id: stopwatch.id)
}
stopwatchToDelete = stopwatch
})
}
.buttonStyle(.plain)
@@ -158,9 +181,7 @@ struct StopwatchListItem: View {
.contentShape(Rectangle())
.contextMenu {
Button(role: .destructive) {
withAnimation {
viewModel.deleteStopwatch(id: stopwatch.id)
}
stopwatchToDelete = stopwatch
} label: {
Label("Delete", systemImage: "trash")
}

View File

@@ -0,0 +1,165 @@
//
// EditLapView.swift
// MultiChrono
//
// Created by Beatrice Dellacà on 27/01/26.
//
import SwiftUI
struct EditLapView: View {
@Environment(\.dismiss) var dismiss
@State var lap: Stopwatch.Lap
var onSave: (Stopwatch.Lap) -> Void
var body: some View {
NavigationStack {
Form {
Section(header: Text("Start Time")) {
PreciseTimePicker(date: $lap.startTime)
}
Section(header: Text("End Time")) {
PreciseTimePicker(date: $lap.endTime)
}
Section(header: Text("Duration")) {
HStack {
Text("Duration")
Spacer()
Text(Stopwatch.format(interval: lap.endTime.timeIntervalSince(lap.startTime), format: AppSettings.shared.timeFormat))
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Edit Lap \(lap.number)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(lap)
dismiss()
}
}
}
}
}
}
struct PreciseTimePicker: View {
@Binding var date: Date
var body: some View {
VStack(spacing: 12) {
DatePicker("Date & Time", selection: $date, displayedComponents: [.hourAndMinute, .date])
HStack(spacing: 0) {
Text("Sec")
.font(.caption)
.frame(width: 40)
Picker("Seconds", selection: Binding(
get: { Calendar.current.component(.second, from: date) },
set: { newSecond in
if let newDate = Calendar.current.date(bySetting: .second, value: newSecond, of: date) {
date = newDate
}
}
)) {
ForEach(0..<60) {
Text(String(format: "%02d", $0)).tag($0)
}
}
.pickerStyle(.wheel)
.frame(height: 100)
.clipped()
Spacer()
.frame(width: 20)
Text("ms")
.font(.caption)
.frame(width: 30)
// Milliseconds - Hundreds
Picker("Hundreds", selection: Binding(
get: { (Calendar.current.component(.nanosecond, from: date) / 1_000_000) / 100 },
set: { newHundreds in
let currentMillis = Calendar.current.component(.nanosecond, from: date) / 1_000_000
let tensAndUnits = currentMillis % 100
let totalMillis = (newHundreds * 100) + tensAndUnits
if let newDate = Calendar.current.date(bySetting: .nanosecond, value: totalMillis * 1_000_000, of: date) {
date = newDate
}
}
)) {
ForEach(0..<10) {
Text("\($0)").tag($0)
}
}
.pickerStyle(.wheel)
.frame(width: 40, height: 100)
.clipped()
// Milliseconds - Tens
Picker("Tens", selection: Binding(
get: { ((Calendar.current.component(.nanosecond, from: date) / 1_000_000) % 100) / 10 },
set: { newTens in
let currentMillis = Calendar.current.component(.nanosecond, from: date) / 1_000_000
let hundreds = currentMillis / 100
let units = currentMillis % 10
let totalMillis = (hundreds * 100) + (newTens * 10) + units
if let newDate = Calendar.current.date(bySetting: .nanosecond, value: totalMillis * 1_000_000, of: date) {
date = newDate
}
}
)) {
ForEach(0..<10) {
Text("\($0)").tag($0)
}
}
.pickerStyle(.wheel)
.frame(width: 40, height: 100)
.clipped()
// Milliseconds - Units
Picker("Units", selection: Binding(
get: { (Calendar.current.component(.nanosecond, from: date) / 1_000_000) % 10 },
set: { newUnits in
let currentMillis = Calendar.current.component(.nanosecond, from: date) / 1_000_000
let hundredsAndTens = (currentMillis / 10) * 10
let totalMillis = hundredsAndTens + newUnits
if let newDate = Calendar.current.date(bySetting: .nanosecond, value: totalMillis * 1_000_000, of: date) {
date = newDate
}
}
)) {
ForEach(0..<10) {
Text("\($0)").tag($0)
}
}
.pickerStyle(.wheel)
.frame(width: 40, height: 100)
.clipped()
}
}
}
}
#Preview {
EditLapView(
lap: Stopwatch.Lap(
id: UUID(),
number: 1,
startTime: Date(),
endTime: Date().addingTimeInterval(65),
duration: 65
),
onSave: { _ in }
)
}

View File

@@ -9,17 +9,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
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)
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,51 @@ 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
}
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
}
}
}

View File

@@ -8,16 +8,29 @@
import SwiftUI
struct StopwatchDetailView: View {
let stopwatch: Stopwatch
@ObservedObject var stopwatch: Stopwatch
let onSave: (String) -> Void
let onCancel: () -> Void
let onReset: () -> Void
let onDelete: () -> Void
@State private var draftName: String
@State private var isShowingResetAlert = false
@State private var isShowingDeleteAlert = false
@State private var editingLap: Stopwatch.Lap?
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, onDelete: @escaping () -> Void) {
self.stopwatch = stopwatch
self.onSave = onSave
self.onCancel = onCancel
self.onReset = onReset
self.onDelete = onDelete
_draftName = State(initialValue: stopwatch.name)
}
@@ -27,9 +40,109 @@ 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
Button {
editingLap = lap
} label: {
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)
}
.buttonStyle(.plain)
}
}
}
}
Section {
Button(role: .destructive) {
isShowingResetAlert = true
} label: {
Text("Reset Stopwatch")
}
.frame(maxWidth: .infinity, alignment: .center)
}
Section {
Button(role: .destructive) {
isShowingDeleteAlert = true
} label: {
Text("Delete Stopwatch")
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
.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) {}
}
.alert("Are you sure you want to delete the Stopwatch \(stopwatch.name)?", isPresented: $isShowingDeleteAlert) {
Button("Delete", role: .destructive) {
onDelete()
}
Button("Cancel", role: .cancel) {}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
@@ -43,6 +156,11 @@ struct StopwatchDetailView: View {
.disabled(draftName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.sheet(item: $editingLap) { lap in
EditLapView(lap: lap) { updatedLap in
stopwatch.update(lap: updatedLap)
}
}
}
}
}
@@ -51,6 +169,8 @@ struct StopwatchDetailView: View {
StopwatchDetailView(
stopwatch: Stopwatch(name: "Test Timer"),
onSave: { _ in },
onCancel: {}
onCancel: {},
onReset: {},
onDelete: {}
)
}

View File

@@ -20,6 +20,10 @@ struct StopwatchRow: View {
Text("\(stopwatch.formattedTime(format: settings.timeFormat))")
.font(.largeTitle)
.monospacedDigit()
Text("Laps: \(stopwatch.laps.count + (stopwatch.isRunning ? 1 : 0))")
.font(.caption)
.monospaced()
.foregroundStyle(.secondary)
}
Spacer()