From c74f31f646b089db65fdc90aad61cbba7ae8a7e6 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 20:40:24 -0500 Subject: [PATCH 01/53] feat: add Siri App Intents for pace/speed conversion Add inline App Intents (iOS 16+) for Siri integration: - PaceToSpeedIntent: converts mm:ss pace to MPH/KPH - SpeedToPaceIntent: converts speed value to mm:ss pace - SpeedUnitEntity: AppEnum for MPH/KPH selection - RunPaceShortcuts: AppShortcutsProvider with Siri phrases Uses existing ConversionEngine for all conversion logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pace-to-mph/Intents/PaceToSpeedIntent.swift | 33 +++++++++++++++++++++ pace-to-mph/Intents/RunPaceShortcuts.swift | 24 +++++++++++++++ pace-to-mph/Intents/SpeedToPaceIntent.swift | 31 +++++++++++++++++++ pace-to-mph/Intents/SpeedUnitEntity.swift | 20 +++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 pace-to-mph/Intents/PaceToSpeedIntent.swift create mode 100644 pace-to-mph/Intents/RunPaceShortcuts.swift create mode 100644 pace-to-mph/Intents/SpeedToPaceIntent.swift create mode 100644 pace-to-mph/Intents/SpeedUnitEntity.swift diff --git a/pace-to-mph/Intents/PaceToSpeedIntent.swift b/pace-to-mph/Intents/PaceToSpeedIntent.swift new file mode 100644 index 0000000..259cd8c --- /dev/null +++ b/pace-to-mph/Intents/PaceToSpeedIntent.swift @@ -0,0 +1,33 @@ +import AppIntents + +struct PaceToSpeedIntent: AppIntent { + static var title: LocalizedStringResource = "Convert Pace to Speed" + static var description = IntentDescription("Convert a running pace to speed in MPH or KPH") + + @Parameter(title: "Pace", description: "Running pace in mm:ss format, e.g. 7:30") + var pace: String + + @Parameter(title: "Unit", description: "Speed unit", default: .mph) + var unit: SpeedUnitEntity + + func perform() async throws -> some ReturnsValue & ProvidesDialog { + guard let paceMinutes = ConversionEngine.parsePace(pace) else { + throw $pace.needsValueError("Please provide a valid pace in mm:ss format, e.g. 7:30") + } + + var speed = ConversionEngine.paceToSpeed(paceMinutes) + let paceLabel: String + + if unit == .kph { + speed = speed * ConversionEngine.kmPerMile + paceLabel = "per mile" + } else { + paceLabel = "per mile" + } + + let formatted = ConversionEngine.formatSpeed(speed) + let dialog = "A pace of \(pace) \(paceLabel) is \(formatted) \(unit.rawValue)" + + return .result(value: formatted, dialog: IntentDialog(stringLiteral: dialog)) + } +} diff --git a/pace-to-mph/Intents/RunPaceShortcuts.swift b/pace-to-mph/Intents/RunPaceShortcuts.swift new file mode 100644 index 0000000..0c02d23 --- /dev/null +++ b/pace-to-mph/Intents/RunPaceShortcuts.swift @@ -0,0 +1,24 @@ +import AppIntents + +struct RunPaceShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: PaceToSpeedIntent(), + phrases: [ + "Convert pace to speed with \(.applicationName)", + "What speed is my pace in \(.applicationName)", + ], + shortTitle: "Convert Pace", + systemImageName: "figure.run" + ) + AppShortcut( + intent: SpeedToPaceIntent(), + phrases: [ + "Convert speed to pace with \(.applicationName)", + "What pace is my speed in \(.applicationName)", + ], + shortTitle: "Convert Speed", + systemImageName: "speedometer" + ) + } +} diff --git a/pace-to-mph/Intents/SpeedToPaceIntent.swift b/pace-to-mph/Intents/SpeedToPaceIntent.swift new file mode 100644 index 0000000..d93a22a --- /dev/null +++ b/pace-to-mph/Intents/SpeedToPaceIntent.swift @@ -0,0 +1,31 @@ +import AppIntents + +struct SpeedToPaceIntent: AppIntent { + static var title: LocalizedStringResource = "Convert Speed to Pace" + static var description = IntentDescription("Convert a speed in MPH or KPH to a running pace") + + @Parameter(title: "Speed", description: "Speed value, e.g. 8.0") + var speed: Double + + @Parameter(title: "Unit", description: "Speed unit", default: .mph) + var unit: SpeedUnitEntity + + func perform() async throws -> some ReturnsValue & ProvidesDialog { + guard speed > 0 else { + throw $speed.needsValueError("Please provide a speed greater than zero") + } + + // Convert to MPH if input is KPH, since base conversion assumes miles + let mphSpeed = unit == .kph ? speed / ConversionEngine.kmPerMile : speed + let paceMinutes = ConversionEngine.speedToPace(mphSpeed) + + guard let formatted = ConversionEngine.formatPace(paceMinutes) else { + throw $speed.needsValueError("Could not convert that speed to a pace") + } + + let paceUnit = unit == .kph ? "/km" : "/mi" + let dialog = "\(ConversionEngine.formatSpeed(speed)) \(unit.rawValue) is a \(formatted) \(paceUnit) pace" + + return .result(value: formatted, dialog: IntentDialog(stringLiteral: dialog)) + } +} diff --git a/pace-to-mph/Intents/SpeedUnitEntity.swift b/pace-to-mph/Intents/SpeedUnitEntity.swift new file mode 100644 index 0000000..7ba283a --- /dev/null +++ b/pace-to-mph/Intents/SpeedUnitEntity.swift @@ -0,0 +1,20 @@ +import AppIntents + +enum SpeedUnitEntity: String, AppEnum { + case mph = "MPH" + case kph = "KM/H" + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Speed Unit" + + static var caseDisplayRepresentations: [SpeedUnitEntity: DisplayRepresentation] = [ + .mph: "MPH", + .kph: "KM/H", + ] + + var speedUnit: SpeedUnit { + switch self { + case .mph: return .mph + case .kph: return .kph + } + } +} From f480977b6f3cb8eb660693076dbd3bae84130d96 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 20:41:39 -0500 Subject: [PATCH 02/53] feat(race): add race finish time and even split calculators --- pace-to-mph/Models/RaceCalculator.swift | 85 ++++++++ pace-to-mph/RaceTimeView.swift | 250 +++++++++++++++++++++++ pace-to-mph/SplitCalculatorView.swift | 255 ++++++++++++++++++++++++ 3 files changed, 590 insertions(+) create mode 100644 pace-to-mph/Models/RaceCalculator.swift create mode 100644 pace-to-mph/RaceTimeView.swift create mode 100644 pace-to-mph/SplitCalculatorView.swift diff --git a/pace-to-mph/Models/RaceCalculator.swift b/pace-to-mph/Models/RaceCalculator.swift new file mode 100644 index 0000000..d365b47 --- /dev/null +++ b/pace-to-mph/Models/RaceCalculator.swift @@ -0,0 +1,85 @@ +import Foundation + +enum RaceCalculator { + enum Distance: String, CaseIterable, Identifiable { + case fiveK = "5K" + case tenK = "10K" + case halfMarathon = "Half Marathon" + case marathon = "Marathon" + case custom = "Custom" + + var id: String { rawValue } + + var miles: Double? { + switch self { + case .fiveK: return 3.10686 + case .tenK: return 6.21371 + case .halfMarathon: return 13.1094 + case .marathon: return 26.2188 + case .custom: return nil + } + } + + var kilometers: Double? { + switch self { + case .fiveK: return 5.0 + case .tenK: return 10.0 + case .halfMarathon: return 21.0975 + case .marathon: return 42.195 + case .custom: return nil + } + } + } + + /// Given pace (min/mile or min/km) and distance in the same unit, return total seconds. + static func finishTime(paceMinutes: Double, distanceInUnits: Double) -> Int { + Int(round(paceMinutes * distanceInUnits * 60)) + } + + /// Format total seconds as "h:mm:ss" or "mm:ss" if under 1 hour. + static func formatDuration(_ totalSeconds: Int) -> String { + guard totalSeconds >= 0 else { return "0:00" } + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + + if hours > 0 { + return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" + } else { + return "\(minutes):\(String(format: "%02d", seconds))" + } + } + + /// Given finish time (total seconds) and distance, return pace in minutes. + static func requiredPace(totalSeconds: Int, distanceInUnits: Double) -> Double { + guard distanceInUnits > 0 else { return 0 } + return Double(totalSeconds) / 60.0 / distanceInUnits + } + + /// Parse "h:mm:ss" or "mm:ss" into total seconds, returns nil if invalid. + static func parseDuration(_ input: String) -> Int? { + let trimmed = input.trimmingCharacters(in: .whitespaces) + let parts = trimmed.split(separator: ":") + guard !parts.isEmpty, parts.count <= 3 else { return nil } + + let ints = parts.compactMap { Int($0) } + guard ints.count == parts.count else { return nil } + + switch ints.count { + case 1: + // Just minutes + guard ints[0] >= 0 else { return nil } + return ints[0] * 60 + case 2: + // mm:ss + guard ints[0] >= 0, ints[1] >= 0, ints[1] < 60 else { return nil } + return ints[0] * 60 + ints[1] + case 3: + // h:mm:ss + guard ints[0] >= 0, ints[1] >= 0, ints[1] < 60, ints[2] >= 0, ints[2] < 60 else { return nil } + return ints[0] * 3600 + ints[1] * 60 + ints[2] + default: + return nil + } + } +} diff --git a/pace-to-mph/RaceTimeView.swift b/pace-to-mph/RaceTimeView.swift new file mode 100644 index 0000000..ae7cc77 --- /dev/null +++ b/pace-to-mph/RaceTimeView.swift @@ -0,0 +1,250 @@ +import SwiftUI + +struct RaceTimeView: View { + @State private var paceInput: String = "" + @State private var selectedUnit: SpeedUnit = .mph + @State private var selectedDistance: RaceCalculator.Distance = .fiveK + @State private var customDistanceInput: String = "" + @FocusState private var isPaceFocused: Bool + @FocusState private var isDistanceFocused: Bool + + private var distanceInUnits: Double? { + if selectedDistance == .custom { + guard let val = Double(customDistanceInput), val > 0 else { return nil } + return val + } + return selectedUnit == .mph ? selectedDistance.miles : selectedDistance.kilometers + } + + private var finishTimeText: String { + guard let pace = ConversionEngine.parsePace(paceInput), + let distance = distanceInUnits else { return "" } + let seconds = RaceCalculator.finishTime(paceMinutes: pace, distanceInUnits: distance) + return RaceCalculator.formatDuration(seconds) + } + + var body: some View { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + .onTapGesture { + isPaceFocused = false + isDistanceFocused = false + } + + ScrollView { + VStack(spacing: 20) { + // Pace input card + VStack(spacing: 16) { + Text("PACE") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 6) { + TextField("mm:ss", text: $paceInput) + .font(.system(size: 44, weight: .bold, design: .rounded)) + .monospacedDigit() + .multilineTextAlignment(.center) + .keyboardType(.numbersAndPunctuation) + .textFieldStyle(.plain) + .focused($isPaceFocused) + .minimumScaleFactor(0.5) + .onChange(of: paceInput) { _, newValue in + paceInput = newValue.filter { $0.isNumber || $0 == ":" || $0 == "." } + } + + Text(selectedUnit.paceLabel) + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + } + + RoundedRectangle(cornerRadius: 1) + .fill(Color.green) + .frame(height: 2) + .frame(maxWidth: 200) + } + .padding(24) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .strokeBorder(.quaternary, lineWidth: 1) + ) + + // Unit picker + unitPicker + + // Distance picker + distancePicker + + // Custom distance input + if selectedDistance == .custom { + customDistanceField + } + + // Result card + resultCard + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 32) + } + } + .navigationTitle("Race Finish Time") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Unit Picker + + private var unitPicker: some View { + HStack(spacing: 16) { + ForEach(SpeedUnit.allCases, id: \.self) { u in + Button { + withAnimation(.snappy(duration: 0.25)) { + selectedUnit = u + } + } label: { + Text(u.paceLabel) + .font(.system(size: 14, weight: .bold)) + .tracking(1.5) + .padding(.horizontal, 32) + .padding(.vertical, 14) + .background( + Capsule() + .strokeBorder( + selectedUnit == u ? Color.green : Color(.separator), + lineWidth: 2 + ) + ) + .foregroundStyle(selectedUnit == u ? .green : .secondary) + } + .buttonStyle(.plain) + } + } + } + + // MARK: - Distance Picker + + private var distancePicker: some View { + VStack(spacing: 8) { + Text("DISTANCE") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(RaceCalculator.Distance.allCases) { d in + Button { + withAnimation(.snappy(duration: 0.25)) { + selectedDistance = d + } + } label: { + Text(d.rawValue) + .font(.system(size: 14, weight: .semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Capsule() + .fill(selectedDistance == d + ? Color.green + : Color(.tertiarySystemGroupedBackground)) + ) + .foregroundStyle(selectedDistance == d ? .white : .secondary) + } + .buttonStyle(.plain) + } + } + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.quaternary, lineWidth: 1) + ) + } + + // MARK: - Custom Distance + + private var customDistanceField: some View { + VStack(spacing: 8) { + Text("CUSTOM DISTANCE") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 6) { + TextField("0.0", text: $customDistanceInput) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .monospacedDigit() + .multilineTextAlignment(.center) + .keyboardType(.decimalPad) + .textFieldStyle(.plain) + .focused($isDistanceFocused) + .onChange(of: customDistanceInput) { _, newValue in + customDistanceInput = newValue.filter { $0.isNumber || $0 == "." } + } + + Text(selectedUnit == .mph ? "miles" : "km") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.quaternary, lineWidth: 1) + ) + } + + // MARK: - Result + + private var resultCard: some View { + VStack(spacing: 6) { + Text("FINISH TIME") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + + Text(finishTimeText.isEmpty ? "–" : finishTimeText) + .font(.largeTitle.bold().monospacedDigit()) + .foregroundStyle(finishTimeText.isEmpty ? .tertiary : .primary) + .contentTransition(.numericText()) + .animation(.snappy(duration: 0.2), value: finishTimeText) + } + .frame(maxWidth: .infinity) + .padding(24) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .strokeBorder(.quaternary, lineWidth: 1) + ) + } +} + +#Preview { + NavigationStack { + RaceTimeView() + } +} diff --git a/pace-to-mph/SplitCalculatorView.swift b/pace-to-mph/SplitCalculatorView.swift new file mode 100644 index 0000000..55342e9 --- /dev/null +++ b/pace-to-mph/SplitCalculatorView.swift @@ -0,0 +1,255 @@ +import SwiftUI + +struct SplitCalculatorView: View { + @State private var timeInput: String = "" + @State private var selectedUnit: SpeedUnit = .mph + @State private var selectedDistance: RaceCalculator.Distance = .fiveK + @State private var customDistanceInput: String = "" + @FocusState private var isTimeFocused: Bool + @FocusState private var isDistanceFocused: Bool + + private var distanceInUnits: Double? { + if selectedDistance == .custom { + guard let val = Double(customDistanceInput), val > 0 else { return nil } + return val + } + return selectedUnit == .mph ? selectedDistance.miles : selectedDistance.kilometers + } + + private var paceText: String { + guard let totalSeconds = RaceCalculator.parseDuration(timeInput), + totalSeconds > 0, + let distance = distanceInUnits, + distance > 0 else { return "" } + let paceMinutes = RaceCalculator.requiredPace(totalSeconds: totalSeconds, distanceInUnits: distance) + guard let formatted = ConversionEngine.formatPace(paceMinutes) else { return "" } + return formatted + } + + var body: some View { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + .onTapGesture { + isTimeFocused = false + isDistanceFocused = false + } + + ScrollView { + VStack(spacing: 20) { + // Time input card + VStack(spacing: 16) { + Text("TARGET FINISH TIME") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + TextField("h:mm:ss", text: $timeInput) + .font(.system(size: 44, weight: .bold, design: .rounded)) + .monospacedDigit() + .multilineTextAlignment(.center) + .keyboardType(.numbersAndPunctuation) + .textFieldStyle(.plain) + .focused($isTimeFocused) + .minimumScaleFactor(0.5) + .onChange(of: timeInput) { _, newValue in + timeInput = newValue.filter { $0.isNumber || $0 == ":" } + } + + RoundedRectangle(cornerRadius: 1) + .fill(Color.green) + .frame(height: 2) + .frame(maxWidth: 200) + } + .padding(24) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .strokeBorder(.quaternary, lineWidth: 1) + ) + + // Unit picker + unitPicker + + // Distance picker + distancePicker + + // Custom distance input + if selectedDistance == .custom { + customDistanceField + } + + // Result card + resultCard + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 32) + } + } + .navigationTitle("Even Splits") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Unit Picker + + private var unitPicker: some View { + HStack(spacing: 16) { + ForEach(SpeedUnit.allCases, id: \.self) { u in + Button { + withAnimation(.snappy(duration: 0.25)) { + selectedUnit = u + } + } label: { + Text(u == .mph ? "Mile" : "KM") + .font(.system(size: 14, weight: .bold)) + .tracking(1.5) + .padding(.horizontal, 32) + .padding(.vertical, 14) + .background( + Capsule() + .strokeBorder( + selectedUnit == u ? Color.green : Color(.separator), + lineWidth: 2 + ) + ) + .foregroundStyle(selectedUnit == u ? .green : .secondary) + } + .buttonStyle(.plain) + } + } + } + + // MARK: - Distance Picker + + private var distancePicker: some View { + VStack(spacing: 8) { + Text("DISTANCE") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(RaceCalculator.Distance.allCases) { d in + Button { + withAnimation(.snappy(duration: 0.25)) { + selectedDistance = d + } + } label: { + Text(d.rawValue) + .font(.system(size: 14, weight: .semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Capsule() + .fill(selectedDistance == d + ? Color.green + : Color(.tertiarySystemGroupedBackground)) + ) + .foregroundStyle(selectedDistance == d ? .white : .secondary) + } + .buttonStyle(.plain) + } + } + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.quaternary, lineWidth: 1) + ) + } + + // MARK: - Custom Distance + + private var customDistanceField: some View { + VStack(spacing: 8) { + Text("CUSTOM DISTANCE") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 6) { + TextField("0.0", text: $customDistanceInput) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .monospacedDigit() + .multilineTextAlignment(.center) + .keyboardType(.decimalPad) + .textFieldStyle(.plain) + .focused($isDistanceFocused) + .onChange(of: customDistanceInput) { _, newValue in + customDistanceInput = newValue.filter { $0.isNumber || $0 == "." } + } + + Text(selectedUnit == .mph ? "miles" : "km") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.quaternary, lineWidth: 1) + ) + } + + // MARK: - Result + + private var resultCard: some View { + VStack(spacing: 6) { + Text("REQUIRED PACE") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(paceText.isEmpty ? "–" : paceText) + .font(.largeTitle.bold().monospacedDigit()) + .foregroundStyle(paceText.isEmpty ? .tertiary : .primary) + .contentTransition(.numericText()) + .animation(.snappy(duration: 0.2), value: paceText) + + if !paceText.isEmpty { + Text(selectedUnit.paceLabel) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + } + .frame(maxWidth: .infinity) + .padding(24) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(Color(.secondarySystemGroupedBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .strokeBorder(.quaternary, lineWidth: 1) + ) + } +} + +#Preview { + NavigationStack { + SplitCalculatorView() + } +} From 51985872d8a64e267a029bc2f61ceb92732497d2 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 20:41:42 -0500 Subject: [PATCH 03/53] feat(history): add recent conversions history with persistence --- pace-to-mph/HistoryView.swift | 78 +++++++++++++++++++ pace-to-mph/Models/ConversionHistory.swift | 54 +++++++++++++ .../ViewModels/ConverterViewModel.swift | 13 ++++ 3 files changed, 145 insertions(+) create mode 100644 pace-to-mph/HistoryView.swift create mode 100644 pace-to-mph/Models/ConversionHistory.swift diff --git a/pace-to-mph/HistoryView.swift b/pace-to-mph/HistoryView.swift new file mode 100644 index 0000000..69b05f5 --- /dev/null +++ b/pace-to-mph/HistoryView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +struct HistoryView: View { + @Bindable var history: ConversionHistory + @State private var showClearConfirmation = false + + var body: some View { + Group { + if history.records.isEmpty { + ContentUnavailableView { + Label("No conversions yet", systemImage: "clock") + } description: { + Text("Your recent conversions will appear here.") + } + } else { + List { + ForEach(history.records) { record in + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(record.input) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .monospacedDigit() + Text(record.inputSuffix) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + Text("→") + .foregroundStyle(.secondary) + Text(record.result) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.green) + Text(record.resultSuffix) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + } + + Text(record.date, format: .relative(presentation: .named)) + .font(.caption) + .foregroundStyle(.tertiary) + } + Spacer() + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(record.input) \(record.inputSuffix) equals \(record.result) \(record.resultSuffix)") + } + } + .listStyle(.insetGrouped) + } + } + .navigationTitle("Recent Conversions") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if !history.records.isEmpty { + Button("Clear") { + showClearConfirmation = true + } + } + } + } + .alert("Clear History", isPresented: $showClearConfirmation) { + Button("Clear", role: .destructive) { + history.clear() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Are you sure you want to clear all conversion history?") + } + } +} + +#Preview { + NavigationStack { + HistoryView(history: ConversionHistory()) + } +} diff --git a/pace-to-mph/Models/ConversionHistory.swift b/pace-to-mph/Models/ConversionHistory.swift new file mode 100644 index 0000000..2091e1f --- /dev/null +++ b/pace-to-mph/Models/ConversionHistory.swift @@ -0,0 +1,54 @@ +import Foundation + +struct ConversionRecord: Codable, Identifiable { + let id: UUID + let input: String + let inputSuffix: String + let result: String + let resultSuffix: String + let date: Date +} + +@Observable +class ConversionHistory { + private(set) var records: [ConversionRecord] = [] + private let maxRecords = 20 + private let storageKey = "conversion_history" + + init() { load() } + + func add(input: String, inputSuffix: String, result: String, resultSuffix: String) { + let trimmed = result.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !input.trimmingCharacters(in: .whitespaces).isEmpty else { return } + + let record = ConversionRecord( + id: UUID(), + input: input, + inputSuffix: inputSuffix, + result: result, + resultSuffix: resultSuffix, + date: Date() + ) + records.insert(record, at: 0) + if records.count > maxRecords { + records = Array(records.prefix(maxRecords)) + } + save() + } + + func clear() { + records.removeAll() + save() + } + + private func save() { + guard let data = try? JSONEncoder().encode(records) else { return } + UserDefaults.standard.set(data, forKey: storageKey) + } + + private func load() { + guard let data = UserDefaults.standard.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode([ConversionRecord].self, from: data) else { return } + records = decoded + } +} diff --git a/pace-to-mph/ViewModels/ConverterViewModel.swift b/pace-to-mph/ViewModels/ConverterViewModel.swift index cc75f7f..a62b462 100644 --- a/pace-to-mph/ViewModels/ConverterViewModel.swift +++ b/pace-to-mph/ViewModels/ConverterViewModel.swift @@ -2,6 +2,8 @@ import SwiftUI @Observable final class ConverterViewModel { + let history = ConversionHistory() + var direction: ConversionDirection { didSet { storedDirection = direction.rawValue @@ -86,6 +88,17 @@ final class ConverterViewModel { direction = newDirection } + func recordCurrentConversion() { + let currentResult = result + guard !currentResult.isEmpty, inputText.count > 1 else { return } + history.add( + input: inputText, + inputSuffix: inputSuffix, + result: currentResult, + resultSuffix: resultSuffix + ) + } + func switchUnit(to newUnit: SpeedUnit) { guard newUnit != unit else { return } oldUnit = unit From 098e946050d2fb48e4818f79de61dd7841b8a90f Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 20:41:45 -0500 Subject: [PATCH 04/53] feat(nav): add toolbar buttons for race time, splits, and history --- pace-to-mph/ContentView.swift | 41 ++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/pace-to-mph/ContentView.swift b/pace-to-mph/ContentView.swift index 76390af..cddc147 100644 --- a/pace-to-mph/ContentView.swift +++ b/pace-to-mph/ContentView.swift @@ -9,7 +9,10 @@ struct ContentView: View { ZStack { Color(.systemGroupedBackground) .ignoresSafeArea() - .onTapGesture { isInputFocused = false } + .onTapGesture { + isInputFocused = false + viewModel.recordCurrentConversion() + } VStack(spacing: 0) { // Header @@ -32,14 +35,41 @@ struct ContentView: View { } .containerShape(RoundedRectangle(cornerRadius: 44, style: .continuous)) .toolbar { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .topBarLeading) { NavigationLink { - ReferenceView() + HistoryView(history: viewModel.history) } label: { - Image(systemName: "table") + Image(systemName: "clock") .font(.system(size: 15, weight: .semibold)) } - .accessibilityLabel("Pace reference table") + .accessibilityLabel("Conversion history") + } + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + NavigationLink { + RaceTimeView() + } label: { + Image(systemName: "flag.checkered") + .font(.system(size: 15, weight: .semibold)) + } + .accessibilityLabel("Race finish time") + + NavigationLink { + SplitCalculatorView() + } label: { + Image(systemName: "chart.bar") + .font(.system(size: 15, weight: .semibold)) + } + .accessibilityLabel("Even splits calculator") + + NavigationLink { + ReferenceView() + } label: { + Image(systemName: "table") + .font(.system(size: 15, weight: .semibold)) + } + .accessibilityLabel("Pace reference table") + } } } } @@ -86,6 +116,7 @@ struct ContentView: View { .keyboardType(viewModel.direction == .paceToSpeed ? .numbersAndPunctuation : .decimalPad) .textFieldStyle(.plain) .focused($isInputFocused) + .onSubmit { viewModel.recordCurrentConversion() } .minimumScaleFactor(0.5) .accessibilityLabel("Enter \(viewModel.direction == .paceToSpeed ? "pace" : "speed")") .accessibilityHint(viewModel.helperText) From f6b7b34d43b998136fe7a50823bd5948edb906e9 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 20:41:48 -0500 Subject: [PATCH 05/53] feat(watch): add Apple Watch companion app target --- .../AccentColor.colorset/Contents.json | 11 ++ .../AppIcon.appiconset/Contents.json | 13 ++ .../Assets.xcassets/Contents.json | 6 + pace-to-mph WatchKit App/ContentView.swift | 116 ++++++++++++++++++ .../pace_to_mphWatchApp.swift | 10 ++ 5 files changed, 156 insertions(+) create mode 100644 pace-to-mph WatchKit App/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 pace-to-mph WatchKit App/Assets.xcassets/Contents.json create mode 100644 pace-to-mph WatchKit App/ContentView.swift create mode 100644 pace-to-mph WatchKit App/pace_to_mphWatchApp.swift diff --git a/pace-to-mph WatchKit App/Assets.xcassets/AccentColor.colorset/Contents.json b/pace-to-mph WatchKit App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/pace-to-mph WatchKit App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/Contents.json b/pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..49c81cd --- /dev/null +++ b/pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pace-to-mph WatchKit App/Assets.xcassets/Contents.json b/pace-to-mph WatchKit App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/pace-to-mph WatchKit App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pace-to-mph WatchKit App/ContentView.swift b/pace-to-mph WatchKit App/ContentView.swift new file mode 100644 index 0000000..ce06784 --- /dev/null +++ b/pace-to-mph WatchKit App/ContentView.swift @@ -0,0 +1,116 @@ +import SwiftUI + +// MARK: - Conversion Logic (self-contained for watch) + +private enum WatchSpeedUnit: String, CaseIterable { + case mph + case kph + + var paceLabel: String { + switch self { + case .mph: return "/mi" + case .kph: return "/km" + } + } + + var speedLabel: String { + switch self { + case .mph: return "MPH" + case .kph: return "KM/H" + } + } +} + +private func paceToSpeed(_ paceMinutes: Double) -> Double { + 60.0 / paceMinutes +} + +private func formatSpeed(_ value: Double) -> String { + String(format: "%.2f", value) +} + +// MARK: - Pace Entry + +private struct PaceEntry: Identifiable { + let min: Int + let sec: Int + var id: Int { min * 60 + sec } + var paceMinutes: Double { Double(min) + Double(sec) / 60.0 } + var label: String { "\(min):\(String(format: "%02d", sec))" } +} + +// MARK: - View + +struct ContentView: View { + @State private var selectedUnit: WatchSpeedUnit = .mph + + // Paces per mile: 5:00–12:00 in 30s steps + private let mphPaces: [PaceEntry] = { + var result: [PaceEntry] = [] + for m in 5...12 { + result.append(PaceEntry(min: m, sec: 0)) + if m < 12 { result.append(PaceEntry(min: m, sec: 30)) } + } + return result + }() + + // Paces per km: 3:00–8:00 in 30s steps + private let kphPaces: [PaceEntry] = { + var result: [PaceEntry] = [] + for m in 3...8 { + result.append(PaceEntry(min: m, sec: 0)) + if m < 8 { result.append(PaceEntry(min: m, sec: 30)) } + } + return result + }() + + private var activePaces: [PaceEntry] { + selectedUnit == .mph ? mphPaces : kphPaces + } + + var body: some View { + NavigationStack { + List { + Picker("Unit", selection: $selectedUnit) { + ForEach(WatchSpeedUnit.allCases, id: \.self) { unit in + Text(unit.speedLabel).tag(unit) + } + } + + ForEach(activePaces) { pace in + let speed = paceToSpeed(pace.paceMinutes) + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(pace.label) + .font(.system(.title3, design: .rounded, weight: .bold)) + .monospacedDigit() + Text(selectedUnit.paceLabel) + .font(.caption2) + .foregroundStyle(.secondary) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(formatSpeed(speed)) + .font(.system(.title3, design: .rounded, weight: .bold)) + .monospacedDigit() + .foregroundStyle(.green) + Text(selectedUnit.speedLabel) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(pace.label) \(selectedUnit.paceLabel) equals \(formatSpeed(speed)) \(selectedUnit.speedLabel)") + } + } + .navigationTitle("RunPace") + } + } +} + +#Preview { + ContentView() +} diff --git a/pace-to-mph WatchKit App/pace_to_mphWatchApp.swift b/pace-to-mph WatchKit App/pace_to_mphWatchApp.swift new file mode 100644 index 0000000..23032a4 --- /dev/null +++ b/pace-to-mph WatchKit App/pace_to_mphWatchApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct pace_to_mphWatchApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} From 5e05e08c6b9660700b0abfae819a5d1ecf3b408d Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 20:41:52 -0500 Subject: [PATCH 06/53] feat(widget): add WidgetKit home/lock screen widget --- .../Assets.xcassets/Contents.json | 6 + pace-to-mphWidgets/pace_to_mphWidgets.swift | 241 ++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 pace-to-mphWidgets/Assets.xcassets/Contents.json create mode 100644 pace-to-mphWidgets/pace_to_mphWidgets.swift diff --git a/pace-to-mphWidgets/Assets.xcassets/Contents.json b/pace-to-mphWidgets/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/pace-to-mphWidgets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/pace-to-mphWidgets/pace_to_mphWidgets.swift b/pace-to-mphWidgets/pace_to_mphWidgets.swift new file mode 100644 index 0000000..09863f8 --- /dev/null +++ b/pace-to-mphWidgets/pace_to_mphWidgets.swift @@ -0,0 +1,241 @@ +import WidgetKit +import SwiftUI + +// MARK: - Shared Model + +struct WidgetConversionRecord: Codable { + let id: UUID + let input: String + let inputSuffix: String + let result: String + let resultSuffix: String + let date: Date +} + +// MARK: - Timeline Entry + +struct LastConversionEntry: TimelineEntry { + let date: Date + let input: String + let inputSuffix: String + let result: String + let resultSuffix: String + let isPlaceholder: Bool + + static var empty: LastConversionEntry { + LastConversionEntry( + date: .now, + input: "8:00", + inputSuffix: "min/mi", + result: "7.5", + resultSuffix: "mph", + isPlaceholder: true + ) + } +} + +// MARK: - Provider + +struct LastConversionProvider: TimelineProvider { + // TODO: Switch to UserDefaults(suiteName: "group.sh.saad.pace-to-mph") once App Group + // entitlement is added to both the app and widget targets. For now, this reads from + // UserDefaults.standard which may not reflect the app's data in production, but allows + // the widget to build and run without entitlement configuration. + private let storageKey = "conversion_history" + + func placeholder(in context: Context) -> LastConversionEntry { + .empty + } + + func getSnapshot(in context: Context, completion: @escaping (LastConversionEntry) -> Void) { + completion(loadLatestEntry()) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = loadLatestEntry() + let refreshDate = Calendar.current.date(byAdding: .hour, value: 1, to: .now) ?? .now + let timeline = Timeline(entries: [entry], policy: .after(refreshDate)) + completion(timeline) + } + + private func loadLatestEntry() -> LastConversionEntry { + guard let data = UserDefaults.standard.data(forKey: storageKey), + let records = try? JSONDecoder().decode([WidgetConversionRecord].self, from: data), + let latest = records.first else { + return .empty + } + return LastConversionEntry( + date: .now, + input: latest.input, + inputSuffix: latest.inputSuffix, + result: latest.result, + resultSuffix: latest.resultSuffix, + isPlaceholder: false + ) + } +} + +// MARK: - Small Widget View + +struct SmallWidgetView: View { + let entry: LastConversionEntry + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + if entry.isPlaceholder { + Text("No conversions yet") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(entry.result) + .font(.system(size: 36, weight: .bold, design: .rounded)) + .minimumScaleFactor(0.5) + .lineLimit(1) + Text(entry.resultSuffix) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + HStack(spacing: 2) { + Image(systemName: "figure.run") + .font(.caption2) + Text("RunPace") + .font(.caption2) + } + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .containerBackground(.fill.tertiary, for: .widget) + } +} + +// MARK: - Medium Widget View + +struct MediumWidgetView: View { + let entry: LastConversionEntry + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 2) { + Image(systemName: "figure.run") + .font(.caption) + Text("Last Conversion") + .font(.caption) + .foregroundStyle(.secondary) + } + if entry.isPlaceholder { + Text("No conversions yet") + .font(.body) + .foregroundStyle(.secondary) + } else { + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .trailing, spacing: 2) { + Text(entry.input) + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .lineLimit(1) + Text(entry.inputSuffix) + .font(.caption) + .foregroundStyle(.secondary) + } + Image(systemName: "arrow.right") + .font(.body) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(entry.result) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .lineLimit(1) + Text(entry.resultSuffix) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .minimumScaleFactor(0.6) + } + Spacer() + } + Spacer() + } + .containerBackground(.fill.tertiary, for: .widget) + } +} + +// MARK: - Widget Entry View (dispatches by family) + +struct LastConversionEntryView: View { + @Environment(\.widgetFamily) var family + let entry: LastConversionEntry + + var body: some View { + switch family { + case .systemMedium: + MediumWidgetView(entry: entry) + case .accessoryRectangular: + AccessoryRectangularView(entry: entry) + default: + SmallWidgetView(entry: entry) + } + } +} + +// MARK: - Widget Definition + +struct LastConversionWidget: Widget { + let kind: String = "LastConversionWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: LastConversionProvider()) { entry in + LastConversionEntryView(entry: entry) + } + .configurationDisplayName("Last Conversion") + .description("Shows your most recent pace/speed conversion.") + .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular]) + } +} + +// MARK: - Accessory (Lock Screen) View + +struct AccessoryRectangularView: View { + let entry: LastConversionEntry + + var body: some View { + if entry.isPlaceholder { + Text("No conversions") + .font(.caption) + } else { + VStack(alignment: .leading) { + Text("RunPace") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(entry.input) \(entry.inputSuffix) → \(entry.result) \(entry.resultSuffix)") + .font(.caption) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } + } +} + +// MARK: - Widget Bundle + +@main +struct PaceToMphWidgets: WidgetBundle { + var body: some Widget { + LastConversionWidget() + } +} + +// MARK: - Previews + +#Preview("Small", as: .systemSmall) { + LastConversionWidget() +} timeline: { + LastConversionEntry(date: .now, input: "8:00", inputSuffix: "min/mi", result: "7.5", resultSuffix: "mph", isPlaceholder: false) + LastConversionEntry.empty +} + +#Preview("Medium", as: .systemMedium) { + LastConversionWidget() +} timeline: { + LastConversionEntry(date: .now, input: "8:00", inputSuffix: "min/mi", result: "7.5", resultSuffix: "mph", isPlaceholder: false) +} From 311c67b7b0af34d8484c4205e095989026019e20 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 20:42:09 -0500 Subject: [PATCH 07/53] build(xcode): add watchOS, WidgetKit, and App Intents targets to project --- pace-to-mph.xcodeproj/project.pbxproj | 280 ++++++++++++++++++ .../xcschemes/xcschememanagement.plist | 10 + 2 files changed, 290 insertions(+) diff --git a/pace-to-mph.xcodeproj/project.pbxproj b/pace-to-mph.xcodeproj/project.pbxproj index 46a3f96..ae49343 100644 --- a/pace-to-mph.xcodeproj/project.pbxproj +++ b/pace-to-mph.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + W1D6E70D0000000000000013 /* pace-to-mphWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = W1D6E7010000000000000001 /* pace-to-mphWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 939A2E6A2F539928000B40F9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -21,12 +25,35 @@ remoteGlobalIDString = 939A2E5B2F539927000B40F9; remoteInfo = "pace-to-mph"; }; + W1D6E70A0000000000000010 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 939A2E542F539927000B40F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = W1D6E7060000000000000006; + remoteInfo = "pace-to-mphWidgets"; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + W1D6E70C0000000000000012 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + W1D6E70D0000000000000013 /* pace-to-mphWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 939A2E5C2F539927000B40F9 /* pace-to-mph.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "pace-to-mph.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 939A2E692F539928000B40F9 /* pace-to-mphTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "pace-to-mphTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 939A2E732F539928000B40F9 /* pace-to-mphUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "pace-to-mphUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + C0FFEE010000000000000001 /* pace-to-mph WatchKit App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "pace-to-mph WatchKit App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + W1D6E7010000000000000001 /* pace-to-mphWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "pace-to-mphWidgets.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -45,6 +72,16 @@ path = "pace-to-mphUITests"; sourceTree = ""; }; + C0FFEE020000000000000002 /* pace-to-mph WatchKit App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "pace-to-mph WatchKit App"; + sourceTree = ""; + }; + W1D6E7020000000000000002 /* pace-to-mphWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "pace-to-mphWidgets"; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -69,6 +106,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C0FFEE040000000000000004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + W1D6E7040000000000000004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -76,6 +127,8 @@ isa = PBXGroup; children = ( 939A2E5E2F539927000B40F9 /* pace-to-mph */, + C0FFEE020000000000000002 /* pace-to-mph WatchKit App */, + W1D6E7020000000000000002 /* pace-to-mphWidgets */, 939A2E6C2F539928000B40F9 /* pace-to-mphTests */, 939A2E762F539928000B40F9 /* pace-to-mphUITests */, 939A2E5D2F539927000B40F9 /* Products */, @@ -88,6 +141,8 @@ 939A2E5C2F539927000B40F9 /* pace-to-mph.app */, 939A2E692F539928000B40F9 /* pace-to-mphTests.xctest */, 939A2E732F539928000B40F9 /* pace-to-mphUITests.xctest */, + C0FFEE010000000000000001 /* pace-to-mph WatchKit App.app */, + W1D6E7010000000000000001 /* pace-to-mphWidgets.appex */, ); name = Products; sourceTree = ""; @@ -102,10 +157,12 @@ 939A2E582F539927000B40F9 /* Sources */, 939A2E592F539927000B40F9 /* Frameworks */, 939A2E5A2F539927000B40F9 /* Resources */, + W1D6E70C0000000000000012 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + W1D6E70B0000000000000011 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 939A2E5E2F539927000B40F9 /* pace-to-mph */, @@ -163,6 +220,50 @@ productReference = 939A2E732F539928000B40F9 /* pace-to-mphUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + C0FFEE060000000000000006 /* pace-to-mph WatchKit App */ = { + isa = PBXNativeTarget; + buildConfigurationList = C0FFEE090000000000000009 /* Build configuration list for PBXNativeTarget "pace-to-mph WatchKit App" */; + buildPhases = ( + C0FFEE030000000000000003 /* Sources */, + C0FFEE040000000000000004 /* Frameworks */, + C0FFEE050000000000000005 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + C0FFEE020000000000000002 /* pace-to-mph WatchKit App */, + ); + name = "pace-to-mph WatchKit App"; + packageProductDependencies = ( + ); + productName = "pace-to-mph WatchKit App"; + productReference = C0FFEE010000000000000001 /* pace-to-mph WatchKit App.app */; + productType = "com.apple.product-type.application"; + }; + W1D6E7060000000000000006 /* pace-to-mphWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = W1D6E7090000000000000009 /* Build configuration list for PBXNativeTarget "pace-to-mphWidgets" */; + buildPhases = ( + W1D6E7030000000000000003 /* Sources */, + W1D6E7040000000000000004 /* Frameworks */, + W1D6E7050000000000000005 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + W1D6E7020000000000000002 /* pace-to-mphWidgets */, + ); + name = "pace-to-mphWidgets"; + packageProductDependencies = ( + ); + productName = "pace-to-mphWidgets"; + productReference = W1D6E7010000000000000001 /* pace-to-mphWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -184,6 +285,12 @@ CreatedOnToolsVersion = 26.0.1; TestTargetID = 939A2E5B2F539927000B40F9; }; + C0FFEE060000000000000006 = { + CreatedOnToolsVersion = 26.0.1; + }; + W1D6E7060000000000000006 = { + CreatedOnToolsVersion = 26.0.1; + }; }; }; buildConfigurationList = 939A2E572F539927000B40F9 /* Build configuration list for PBXProject "pace-to-mph" */; @@ -203,6 +310,8 @@ 939A2E5B2F539927000B40F9 /* pace-to-mph */, 939A2E682F539928000B40F9 /* pace-to-mphTests */, 939A2E722F539928000B40F9 /* pace-to-mphUITests */, + C0FFEE060000000000000006 /* pace-to-mph WatchKit App */, + W1D6E7060000000000000006 /* pace-to-mphWidgets */, ); }; /* End PBXProject section */ @@ -229,6 +338,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C0FFEE050000000000000005 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + W1D6E7050000000000000005 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -253,6 +376,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C0FFEE030000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + W1D6E7030000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -266,6 +403,11 @@ target = 939A2E5B2F539927000B40F9 /* pace-to-mph */; targetProxy = 939A2E742F539928000B40F9 /* PBXContainerItemProxy */; }; + W1D6E70B0000000000000011 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = W1D6E7060000000000000006 /* pace-to-mphWidgets */; + targetProxy = W1D6E70A0000000000000010 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -538,6 +680,126 @@ }; name = Release; }; + C0FFEE070000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2ZPA772V9V; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = RunPace; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "sh.saad.pace-to-mph"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sh.saad.pace-to-mph.watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Debug; + }; + C0FFEE080000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2ZPA772V9V; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = RunPace; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "sh.saad.pace-to-mph"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sh.saad.pace-to-mph.watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Release; + }; + W1D6E7070000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2ZPA772V9V; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "RunPace Widgets"; + INFOPLIST_KEY_NSExtension_NSExtensionPointIdentifier = "com.apple.widgetkit-extension"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sh.saad.pace-to-mph.widgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + W1D6E7080000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 2ZPA772V9V; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "RunPace Widgets"; + INFOPLIST_KEY_NSExtension_NSExtensionPointIdentifier = "com.apple.widgetkit-extension"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "sh.saad.pace-to-mph.widgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -577,6 +839,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C0FFEE090000000000000009 /* Build configuration list for PBXNativeTarget "pace-to-mph WatchKit App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C0FFEE070000000000000007 /* Debug */, + C0FFEE080000000000000008 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + W1D6E7090000000000000009 /* Build configuration list for PBXNativeTarget "pace-to-mphWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + W1D6E7070000000000000007 /* Debug */, + W1D6E7080000000000000008 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 939A2E542F539927000B40F9 /* Project object */; diff --git a/pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist b/pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist index df6fe5a..813eccb 100644 --- a/pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,11 +4,21 @@ SchemeUserState + pace-to-mph WatchKit App.xcscheme_^#shared#^_ + + orderHint + 0 + pace-to-mph.xcscheme_^#shared#^_ orderHint 0 + pace-to-mphWidgets.xcscheme_^#shared#^_ + + orderHint + 1 + From 212793178f76cc10bca1b32c3cc3fe0167d9c161 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 20:51:47 -0500 Subject: [PATCH 08/53] fix(widget): add proper Info.plist with NSExtension dict to fix preview install --- pace-to-mph.xcodeproj/project.pbxproj | 12 ++++-------- pace-to-mphWidgets-Info.plist | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 pace-to-mphWidgets-Info.plist diff --git a/pace-to-mph.xcodeproj/project.pbxproj b/pace-to-mph.xcodeproj/project.pbxproj index ae49343..e3152ed 100644 --- a/pace-to-mph.xcodeproj/project.pbxproj +++ b/pace-to-mph.xcodeproj/project.pbxproj @@ -749,10 +749,8 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 2ZPA772V9V; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "RunPace Widgets"; - INFOPLIST_KEY_NSExtension_NSExtensionPointIdentifier = "com.apple.widgetkit-extension"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "pace-to-mphWidgets-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -778,10 +776,8 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 2ZPA772V9V; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "RunPace Widgets"; - INFOPLIST_KEY_NSExtension_NSExtensionPointIdentifier = "com.apple.widgetkit-extension"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "pace-to-mphWidgets-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/pace-to-mphWidgets-Info.plist b/pace-to-mphWidgets-Info.plist new file mode 100644 index 0000000..ee02f74 --- /dev/null +++ b/pace-to-mphWidgets-Info.plist @@ -0,0 +1,23 @@ + + + + + CFBundleDisplayName + RunPace Widgets + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + From 99c63504264fc312547f34ab83ccd84c6e5843d1 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 20:56:01 -0500 Subject: [PATCH 09/53] fix(widget): add CFBundleExecutable to Info.plist --- pace-to-mphWidgets-Info.plist | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pace-to-mphWidgets-Info.plist b/pace-to-mphWidgets-Info.plist index ee02f74..4d3c3e8 100644 --- a/pace-to-mphWidgets-Info.plist +++ b/pace-to-mphWidgets-Info.plist @@ -2,8 +2,12 @@ + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName RunPace Widgets + CFBundleExecutable + $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleName From b8ebf7f77db2f0ffd4d46c78798f7a63d7e5998a Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 21:53:15 -0500 Subject: [PATCH 10/53] fix(history): deduplicate saves and trigger on direction/unit changes - Add dedup check in ConversionHistory.add() to skip identical consecutive saves - Trigger recordCurrentConversion() in switchDirection() and switchUnit() - Add onChange listener for keyboard dismiss to capture intent to navigate away - Relax input validation from count>1 to non-empty to allow single-digit speeds --- pace-to-mph/Models/ConversionHistory.swift | 9 +++++++++ pace-to-mph/ViewModels/ConverterViewModel.swift | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/pace-to-mph/Models/ConversionHistory.swift b/pace-to-mph/Models/ConversionHistory.swift index 2091e1f..75e873e 100644 --- a/pace-to-mph/Models/ConversionHistory.swift +++ b/pace-to-mph/Models/ConversionHistory.swift @@ -21,6 +21,15 @@ class ConversionHistory { let trimmed = result.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, !input.trimmingCharacters(in: .whitespaces).isEmpty else { return } + // Skip if identical to the most recent record + if let last = records.first, + last.input == input, + last.inputSuffix == inputSuffix, + last.result == result, + last.resultSuffix == resultSuffix { + return + } + let record = ConversionRecord( id: UUID(), input: input, diff --git a/pace-to-mph/ViewModels/ConverterViewModel.swift b/pace-to-mph/ViewModels/ConverterViewModel.swift index a62b462..1658108 100644 --- a/pace-to-mph/ViewModels/ConverterViewModel.swift +++ b/pace-to-mph/ViewModels/ConverterViewModel.swift @@ -85,12 +85,13 @@ final class ConverterViewModel { func switchDirection(to newDirection: ConversionDirection) { guard newDirection != direction else { return } + recordCurrentConversion() direction = newDirection } func recordCurrentConversion() { let currentResult = result - guard !currentResult.isEmpty, inputText.count > 1 else { return } + guard !currentResult.isEmpty, !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return } history.add( input: inputText, inputSuffix: inputSuffix, @@ -101,6 +102,7 @@ final class ConverterViewModel { func switchUnit(to newUnit: SpeedUnit) { guard newUnit != unit else { return } + recordCurrentConversion() oldUnit = unit unit = newUnit } From 9a1326a1d91c9e85f9e7cb101e5493fef3355758 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 21:53:19 -0500 Subject: [PATCH 11/53] fix(history): save conversion when keyboard dismisses Add onChange listener to detect keyboard focus loss and trigger save, capturing intent to navigate away or interact with controls. --- pace-to-mph/ContentView.swift | 72 ++++++++--------------------------- 1 file changed, 16 insertions(+), 56 deletions(-) diff --git a/pace-to-mph/ContentView.swift b/pace-to-mph/ContentView.swift index cddc147..012585e 100644 --- a/pace-to-mph/ContentView.swift +++ b/pace-to-mph/ContentView.swift @@ -6,14 +6,7 @@ struct ContentView: View { var body: some View { NavigationStack { - ZStack { - Color(.systemGroupedBackground) - .ignoresSafeArea() - .onTapGesture { - isInputFocused = false - viewModel.recordCurrentConversion() - } - + GlassEffectContainer { VStack(spacing: 0) { // Header headerSection @@ -32,8 +25,16 @@ struct ContentView: View { .padding(.horizontal, 16) .padding(.bottom, 8) } + .onTapGesture { + isInputFocused = false + viewModel.recordCurrentConversion() + } + .onChange(of: isInputFocused) { _, focused in + if !focused { + viewModel.recordCurrentConversion() + } + } } - .containerShape(RoundedRectangle(cornerRadius: 44, style: .continuous)) .toolbar { ToolbarItem(placement: .topBarLeading) { NavigationLink { @@ -156,14 +157,7 @@ struct ContentView: View { .accessibilityLabel(viewModel.result.isEmpty ? "No result" : "\(viewModel.result) \(viewModel.resultSuffix)") } .padding(24) - .background( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(Color(.secondarySystemGroupedBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .strokeBorder(.quaternary, lineWidth: 1) - ) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) } // MARK: - Control Panel @@ -192,14 +186,7 @@ struct ContentView: View { unitPicker } .padding(16) - .background( - ContainerRelativeShape() - .fill(Color(.secondarySystemGroupedBackground)) - ) - .overlay( - ContainerRelativeShape() - .strokeBorder(.quaternary, lineWidth: 1) - ) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) } private var directionPicker: some View { @@ -215,31 +202,13 @@ struct ContentView: View { Text(dir.label) .font(.system(size: 15, weight: .semibold)) .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background( - viewModel.direction == dir - ? AnyShapeStyle( - LinearGradient( - colors: [Color.green.opacity(0.8), Color.green], - startPoint: .leading, - endPoint: .trailing - ) - ) - : AnyShapeStyle(Color.clear) - ) - .foregroundStyle(viewModel.direction == dir ? .white : .secondary) - .clipShape(Capsule()) } - .buttonStyle(.plain) + .buttonStyle(.glass) + .tint(viewModel.direction == dir ? .green : nil) .accessibilityLabel(dir.label) .accessibilityAddTraits(viewModel.direction == dir ? .isSelected : []) } } - .padding(6) - .background( - Capsule() - .fill(Color(.tertiarySystemGroupedBackground)) - ) .accessibilityElement(children: .contain) .accessibilityLabel("Conversion direction") } @@ -256,18 +225,9 @@ struct ContentView: View { Text(u.label) .font(.system(size: 14, weight: .bold)) .tracking(1.5) - .padding(.horizontal, 32) - .padding(.vertical, 14) - .background( - Capsule() - .strokeBorder( - viewModel.unit == u ? Color.green : Color(.separator), - lineWidth: 2 - ) - ) - .foregroundStyle(viewModel.unit == u ? .green : .secondary) } - .buttonStyle(.plain) + .buttonStyle(.glass) + .tint(viewModel.unit == u ? .green : nil) .accessibilityLabel(u.label) .accessibilityAddTraits(viewModel.unit == u ? .isSelected : []) } From f97c965e422292f483e053ba16bade925ada6e9a Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:00:15 -0500 Subject: [PATCH 12/53] chore: remove widget extension target and files --- pace-to-mph.xcodeproj/project.pbxproj | 150 ----------- pace-to-mphWidgets-Info.plist | 27 -- .../Assets.xcassets/Contents.json | 6 - pace-to-mphWidgets/pace_to_mphWidgets.swift | 241 ------------------ 4 files changed, 424 deletions(-) delete mode 100644 pace-to-mphWidgets-Info.plist delete mode 100644 pace-to-mphWidgets/Assets.xcassets/Contents.json delete mode 100644 pace-to-mphWidgets/pace_to_mphWidgets.swift diff --git a/pace-to-mph.xcodeproj/project.pbxproj b/pace-to-mph.xcodeproj/project.pbxproj index e3152ed..5d1d347 100644 --- a/pace-to-mph.xcodeproj/project.pbxproj +++ b/pace-to-mph.xcodeproj/project.pbxproj @@ -6,10 +6,6 @@ objectVersion = 77; objects = { -/* Begin PBXBuildFile section */ - W1D6E70D0000000000000013 /* pace-to-mphWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = W1D6E7010000000000000001 /* pace-to-mphWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; -/* End PBXBuildFile section */ - /* Begin PBXContainerItemProxy section */ 939A2E6A2F539928000B40F9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -25,35 +21,13 @@ remoteGlobalIDString = 939A2E5B2F539927000B40F9; remoteInfo = "pace-to-mph"; }; - W1D6E70A0000000000000010 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 939A2E542F539927000B40F9 /* Project object */; - proxyType = 1; - remoteGlobalIDString = W1D6E7060000000000000006; - remoteInfo = "pace-to-mphWidgets"; - }; /* End PBXContainerItemProxy section */ -/* Begin PBXCopyFilesBuildPhase section */ - W1D6E70C0000000000000012 /* Embed Foundation Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - W1D6E70D0000000000000013 /* pace-to-mphWidgets.appex in Embed Foundation Extensions */, - ); - name = "Embed Foundation Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - /* Begin PBXFileReference section */ 939A2E5C2F539927000B40F9 /* pace-to-mph.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "pace-to-mph.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 939A2E692F539928000B40F9 /* pace-to-mphTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "pace-to-mphTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 939A2E732F539928000B40F9 /* pace-to-mphUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "pace-to-mphUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; C0FFEE010000000000000001 /* pace-to-mph WatchKit App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "pace-to-mph WatchKit App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - W1D6E7010000000000000001 /* pace-to-mphWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "pace-to-mphWidgets.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -77,11 +51,6 @@ path = "pace-to-mph WatchKit App"; sourceTree = ""; }; - W1D6E7020000000000000002 /* pace-to-mphWidgets */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = "pace-to-mphWidgets"; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -113,13 +82,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - W1D6E7040000000000000004 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -128,7 +90,6 @@ children = ( 939A2E5E2F539927000B40F9 /* pace-to-mph */, C0FFEE020000000000000002 /* pace-to-mph WatchKit App */, - W1D6E7020000000000000002 /* pace-to-mphWidgets */, 939A2E6C2F539928000B40F9 /* pace-to-mphTests */, 939A2E762F539928000B40F9 /* pace-to-mphUITests */, 939A2E5D2F539927000B40F9 /* Products */, @@ -142,7 +103,6 @@ 939A2E692F539928000B40F9 /* pace-to-mphTests.xctest */, 939A2E732F539928000B40F9 /* pace-to-mphUITests.xctest */, C0FFEE010000000000000001 /* pace-to-mph WatchKit App.app */, - W1D6E7010000000000000001 /* pace-to-mphWidgets.appex */, ); name = Products; sourceTree = ""; @@ -157,12 +117,10 @@ 939A2E582F539927000B40F9 /* Sources */, 939A2E592F539927000B40F9 /* Frameworks */, 939A2E5A2F539927000B40F9 /* Resources */, - W1D6E70C0000000000000012 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( - W1D6E70B0000000000000011 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 939A2E5E2F539927000B40F9 /* pace-to-mph */, @@ -242,28 +200,6 @@ productReference = C0FFEE010000000000000001 /* pace-to-mph WatchKit App.app */; productType = "com.apple.product-type.application"; }; - W1D6E7060000000000000006 /* pace-to-mphWidgets */ = { - isa = PBXNativeTarget; - buildConfigurationList = W1D6E7090000000000000009 /* Build configuration list for PBXNativeTarget "pace-to-mphWidgets" */; - buildPhases = ( - W1D6E7030000000000000003 /* Sources */, - W1D6E7040000000000000004 /* Frameworks */, - W1D6E7050000000000000005 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - W1D6E7020000000000000002 /* pace-to-mphWidgets */, - ); - name = "pace-to-mphWidgets"; - packageProductDependencies = ( - ); - productName = "pace-to-mphWidgets"; - productReference = W1D6E7010000000000000001 /* pace-to-mphWidgets.appex */; - productType = "com.apple.product-type.app-extension"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -288,9 +224,6 @@ C0FFEE060000000000000006 = { CreatedOnToolsVersion = 26.0.1; }; - W1D6E7060000000000000006 = { - CreatedOnToolsVersion = 26.0.1; - }; }; }; buildConfigurationList = 939A2E572F539927000B40F9 /* Build configuration list for PBXProject "pace-to-mph" */; @@ -311,7 +244,6 @@ 939A2E682F539928000B40F9 /* pace-to-mphTests */, 939A2E722F539928000B40F9 /* pace-to-mphUITests */, C0FFEE060000000000000006 /* pace-to-mph WatchKit App */, - W1D6E7060000000000000006 /* pace-to-mphWidgets */, ); }; /* End PBXProject section */ @@ -345,13 +277,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - W1D6E7050000000000000005 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -383,13 +308,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - W1D6E7030000000000000003 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -403,11 +321,6 @@ target = 939A2E5B2F539927000B40F9 /* pace-to-mph */; targetProxy = 939A2E742F539928000B40F9 /* PBXContainerItemProxy */; }; - W1D6E70B0000000000000011 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = W1D6E7060000000000000006 /* pace-to-mphWidgets */; - targetProxy = W1D6E70A0000000000000010 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -742,60 +655,6 @@ }; name = Release; }; - W1D6E7070000000000000007 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2ZPA772V9V; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = "pace-to-mphWidgets-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "sh.saad.pace-to-mph.widgets"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - W1D6E7080000000000000008 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2ZPA772V9V; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = "pace-to-mphWidgets-Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "sh.saad.pace-to-mph.widgets"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -844,15 +703,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - W1D6E7090000000000000009 /* Build configuration list for PBXNativeTarget "pace-to-mphWidgets" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - W1D6E7070000000000000007 /* Debug */, - W1D6E7080000000000000008 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ }; rootObject = 939A2E542F539927000B40F9 /* Project object */; diff --git a/pace-to-mphWidgets-Info.plist b/pace-to-mphWidgets-Info.plist deleted file mode 100644 index 4d3c3e8..0000000 --- a/pace-to-mphWidgets-Info.plist +++ /dev/null @@ -1,27 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - RunPace Widgets - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - NSExtension - - NSExtensionPointIdentifier - com.apple.widgetkit-extension - - - diff --git a/pace-to-mphWidgets/Assets.xcassets/Contents.json b/pace-to-mphWidgets/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/pace-to-mphWidgets/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/pace-to-mphWidgets/pace_to_mphWidgets.swift b/pace-to-mphWidgets/pace_to_mphWidgets.swift deleted file mode 100644 index 09863f8..0000000 --- a/pace-to-mphWidgets/pace_to_mphWidgets.swift +++ /dev/null @@ -1,241 +0,0 @@ -import WidgetKit -import SwiftUI - -// MARK: - Shared Model - -struct WidgetConversionRecord: Codable { - let id: UUID - let input: String - let inputSuffix: String - let result: String - let resultSuffix: String - let date: Date -} - -// MARK: - Timeline Entry - -struct LastConversionEntry: TimelineEntry { - let date: Date - let input: String - let inputSuffix: String - let result: String - let resultSuffix: String - let isPlaceholder: Bool - - static var empty: LastConversionEntry { - LastConversionEntry( - date: .now, - input: "8:00", - inputSuffix: "min/mi", - result: "7.5", - resultSuffix: "mph", - isPlaceholder: true - ) - } -} - -// MARK: - Provider - -struct LastConversionProvider: TimelineProvider { - // TODO: Switch to UserDefaults(suiteName: "group.sh.saad.pace-to-mph") once App Group - // entitlement is added to both the app and widget targets. For now, this reads from - // UserDefaults.standard which may not reflect the app's data in production, but allows - // the widget to build and run without entitlement configuration. - private let storageKey = "conversion_history" - - func placeholder(in context: Context) -> LastConversionEntry { - .empty - } - - func getSnapshot(in context: Context, completion: @escaping (LastConversionEntry) -> Void) { - completion(loadLatestEntry()) - } - - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let entry = loadLatestEntry() - let refreshDate = Calendar.current.date(byAdding: .hour, value: 1, to: .now) ?? .now - let timeline = Timeline(entries: [entry], policy: .after(refreshDate)) - completion(timeline) - } - - private func loadLatestEntry() -> LastConversionEntry { - guard let data = UserDefaults.standard.data(forKey: storageKey), - let records = try? JSONDecoder().decode([WidgetConversionRecord].self, from: data), - let latest = records.first else { - return .empty - } - return LastConversionEntry( - date: .now, - input: latest.input, - inputSuffix: latest.inputSuffix, - result: latest.result, - resultSuffix: latest.resultSuffix, - isPlaceholder: false - ) - } -} - -// MARK: - Small Widget View - -struct SmallWidgetView: View { - let entry: LastConversionEntry - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - if entry.isPlaceholder { - Text("No conversions yet") - .font(.caption) - .foregroundStyle(.secondary) - } else { - Text(entry.result) - .font(.system(size: 36, weight: .bold, design: .rounded)) - .minimumScaleFactor(0.5) - .lineLimit(1) - Text(entry.resultSuffix) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - HStack(spacing: 2) { - Image(systemName: "figure.run") - .font(.caption2) - Text("RunPace") - .font(.caption2) - } - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .containerBackground(.fill.tertiary, for: .widget) - } -} - -// MARK: - Medium Widget View - -struct MediumWidgetView: View { - let entry: LastConversionEntry - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 2) { - Image(systemName: "figure.run") - .font(.caption) - Text("Last Conversion") - .font(.caption) - .foregroundStyle(.secondary) - } - if entry.isPlaceholder { - Text("No conversions yet") - .font(.body) - .foregroundStyle(.secondary) - } else { - HStack(alignment: .firstTextBaseline, spacing: 8) { - VStack(alignment: .trailing, spacing: 2) { - Text(entry.input) - .font(.system(size: 24, weight: .semibold, design: .rounded)) - .lineLimit(1) - Text(entry.inputSuffix) - .font(.caption) - .foregroundStyle(.secondary) - } - Image(systemName: "arrow.right") - .font(.body) - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(entry.result) - .font(.system(size: 24, weight: .bold, design: .rounded)) - .lineLimit(1) - Text(entry.resultSuffix) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .minimumScaleFactor(0.6) - } - Spacer() - } - Spacer() - } - .containerBackground(.fill.tertiary, for: .widget) - } -} - -// MARK: - Widget Entry View (dispatches by family) - -struct LastConversionEntryView: View { - @Environment(\.widgetFamily) var family - let entry: LastConversionEntry - - var body: some View { - switch family { - case .systemMedium: - MediumWidgetView(entry: entry) - case .accessoryRectangular: - AccessoryRectangularView(entry: entry) - default: - SmallWidgetView(entry: entry) - } - } -} - -// MARK: - Widget Definition - -struct LastConversionWidget: Widget { - let kind: String = "LastConversionWidget" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: LastConversionProvider()) { entry in - LastConversionEntryView(entry: entry) - } - .configurationDisplayName("Last Conversion") - .description("Shows your most recent pace/speed conversion.") - .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular]) - } -} - -// MARK: - Accessory (Lock Screen) View - -struct AccessoryRectangularView: View { - let entry: LastConversionEntry - - var body: some View { - if entry.isPlaceholder { - Text("No conversions") - .font(.caption) - } else { - VStack(alignment: .leading) { - Text("RunPace") - .font(.caption2) - .foregroundStyle(.secondary) - Text("\(entry.input) \(entry.inputSuffix) → \(entry.result) \(entry.resultSuffix)") - .font(.caption) - .lineLimit(1) - .minimumScaleFactor(0.7) - } - } - } -} - -// MARK: - Widget Bundle - -@main -struct PaceToMphWidgets: WidgetBundle { - var body: some Widget { - LastConversionWidget() - } -} - -// MARK: - Previews - -#Preview("Small", as: .systemSmall) { - LastConversionWidget() -} timeline: { - LastConversionEntry(date: .now, input: "8:00", inputSuffix: "min/mi", result: "7.5", resultSuffix: "mph", isPlaceholder: false) - LastConversionEntry.empty -} - -#Preview("Medium", as: .systemMedium) { - LastConversionWidget() -} timeline: { - LastConversionEntry(date: .now, input: "8:00", inputSuffix: "min/mi", result: "7.5", resultSuffix: "mph", isPlaceholder: false) -} From ef829c2e7cf4165bee4745dd53415b2ddc56c372 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:00:19 -0500 Subject: [PATCH 13/53] fix: add nonisolated to ConversionEngine enum --- pace-to-mph/Models/ConversionEngine.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pace-to-mph/Models/ConversionEngine.swift b/pace-to-mph/Models/ConversionEngine.swift index 10bbbfd..8e27ded 100644 --- a/pace-to-mph/Models/ConversionEngine.swift +++ b/pace-to-mph/Models/ConversionEngine.swift @@ -33,7 +33,7 @@ enum SpeedUnit: String, CaseIterable { } } -enum ConversionEngine { +nonisolated enum ConversionEngine { static let kmPerMile: Double = 1.60934 // MARK: - Parsing From a211effc04db3544f63cc2827e9c9cbb51bc026b Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:00:24 -0500 Subject: [PATCH 14/53] feat(calculator): add speed and pace to race calculator and even splits results --- pace-to-mph/RaceTimeView.swift | 148 +++++++++++++------------- pace-to-mph/SplitCalculatorView.swift | 145 ++++++++++++------------- 2 files changed, 140 insertions(+), 153 deletions(-) diff --git a/pace-to-mph/RaceTimeView.swift b/pace-to-mph/RaceTimeView.swift index ae7cc77..66f9443 100644 --- a/pace-to-mph/RaceTimeView.swift +++ b/pace-to-mph/RaceTimeView.swift @@ -23,15 +23,14 @@ struct RaceTimeView: View { return RaceCalculator.formatDuration(seconds) } - var body: some View { - ZStack { - Color(.systemGroupedBackground) - .ignoresSafeArea() - .onTapGesture { - isPaceFocused = false - isDistanceFocused = false - } + private var speedText: String { + guard let pace = ConversionEngine.parsePace(paceInput) else { return "" } + let speed = ConversionEngine.paceToSpeed(pace) + return ConversionEngine.formatSpeed(speed) + } + var body: some View { + GlassEffectContainer { ScrollView { VStack(spacing: 20) { // Pace input card @@ -67,14 +66,7 @@ struct RaceTimeView: View { .frame(maxWidth: 200) } .padding(24) - .background( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(Color(.secondarySystemGroupedBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .strokeBorder(.quaternary, lineWidth: 1) - ) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) // Unit picker unitPicker @@ -95,6 +87,10 @@ struct RaceTimeView: View { .padding(.bottom, 32) } } + .onTapGesture { + isPaceFocused = false + isDistanceFocused = false + } .navigationTitle("Race Finish Time") .navigationBarTitleDisplayMode(.inline) } @@ -112,18 +108,9 @@ struct RaceTimeView: View { Text(u.paceLabel) .font(.system(size: 14, weight: .bold)) .tracking(1.5) - .padding(.horizontal, 32) - .padding(.vertical, 14) - .background( - Capsule() - .strokeBorder( - selectedUnit == u ? Color.green : Color(.separator), - lineWidth: 2 - ) - ) - .foregroundStyle(selectedUnit == u ? .green : .secondary) } - .buttonStyle(.plain) + .buttonStyle(.glass) + .tint(selectedUnit == u ? .green : nil) } } } @@ -132,7 +119,7 @@ struct RaceTimeView: View { private var distancePicker: some View { VStack(spacing: 8) { - Text("DISTANCE") + Text("Distance") .font(.caption) .fontWeight(.bold) .tracking(0.6) @@ -149,37 +136,22 @@ struct RaceTimeView: View { } label: { Text(d.rawValue) .font(.system(size: 14, weight: .semibold)) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background( - Capsule() - .fill(selectedDistance == d - ? Color.green - : Color(.tertiarySystemGroupedBackground)) - ) - .foregroundStyle(selectedDistance == d ? .white : .secondary) } - .buttonStyle(.plain) + .buttonStyle(.glass) + .tint(selectedDistance == d ? .green : nil) } } } } .padding(16) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(.secondarySystemGroupedBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(.quaternary, lineWidth: 1) - ) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) } // MARK: - Custom Distance private var customDistanceField: some View { VStack(spacing: 8) { - Text("CUSTOM DISTANCE") + Text("Custom Distance") .font(.caption) .fontWeight(.bold) .tracking(0.6) @@ -204,42 +176,70 @@ struct RaceTimeView: View { } } .padding(16) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(.secondarySystemGroupedBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(.quaternary, lineWidth: 1) - ) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) } // MARK: - Result private var resultCard: some View { - VStack(spacing: 6) { - Text("FINISH TIME") - .font(.caption) - .fontWeight(.bold) - .tracking(0.6) - .foregroundStyle(.secondary) + VStack(spacing: 16) { + VStack(spacing: 6) { + Text("Finish Time") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + + Text(finishTimeText.isEmpty ? "–" : finishTimeText) + .font(.largeTitle.bold().monospacedDigit()) + .foregroundStyle(finishTimeText.isEmpty ? .tertiary : .primary) + .contentTransition(.numericText()) + .animation(.snappy(duration: 0.2), value: finishTimeText) + } + + if !speedText.isEmpty { + Divider() + + HStack(spacing: 24) { + VStack(spacing: 4) { + Text("PACE") + .font(.caption2) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) - Text(finishTimeText.isEmpty ? "–" : finishTimeText) - .font(.largeTitle.bold().monospacedDigit()) - .foregroundStyle(finishTimeText.isEmpty ? .tertiary : .primary) - .contentTransition(.numericText()) - .animation(.snappy(duration: 0.2), value: finishTimeText) + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(paceInput) + .font(.title2.bold().monospacedDigit()) + Text(selectedUnit.paceLabel) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + + VStack(spacing: 4) { + Text("SPEED") + .font(.caption2) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(speedText) + .font(.title2.bold().monospacedDigit()) + .contentTransition(.numericText()) + .animation(.snappy(duration: 0.2), value: speedText) + Text(selectedUnit.speedLabel) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + } + } } .frame(maxWidth: .infinity) .padding(24) - .background( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(Color(.secondarySystemGroupedBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .strokeBorder(.quaternary, lineWidth: 1) - ) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) } } diff --git a/pace-to-mph/SplitCalculatorView.swift b/pace-to-mph/SplitCalculatorView.swift index 55342e9..d24aa0a 100644 --- a/pace-to-mph/SplitCalculatorView.swift +++ b/pace-to-mph/SplitCalculatorView.swift @@ -26,20 +26,23 @@ struct SplitCalculatorView: View { return formatted } - var body: some View { - ZStack { - Color(.systemGroupedBackground) - .ignoresSafeArea() - .onTapGesture { - isTimeFocused = false - isDistanceFocused = false - } + private var speedText: String { + guard let totalSeconds = RaceCalculator.parseDuration(timeInput), + totalSeconds > 0, + let distance = distanceInUnits, + distance > 0 else { return "" } + let paceMinutes = RaceCalculator.requiredPace(totalSeconds: totalSeconds, distanceInUnits: distance) + let speed = ConversionEngine.paceToSpeed(paceMinutes) + return ConversionEngine.formatSpeed(speed) + } + var body: some View { + GlassEffectContainer { ScrollView { VStack(spacing: 20) { // Time input card VStack(spacing: 16) { - Text("TARGET FINISH TIME") + Text("Target Finish Time") .font(.caption) .fontWeight(.bold) .tracking(0.6) @@ -64,14 +67,7 @@ struct SplitCalculatorView: View { .frame(maxWidth: 200) } .padding(24) - .background( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(Color(.secondarySystemGroupedBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .strokeBorder(.quaternary, lineWidth: 1) - ) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) // Unit picker unitPicker @@ -92,6 +88,10 @@ struct SplitCalculatorView: View { .padding(.bottom, 32) } } + .onTapGesture { + isTimeFocused = false + isDistanceFocused = false + } .navigationTitle("Even Splits") .navigationBarTitleDisplayMode(.inline) } @@ -109,18 +109,9 @@ struct SplitCalculatorView: View { Text(u == .mph ? "Mile" : "KM") .font(.system(size: 14, weight: .bold)) .tracking(1.5) - .padding(.horizontal, 32) - .padding(.vertical, 14) - .background( - Capsule() - .strokeBorder( - selectedUnit == u ? Color.green : Color(.separator), - lineWidth: 2 - ) - ) - .foregroundStyle(selectedUnit == u ? .green : .secondary) } - .buttonStyle(.plain) + .buttonStyle(.glass) + .tint(selectedUnit == u ? .green : nil) } } } @@ -129,7 +120,7 @@ struct SplitCalculatorView: View { private var distancePicker: some View { VStack(spacing: 8) { - Text("DISTANCE") + Text("Distance") .font(.caption) .fontWeight(.bold) .tracking(0.6) @@ -146,37 +137,22 @@ struct SplitCalculatorView: View { } label: { Text(d.rawValue) .font(.system(size: 14, weight: .semibold)) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background( - Capsule() - .fill(selectedDistance == d - ? Color.green - : Color(.tertiarySystemGroupedBackground)) - ) - .foregroundStyle(selectedDistance == d ? .white : .secondary) } - .buttonStyle(.plain) + .buttonStyle(.glass) + .tint(selectedDistance == d ? .green : nil) } } } } .padding(16) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(.secondarySystemGroupedBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(.quaternary, lineWidth: 1) - ) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) } // MARK: - Custom Distance private var customDistanceField: some View { VStack(spacing: 8) { - Text("CUSTOM DISTANCE") + Text("Custom Distance") .font(.caption) .fontWeight(.bold) .tracking(0.6) @@ -201,50 +177,61 @@ struct SplitCalculatorView: View { } } .padding(16) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(.secondarySystemGroupedBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(.quaternary, lineWidth: 1) - ) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) } // MARK: - Result private var resultCard: some View { - VStack(spacing: 6) { - Text("REQUIRED PACE") - .font(.caption) - .fontWeight(.bold) - .tracking(0.6) - .foregroundStyle(.secondary) + VStack(spacing: 16) { + VStack(spacing: 6) { + Text("Required Pace") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(paceText.isEmpty ? "–" : paceText) + .font(.largeTitle.bold().monospacedDigit()) + .foregroundStyle(paceText.isEmpty ? .tertiary : .primary) + .contentTransition(.numericText()) + .animation(.snappy(duration: 0.2), value: paceText) + + if !paceText.isEmpty { + Text(selectedUnit.paceLabel) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + } - HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(paceText.isEmpty ? "–" : paceText) - .font(.largeTitle.bold().monospacedDigit()) - .foregroundStyle(paceText.isEmpty ? .tertiary : .primary) - .contentTransition(.numericText()) - .animation(.snappy(duration: 0.2), value: paceText) + if !speedText.isEmpty { + Divider() - if !paceText.isEmpty { - Text(selectedUnit.paceLabel) - .font(.system(size: 18, weight: .semibold)) + VStack(spacing: 6) { + Text("Required Speed") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) .foregroundStyle(.secondary) + + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(speedText) + .font(.title.bold().monospacedDigit()) + .contentTransition(.numericText()) + .animation(.snappy(duration: 0.2), value: speedText) + + Text(selectedUnit.speedLabel) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.secondary) + } } } } .frame(maxWidth: .infinity) .padding(24) - .background( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(Color(.secondarySystemGroupedBackground)) - ) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .strokeBorder(.quaternary, lineWidth: 1) - ) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) } } From 364291a9e40fc2022145f88663f779278e8309ba Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:00:34 -0500 Subject: [PATCH 15/53] docs: add AGENTS.md with git commit guidelines --- AGENTS.md | 7 +++++++ .../saad.xcuserdatad/xcschemes/xcschememanagement.plist | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8f51d13 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Git Commits Guidelines + +DO NOT COMMIT automatically unless asked to do so by the user. Always ask for confirmation before making any commits to the repository. This ensures that you have the user's approval and that they are aware of the changes being made to the codebase. + +When committing, always use atomic commits format with Conventoional Commits format. This means that each commit should represent a single logical change to the codebase, and the commit message should follow the Conventional Commits format, which includes a type, scope, and description of the change. + +Be extremely concise in commit messages, sacrifice grammar for sake of conciseness. diff --git a/pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist b/pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist index 813eccb..31bb7f5 100644 --- a/pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ pace-to-mph WatchKit App.xcscheme_^#shared#^_ orderHint - 0 + 1 pace-to-mph.xcscheme_^#shared#^_ @@ -17,7 +17,7 @@ pace-to-mphWidgets.xcscheme_^#shared#^_ orderHint - 1 + 2 From 86eb10731dc75d2173bc536b080143764a2483ab Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:04:58 -0500 Subject: [PATCH 16/53] update AGENTS.md --- AGENTS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8f51d13..740bf26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,11 @@ # Git Commits Guidelines -DO NOT COMMIT automatically unless asked to do so by the user. Always ask for confirmation before making any commits to the repository. This ensures that you have the user's approval and that they are aware of the changes being made to the codebase. +- DO NOT COMMIT automatically unless asked to do so by the user. +- Always ask for confirmation before making any commits to the repository. +- This ensures that you have the user's approval and that they are aware of the changes being made to the codebase. -When committing, always use atomic commits format with Conventoional Commits format. This means that each commit should represent a single logical change to the codebase, and the commit message should follow the Conventional Commits format, which includes a type, scope, and description of the change. +When committing, always use atomic commits format with Conventional Commits format. This means that each commit should represent a single logical change to the codebase, and the commit message should follow the Conventional Commits format, which includes a type, scope, and description of the change. Be extremely concise in commit messages, sacrifice grammar for sake of conciseness. + +Don't add co-authored by any AI tools in any commits. e.g. Claude, Copilot, ChatGPT, Gemini, etc. From 9e9cd45195a6f53a809ab9e63ac145d13b4a1db6 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:42:19 -0500 Subject: [PATCH 17/53] feat(favorites): add FavoritesStore model and tests --- pace-to-mph/Models/FavoritesStore.swift | 68 +++++++++++++++++++++ pace-to-mphTests/FavoritesStoreTests.swift | 69 ++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 pace-to-mph/Models/FavoritesStore.swift create mode 100644 pace-to-mphTests/FavoritesStoreTests.swift diff --git a/pace-to-mph/Models/FavoritesStore.swift b/pace-to-mph/Models/FavoritesStore.swift new file mode 100644 index 0000000..04f6059 --- /dev/null +++ b/pace-to-mph/Models/FavoritesStore.swift @@ -0,0 +1,68 @@ +import Foundation + +struct FavoriteConversion: Codable, Identifiable, Equatable { + let id: UUID + let input: String + let inputSuffix: String + let result: String + let resultSuffix: String +} + +@Observable +class FavoritesStore { + private(set) var favorites: [FavoriteConversion] = [] + private let maxFavorites = 20 + private let storageKey = "pinned_favorites" + + init() { load() } + + // Add a favorite. Skip if duplicate (same input+inputSuffix+result+resultSuffix). + func add(input: String, inputSuffix: String, result: String, resultSuffix: String) { + // check for duplicate + if favorites.contains(where: { $0.input == input && $0.inputSuffix == inputSuffix && $0.result == result && $0.resultSuffix == resultSuffix }) { + return + } + let fav = FavoriteConversion(id: UUID(), input: input, inputSuffix: inputSuffix, result: result, resultSuffix: resultSuffix) + favorites.insert(fav, at: 0) + if favorites.count > maxFavorites { + favorites = Array(favorites.prefix(maxFavorites)) + } + save() + } + + // Remove by id + func remove(id: UUID) { + favorites.removeAll { $0.id == id } + save() + } + + // Check if a conversion is already favorited + func isFavorited(input: String, inputSuffix: String, result: String, resultSuffix: String) -> Bool { + favorites.contains { $0.input == input && $0.inputSuffix == inputSuffix && $0.result == result && $0.resultSuffix == resultSuffix } + } + + // Toggle favorite status + func toggle(input: String, inputSuffix: String, result: String, resultSuffix: String) { + if let existing = favorites.first(where: { $0.input == input && $0.inputSuffix == inputSuffix && $0.result == result && $0.resultSuffix == resultSuffix }) { + remove(id: existing.id) + } else { + add(input: input, inputSuffix: inputSuffix, result: result, resultSuffix: resultSuffix) + } + } + + func clear() { + favorites.removeAll() + save() + } + + private func save() { + guard let data = try? JSONEncoder().encode(favorites) else { return } + UserDefaults.standard.set(data, forKey: storageKey) + } + + private func load() { + guard let data = UserDefaults.standard.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode([FavoriteConversion].self, from: data) else { return } + favorites = decoded + } +} diff --git a/pace-to-mphTests/FavoritesStoreTests.swift b/pace-to-mphTests/FavoritesStoreTests.swift new file mode 100644 index 0000000..f2839ee --- /dev/null +++ b/pace-to-mphTests/FavoritesStoreTests.swift @@ -0,0 +1,69 @@ +import Testing +@testable import pace_to_mph + +struct FavoritesStoreTests { + + @Test func addFavorite() { + let store = FavoritesStore() + store.clear() + store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") + #expect(store.favorites.count == 1) + #expect(store.favorites.first?.input == "8:00") + store.clear() + } + + @Test func addDuplicateSkipped() { + let store = FavoritesStore() + store.clear() + store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") + store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") + #expect(store.favorites.count == 1) + store.clear() + } + + @Test func removeFavorite() { + let store = FavoritesStore() + store.clear() + store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") + let id = store.favorites.first!.id + store.remove(id: id) + #expect(store.favorites.isEmpty) + store.clear() + } + + @Test func isFavorited() { + let store = FavoritesStore() + store.clear() + store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") + #expect(store.isFavorited(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH")) + #expect(!store.isFavorited(input: "7:00", inputSuffix: "/mi", result: "8.57", resultSuffix: "MPH")) + store.clear() + } + + @Test func toggleFavorite() { + let store = FavoritesStore() + store.clear() + store.toggle(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") + #expect(store.favorites.count == 1) + store.toggle(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") + #expect(store.favorites.isEmpty) + store.clear() + } + + @Test func maxFavoritesEnforced() { + let store = FavoritesStore() + store.clear() + for i in 0..<25 { + store.add(input: "\(i):00", inputSuffix: "/mi", result: "\(60.0/Double(max(i,1)))", resultSuffix: "MPH") + } + #expect(store.favorites.count == 20) + store.clear() + } + + @Test func clearFavorites() { + let store = FavoritesStore() + store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") + store.clear() + #expect(store.favorites.isEmpty) + } +} From ce871f2ad6c551b740f083dc41a3b5a422b49327 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:42:29 -0500 Subject: [PATCH 18/53] feat(calculator): add negative split logic and tests --- pace-to-mph/Models/RaceCalculator.swift | 44 +++++++++++ pace-to-mphTests/RaceCalculatorTests.swift | 90 ++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 pace-to-mphTests/RaceCalculatorTests.swift diff --git a/pace-to-mph/Models/RaceCalculator.swift b/pace-to-mph/Models/RaceCalculator.swift index d365b47..d913d66 100644 --- a/pace-to-mph/Models/RaceCalculator.swift +++ b/pace-to-mph/Models/RaceCalculator.swift @@ -82,4 +82,48 @@ enum RaceCalculator { return nil } } + + /// Compute negative (progressive) splits. + /// - Parameters: + /// - totalSeconds: Target finish time in seconds + /// - distanceInUnits: Total distance in miles or km + /// - dropSeconds: Seconds faster per split (positive value = each split is faster) + /// - Returns: Array of (splitDistance, splitSeconds) tuples + static func negativeSplits(totalSeconds: Int, distanceInUnits: Double, dropSeconds: Double) -> [(distance: Double, seconds: Int)] { + guard distanceInUnits > 0, totalSeconds > 0 else { return [] } + + let fullSplits = Int(distanceInUnits) + let partial = distanceInUnits - Double(fullSplits) + let splitCount = fullSplits + (partial > 0.001 ? 1 : 0) + guard splitCount > 0 else { return [] } + + // Calculate base pace for first split such that total = totalSeconds + // Split i has pace: basePace - i * dropSeconds (for full mile/km) + // Last split is scaled by partial distance + // Sum = sum(basePace - i*drop, i=0..fullSplits-1) + (basePace - fullSplits*drop)*partial + // totalSeconds = fullSplits*basePace - drop*(0+1+...+(fullSplits-1)) + partial*(basePace - fullSplits*drop) + // totalSeconds = basePace*(fullSplits + partial) - drop*(fullSplits*(fullSplits-1)/2 + partial*fullSplits) + + let n = Double(fullSplits) + let effectiveDistance = n + (partial > 0.001 ? partial : 0) + let dropSum = dropSeconds * (n * (n - 1) / 2 + (partial > 0.001 ? partial * n : 0)) + + guard effectiveDistance > 0 else { return [] } + let basePace = (Double(totalSeconds) + dropSum) / effectiveDistance + + var results: [(distance: Double, seconds: Int)] = [] + for i in 0.. 0.001 { + dist = partial + } else { + dist = 1.0 + } + let splitTime = Int(round(splitPace * dist)) + results.append((distance: dist, seconds: max(splitTime, 1))) + } + + return results + } } diff --git a/pace-to-mphTests/RaceCalculatorTests.swift b/pace-to-mphTests/RaceCalculatorTests.swift new file mode 100644 index 0000000..2024f80 --- /dev/null +++ b/pace-to-mphTests/RaceCalculatorTests.swift @@ -0,0 +1,90 @@ +import Testing +@testable import pace_to_mph + +struct RaceCalculatorTests { + + // MARK: - Finish Time + + @Test func finishTimeBasic() { + // 8:00/mi pace for 3.10686 miles (5K) ≈ 24:51 + let seconds = RaceCalculator.finishTime(paceMinutes: 8.0, distanceInUnits: 3.10686) + #expect(seconds == 1491) // 24*60 + 51 = 1491 + } + + // MARK: - Format Duration + + @Test func formatDurationUnderHour() { + #expect(RaceCalculator.formatDuration(1491) == "24:51") + } + + @Test func formatDurationOverHour() { + #expect(RaceCalculator.formatDuration(3661) == "1:01:01") + } + + @Test func formatDurationZero() { + #expect(RaceCalculator.formatDuration(0) == "0:00") + } + + // MARK: - Required Pace + + @Test func requiredPaceBasic() { + // 24:51 (1491s) over 3.10686mi = ~8.0 min/mi + let pace = RaceCalculator.requiredPace(totalSeconds: 1491, distanceInUnits: 3.10686) + #expect(abs(pace - 8.0) < 0.01) + } + + // MARK: - Parse Duration + + @Test func parseDurationHMS() { + #expect(RaceCalculator.parseDuration("1:30:00") == 5400) + } + + @Test func parseDurationMS() { + #expect(RaceCalculator.parseDuration("24:51") == 1491) + } + + @Test func parseDurationMinutesOnly() { + #expect(RaceCalculator.parseDuration("30") == 1800) + } + + @Test func parseDurationInvalid() { + #expect(RaceCalculator.parseDuration("") == nil) + #expect(RaceCalculator.parseDuration("abc") == nil) + #expect(RaceCalculator.parseDuration("1:2:3:4") == nil) + } + + // MARK: - Negative Splits + + @Test func negativeSplitsEvenDistance() { + // 30:00 over 3 miles, 5s drop per split + let splits = RaceCalculator.negativeSplits(totalSeconds: 1800, distanceInUnits: 3.0, dropSeconds: 5.0) + #expect(splits.count == 3) + // Total should sum to 1800 + let total = splits.reduce(0) { $0 + $1.seconds } + #expect(abs(total - 1800) <= 1) // allow 1s rounding + // Each split should be faster than previous + #expect(splits[1].seconds < splits[0].seconds) + #expect(splits[2].seconds < splits[1].seconds) + } + + @Test func negativeSplitsWithPartial() { + // 25:00 over 3.1 miles + let splits = RaceCalculator.negativeSplits(totalSeconds: 1500, distanceInUnits: 3.1, dropSeconds: 3.0) + #expect(splits.count == 4) // 3 full + 1 partial + #expect(splits.last!.distance < 1.0) // partial + #expect(abs(splits.last!.distance - 0.1) < 0.01) + } + + @Test func negativeSplitsZeroDrop() { + // Even splits when drop is 0 + let splits = RaceCalculator.negativeSplits(totalSeconds: 1800, distanceInUnits: 3.0, dropSeconds: 0.0) + #expect(splits.count == 3) + #expect(splits[0].seconds == splits[1].seconds) + #expect(splits[1].seconds == splits[2].seconds) + } + + @Test func negativeSplitsInvalidInput() { + #expect(RaceCalculator.negativeSplits(totalSeconds: 0, distanceInUnits: 3.0, dropSeconds: 5.0).isEmpty) + #expect(RaceCalculator.negativeSplits(totalSeconds: 1800, distanceInUnits: 0, dropSeconds: 5.0).isEmpty) + } +} From c49dd5290ee391d50542f91f39cec20c180cc9a2 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:42:35 -0500 Subject: [PATCH 19/53] feat(calculator): add negative split calculator view --- pace-to-mph/NegativeSplitView.swift | 279 ++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 pace-to-mph/NegativeSplitView.swift diff --git a/pace-to-mph/NegativeSplitView.swift b/pace-to-mph/NegativeSplitView.swift new file mode 100644 index 0000000..0d4c7a8 --- /dev/null +++ b/pace-to-mph/NegativeSplitView.swift @@ -0,0 +1,279 @@ +import SwiftUI + +struct NegativeSplitView: View { + @State private var timeInput: String = "" + @State private var selectedUnit: SpeedUnit = .mph + @State private var selectedDistance: RaceCalculator.Distance = .fiveK + @State private var customDistanceInput: String = "" + @State private var dropSecondsInput: String = "5" + @FocusState private var isTimeFocused: Bool + @FocusState private var isDistanceFocused: Bool + @FocusState private var isDropFocused: Bool + + private var distanceInUnits: Double? { + if selectedDistance == .custom { + guard let val = Double(customDistanceInput), val > 0 else { return nil } + return val + } + return selectedUnit == .mph ? selectedDistance.miles : selectedDistance.kilometers + } + + private var dropSeconds: Double { + Double(dropSecondsInput) ?? 0 + } + + private var splits: [(distance: Double, seconds: Int)] { + guard let totalSeconds = RaceCalculator.parseDuration(timeInput), + totalSeconds > 0, + let distance = distanceInUnits, + distance > 0 else { return [] } + return RaceCalculator.negativeSplits(totalSeconds: totalSeconds, distanceInUnits: distance, dropSeconds: dropSeconds) + } + + var body: some View { + GlassEffectContainer { + ScrollView { + VStack(spacing: 20) { + // Target time input + VStack(spacing: 16) { + Text("TARGET TIME") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + TextField("h:mm:ss", text: $timeInput) + .font(.system(size: 44, weight: .bold, design: .rounded)) + .monospacedDigit() + .multilineTextAlignment(.center) + .keyboardType(.numbersAndPunctuation) + .textFieldStyle(.plain) + .focused($isTimeFocused) + .minimumScaleFactor(0.5) + .onChange(of: timeInput) { _, newValue in + timeInput = newValue.filter { $0.isNumber || $0 == ":" } + } + + RoundedRectangle(cornerRadius: 1) + .fill(Color.green) + .frame(height: 2) + .frame(maxWidth: 200) + } + .padding(24) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) + + // Unit picker + HStack(spacing: 16) { + ForEach(SpeedUnit.allCases, id: \.self) { u in + Button { + withAnimation(.snappy(duration: 0.25)) { + selectedUnit = u + } + } label: { + Text(u == .mph ? "Mile" : "KM") + .font(.system(size: 14, weight: .bold)) + .tracking(1.5) + } + .buttonStyle(.glass) + .tint(selectedUnit == u ? .green : nil) + } + } + + // Distance picker + VStack(spacing: 8) { + Text("Distance") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(RaceCalculator.Distance.allCases) { d in + Button { + withAnimation(.snappy(duration: 0.25)) { + selectedDistance = d + } + } label: { + Text(d.rawValue) + .font(.system(size: 14, weight: .semibold)) + } + .buttonStyle(.glass) + .tint(selectedDistance == d ? .green : nil) + } + } + } + } + .padding(16) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) + + // Custom distance + if selectedDistance == .custom { + VStack(spacing: 8) { + Text("Custom Distance") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 6) { + TextField("0.0", text: $customDistanceInput) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .monospacedDigit() + .multilineTextAlignment(.center) + .keyboardType(.decimalPad) + .textFieldStyle(.plain) + .focused($isDistanceFocused) + .onChange(of: customDistanceInput) { _, newValue in + customDistanceInput = newValue.filter { $0.isNumber || $0 == "." } + } + + Text(selectedUnit == .mph ? "miles" : "km") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + } + } + .padding(16) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) + } + + // Drop per split input + VStack(spacing: 8) { + Text("DROP PER SPLIT") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .firstTextBaseline, spacing: 6) { + TextField("5", text: $dropSecondsInput) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .monospacedDigit() + .multilineTextAlignment(.center) + .keyboardType(.numberPad) + .textFieldStyle(.plain) + .focused($isDropFocused) + .onChange(of: dropSecondsInput) { _, newValue in + dropSecondsInput = newValue.filter { $0.isNumber } + } + + Text("sec faster per \(selectedUnit == .mph ? "mile" : "km")") + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + } + } + .padding(16) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) + + // Splits list + if !splits.isEmpty { + splitsCard + } + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 32) + } + } + .onTapGesture { + isTimeFocused = false + isDistanceFocused = false + isDropFocused = false + } + .navigationTitle("Negative Splits") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Splits Card + + private var splitsCard: some View { + VStack(spacing: 12) { + Text("SPLITS") + .font(.caption) + .fontWeight(.bold) + .tracking(0.6) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + // Header row + HStack { + Text("#") + .font(.caption2.bold()) + .foregroundStyle(.secondary) + .frame(width: 28, alignment: .leading) + Text("Dist") + .font(.caption2.bold()) + .foregroundStyle(.secondary) + .frame(width: 44, alignment: .trailing) + Spacer() + Text("Split") + .font(.caption2.bold()) + .foregroundStyle(.secondary) + .frame(width: 64, alignment: .trailing) + Text("Cumul.") + .font(.caption2.bold()) + .foregroundStyle(.secondary) + .frame(width: 72, alignment: .trailing) + Text("Pace") + .font(.caption2.bold()) + .foregroundStyle(.secondary) + .frame(width: 56, alignment: .trailing) + } + + ForEach(Array(splits.enumerated()), id: \.offset) { index, split in + let cumulative = splits.prefix(index + 1).reduce(0) { $0 + $1.seconds } + let paceMinutes = split.distance > 0 ? Double(split.seconds) / 60.0 / split.distance : 0 + + HStack { + Text("\(index + 1)") + .font(.system(size: 15, weight: .bold, design: .rounded)) + .monospacedDigit() + .frame(width: 28, alignment: .leading) + + Text(split.distance < 1.0 ? String(format: "%.2f", split.distance) : "1.00") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(width: 44, alignment: .trailing) + + Spacer() + + Text(RaceCalculator.formatDuration(split.seconds)) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .monospacedDigit() + .frame(width: 64, alignment: .trailing) + + Text(RaceCalculator.formatDuration(cumulative)) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.green) + .frame(width: 72, alignment: .trailing) + + Text(ConversionEngine.formatPace(paceMinutes) ?? "–") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(width: 56, alignment: .trailing) + } + .padding(.vertical, 4) + + if index < splits.count - 1 { + Divider() + } + } + } + .padding(20) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) + .sensoryFeedback(.impact(flexibility: .soft), trigger: splits.count) + } +} + +#Preview { + NavigationStack { + NegativeSplitView() + } +} From df7e53a08a4db4a19a3b1aa5a0500cf237c1926b Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:42:41 -0500 Subject: [PATCH 20/53] feat(favorites): add FavoritesView and star toggle in history --- pace-to-mph/FavoritesView.swift | 99 +++++++++++++++++++++++++++++++++ pace-to-mph/HistoryView.swift | 89 +++++++++++++++++++---------- 2 files changed, 158 insertions(+), 30 deletions(-) create mode 100644 pace-to-mph/FavoritesView.swift diff --git a/pace-to-mph/FavoritesView.swift b/pace-to-mph/FavoritesView.swift new file mode 100644 index 0000000..b617010 --- /dev/null +++ b/pace-to-mph/FavoritesView.swift @@ -0,0 +1,99 @@ +import SwiftUI + +struct FavoritesView: View { + @Bindable var store: FavoritesStore + @State private var showClearConfirmation = false + + var body: some View { + Group { + if store.favorites.isEmpty { + ContentUnavailableView { + Label("No Favorites", systemImage: "star") + } description: { + Text("Pin conversions from the converter or history to see them here.") + } + } else { + GlassEffectContainer { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(store.favorites) { fav in + favoriteCard(fav) + } + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 32) + } + } + } + } + .navigationTitle("Favorites") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if !store.favorites.isEmpty { + Button("Clear") { + showClearConfirmation = true + } + } + } + } + .alert("Clear Favorites", isPresented: $showClearConfirmation) { + Button("Clear", role: .destructive) { + store.clear() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Are you sure you want to remove all favorites?") + } + } + + private func favoriteCard(_ fav: FavoriteConversion) -> some View { + HStack { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 4) { + Text(fav.input) + .font(.system(size: 22, weight: .bold, design: .rounded)) + .monospacedDigit() + Text(fav.inputSuffix) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.secondary) + } + + HStack(spacing: 4) { + Text(fav.result) + .font(.system(size: 22, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.green) + Text(fav.resultSuffix) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Button { + withAnimation(.snappy(duration: 0.25)) { + store.remove(id: fav.id) + } + } label: { + Image(systemName: "star.fill") + .font(.system(size: 20)) + .foregroundStyle(.yellow) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove from favorites") + } + .padding(16) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(fav.input) \(fav.inputSuffix) equals \(fav.result) \(fav.resultSuffix)") + } +} + +#Preview { + NavigationStack { + FavoritesView(store: FavoritesStore()) + } +} diff --git a/pace-to-mph/HistoryView.swift b/pace-to-mph/HistoryView.swift index 69b05f5..5f944ee 100644 --- a/pace-to-mph/HistoryView.swift +++ b/pace-to-mph/HistoryView.swift @@ -2,6 +2,7 @@ import SwiftUI struct HistoryView: View { @Bindable var history: ConversionHistory + var favoritesStore: FavoritesStore @State private var showClearConfirmation = false var body: some View { @@ -15,35 +16,7 @@ struct HistoryView: View { } else { List { ForEach(history.records) { record in - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Text(record.input) - .font(.system(size: 17, weight: .bold, design: .rounded)) - .monospacedDigit() - Text(record.inputSuffix) - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) - Text("→") - .foregroundStyle(.secondary) - Text(record.result) - .font(.system(size: 17, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.green) - Text(record.resultSuffix) - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) - } - - Text(record.date, format: .relative(presentation: .named)) - .font(.caption) - .foregroundStyle(.tertiary) - } - Spacer() - } - .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(record.input) \(record.inputSuffix) equals \(record.result) \(record.resultSuffix)") + recordRow(record) } } .listStyle(.insetGrouped) @@ -69,10 +42,66 @@ struct HistoryView: View { Text("Are you sure you want to clear all conversion history?") } } + // MARK: - Row + + private func recordRow(_ record: ConversionRecord) -> some View { + let isFav = favoritesStore.isFavorited( + input: record.input, + inputSuffix: record.inputSuffix, + result: record.result, + resultSuffix: record.resultSuffix + ) + return HStack { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(record.input) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .monospacedDigit() + Text(record.inputSuffix) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + Text("→") + .foregroundStyle(.secondary) + Text(record.result) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.green) + Text(record.resultSuffix) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + } + + Text(record.date, format: .relative(presentation: .named)) + .font(.caption) + .foregroundStyle(.tertiary) + } + Spacer() + + Button { + withAnimation(.snappy(duration: 0.25)) { + favoritesStore.toggle( + input: record.input, + inputSuffix: record.inputSuffix, + result: record.result, + resultSuffix: record.resultSuffix + ) + } + } label: { + Image(systemName: isFav ? "star.fill" : "star") + .font(.system(size: 16)) + .foregroundStyle(isFav ? Color.yellow : Color.gray.opacity(0.4)) + } + .buttonStyle(.plain) + .accessibilityLabel("Toggle favorite") + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(record.input) \(record.inputSuffix) equals \(record.result) \(record.resultSuffix)") + } } #Preview { NavigationStack { - HistoryView(history: ConversionHistory()) + HistoryView(history: ConversionHistory(), favoritesStore: FavoritesStore()) } } From d95b067ca26ab57f8236cc1459792e818d738229 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:42:47 -0500 Subject: [PATCH 21/53] feat(ux): add haptic feedback on result changes --- pace-to-mph/RaceTimeView.swift | 1 + pace-to-mph/SplitCalculatorView.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/pace-to-mph/RaceTimeView.swift b/pace-to-mph/RaceTimeView.swift index 66f9443..f677098 100644 --- a/pace-to-mph/RaceTimeView.swift +++ b/pace-to-mph/RaceTimeView.swift @@ -240,6 +240,7 @@ struct RaceTimeView: View { .frame(maxWidth: .infinity) .padding(24) .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) + .sensoryFeedback(.impact(flexibility: .soft), trigger: finishTimeText) } } diff --git a/pace-to-mph/SplitCalculatorView.swift b/pace-to-mph/SplitCalculatorView.swift index d24aa0a..7be1de5 100644 --- a/pace-to-mph/SplitCalculatorView.swift +++ b/pace-to-mph/SplitCalculatorView.swift @@ -232,6 +232,7 @@ struct SplitCalculatorView: View { .frame(maxWidth: .infinity) .padding(24) .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) + .sensoryFeedback(.impact(flexibility: .soft), trigger: paceText) } } From e1cdb4e9fb7636c743d0d7c00a14263c5467dc27 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:42:53 -0500 Subject: [PATCH 22/53] feat(nav): consolidate toolbar into glass popup menu with star button --- pace-to-mph/ContentView.swift | 80 ++++++++++++++++++++++++++--------- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/pace-to-mph/ContentView.swift b/pace-to-mph/ContentView.swift index 012585e..0c8347e 100644 --- a/pace-to-mph/ContentView.swift +++ b/pace-to-mph/ContentView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ContentView: View { @State private var viewModel = ConverterViewModel() + @State private var favoritesStore = FavoritesStore() @FocusState private var isInputFocused: Bool var body: some View { @@ -37,40 +38,50 @@ struct ContentView: View { } .toolbar { ToolbarItem(placement: .topBarLeading) { - NavigationLink { - HistoryView(history: viewModel.history) - } label: { - Image(systemName: "clock") - .font(.system(size: 15, weight: .semibold)) - } - .accessibilityLabel("Conversion history") - } - ToolbarItem(placement: .topBarTrailing) { - HStack(spacing: 16) { + Menu { NavigationLink { RaceTimeView() } label: { - Image(systemName: "flag.checkered") - .font(.system(size: 15, weight: .semibold)) + Label("Race Calculator", systemImage: "flag.checkered") } - .accessibilityLabel("Race finish time") NavigationLink { SplitCalculatorView() } label: { - Image(systemName: "chart.bar") - .font(.system(size: 15, weight: .semibold)) + Label("Even Splits", systemImage: "chart.bar") + } + + NavigationLink { + NegativeSplitView() + } label: { + Label("Negative Splits", systemImage: "arrow.down.right") + } + + Divider() + + NavigationLink { + FavoritesView(store: favoritesStore) + } label: { + Label("Favorites", systemImage: "star") + } + + NavigationLink { + HistoryView(history: viewModel.history, favoritesStore: favoritesStore) + } label: { + Label("History", systemImage: "clock") } - .accessibilityLabel("Even splits calculator") NavigationLink { ReferenceView() } label: { - Image(systemName: "table") - .font(.system(size: 15, weight: .semibold)) + Label("Reference Table", systemImage: "table") } - .accessibilityLabel("Pace reference table") + } label: { + Image(systemName: "line.3.horizontal") + .font(.system(size: 15, weight: .semibold)) } + .menuStyle(.button) + .accessibilityLabel("Tools menu") } } } @@ -152,9 +163,40 @@ struct ContentView: View { .font(.system(size: 18, weight: .semibold)) .tracking(2) .foregroundStyle(.secondary) + + if !viewModel.result.isEmpty { + Button { + withAnimation(.snappy(duration: 0.25)) { + favoritesStore.toggle( + input: viewModel.inputText, + inputSuffix: viewModel.inputSuffix, + result: viewModel.result, + resultSuffix: viewModel.resultSuffix + ) + } + } label: { + Image(systemName: favoritesStore.isFavorited( + input: viewModel.inputText, + inputSuffix: viewModel.inputSuffix, + result: viewModel.result, + resultSuffix: viewModel.resultSuffix + ) ? "star.fill" : "star") + .font(.system(size: 20)) + .foregroundStyle(favoritesStore.isFavorited( + input: viewModel.inputText, + inputSuffix: viewModel.inputSuffix, + result: viewModel.result, + resultSuffix: viewModel.resultSuffix + ) ? .yellow : .secondary) + } + .buttonStyle(.plain) + .padding(.top, 8) + .accessibilityLabel("Toggle favorite") + } } .accessibilityElement(children: .combine) .accessibilityLabel(viewModel.result.isEmpty ? "No result" : "\(viewModel.result) \(viewModel.resultSuffix)") + .sensoryFeedback(.impact(flexibility: .soft), trigger: viewModel.result) } .padding(24) .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) From cf9c77974ebfccbdca988f021a4c45a1714c4d7b Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:42:58 -0500 Subject: [PATCH 23/53] feat(watch): add converter and race calculator tabs --- pace-to-mph WatchKit App/ContentView.swift | 232 ++++++++++++++++++++- 1 file changed, 224 insertions(+), 8 deletions(-) diff --git a/pace-to-mph WatchKit App/ContentView.swift b/pace-to-mph WatchKit App/ContentView.swift index ce06784..572c947 100644 --- a/pace-to-mph WatchKit App/ContentView.swift +++ b/pace-to-mph WatchKit App/ContentView.swift @@ -1,6 +1,6 @@ import SwiftUI -// MARK: - Conversion Logic (self-contained for watch) +// MARK: - Shared Types private enum WatchSpeedUnit: String, CaseIterable { case mph @@ -21,14 +21,38 @@ private enum WatchSpeedUnit: String, CaseIterable { } } +// MARK: - Conversion Helpers + private func paceToSpeed(_ paceMinutes: Double) -> Double { 60.0 / paceMinutes } +private func speedToPace(_ speed: Double) -> Double { + 60.0 / speed +} + private func formatSpeed(_ value: Double) -> String { String(format: "%.2f", value) } +private func formatPace(_ minutesPerUnit: Double) -> String { + let totalSeconds = Int(round(minutesPerUnit * 60)) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return "\(minutes):\(String(format: "%02d", seconds))" +} + +private func formatDuration(_ totalSeconds: Int) -> String { + guard totalSeconds >= 0 else { return "0:00" } + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + if hours > 0 { + return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" + } + return "\(minutes):\(String(format: "%02d", seconds))" +} + // MARK: - Pace Entry private struct PaceEntry: Identifiable { @@ -39,12 +63,51 @@ private struct PaceEntry: Identifiable { var label: String { "\(min):\(String(format: "%02d", sec))" } } -// MARK: - View +// MARK: - Race Distances + +private enum WatchRaceDistance: String, CaseIterable, Identifiable { + case fiveK = "5K" + case tenK = "10K" + case halfMarathon = "Half" + case marathon = "Marathon" + + var id: String { rawValue } + + func distance(unit: WatchSpeedUnit) -> Double { + switch self { + case .fiveK: return unit == .mph ? 3.10686 : 5.0 + case .tenK: return unit == .mph ? 6.21371 : 10.0 + case .halfMarathon: return unit == .mph ? 13.1094 : 21.0975 + case .marathon: return unit == .mph ? 26.2188 : 42.195 + } + } +} + +// MARK: - Main View struct ContentView: View { + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + ReferenceTab() + .tag(0) + + ConverterTab() + .tag(1) + + RaceCalcTab() + .tag(2) + } + .tabViewStyle(.verticalPage) + } +} + +// MARK: - Reference Tab + +private struct ReferenceTab: View { @State private var selectedUnit: WatchSpeedUnit = .mph - // Paces per mile: 5:00–12:00 in 30s steps private let mphPaces: [PaceEntry] = { var result: [PaceEntry] = [] for m in 5...12 { @@ -54,7 +117,6 @@ struct ContentView: View { return result }() - // Paces per km: 3:00–8:00 in 30s steps private let kphPaces: [PaceEntry] = { var result: [PaceEntry] = [] for m in 3...8 { @@ -79,7 +141,6 @@ struct ContentView: View { ForEach(activePaces) { pace in let speed = paceToSpeed(pace.paceMinutes) - HStack { VStack(alignment: .leading, spacing: 2) { Text(pace.label) @@ -89,9 +150,7 @@ struct ContentView: View { .font(.caption2) .foregroundStyle(.secondary) } - Spacer() - VStack(alignment: .trailing, spacing: 2) { Text(formatSpeed(speed)) .font(.system(.title3, design: .rounded, weight: .bold)) @@ -106,7 +165,164 @@ struct ContentView: View { .accessibilityLabel("\(pace.label) \(selectedUnit.paceLabel) equals \(formatSpeed(speed)) \(selectedUnit.speedLabel)") } } - .navigationTitle("RunPace") + .navigationTitle("Reference") + } + } +} + +// MARK: - Converter Tab + +private struct ConverterTab: View { + @State private var selectedUnit: WatchSpeedUnit = .mph + @State private var paceMinutes: Int = 8 + @State private var paceSeconds: Int = 0 + + private var paceValue: Double { + Double(paceMinutes) + Double(paceSeconds) / 60.0 + } + + private var speed: Double { + guard paceValue > 0 else { return 0 } + return paceToSpeed(paceValue) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 12) { + // Unit picker + Picker("Unit", selection: $selectedUnit) { + ForEach(WatchSpeedUnit.allCases, id: \.self) { unit in + Text(unit.speedLabel).tag(unit) + } + } + .pickerStyle(.segmented) + + // Pace input + HStack(spacing: 2) { + Picker("Min", selection: $paceMinutes) { + ForEach(1...30, id: \.self) { m in + Text("\(m)").tag(m) + } + } + .pickerStyle(.wheel) + .frame(width: 55, height: 60) + + Text(":") + .font(.title3.bold()) + + Picker("Sec", selection: $paceSeconds) { + ForEach(0..<60, id: \.self) { s in + Text(String(format: "%02d", s)).tag(s) + } + } + .pickerStyle(.wheel) + .frame(width: 55, height: 60) + + Text(selectedUnit.paceLabel) + .font(.caption2) + .foregroundStyle(.secondary) + } + + Divider() + + // Speed result + VStack(spacing: 4) { + Text(formatSpeed(speed)) + .font(.system(.title, design: .rounded, weight: .bold)) + .monospacedDigit() + .foregroundStyle(.green) + Text(selectedUnit.speedLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal) + } + .navigationTitle("Converter") + } + } +} + +// MARK: - Race Calculator Tab + +private struct RaceCalcTab: View { + @State private var selectedUnit: WatchSpeedUnit = .mph + @State private var selectedDistance: WatchRaceDistance = .fiveK + @State private var paceMinutes: Int = 8 + @State private var paceSeconds: Int = 0 + + private var paceValue: Double { + Double(paceMinutes) + Double(paceSeconds) / 60.0 + } + + private var finishTimeSeconds: Int { + guard paceValue > 0 else { return 0 } + let distance = selectedDistance.distance(unit: selectedUnit) + return Int(round(paceValue * distance * 60)) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 10) { + // Unit picker + Picker("Unit", selection: $selectedUnit) { + ForEach(WatchSpeedUnit.allCases, id: \.self) { unit in + Text(unit.speedLabel).tag(unit) + } + } + .pickerStyle(.segmented) + + // Distance picker + Picker("Distance", selection: $selectedDistance) { + ForEach(WatchRaceDistance.allCases) { d in + Text(d.rawValue).tag(d) + } + } + + // Pace input + HStack(spacing: 2) { + Picker("Min", selection: $paceMinutes) { + ForEach(1...30, id: \.self) { m in + Text("\(m)").tag(m) + } + } + .pickerStyle(.wheel) + .frame(width: 55, height: 60) + + Text(":") + .font(.title3.bold()) + + Picker("Sec", selection: $paceSeconds) { + ForEach(0..<60, id: \.self) { s in + Text(String(format: "%02d", s)).tag(s) + } + } + .pickerStyle(.wheel) + .frame(width: 55, height: 60) + + Text(selectedUnit.paceLabel) + .font(.caption2) + .foregroundStyle(.secondary) + } + + Divider() + + // Finish time result + VStack(spacing: 4) { + Text("Finish Time") + .font(.caption2) + .foregroundStyle(.secondary) + Text(formatDuration(finishTimeSeconds)) + .font(.system(.title, design: .rounded, weight: .bold)) + .monospacedDigit() + .foregroundStyle(.green) + } + } + .padding(.horizontal) + } + .navigationTitle("Race Calc") } } } From 101fde1f15b1abf128c0e872b82b5ecd7af97528 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:43:29 -0500 Subject: [PATCH 24/53] fix(intents): fix pace label for kph and simplify speed-to-pace conversion --- pace-to-mph/Intents/PaceToSpeedIntent.swift | 11 +---- pace-to-mph/Intents/SpeedToPaceIntent.swift | 4 +- .../IntentConversionRegressionTests.swift | 49 +++++++++++++++++++ 3 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 pace-to-mphTests/IntentConversionRegressionTests.swift diff --git a/pace-to-mph/Intents/PaceToSpeedIntent.swift b/pace-to-mph/Intents/PaceToSpeedIntent.swift index 259cd8c..5461592 100644 --- a/pace-to-mph/Intents/PaceToSpeedIntent.swift +++ b/pace-to-mph/Intents/PaceToSpeedIntent.swift @@ -15,15 +15,8 @@ struct PaceToSpeedIntent: AppIntent { throw $pace.needsValueError("Please provide a valid pace in mm:ss format, e.g. 7:30") } - var speed = ConversionEngine.paceToSpeed(paceMinutes) - let paceLabel: String - - if unit == .kph { - speed = speed * ConversionEngine.kmPerMile - paceLabel = "per mile" - } else { - paceLabel = "per mile" - } + let speed = ConversionEngine.paceToSpeed(paceMinutes) + let paceLabel = unit == .kph ? "per kilometer" : "per mile" let formatted = ConversionEngine.formatSpeed(speed) let dialog = "A pace of \(pace) \(paceLabel) is \(formatted) \(unit.rawValue)" diff --git a/pace-to-mph/Intents/SpeedToPaceIntent.swift b/pace-to-mph/Intents/SpeedToPaceIntent.swift index d93a22a..4dc24eb 100644 --- a/pace-to-mph/Intents/SpeedToPaceIntent.swift +++ b/pace-to-mph/Intents/SpeedToPaceIntent.swift @@ -15,9 +15,7 @@ struct SpeedToPaceIntent: AppIntent { throw $speed.needsValueError("Please provide a speed greater than zero") } - // Convert to MPH if input is KPH, since base conversion assumes miles - let mphSpeed = unit == .kph ? speed / ConversionEngine.kmPerMile : speed - let paceMinutes = ConversionEngine.speedToPace(mphSpeed) + let paceMinutes = ConversionEngine.speedToPace(speed) guard let formatted = ConversionEngine.formatPace(paceMinutes) else { throw $speed.needsValueError("Could not convert that speed to a pace") diff --git a/pace-to-mphTests/IntentConversionRegressionTests.swift b/pace-to-mphTests/IntentConversionRegressionTests.swift new file mode 100644 index 0000000..5b083c1 --- /dev/null +++ b/pace-to-mphTests/IntentConversionRegressionTests.swift @@ -0,0 +1,49 @@ +import AppIntents +import Testing +@testable import pace_to_mph + +struct IntentConversionRegressionTests { + private func unpack(_ result: some ReturnsValue & ProvidesDialog) -> IntentResultContainer { + result as! IntentResultContainer + } + + @Test func paceToSpeedIntentForMph() async throws { + var intent = PaceToSpeedIntent() + intent.pace = "8:00" + intent.unit = .mph + + let result = unpack(try await intent.perform()) + #expect(result.value == "7.50") + #expect(String(describing: result.dialog).contains("per mile")) + } + + @Test func paceToSpeedIntentForKph() async throws { + var intent = PaceToSpeedIntent() + intent.pace = "8:00" + intent.unit = .kph + + let result = unpack(try await intent.perform()) + #expect(result.value == "7.50") + #expect(String(describing: result.dialog).contains("per kilometer")) + } + + @Test func speedToPaceIntentForMph() async throws { + var intent = SpeedToPaceIntent() + intent.speed = 10 + intent.unit = .mph + + let result = unpack(try await intent.perform()) + #expect(result.value == "6:00") + #expect(String(describing: result.dialog).contains("/mi")) + } + + @Test func speedToPaceIntentForKph() async throws { + var intent = SpeedToPaceIntent() + intent.speed = 12 + intent.unit = .kph + + let result = unpack(try await intent.perform()) + #expect(result.value == "5:00") + #expect(String(describing: result.dialog).contains("/km")) + } +} From 186ce15d9d054b6f752fbf69fbe418815c0d7347 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 22:43:35 -0500 Subject: [PATCH 25/53] docs: update AGENTS.md --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 740bf26..f181469 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,3 +9,8 @@ When committing, always use atomic commits format with Conventional Commits form Be extremely concise in commit messages, sacrifice grammar for sake of conciseness. Don't add co-authored by any AI tools in any commits. e.g. Claude, Copilot, ChatGPT, Gemini, etc. + +# After Review + +- When review has been completed, and there are changes to be made, add regression tests first reproducing the findings. +- After adding regression tests, make the necessary code changes to fix the issues identified during the review process. From 433838939ed431e3e2dcca36608051e8245daa23 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sat, 28 Feb 2026 23:19:45 -0500 Subject: [PATCH 26/53] fix(watch): make converter home --- pace-to-mph WatchKit App/ContentView.swift | 176 ++++++++++----------- 1 file changed, 85 insertions(+), 91 deletions(-) diff --git a/pace-to-mph WatchKit App/ContentView.swift b/pace-to-mph WatchKit App/ContentView.swift index 572c947..d9d5df7 100644 --- a/pace-to-mph WatchKit App/ContentView.swift +++ b/pace-to-mph WatchKit App/ContentView.swift @@ -86,20 +86,8 @@ private enum WatchRaceDistance: String, CaseIterable, Identifiable { // MARK: - Main View struct ContentView: View { - @State private var selectedTab = 0 - var body: some View { - TabView(selection: $selectedTab) { - ReferenceTab() - .tag(0) - - ConverterTab() - .tag(1) - - RaceCalcTab() - .tag(2) - } - .tabViewStyle(.verticalPage) + ConverterTab() } } @@ -131,42 +119,40 @@ private struct ReferenceTab: View { } var body: some View { - NavigationStack { - List { - Picker("Unit", selection: $selectedUnit) { - ForEach(WatchSpeedUnit.allCases, id: \.self) { unit in - Text(unit.speedLabel).tag(unit) - } + List { + Picker("Unit", selection: $selectedUnit) { + ForEach(WatchSpeedUnit.allCases, id: \.self) { unit in + Text(unit.speedLabel).tag(unit) } + } - ForEach(activePaces) { pace in - let speed = paceToSpeed(pace.paceMinutes) - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(pace.label) - .font(.system(.title3, design: .rounded, weight: .bold)) - .monospacedDigit() - Text(selectedUnit.paceLabel) - .font(.caption2) - .foregroundStyle(.secondary) - } - Spacer() - VStack(alignment: .trailing, spacing: 2) { - Text(formatSpeed(speed)) - .font(.system(.title3, design: .rounded, weight: .bold)) - .monospacedDigit() - .foregroundStyle(.green) - Text(selectedUnit.speedLabel) - .font(.caption2) - .foregroundStyle(.secondary) - } + ForEach(activePaces) { pace in + let speed = paceToSpeed(pace.paceMinutes) + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(pace.label) + .font(.system(.title3, design: .rounded, weight: .bold)) + .monospacedDigit() + Text(selectedUnit.paceLabel) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text(formatSpeed(speed)) + .font(.system(.title3, design: .rounded, weight: .bold)) + .monospacedDigit() + .foregroundStyle(.green) + Text(selectedUnit.speedLabel) + .font(.caption2) + .foregroundStyle(.secondary) } - .accessibilityElement(children: .combine) - .accessibilityLabel("\(pace.label) \(selectedUnit.paceLabel) equals \(formatSpeed(speed)) \(selectedUnit.speedLabel)") } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(pace.label) \(selectedUnit.paceLabel) equals \(formatSpeed(speed)) \(selectedUnit.speedLabel)") } - .navigationTitle("Reference") } + .navigationTitle("Reference") } } @@ -196,7 +182,6 @@ private struct ConverterTab: View { Text(unit.speedLabel).tag(unit) } } - .pickerStyle(.segmented) // Pace input HStack(spacing: 2) { @@ -236,6 +221,17 @@ private struct ConverterTab: View { .font(.caption) .foregroundStyle(.secondary) } + + Divider() + + VStack(spacing: 8) { + NavigationLink("Reference Table") { + ReferenceTab() + } + NavigationLink("Race Calculator") { + RaceCalcTab() + } + } } .padding(.horizontal) } @@ -263,70 +259,68 @@ private struct RaceCalcTab: View { } var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 10) { - // Unit picker - Picker("Unit", selection: $selectedUnit) { - ForEach(WatchSpeedUnit.allCases, id: \.self) { unit in - Text(unit.speedLabel).tag(unit) - } + ScrollView { + VStack(spacing: 10) { + // Unit picker + Picker("Unit", selection: $selectedUnit) { + ForEach(WatchSpeedUnit.allCases, id: \.self) { unit in + Text(unit.speedLabel).tag(unit) } - .pickerStyle(.segmented) + } - // Distance picker - Picker("Distance", selection: $selectedDistance) { - ForEach(WatchRaceDistance.allCases) { d in - Text(d.rawValue).tag(d) - } + // Distance picker + Picker("Distance", selection: $selectedDistance) { + ForEach(WatchRaceDistance.allCases) { d in + Text(d.rawValue).tag(d) } + } - // Pace input - HStack(spacing: 2) { - Picker("Min", selection: $paceMinutes) { - ForEach(1...30, id: \.self) { m in - Text("\(m)").tag(m) - } + // Pace input + HStack(spacing: 2) { + Picker("Min", selection: $paceMinutes) { + ForEach(1...30, id: \.self) { m in + Text("\(m)").tag(m) } - .pickerStyle(.wheel) - .frame(width: 55, height: 60) + } + .pickerStyle(.wheel) + .frame(width: 55, height: 60) - Text(":") - .font(.title3.bold()) + Text(":") + .font(.title3.bold()) - Picker("Sec", selection: $paceSeconds) { - ForEach(0..<60, id: \.self) { s in - Text(String(format: "%02d", s)).tag(s) - } + Picker("Sec", selection: $paceSeconds) { + ForEach(0..<60, id: \.self) { s in + Text(String(format: "%02d", s)).tag(s) } - .pickerStyle(.wheel) - .frame(width: 55, height: 60) - - Text(selectedUnit.paceLabel) - .font(.caption2) - .foregroundStyle(.secondary) } + .pickerStyle(.wheel) + .frame(width: 55, height: 60) - Divider() + Text(selectedUnit.paceLabel) + .font(.caption2) + .foregroundStyle(.secondary) + } - // Finish time result - VStack(spacing: 4) { - Text("Finish Time") - .font(.caption2) - .foregroundStyle(.secondary) - Text(formatDuration(finishTimeSeconds)) - .font(.system(.title, design: .rounded, weight: .bold)) - .monospacedDigit() - .foregroundStyle(.green) - } + Divider() + + // Finish time result + VStack(spacing: 4) { + Text("Finish Time") + .font(.caption2) + .foregroundStyle(.secondary) + Text(formatDuration(finishTimeSeconds)) + .font(.system(.title, design: .rounded, weight: .bold)) + .monospacedDigit() + .foregroundStyle(.green) } - .padding(.horizontal) } - .navigationTitle("Race Calc") + .padding(.horizontal) } + .navigationTitle("Race Calc") } } + #Preview { ContentView() } From 7f28dd5d712b67f585fdd80dd90a51d9a8e70c75 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 10:14:34 -0500 Subject: [PATCH 27/53] fix(pr-1): address review feedback --- .../xcschemes/xcschememanagement.plist | 24 --------------- pace-to-mph/Models/FavoritesStore.swift | 13 ++++++--- pace-to-mph/Models/RaceCalculator.swift | 29 +++++++++++++++---- pace-to-mphTests/FavoritesStoreTests.swift | 18 ++++++++++++ .../IntentConversionRegressionTests.swift | 25 +++++++--------- pace-to-mphTests/RaceCalculatorTests.swift | 7 +++++ 6 files changed, 69 insertions(+), 47 deletions(-) delete mode 100644 pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist diff --git a/pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist b/pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 31bb7f5..0000000 --- a/pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - SchemeUserState - - pace-to-mph WatchKit App.xcscheme_^#shared#^_ - - orderHint - 1 - - pace-to-mph.xcscheme_^#shared#^_ - - orderHint - 0 - - pace-to-mphWidgets.xcscheme_^#shared#^_ - - orderHint - 2 - - - - diff --git a/pace-to-mph/Models/FavoritesStore.swift b/pace-to-mph/Models/FavoritesStore.swift index 04f6059..00cf295 100644 --- a/pace-to-mph/Models/FavoritesStore.swift +++ b/pace-to-mph/Models/FavoritesStore.swift @@ -12,9 +12,14 @@ struct FavoriteConversion: Codable, Identifiable, Equatable { class FavoritesStore { private(set) var favorites: [FavoriteConversion] = [] private let maxFavorites = 20 - private let storageKey = "pinned_favorites" + private let storageKey: String + private let userDefaults: UserDefaults - init() { load() } + init(userDefaults: UserDefaults = .standard, storageKey: String = "pinned_favorites") { + self.userDefaults = userDefaults + self.storageKey = storageKey + load() + } // Add a favorite. Skip if duplicate (same input+inputSuffix+result+resultSuffix). func add(input: String, inputSuffix: String, result: String, resultSuffix: String) { @@ -57,11 +62,11 @@ class FavoritesStore { private func save() { guard let data = try? JSONEncoder().encode(favorites) else { return } - UserDefaults.standard.set(data, forKey: storageKey) + userDefaults.set(data, forKey: storageKey) } private func load() { - guard let data = UserDefaults.standard.data(forKey: storageKey), + guard let data = userDefaults.data(forKey: storageKey), let decoded = try? JSONDecoder().decode([FavoriteConversion].self, from: data) else { return } favorites = decoded } diff --git a/pace-to-mph/Models/RaceCalculator.swift b/pace-to-mph/Models/RaceCalculator.swift index d913d66..29337c1 100644 --- a/pace-to-mph/Models/RaceCalculator.swift +++ b/pace-to-mph/Models/RaceCalculator.swift @@ -90,7 +90,7 @@ enum RaceCalculator { /// - dropSeconds: Seconds faster per split (positive value = each split is faster) /// - Returns: Array of (splitDistance, splitSeconds) tuples static func negativeSplits(totalSeconds: Int, distanceInUnits: Double, dropSeconds: Double) -> [(distance: Double, seconds: Int)] { - guard distanceInUnits > 0, totalSeconds > 0 else { return [] } + guard distanceInUnits > 0, totalSeconds > 0, dropSeconds >= 0 else { return [] } let fullSplits = Int(distanceInUnits) let partial = distanceInUnits - Double(fullSplits) @@ -110,8 +110,10 @@ enum RaceCalculator { guard effectiveDistance > 0 else { return [] } let basePace = (Double(totalSeconds) + dropSum) / effectiveDistance + guard basePace - Double(splitCount - 1) * dropSeconds > 0 else { return [] } - var results: [(distance: Double, seconds: Int)] = [] + var distances: [Double] = [] + var splitTimes: [Int] = [] for i in 0.. 0 { + splitTimes[splitTimes.count - 1] += difference + } else if difference < 0 { + var remaining = -difference + for index in stride(from: splitTimes.count - 1, through: 0, by: -1) { + let reducible = splitTimes[index] - 1 + guard reducible > 0 else { continue } + let reduction = min(reducible, remaining) + splitTimes[index] -= reduction + remaining -= reduction + if remaining == 0 { break } + } + guard remaining == 0 else { return [] } + } + + return zip(distances, splitTimes).map { (distance: $0.0, seconds: $0.1) } } } diff --git a/pace-to-mphTests/FavoritesStoreTests.swift b/pace-to-mphTests/FavoritesStoreTests.swift index f2839ee..3f28d4e 100644 --- a/pace-to-mphTests/FavoritesStoreTests.swift +++ b/pace-to-mphTests/FavoritesStoreTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import pace_to_mph @@ -66,4 +67,21 @@ struct FavoritesStoreTests { store.clear() #expect(store.favorites.isEmpty) } + + @Test func customStorageIsolation() { + let suite1Name = "FavoritesStoreTests.suite1.\(UUID().uuidString)" + let suite2Name = "FavoritesStoreTests.suite2.\(UUID().uuidString)" + let suite1 = UserDefaults(suiteName: suite1Name)! + let suite2 = UserDefaults(suiteName: suite2Name)! + defer { + suite1.removePersistentDomain(forName: suite1Name) + suite2.removePersistentDomain(forName: suite2Name) + } + + let first = FavoritesStore(userDefaults: suite1, storageKey: "favorites") + let second = FavoritesStore(userDefaults: suite2, storageKey: "favorites") + first.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") + #expect(first.favorites.count == 1) + #expect(second.favorites.isEmpty) + } } diff --git a/pace-to-mphTests/IntentConversionRegressionTests.swift b/pace-to-mphTests/IntentConversionRegressionTests.swift index 5b083c1..876304f 100644 --- a/pace-to-mphTests/IntentConversionRegressionTests.swift +++ b/pace-to-mphTests/IntentConversionRegressionTests.swift @@ -3,8 +3,9 @@ import Testing @testable import pace_to_mph struct IntentConversionRegressionTests { - private func unpack(_ result: some ReturnsValue & ProvidesDialog) -> IntentResultContainer { - result as! IntentResultContainer + private func assertResult(_ result: some ReturnsValue, value: String, dialogContains: String) { + #expect(result.value == value) + #expect(String(describing: result).contains(dialogContains)) } @Test func paceToSpeedIntentForMph() async throws { @@ -12,9 +13,8 @@ struct IntentConversionRegressionTests { intent.pace = "8:00" intent.unit = .mph - let result = unpack(try await intent.perform()) - #expect(result.value == "7.50") - #expect(String(describing: result.dialog).contains("per mile")) + let result = try await intent.perform() + assertResult(result, value: "7.50", dialogContains: "per mile") } @Test func paceToSpeedIntentForKph() async throws { @@ -22,9 +22,8 @@ struct IntentConversionRegressionTests { intent.pace = "8:00" intent.unit = .kph - let result = unpack(try await intent.perform()) - #expect(result.value == "7.50") - #expect(String(describing: result.dialog).contains("per kilometer")) + let result = try await intent.perform() + assertResult(result, value: "7.50", dialogContains: "per kilometer") } @Test func speedToPaceIntentForMph() async throws { @@ -32,9 +31,8 @@ struct IntentConversionRegressionTests { intent.speed = 10 intent.unit = .mph - let result = unpack(try await intent.perform()) - #expect(result.value == "6:00") - #expect(String(describing: result.dialog).contains("/mi")) + let result = try await intent.perform() + assertResult(result, value: "6:00", dialogContains: "/mi") } @Test func speedToPaceIntentForKph() async throws { @@ -42,8 +40,7 @@ struct IntentConversionRegressionTests { intent.speed = 12 intent.unit = .kph - let result = unpack(try await intent.perform()) - #expect(result.value == "5:00") - #expect(String(describing: result.dialog).contains("/km")) + let result = try await intent.perform() + assertResult(result, value: "5:00", dialogContains: "/km") } } diff --git a/pace-to-mphTests/RaceCalculatorTests.swift b/pace-to-mphTests/RaceCalculatorTests.swift index 2024f80..714393f 100644 --- a/pace-to-mphTests/RaceCalculatorTests.swift +++ b/pace-to-mphTests/RaceCalculatorTests.swift @@ -87,4 +87,11 @@ struct RaceCalculatorTests { #expect(RaceCalculator.negativeSplits(totalSeconds: 0, distanceInUnits: 3.0, dropSeconds: 5.0).isEmpty) #expect(RaceCalculator.negativeSplits(totalSeconds: 1800, distanceInUnits: 0, dropSeconds: 5.0).isEmpty) } + + @Test func negativeSplitsLargeDropPreservesRequestedTotal() { + let splits = RaceCalculator.negativeSplits(totalSeconds: 60, distanceInUnits: 2.0, dropSeconds: 59) + #expect(splits.count == 2) + #expect(splits.reduce(0) { $0 + $1.seconds } == 60) + #expect(splits.allSatisfy { $0.seconds > 0 }) + } } From 2d1086387da7f32f9cc02172b99d90391c857e14 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 10:23:20 -0500 Subject: [PATCH 28/53] add shared conversions for pace and speed --- Shared/SharedConversionMath.swift | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 Shared/SharedConversionMath.swift diff --git a/Shared/SharedConversionMath.swift b/Shared/SharedConversionMath.swift new file mode 100644 index 0000000..2046300 --- /dev/null +++ b/Shared/SharedConversionMath.swift @@ -0,0 +1,44 @@ +import Foundation + +nonisolated enum SharedConversionMath { + static func paceToSpeed(_ paceMinutes: Double) -> Double { + 60.0 / paceMinutes + } + + static func speedToPace(_ speed: Double) -> Double { + 60.0 / speed + } + + static func formatSpeed(_ value: Double) -> String { + String(format: "%.2f", value) + } + + static func formatPace(_ minutesPerUnit: Double) -> String? { + guard minutesPerUnit.isFinite, minutesPerUnit > 0 else { return nil } + + let totalSeconds = Int(round(minutesPerUnit * 60)) + var minutes = totalSeconds / 60 + var seconds = totalSeconds % 60 + + if seconds == 60 { + minutes += 1 + seconds = 0 + } + + return "\(minutes):\(String(format: "%02d", seconds))" + } + + static func formatDuration(_ totalSeconds: Int) -> String { + guard totalSeconds >= 0 else { return "0:00" } + + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + + if hours > 0 { + return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" + } + + return "\(minutes):\(String(format: "%02d", seconds))" + } +} From 56f500b80c964cb4e2c243bdb50de4fe4a0238c2 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 10:23:43 -0500 Subject: [PATCH 29/53] delegate formatting and conversion to shared --- pace-to-mph/Models/ConversionEngine.swift | 19 ++++--------------- pace-to-mph/Models/RaceCalculator.swift | 11 +---------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/pace-to-mph/Models/ConversionEngine.swift b/pace-to-mph/Models/ConversionEngine.swift index 8e27ded..880abd5 100644 --- a/pace-to-mph/Models/ConversionEngine.swift +++ b/pace-to-mph/Models/ConversionEngine.swift @@ -79,33 +79,22 @@ nonisolated enum ConversionEngine { /// Format a speed value to 2 decimal places static func formatSpeed(_ value: Double) -> String { - String(format: "%.2f", value) + SharedConversionMath.formatSpeed(value) } /// Format minutes-per-unit as "mm:ss" static func formatPace(_ minutesPerUnit: Double) -> String? { - guard minutesPerUnit.isFinite, minutesPerUnit > 0 else { return nil } - - let totalSeconds = Int(round(minutesPerUnit * 60)) - var minutes = totalSeconds / 60 - var seconds = totalSeconds % 60 - - if seconds == 60 { - minutes += 1 - seconds = 0 - } - - return "\(minutes):\(String(format: "%02d", seconds))" + SharedConversionMath.formatPace(minutesPerUnit) } // MARK: - Conversion static func paceToSpeed(_ paceMinutes: Double) -> Double { - 60.0 / paceMinutes + SharedConversionMath.paceToSpeed(paceMinutes) } static func speedToPace(_ speed: Double) -> Double { - 60.0 / speed + SharedConversionMath.speedToPace(speed) } static func convertPaceBetweenUnits(_ paceMinutes: Double, from: SpeedUnit, to: SpeedUnit) -> Double { diff --git a/pace-to-mph/Models/RaceCalculator.swift b/pace-to-mph/Models/RaceCalculator.swift index d913d66..191d229 100644 --- a/pace-to-mph/Models/RaceCalculator.swift +++ b/pace-to-mph/Models/RaceCalculator.swift @@ -38,16 +38,7 @@ enum RaceCalculator { /// Format total seconds as "h:mm:ss" or "mm:ss" if under 1 hour. static func formatDuration(_ totalSeconds: Int) -> String { - guard totalSeconds >= 0 else { return "0:00" } - let hours = totalSeconds / 3600 - let minutes = (totalSeconds % 3600) / 60 - let seconds = totalSeconds % 60 - - if hours > 0 { - return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" - } else { - return "\(minutes):\(String(format: "%02d", seconds))" - } + SharedConversionMath.formatDuration(totalSeconds) } /// Given finish time (total seconds) and distance, return pace in minutes. From b2cf0914abbd3cc2cdaaadc6cfc445623a33d073 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 10:23:55 -0500 Subject: [PATCH 30/53] add icon for watch --- .../App-Icon-1024x1024@1x.png | Bin 0 -> 22783 bytes .../AppIcon.appiconset/Contents.json | 1 + 2 files changed, 1 insertion(+) create mode 100644 pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png diff --git a/pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..fc44c098ae9ae54ef0d93314452bae1d6a295a76 GIT binary patch literal 22783 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 Q1c3C{8U2~8V|)1j07YWvDF6Tf literal 0 HcmV?d00001 diff --git a/pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/Contents.json b/pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/Contents.json index 49c81cd..9fc2aaa 100644 --- a/pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "App-Icon-1024x1024@1x.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" From d1617c56060c24550ea860bdd40dd61e4b22f323 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 10:24:14 -0500 Subject: [PATCH 31/53] move conversion to shared --- pace-to-mph WatchKit App/ContentView.swift | 208 ++++++++++++--------- 1 file changed, 122 insertions(+), 86 deletions(-) diff --git a/pace-to-mph WatchKit App/ContentView.swift b/pace-to-mph WatchKit App/ContentView.swift index d9d5df7..6e366c1 100644 --- a/pace-to-mph WatchKit App/ContentView.swift +++ b/pace-to-mph WatchKit App/ContentView.swift @@ -21,38 +21,6 @@ private enum WatchSpeedUnit: String, CaseIterable { } } -// MARK: - Conversion Helpers - -private func paceToSpeed(_ paceMinutes: Double) -> Double { - 60.0 / paceMinutes -} - -private func speedToPace(_ speed: Double) -> Double { - 60.0 / speed -} - -private func formatSpeed(_ value: Double) -> String { - String(format: "%.2f", value) -} - -private func formatPace(_ minutesPerUnit: Double) -> String { - let totalSeconds = Int(round(minutesPerUnit * 60)) - let minutes = totalSeconds / 60 - let seconds = totalSeconds % 60 - return "\(minutes):\(String(format: "%02d", seconds))" -} - -private func formatDuration(_ totalSeconds: Int) -> String { - guard totalSeconds >= 0 else { return "0:00" } - let hours = totalSeconds / 3600 - let minutes = (totalSeconds % 3600) / 60 - let seconds = totalSeconds % 60 - if hours > 0 { - return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" - } - return "\(minutes):\(String(format: "%02d", seconds))" -} - // MARK: - Pace Entry private struct PaceEntry: Identifiable { @@ -83,6 +51,26 @@ private enum WatchRaceDistance: String, CaseIterable, Identifiable { } } +private extension View { + @ViewBuilder + func watchGlassCard(cornerRadius: CGFloat = 16) -> some View { + if #available(watchOS 26.0, *) { + self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: cornerRadius)) + } else { + self.background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: cornerRadius)) + } + } + + @ViewBuilder + func watchGlassButton() -> some View { + if #available(watchOS 26.0, *) { + self.buttonStyle(.glass) + } else { + self.buttonStyle(.bordered) + } + } +} + // MARK: - Main View struct ContentView: View { @@ -127,7 +115,7 @@ private struct ReferenceTab: View { } ForEach(activePaces) { pace in - let speed = paceToSpeed(pace.paceMinutes) + let speed = SharedConversionMath.paceToSpeed(pace.paceMinutes) HStack { VStack(alignment: .leading, spacing: 2) { Text(pace.label) @@ -139,7 +127,7 @@ private struct ReferenceTab: View { } Spacer() VStack(alignment: .trailing, spacing: 2) { - Text(formatSpeed(speed)) + Text(SharedConversionMath.formatSpeed(speed)) .font(.system(.title3, design: .rounded, weight: .bold)) .monospacedDigit() .foregroundStyle(.green) @@ -149,7 +137,7 @@ private struct ReferenceTab: View { } } .accessibilityElement(children: .combine) - .accessibilityLabel("\(pace.label) \(selectedUnit.paceLabel) equals \(formatSpeed(speed)) \(selectedUnit.speedLabel)") + .accessibilityLabel("\(pace.label) \(selectedUnit.paceLabel) equals \(SharedConversionMath.formatSpeed(speed)) \(selectedUnit.speedLabel)") } } .navigationTitle("Reference") @@ -169,73 +157,97 @@ private struct ConverterTab: View { private var speed: Double { guard paceValue > 0 else { return 0 } - return paceToSpeed(paceValue) + return SharedConversionMath.paceToSpeed(paceValue) } var body: some View { NavigationStack { - ScrollView { - VStack(spacing: 12) { - // Unit picker - Picker("Unit", selection: $selectedUnit) { - ForEach(WatchSpeedUnit.allCases, id: \.self) { unit in - Text(unit.speedLabel).tag(unit) - } + converterContent + .navigationTitle("Converter") + } + } + + @ViewBuilder + private var converterContent: some View { + if #available(watchOS 26.0, *) { + GlassEffectContainer { + converterScrollView + } + } else { + converterScrollView + } + } + + private var converterScrollView: some View { + ScrollView { + VStack(spacing: 12) { + // Unit picker + Picker("Unit", selection: $selectedUnit) { + ForEach(WatchSpeedUnit.allCases, id: \.self) { unit in + Text(unit.speedLabel).tag(unit) } + } + .pickerStyle(.wheel) + .frame(height: 44) + .clipped() - // Pace input - HStack(spacing: 2) { - Picker("Min", selection: $paceMinutes) { - ForEach(1...30, id: \.self) { m in - Text("\(m)").tag(m) - } + // Pace input + HStack(spacing: 2) { + Picker("Min", selection: $paceMinutes) { + ForEach(1...30, id: \.self) { m in + Text("\(m)").tag(m) } - .pickerStyle(.wheel) - .frame(width: 55, height: 60) + } + .pickerStyle(.wheel) + .frame(width: 55, height: 60) - Text(":") - .font(.title3.bold()) + Text(":") + .font(.title3.bold()) - Picker("Sec", selection: $paceSeconds) { - ForEach(0..<60, id: \.self) { s in - Text(String(format: "%02d", s)).tag(s) - } + Picker("Sec", selection: $paceSeconds) { + ForEach(0..<60, id: \.self) { s in + Text(String(format: "%02d", s)).tag(s) } - .pickerStyle(.wheel) - .frame(width: 55, height: 60) - - Text(selectedUnit.paceLabel) - .font(.caption2) - .foregroundStyle(.secondary) } + .pickerStyle(.wheel) + .frame(width: 55, height: 60) - Divider() + Text(selectedUnit.paceLabel) + .font(.caption2) + .foregroundStyle(.secondary) + } - // Speed result - VStack(spacing: 4) { - Text(formatSpeed(speed)) - .font(.system(.title, design: .rounded, weight: .bold)) - .monospacedDigit() - .foregroundStyle(.green) - Text(selectedUnit.speedLabel) - .font(.caption) - .foregroundStyle(.secondary) - } + Divider() + + // Speed result + VStack(spacing: 4) { + Text(SharedConversionMath.formatSpeed(speed)) + .font(.system(.title, design: .rounded, weight: .bold)) + .monospacedDigit() + .foregroundStyle(.green) + Text(selectedUnit.speedLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .watchGlassCard() - Divider() + Divider() - VStack(spacing: 8) { - NavigationLink("Reference Table") { - ReferenceTab() - } - NavigationLink("Race Calculator") { - RaceCalcTab() - } + VStack(spacing: 8) { + NavigationLink("Reference Table") { + ReferenceTab() + } + .watchGlassButton() + + NavigationLink("Race Calculator") { + RaceCalcTab() } + .watchGlassButton() } - .padding(.horizontal) } - .navigationTitle("Converter") + .padding(.horizontal) } } } @@ -259,6 +271,22 @@ private struct RaceCalcTab: View { } var body: some View { + raceContent + .navigationTitle("Race Calc") + } + + @ViewBuilder + private var raceContent: some View { + if #available(watchOS 26.0, *) { + GlassEffectContainer { + raceScrollView + } + } else { + raceScrollView + } + } + + private var raceScrollView: some View { ScrollView { VStack(spacing: 10) { // Unit picker @@ -267,6 +295,9 @@ private struct RaceCalcTab: View { Text(unit.speedLabel).tag(unit) } } + .pickerStyle(.wheel) + .frame(height: 44) + .clipped() // Distance picker Picker("Distance", selection: $selectedDistance) { @@ -274,6 +305,9 @@ private struct RaceCalcTab: View { Text(d.rawValue).tag(d) } } + .pickerStyle(.wheel) + .frame(height: 44) + .clipped() // Pace input HStack(spacing: 2) { @@ -308,15 +342,17 @@ private struct RaceCalcTab: View { Text("Finish Time") .font(.caption2) .foregroundStyle(.secondary) - Text(formatDuration(finishTimeSeconds)) + Text(SharedConversionMath.formatDuration(finishTimeSeconds)) .font(.system(.title, design: .rounded, weight: .bold)) .monospacedDigit() .foregroundStyle(.green) } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .watchGlassCard() } .padding(.horizontal) } - .navigationTitle("Race Calc") } } From 0b6cd6bd8edc0f7fa5a860dbe0c53bb1bb29d896 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 10:24:24 -0500 Subject: [PATCH 32/53] add shared group to project --- pace-to-mph.xcodeproj/project.pbxproj | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pace-to-mph.xcodeproj/project.pbxproj b/pace-to-mph.xcodeproj/project.pbxproj index 5d1d347..8e034fa 100644 --- a/pace-to-mph.xcodeproj/project.pbxproj +++ b/pace-to-mph.xcodeproj/project.pbxproj @@ -51,6 +51,11 @@ path = "pace-to-mph WatchKit App"; sourceTree = ""; }; + C0FFEE110000000000000011 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -90,6 +95,7 @@ children = ( 939A2E5E2F539927000B40F9 /* pace-to-mph */, C0FFEE020000000000000002 /* pace-to-mph WatchKit App */, + C0FFEE110000000000000011 /* Shared */, 939A2E6C2F539928000B40F9 /* pace-to-mphTests */, 939A2E762F539928000B40F9 /* pace-to-mphUITests */, 939A2E5D2F539927000B40F9 /* Products */, @@ -124,6 +130,7 @@ ); fileSystemSynchronizedGroups = ( 939A2E5E2F539927000B40F9 /* pace-to-mph */, + C0FFEE110000000000000011 /* Shared */, ); name = "pace-to-mph"; packageProductDependencies = ( @@ -192,6 +199,7 @@ ); fileSystemSynchronizedGroups = ( C0FFEE020000000000000002 /* pace-to-mph WatchKit App */, + C0FFEE110000000000000011 /* Shared */, ); name = "pace-to-mph WatchKit App"; packageProductDependencies = ( From 106f9d6655ce4646914c8d4ab6448886d2dda8b1 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 10:31:20 -0500 Subject: [PATCH 33/53] fix(ui): use split-content haptic trigger --- pace-to-mph/NegativeSplitView.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pace-to-mph/NegativeSplitView.swift b/pace-to-mph/NegativeSplitView.swift index 0d4c7a8..bb0b19e 100644 --- a/pace-to-mph/NegativeSplitView.swift +++ b/pace-to-mph/NegativeSplitView.swift @@ -30,6 +30,13 @@ struct NegativeSplitView: View { return RaceCalculator.negativeSplits(totalSeconds: totalSeconds, distanceInUnits: distance, dropSeconds: dropSeconds) } + private var splitsFeedbackTrigger: Int { + splits.reduce(17) { hash, split in + let distanceMillis = Int((split.distance * 1_000).rounded()) + return ((hash &* 31) &+ distanceMillis) &+ split.seconds + } + } + var body: some View { GlassEffectContainer { ScrollView { @@ -268,7 +275,7 @@ struct NegativeSplitView: View { } .padding(20) .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) - .sensoryFeedback(.impact(flexibility: .soft), trigger: splits.count) + .sensoryFeedback(.impact(flexibility: .soft), trigger: splitsFeedbackTrigger) } } From ea961f225432eae3f5dc037b9853e2bebf720da5 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 10:45:09 -0500 Subject: [PATCH 34/53] fix(pr-1): address review feedback batch 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - precompute cumulative seconds in NegativeSplitView to avoid O(n²) reduce - compute isFav once in ContentView star button to avoid duplicate work - migrate FavoritesStoreTests to isolated UserDefaults suites via makeStore helper --- pace-to-mph/ContentView.swift | 12 +++---- pace-to-mph/NegativeSplitView.swift | 6 +++- pace-to-mphTests/FavoritesStoreTests.swift | 42 +++++++++++----------- 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/pace-to-mph/ContentView.swift b/pace-to-mph/ContentView.swift index 0c8347e..e6aad40 100644 --- a/pace-to-mph/ContentView.swift +++ b/pace-to-mph/ContentView.swift @@ -175,19 +175,15 @@ struct ContentView: View { ) } } label: { - Image(systemName: favoritesStore.isFavorited( + let isFav = favoritesStore.isFavorited( input: viewModel.inputText, inputSuffix: viewModel.inputSuffix, result: viewModel.result, resultSuffix: viewModel.resultSuffix - ) ? "star.fill" : "star") + ) + Image(systemName: isFav ? "star.fill" : "star") .font(.system(size: 20)) - .foregroundStyle(favoritesStore.isFavorited( - input: viewModel.inputText, - inputSuffix: viewModel.inputSuffix, - result: viewModel.result, - resultSuffix: viewModel.resultSuffix - ) ? .yellow : .secondary) + .foregroundStyle(isFav ? .yellow : .secondary) } .buttonStyle(.plain) .padding(.top, 8) diff --git a/pace-to-mph/NegativeSplitView.swift b/pace-to-mph/NegativeSplitView.swift index bb0b19e..656f205 100644 --- a/pace-to-mph/NegativeSplitView.swift +++ b/pace-to-mph/NegativeSplitView.swift @@ -231,8 +231,12 @@ struct NegativeSplitView: View { .frame(width: 56, alignment: .trailing) } + let cumulativeSeconds: [Int] = splits.reduce(into: []) { result, split in + result.append((result.last ?? 0) + split.seconds) + } + ForEach(Array(splits.enumerated()), id: \.offset) { index, split in - let cumulative = splits.prefix(index + 1).reduce(0) { $0 + $1.seconds } + let cumulative = cumulativeSeconds[index] let paceMinutes = split.distance > 0 ? Double(split.seconds) / 60.0 / split.distance : 0 HStack { diff --git a/pace-to-mphTests/FavoritesStoreTests.swift b/pace-to-mphTests/FavoritesStoreTests.swift index 3f28d4e..ca24c08 100644 --- a/pace-to-mphTests/FavoritesStoreTests.swift +++ b/pace-to-mphTests/FavoritesStoreTests.swift @@ -3,66 +3,68 @@ import Testing @testable import pace_to_mph struct FavoritesStoreTests { - + + private func makeStore(function: String = #function) -> (FavoritesStore, () -> Void) { + let suiteName = "FavoritesStoreTests.\(function).\(UUID().uuidString)" + let suite = UserDefaults(suiteName: suiteName)! + let store = FavoritesStore(userDefaults: suite, storageKey: "favorites-test") + return (store, { suite.removePersistentDomain(forName: suiteName) }) + } + @Test func addFavorite() { - let store = FavoritesStore() - store.clear() + let (store, cleanup) = makeStore() + defer { cleanup() } store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") #expect(store.favorites.count == 1) #expect(store.favorites.first?.input == "8:00") - store.clear() } @Test func addDuplicateSkipped() { - let store = FavoritesStore() - store.clear() + let (store, cleanup) = makeStore() + defer { cleanup() } store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") #expect(store.favorites.count == 1) - store.clear() } @Test func removeFavorite() { - let store = FavoritesStore() - store.clear() + let (store, cleanup) = makeStore() + defer { cleanup() } store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") let id = store.favorites.first!.id store.remove(id: id) #expect(store.favorites.isEmpty) - store.clear() } @Test func isFavorited() { - let store = FavoritesStore() - store.clear() + let (store, cleanup) = makeStore() + defer { cleanup() } store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") #expect(store.isFavorited(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH")) #expect(!store.isFavorited(input: "7:00", inputSuffix: "/mi", result: "8.57", resultSuffix: "MPH")) - store.clear() } @Test func toggleFavorite() { - let store = FavoritesStore() - store.clear() + let (store, cleanup) = makeStore() + defer { cleanup() } store.toggle(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") #expect(store.favorites.count == 1) store.toggle(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") #expect(store.favorites.isEmpty) - store.clear() } @Test func maxFavoritesEnforced() { - let store = FavoritesStore() - store.clear() + let (store, cleanup) = makeStore() + defer { cleanup() } for i in 0..<25 { store.add(input: "\(i):00", inputSuffix: "/mi", result: "\(60.0/Double(max(i,1)))", resultSuffix: "MPH") } #expect(store.favorites.count == 20) - store.clear() } @Test func clearFavorites() { - let store = FavoritesStore() + let (store, cleanup) = makeStore() + defer { cleanup() } store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") store.clear() #expect(store.favorites.isEmpty) From 1e5a6348b8028d15ec4f2b16f607f12ff2d2ad42 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 11:02:01 -0500 Subject: [PATCH 35/53] fix(race): preserve non-increasing split order when rebalancing rounding remainder When difference > 0, add remainder to splitTimes[0] (slowest split) instead of splitTimes[last] (fastest). Adding to the last split can make it equal to or slower than the prior split when dropSeconds is small. Regression: negativeSplitsRoundingPreservesStrictlyDecreasingOrder (202s/6mi/1s-drop produced splits[4]==splits[5]==32) --- pace-to-mph/Models/RaceCalculator.swift | 3 ++- pace-to-mphTests/RaceCalculatorTests.swift | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pace-to-mph/Models/RaceCalculator.swift b/pace-to-mph/Models/RaceCalculator.swift index 29337c1..f596e89 100644 --- a/pace-to-mph/Models/RaceCalculator.swift +++ b/pace-to-mph/Models/RaceCalculator.swift @@ -129,7 +129,8 @@ enum RaceCalculator { let difference = totalSeconds - splitTimes.reduce(0, +) if difference > 0 { - splitTimes[splitTimes.count - 1] += difference + // Add rounding remainder to the first (slowest) split to preserve non-increasing order + splitTimes[0] += difference } else if difference < 0 { var remaining = -difference for index in stride(from: splitTimes.count - 1, through: 0, by: -1) { diff --git a/pace-to-mphTests/RaceCalculatorTests.swift b/pace-to-mphTests/RaceCalculatorTests.swift index 714393f..058a6a0 100644 --- a/pace-to-mphTests/RaceCalculatorTests.swift +++ b/pace-to-mphTests/RaceCalculatorTests.swift @@ -94,4 +94,17 @@ struct RaceCalculatorTests { #expect(splits.reduce(0) { $0 + $1.seconds } == 60) #expect(splits.allSatisfy { $0.seconds > 0 }) } + + // Regression: when rounding produces difference > 0, adding to the last (fastest) + // split can make it equal to or slower than the prior split (202s/6mi/1s drop → diff=1 + // adds to last, making splits[5]==splits[4]==32). Should add to first split instead. + @Test func negativeSplitsRoundingPreservesStrictlyDecreasingOrder() { + let splits = RaceCalculator.negativeSplits(totalSeconds: 202, distanceInUnits: 6.0, dropSeconds: 1.0) + #expect(splits.count == 6) + #expect(splits.reduce(0) { $0 + $1.seconds } == 202) + for i in 1.. Date: Sun, 1 Mar 2026 11:52:46 -0500 Subject: [PATCH 36/53] fix(negative-splits): ensure strictly-decreasing order when adjusting splits with negative differences --- pace-to-mph/Models/RaceCalculator.swift | 15 +++++---------- pace-to-mphTests/RaceCalculatorTests.swift | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/pace-to-mph/Models/RaceCalculator.swift b/pace-to-mph/Models/RaceCalculator.swift index f596e89..5561d11 100644 --- a/pace-to-mph/Models/RaceCalculator.swift +++ b/pace-to-mph/Models/RaceCalculator.swift @@ -132,16 +132,11 @@ enum RaceCalculator { // Add rounding remainder to the first (slowest) split to preserve non-increasing order splitTimes[0] += difference } else if difference < 0 { - var remaining = -difference - for index in stride(from: splitTimes.count - 1, through: 0, by: -1) { - let reducible = splitTimes[index] - 1 - guard reducible > 0 else { continue } - let reduction = min(reducible, remaining) - splitTimes[index] -= reduction - remaining -= reduction - if remaining == 0 { break } - } - guard remaining == 0 else { return [] } + // Remove excess from the first (slowest) split to preserve strictly-decreasing order + splitTimes[0] += difference + guard splitTimes[0] >= 1 else { return [] } + // If reducing the first split made it equal/less than the second, splits are impossible + if splitTimes.count > 1 && splitTimes[0] <= splitTimes[1] { return [] } } return zip(distances, splitTimes).map { (distance: $0.0, seconds: $0.1) } diff --git a/pace-to-mphTests/RaceCalculatorTests.swift b/pace-to-mphTests/RaceCalculatorTests.swift index 058a6a0..61a193a 100644 --- a/pace-to-mphTests/RaceCalculatorTests.swift +++ b/pace-to-mphTests/RaceCalculatorTests.swift @@ -107,4 +107,20 @@ struct RaceCalculatorTests { "split \(i) (\(splits[i].seconds)s) must be faster than split \(i-1) (\(splits[i-1].seconds)s)") } } + + // Regression: when rounding produces difference < 0 (total overshoots), removing + // seconds from the fastest (last) splits can make consecutive splits equal. + // e.g. 2s/2mi/1s drop → basePace=1.5, rounds to [2,1], sum=3, diff=-1, + // reducing last split (already 1) fails, reducing first → [1,1] — not decreasing. + @Test func negativeSplitsNegativeDiffPreservesStrictlyDecreasingOrder() { + let splits = RaceCalculator.negativeSplits(totalSeconds: 2, distanceInUnits: 2.0, dropSeconds: 1.0) + // Either returns valid strictly-decreasing splits or empty (impossible to satisfy) + if !splits.isEmpty { + #expect(splits.reduce(0) { $0 + $1.seconds } == 2) + for i in 1.. Date: Sun, 1 Mar 2026 12:11:34 -0500 Subject: [PATCH 37/53] refactor(shared): move models to Shared, eliminate duplicate types Move ConversionEngine and RaceCalculator to Shared/ so both iOS and watch targets use them directly. Remove SharedConversionMath wrapper and watch-local WatchSpeedUnit/WatchRaceDistance duplicates. Restore inline implementations, add Distance helpers (shortLabel, distance(unit:), standardCases) for watch usage. --- .../Models => Shared}/ConversionEngine.swift | 19 ++++- .../Models => Shared}/RaceCalculator.swift | 29 ++++++- Shared/SharedConversionMath.swift | 44 ----------- pace-to-mph WatchKit App/ContentView.swift | 77 ++++--------------- 4 files changed, 60 insertions(+), 109 deletions(-) rename {pace-to-mph/Models => Shared}/ConversionEngine.swift (90%) rename {pace-to-mph/Models => Shared}/RaceCalculator.swift (84%) delete mode 100644 Shared/SharedConversionMath.swift diff --git a/pace-to-mph/Models/ConversionEngine.swift b/Shared/ConversionEngine.swift similarity index 90% rename from pace-to-mph/Models/ConversionEngine.swift rename to Shared/ConversionEngine.swift index 880abd5..8e27ded 100644 --- a/pace-to-mph/Models/ConversionEngine.swift +++ b/Shared/ConversionEngine.swift @@ -79,22 +79,33 @@ nonisolated enum ConversionEngine { /// Format a speed value to 2 decimal places static func formatSpeed(_ value: Double) -> String { - SharedConversionMath.formatSpeed(value) + String(format: "%.2f", value) } /// Format minutes-per-unit as "mm:ss" static func formatPace(_ minutesPerUnit: Double) -> String? { - SharedConversionMath.formatPace(minutesPerUnit) + guard minutesPerUnit.isFinite, minutesPerUnit > 0 else { return nil } + + let totalSeconds = Int(round(minutesPerUnit * 60)) + var minutes = totalSeconds / 60 + var seconds = totalSeconds % 60 + + if seconds == 60 { + minutes += 1 + seconds = 0 + } + + return "\(minutes):\(String(format: "%02d", seconds))" } // MARK: - Conversion static func paceToSpeed(_ paceMinutes: Double) -> Double { - SharedConversionMath.paceToSpeed(paceMinutes) + 60.0 / paceMinutes } static func speedToPace(_ speed: Double) -> Double { - SharedConversionMath.speedToPace(speed) + 60.0 / speed } static func convertPaceBetweenUnits(_ paceMinutes: Double, from: SpeedUnit, to: SpeedUnit) -> Double { diff --git a/pace-to-mph/Models/RaceCalculator.swift b/Shared/RaceCalculator.swift similarity index 84% rename from pace-to-mph/Models/RaceCalculator.swift rename to Shared/RaceCalculator.swift index 191d229..42e4bc6 100644 --- a/pace-to-mph/Models/RaceCalculator.swift +++ b/Shared/RaceCalculator.swift @@ -29,6 +29,24 @@ enum RaceCalculator { case .custom: return nil } } + + var shortLabel: String { + switch self { + case .halfMarathon: return "Half" + default: return rawValue + } + } + + func distance(unit: SpeedUnit) -> Double? { + switch unit { + case .mph: return miles + case .kph: return kilometers + } + } + + static var standardCases: [Distance] { + allCases.filter { $0 != .custom } + } } /// Given pace (min/mile or min/km) and distance in the same unit, return total seconds. @@ -38,7 +56,16 @@ enum RaceCalculator { /// Format total seconds as "h:mm:ss" or "mm:ss" if under 1 hour. static func formatDuration(_ totalSeconds: Int) -> String { - SharedConversionMath.formatDuration(totalSeconds) + guard totalSeconds >= 0 else { return "0:00" } + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + + if hours > 0 { + return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" + } else { + return "\(minutes):\(String(format: "%02d", seconds))" + } } /// Given finish time (total seconds) and distance, return pace in minutes. diff --git a/Shared/SharedConversionMath.swift b/Shared/SharedConversionMath.swift deleted file mode 100644 index 2046300..0000000 --- a/Shared/SharedConversionMath.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -nonisolated enum SharedConversionMath { - static func paceToSpeed(_ paceMinutes: Double) -> Double { - 60.0 / paceMinutes - } - - static func speedToPace(_ speed: Double) -> Double { - 60.0 / speed - } - - static func formatSpeed(_ value: Double) -> String { - String(format: "%.2f", value) - } - - static func formatPace(_ minutesPerUnit: Double) -> String? { - guard minutesPerUnit.isFinite, minutesPerUnit > 0 else { return nil } - - let totalSeconds = Int(round(minutesPerUnit * 60)) - var minutes = totalSeconds / 60 - var seconds = totalSeconds % 60 - - if seconds == 60 { - minutes += 1 - seconds = 0 - } - - return "\(minutes):\(String(format: "%02d", seconds))" - } - - static func formatDuration(_ totalSeconds: Int) -> String { - guard totalSeconds >= 0 else { return "0:00" } - - let hours = totalSeconds / 3600 - let minutes = (totalSeconds % 3600) / 60 - let seconds = totalSeconds % 60 - - if hours > 0 { - return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" - } - - return "\(minutes):\(String(format: "%02d", seconds))" - } -} diff --git a/pace-to-mph WatchKit App/ContentView.swift b/pace-to-mph WatchKit App/ContentView.swift index 6e366c1..21c6d8a 100644 --- a/pace-to-mph WatchKit App/ContentView.swift +++ b/pace-to-mph WatchKit App/ContentView.swift @@ -1,26 +1,5 @@ import SwiftUI -// MARK: - Shared Types - -private enum WatchSpeedUnit: String, CaseIterable { - case mph - case kph - - var paceLabel: String { - switch self { - case .mph: return "/mi" - case .kph: return "/km" - } - } - - var speedLabel: String { - switch self { - case .mph: return "MPH" - case .kph: return "KM/H" - } - } -} - // MARK: - Pace Entry private struct PaceEntry: Identifiable { @@ -31,26 +10,6 @@ private struct PaceEntry: Identifiable { var label: String { "\(min):\(String(format: "%02d", sec))" } } -// MARK: - Race Distances - -private enum WatchRaceDistance: String, CaseIterable, Identifiable { - case fiveK = "5K" - case tenK = "10K" - case halfMarathon = "Half" - case marathon = "Marathon" - - var id: String { rawValue } - - func distance(unit: WatchSpeedUnit) -> Double { - switch self { - case .fiveK: return unit == .mph ? 3.10686 : 5.0 - case .tenK: return unit == .mph ? 6.21371 : 10.0 - case .halfMarathon: return unit == .mph ? 13.1094 : 21.0975 - case .marathon: return unit == .mph ? 26.2188 : 42.195 - } - } -} - private extension View { @ViewBuilder func watchGlassCard(cornerRadius: CGFloat = 16) -> some View { @@ -82,7 +41,7 @@ struct ContentView: View { // MARK: - Reference Tab private struct ReferenceTab: View { - @State private var selectedUnit: WatchSpeedUnit = .mph + @State private var selectedUnit: SpeedUnit = .mph private let mphPaces: [PaceEntry] = { var result: [PaceEntry] = [] @@ -109,13 +68,13 @@ private struct ReferenceTab: View { var body: some View { List { Picker("Unit", selection: $selectedUnit) { - ForEach(WatchSpeedUnit.allCases, id: \.self) { unit in + ForEach(SpeedUnit.allCases, id: \.self) { unit in Text(unit.speedLabel).tag(unit) } } ForEach(activePaces) { pace in - let speed = SharedConversionMath.paceToSpeed(pace.paceMinutes) + let speed = ConversionEngine.paceToSpeed(pace.paceMinutes) HStack { VStack(alignment: .leading, spacing: 2) { Text(pace.label) @@ -127,7 +86,7 @@ private struct ReferenceTab: View { } Spacer() VStack(alignment: .trailing, spacing: 2) { - Text(SharedConversionMath.formatSpeed(speed)) + Text(ConversionEngine.formatSpeed(speed)) .font(.system(.title3, design: .rounded, weight: .bold)) .monospacedDigit() .foregroundStyle(.green) @@ -137,7 +96,7 @@ private struct ReferenceTab: View { } } .accessibilityElement(children: .combine) - .accessibilityLabel("\(pace.label) \(selectedUnit.paceLabel) equals \(SharedConversionMath.formatSpeed(speed)) \(selectedUnit.speedLabel)") + .accessibilityLabel("\(pace.label) \(selectedUnit.paceLabel) equals \(ConversionEngine.formatSpeed(speed)) \(selectedUnit.speedLabel)") } } .navigationTitle("Reference") @@ -147,7 +106,7 @@ private struct ReferenceTab: View { // MARK: - Converter Tab private struct ConverterTab: View { - @State private var selectedUnit: WatchSpeedUnit = .mph + @State private var selectedUnit: SpeedUnit = .mph @State private var paceMinutes: Int = 8 @State private var paceSeconds: Int = 0 @@ -157,7 +116,7 @@ private struct ConverterTab: View { private var speed: Double { guard paceValue > 0 else { return 0 } - return SharedConversionMath.paceToSpeed(paceValue) + return ConversionEngine.paceToSpeed(paceValue) } var body: some View { @@ -183,7 +142,7 @@ private struct ConverterTab: View { VStack(spacing: 12) { // Unit picker Picker("Unit", selection: $selectedUnit) { - ForEach(WatchSpeedUnit.allCases, id: \.self) { unit in + ForEach(SpeedUnit.allCases, id: \.self) { unit in Text(unit.speedLabel).tag(unit) } } @@ -221,7 +180,7 @@ private struct ConverterTab: View { // Speed result VStack(spacing: 4) { - Text(SharedConversionMath.formatSpeed(speed)) + Text(ConversionEngine.formatSpeed(speed)) .font(.system(.title, design: .rounded, weight: .bold)) .monospacedDigit() .foregroundStyle(.green) @@ -255,8 +214,8 @@ private struct ConverterTab: View { // MARK: - Race Calculator Tab private struct RaceCalcTab: View { - @State private var selectedUnit: WatchSpeedUnit = .mph - @State private var selectedDistance: WatchRaceDistance = .fiveK + @State private var selectedUnit: SpeedUnit = .mph + @State private var selectedDistance: RaceCalculator.Distance = .fiveK @State private var paceMinutes: Int = 8 @State private var paceSeconds: Int = 0 @@ -265,9 +224,8 @@ private struct RaceCalcTab: View { } private var finishTimeSeconds: Int { - guard paceValue > 0 else { return 0 } - let distance = selectedDistance.distance(unit: selectedUnit) - return Int(round(paceValue * distance * 60)) + guard paceValue > 0, let distance = selectedDistance.distance(unit: selectedUnit) else { return 0 } + return RaceCalculator.finishTime(paceMinutes: paceValue, distanceInUnits: distance) } var body: some View { @@ -291,7 +249,7 @@ private struct RaceCalcTab: View { VStack(spacing: 10) { // Unit picker Picker("Unit", selection: $selectedUnit) { - ForEach(WatchSpeedUnit.allCases, id: \.self) { unit in + ForEach(SpeedUnit.allCases, id: \.self) { unit in Text(unit.speedLabel).tag(unit) } } @@ -301,8 +259,8 @@ private struct RaceCalcTab: View { // Distance picker Picker("Distance", selection: $selectedDistance) { - ForEach(WatchRaceDistance.allCases) { d in - Text(d.rawValue).tag(d) + ForEach(RaceCalculator.Distance.standardCases) { d in + Text(d.shortLabel).tag(d) } } .pickerStyle(.wheel) @@ -342,7 +300,7 @@ private struct RaceCalcTab: View { Text("Finish Time") .font(.caption2) .foregroundStyle(.secondary) - Text(SharedConversionMath.formatDuration(finishTimeSeconds)) + Text(RaceCalculator.formatDuration(finishTimeSeconds)) .font(.system(.title, design: .rounded, weight: .bold)) .monospacedDigit() .foregroundStyle(.green) @@ -356,7 +314,6 @@ private struct RaceCalcTab: View { } } - #Preview { ContentView() } From 4cdf3293456a780294181465e2f099ff3107d2b7 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 13:40:06 -0500 Subject: [PATCH 38/53] fix(shared): shorten marathon label to 'Full' for watch display --- Shared/RaceCalculator.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Shared/RaceCalculator.swift b/Shared/RaceCalculator.swift index 42e4bc6..b8e1435 100644 --- a/Shared/RaceCalculator.swift +++ b/Shared/RaceCalculator.swift @@ -33,6 +33,7 @@ enum RaceCalculator { var shortLabel: String { switch self { case .halfMarathon: return "Half" + case .marathon: return "Full" default: return rawValue } } From 433dd2219f3229f2736a3852a902d4c94ba794f0 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 13:40:12 -0500 Subject: [PATCH 39/53] fix(watch): replace wheel pickers with digitalCrownRotation, fix runtime warnings - Custom PaceInputRow with crown input eliminates ScrollView contentOffset warning - Single digitalCrownRotation binding avoids Crown Sequencer warning - UnitToggle/DistanceSelector custom button bars replace incompatible Picker styles - Glass card styling on result displays --- pace-to-mph WatchKit App/ContentView.swift | 220 +++++++++++++-------- 1 file changed, 138 insertions(+), 82 deletions(-) diff --git a/pace-to-mph WatchKit App/ContentView.swift b/pace-to-mph WatchKit App/ContentView.swift index 21c6d8a..b42bc6f 100644 --- a/pace-to-mph WatchKit App/ContentView.swift +++ b/pace-to-mph WatchKit App/ContentView.swift @@ -10,6 +10,129 @@ private struct PaceEntry: Identifiable { var label: String { "\(min):\(String(format: "%02d", sec))" } } +// MARK: - Inline Pace Picker + +private struct PaceInputRow: View { + @Binding var paceMinutes: Int + @Binding var paceSeconds: Int + let paceLabel: String + + @State private var editingSeconds = false + @State private var crownValue: Double = 0 + + var body: some View { + HStack(spacing: 4) { + Text("\(paceMinutes)") + .font(.system(.title3, design: .rounded, weight: .bold)) + .monospacedDigit() + .frame(width: 48, height: 48) + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(!editingSeconds ? Color.green : Color.white.opacity(0.3), lineWidth: 2) + ) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + .onTapGesture { + editingSeconds = false + crownValue = Double(paceMinutes) + } + + Text(":") + .font(.title3.bold()) + + Text(String(format: "%02d", paceSeconds)) + .font(.system(.title3, design: .rounded, weight: .bold)) + .monospacedDigit() + .frame(width: 48, height: 48) + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(editingSeconds ? Color.green : Color.white.opacity(0.3), lineWidth: 2) + ) + .background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + .onTapGesture { + editingSeconds = true + crownValue = Double(paceSeconds) + } + + Text(paceLabel) + .font(.caption2) + .foregroundStyle(.secondary) + } + .focusable() + .digitalCrownRotation( + detent: $crownValue, + from: 0, through: 59, by: 1, + sensitivity: .low, + isContinuous: false + ) + .onChange(of: crownValue) { _, newValue in + if editingSeconds { + paceSeconds = Int(newValue) + } else { + let clamped = max(1, min(30, Int(newValue))) + paceMinutes = clamped + if Int(newValue) != clamped { crownValue = Double(clamped) } + } + } + .onAppear { crownValue = Double(paceMinutes) } + } +} + +// MARK: - Custom Selectors + +private struct UnitToggle: View { + @Binding var selectedUnit: SpeedUnit + + var body: some View { + HStack(spacing: 4) { + ForEach(SpeedUnit.allCases, id: \.self) { unit in + Button { + withAnimation(.easeInOut(duration: 0.15)) { selectedUnit = unit } + } label: { + Text(unit.speedLabel) + .font(.caption.bold()) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .foregroundStyle(selectedUnit == unit ? .white : .secondary) + .background( + selectedUnit == unit ? Color.green : Color.white.opacity(0.08), + in: Capsule() + ) + } + } + } +} + +private struct DistanceSelector: View { + @Binding var selected: RaceCalculator.Distance + + private let distances = RaceCalculator.Distance.standardCases + + var body: some View { + HStack(spacing: 4) { + ForEach(distances) { distance in + Button { + withAnimation(.easeInOut(duration: 0.15)) { selected = distance } + } label: { + Text(distance.shortLabel) + .font(.caption2.bold()) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .foregroundStyle(selected == distance ? .white : .secondary) + .background( + selected == distance ? Color.green : Color.white.opacity(0.08), + in: Capsule() + ) + } + } + } +} + private extension View { @ViewBuilder func watchGlassCard(cornerRadius: CGFloat = 16) -> some View { @@ -67,11 +190,8 @@ private struct ReferenceTab: View { var body: some View { List { - Picker("Unit", selection: $selectedUnit) { - ForEach(SpeedUnit.allCases, id: \.self) { unit in - Text(unit.speedLabel).tag(unit) - } - } + UnitToggle(selectedUnit: $selectedUnit) + .listRowBackground(Color.clear) ForEach(activePaces) { pace in let speed = ConversionEngine.paceToSpeed(pace.paceMinutes) @@ -140,41 +260,13 @@ private struct ConverterTab: View { private var converterScrollView: some View { ScrollView { VStack(spacing: 12) { - // Unit picker - Picker("Unit", selection: $selectedUnit) { - ForEach(SpeedUnit.allCases, id: \.self) { unit in - Text(unit.speedLabel).tag(unit) - } - } - .pickerStyle(.wheel) - .frame(height: 44) - .clipped() - - // Pace input - HStack(spacing: 2) { - Picker("Min", selection: $paceMinutes) { - ForEach(1...30, id: \.self) { m in - Text("\(m)").tag(m) - } - } - .pickerStyle(.wheel) - .frame(width: 55, height: 60) - - Text(":") - .font(.title3.bold()) - - Picker("Sec", selection: $paceSeconds) { - ForEach(0..<60, id: \.self) { s in - Text(String(format: "%02d", s)).tag(s) - } - } - .pickerStyle(.wheel) - .frame(width: 55, height: 60) + UnitToggle(selectedUnit: $selectedUnit) - Text(selectedUnit.paceLabel) - .font(.caption2) - .foregroundStyle(.secondary) - } + PaceInputRow( + paceMinutes: $paceMinutes, + paceSeconds: $paceSeconds, + paceLabel: selectedUnit.paceLabel + ) Divider() @@ -247,51 +339,15 @@ private struct RaceCalcTab: View { private var raceScrollView: some View { ScrollView { VStack(spacing: 10) { - // Unit picker - Picker("Unit", selection: $selectedUnit) { - ForEach(SpeedUnit.allCases, id: \.self) { unit in - Text(unit.speedLabel).tag(unit) - } - } - .pickerStyle(.wheel) - .frame(height: 44) - .clipped() - - // Distance picker - Picker("Distance", selection: $selectedDistance) { - ForEach(RaceCalculator.Distance.standardCases) { d in - Text(d.shortLabel).tag(d) - } - } - .pickerStyle(.wheel) - .frame(height: 44) - .clipped() - - // Pace input - HStack(spacing: 2) { - Picker("Min", selection: $paceMinutes) { - ForEach(1...30, id: \.self) { m in - Text("\(m)").tag(m) - } - } - .pickerStyle(.wheel) - .frame(width: 55, height: 60) - - Text(":") - .font(.title3.bold()) + UnitToggle(selectedUnit: $selectedUnit) - Picker("Sec", selection: $paceSeconds) { - ForEach(0..<60, id: \.self) { s in - Text(String(format: "%02d", s)).tag(s) - } - } - .pickerStyle(.wheel) - .frame(width: 55, height: 60) + DistanceSelector(selected: $selectedDistance) - Text(selectedUnit.paceLabel) - .font(.caption2) - .foregroundStyle(.secondary) - } + PaceInputRow( + paceMinutes: $paceMinutes, + paceSeconds: $paceSeconds, + paceLabel: selectedUnit.paceLabel + ) Divider() From a708b71d921291d09abaf901deffa1dd17ba3166 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 13:51:02 -0500 Subject: [PATCH 40/53] fix(parse): reject seconds >= 60 in parsePace Add bounds check for seconds component to match parseDuration validation. Add regression tests for invalid seconds inputs. --- Shared/ConversionEngine.swift | 3 ++- pace-to-mphTests/pace_to_mphTests.swift | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Shared/ConversionEngine.swift b/Shared/ConversionEngine.swift index 8e27ded..c9644f2 100644 --- a/Shared/ConversionEngine.swift +++ b/Shared/ConversionEngine.swift @@ -56,7 +56,8 @@ nonisolated enum ConversionEngine { if segments.count == 2 { guard let minutes = Double(String(segments[0])), let seconds = Double(String(segments[1])), - minutes.isFinite, seconds.isFinite else { + minutes.isFinite, seconds.isFinite, + seconds >= 0, seconds < 60 else { return nil } let total = minutes + seconds / 60.0 diff --git a/pace-to-mphTests/pace_to_mphTests.swift b/pace-to-mphTests/pace_to_mphTests.swift index 504de5d..e46abe8 100644 --- a/pace-to-mphTests/pace_to_mphTests.swift +++ b/pace-to-mphTests/pace_to_mphTests.swift @@ -31,6 +31,12 @@ struct ConversionEngineTests { #expect(ConversionEngine.parsePace("0:00") == nil) } + @Test func parsePaceRejectsSecondsAbove59() { + #expect(ConversionEngine.parsePace("8:60") == nil) + #expect(ConversionEngine.parsePace("8:90") == nil) + #expect(ConversionEngine.parsePace("10:120") == nil) + } + // MARK: - Speed Parsing @Test func parseSpeedValid() { From 0a8eb641efcaa1baf980efab2c26f3c8e30f2ed0 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 14:17:56 -0500 Subject: [PATCH 41/53] fix(splits): allow equal adj splits from rounding in negative diff path --- Shared/RaceCalculator.swift | 5 ++-- pace-to-mphTests/RaceCalculatorTests.swift | 31 ++++++++++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/Shared/RaceCalculator.swift b/Shared/RaceCalculator.swift index 711e27f..0551362 100644 --- a/Shared/RaceCalculator.swift +++ b/Shared/RaceCalculator.swift @@ -154,8 +154,9 @@ enum RaceCalculator { // Remove excess from the first (slowest) split to preserve strictly-decreasing order splitTimes[0] += difference guard splitTimes[0] >= 1 else { return [] } - // If reducing the first split made it equal/less than the second, splits are impossible - if splitTimes.count > 1 && splitTimes[0] <= splitTimes[1] { return [] } + // If reducing the first split made it strictly less than the second, splits are impossible; + // equal is acceptable — it's a rounding artifact, not an invalid case. + if splitTimes.count > 1 && splitTimes[0] < splitTimes[1] { return [] } } return zip(distances, splitTimes).map { (distance: $0.0, seconds: $0.1) } diff --git a/pace-to-mphTests/RaceCalculatorTests.swift b/pace-to-mphTests/RaceCalculatorTests.swift index 61a193a..c9c5315 100644 --- a/pace-to-mphTests/RaceCalculatorTests.swift +++ b/pace-to-mphTests/RaceCalculatorTests.swift @@ -108,19 +108,34 @@ struct RaceCalculatorTests { } } - // Regression: when rounding produces difference < 0 (total overshoots), removing - // seconds from the fastest (last) splits can make consecutive splits equal. - // e.g. 2s/2mi/1s drop → basePace=1.5, rounds to [2,1], sum=3, diff=-1, - // reducing last split (already 1) fails, reducing first → [1,1] — not decreasing. - @Test func negativeSplitsNegativeDiffPreservesStrictlyDecreasingOrder() { + // Regression: when rounding produces difference < 0 (total overshoots), correcting + // the first split gives [1,1] — equal, not strictly decreasing. Since this is purely + // a rounding artifact (ideal split times are 1.5s and 0.5s), equal adjacent splits + // are acceptable. The result must be non-increasing and sum to totalSeconds. + @Test func negativeSplitsNegativeDiffPreservesNonIncreasingOrder() { let splits = RaceCalculator.negativeSplits(totalSeconds: 2, distanceInUnits: 2.0, dropSeconds: 1.0) - // Either returns valid strictly-decreasing splits or empty (impossible to satisfy) + // Either returns valid non-increasing splits or empty (drop is truly impossible to satisfy) if !splits.isEmpty { #expect(splits.reduce(0) { $0 + $1.seconds } == 2) for i in 1.. Date: Sun, 1 Mar 2026 17:11:41 -0500 Subject: [PATCH 42/53] fix(units): convert inputs on unit switch --- Shared/ConversionEngine.swift | 43 +++++++++++++++++++ pace-to-mph WatchKit App/ContentView.swift | 48 +++++++++++++++++++++- pace-to-mph/NegativeSplitView.swift | 16 +++++++- pace-to-mph/RaceTimeView.swift | 11 ++++- pace-to-mph/SplitCalculatorView.swift | 10 ++++- pace-to-mphTests/pace_to_mphTests.swift | 17 ++++++++ 6 files changed, 140 insertions(+), 5 deletions(-) diff --git a/Shared/ConversionEngine.swift b/Shared/ConversionEngine.swift index c9644f2..bb0897a 100644 --- a/Shared/ConversionEngine.swift +++ b/Shared/ConversionEngine.swift @@ -114,11 +114,40 @@ nonisolated enum ConversionEngine { return from == .mph ? paceMinutes / kmPerMile : paceMinutes * kmPerMile } + static func convertDistanceBetweenUnits(_ distance: Double, from: SpeedUnit, to: SpeedUnit) -> Double { + guard from != to else { return distance } + return from == .mph ? distance * kmPerMile : distance / kmPerMile + } + static func convertSpeedBetweenUnits(_ speed: Double, from: SpeedUnit, to: SpeedUnit) -> Double { guard from != to else { return speed } return from == .mph ? speed * kmPerMile : speed / kmPerMile } + static func convertPaceInput(_ paceInput: String, from: SpeedUnit, to: SpeedUnit) -> String { + guard from != to, let pace = parsePace(paceInput) else { return paceInput } + let converted = convertPaceBetweenUnits(pace, from: from, to: to) + return formatPace(converted) ?? paceInput + } + + static func convertPaceComponents(minutes: Int, seconds: Int, from: SpeedUnit, to: SpeedUnit) -> (minutes: Int, seconds: Int)? { + guard minutes >= 0, seconds >= 0, seconds < 60 else { return nil } + let paceMinutes = Double(minutes) + Double(seconds) / 60.0 + guard paceMinutes > 0 else { return nil } + + let converted = convertPaceBetweenUnits(paceMinutes, from: from, to: to) + let totalSeconds = Int(round(converted * 60.0)) + guard totalSeconds > 0 else { return nil } + + return (minutes: totalSeconds / 60, seconds: totalSeconds % 60) + } + + static func convertDistanceInput(_ distanceInput: String, from: SpeedUnit, to: SpeedUnit) -> String { + guard from != to, let distance = parseSpeed(distanceInput) else { return distanceInput } + let converted = convertDistanceBetweenUnits(distance, from: from, to: to) + return formatDecimalInput(converted) + } + // MARK: - Input Sanitization static func sanitizeInput(direction: ConversionDirection, value: String) -> String { @@ -148,4 +177,18 @@ nonisolated enum ConversionEngine { return formatPace(paceMinutes) ?? "" } } + + private static func formatDecimalInput(_ value: Double) -> String { + var formatted = String(format: "%.2f", value) + + while formatted.contains(".") && formatted.last == "0" { + formatted.removeLast() + } + + if formatted.last == "." { + formatted.removeLast() + } + + return formatted + } } diff --git a/pace-to-mph WatchKit App/ContentView.swift b/pace-to-mph WatchKit App/ContentView.swift index b42bc6f..e00b835 100644 --- a/pace-to-mph WatchKit App/ContentView.swift +++ b/pace-to-mph WatchKit App/ContentView.swift @@ -230,6 +230,28 @@ private struct ConverterTab: View { @State private var paceMinutes: Int = 8 @State private var paceSeconds: Int = 0 + private var selectedUnitBinding: Binding { + Binding( + get: { selectedUnit }, + set: { newUnit in + let previousUnit = selectedUnit + guard previousUnit != newUnit else { return } + + if let converted = ConversionEngine.convertPaceComponents( + minutes: paceMinutes, + seconds: paceSeconds, + from: previousUnit, + to: newUnit + ) { + paceMinutes = converted.minutes + paceSeconds = converted.seconds + } + + selectedUnit = newUnit + } + ) + } + private var paceValue: Double { Double(paceMinutes) + Double(paceSeconds) / 60.0 } @@ -260,7 +282,7 @@ private struct ConverterTab: View { private var converterScrollView: some View { ScrollView { VStack(spacing: 12) { - UnitToggle(selectedUnit: $selectedUnit) + UnitToggle(selectedUnit: selectedUnitBinding) PaceInputRow( paceMinutes: $paceMinutes, @@ -311,6 +333,28 @@ private struct RaceCalcTab: View { @State private var paceMinutes: Int = 8 @State private var paceSeconds: Int = 0 + private var selectedUnitBinding: Binding { + Binding( + get: { selectedUnit }, + set: { newUnit in + let previousUnit = selectedUnit + guard previousUnit != newUnit else { return } + + if let converted = ConversionEngine.convertPaceComponents( + minutes: paceMinutes, + seconds: paceSeconds, + from: previousUnit, + to: newUnit + ) { + paceMinutes = converted.minutes + paceSeconds = converted.seconds + } + + selectedUnit = newUnit + } + ) + } + private var paceValue: Double { Double(paceMinutes) + Double(paceSeconds) / 60.0 } @@ -339,7 +383,7 @@ private struct RaceCalcTab: View { private var raceScrollView: some View { ScrollView { VStack(spacing: 10) { - UnitToggle(selectedUnit: $selectedUnit) + UnitToggle(selectedUnit: selectedUnitBinding) DistanceSelector(selected: $selectedDistance) diff --git a/pace-to-mph/NegativeSplitView.swift b/pace-to-mph/NegativeSplitView.swift index 656f205..c73bb98 100644 --- a/pace-to-mph/NegativeSplitView.swift +++ b/pace-to-mph/NegativeSplitView.swift @@ -75,7 +75,7 @@ struct NegativeSplitView: View { ForEach(SpeedUnit.allCases, id: \.self) { u in Button { withAnimation(.snappy(duration: 0.25)) { - selectedUnit = u + selectUnit(u) } } label: { Text(u == .mph ? "Mile" : "KM") @@ -281,6 +281,20 @@ struct NegativeSplitView: View { .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) .sensoryFeedback(.impact(flexibility: .soft), trigger: splitsFeedbackTrigger) } + + private func selectUnit(_ unit: SpeedUnit) { + let previousUnit = selectedUnit + guard previousUnit != unit else { return } + + customDistanceInput = ConversionEngine.convertDistanceInput(customDistanceInput, from: previousUnit, to: unit) + + if let dropSeconds = Double(dropSecondsInput), dropSeconds >= 0 { + let convertedDrop = ConversionEngine.convertPaceBetweenUnits(dropSeconds, from: previousUnit, to: unit) + dropSecondsInput = String(Int(convertedDrop.rounded())) + } + + selectedUnit = unit + } } #Preview { diff --git a/pace-to-mph/RaceTimeView.swift b/pace-to-mph/RaceTimeView.swift index f677098..7c13e7d 100644 --- a/pace-to-mph/RaceTimeView.swift +++ b/pace-to-mph/RaceTimeView.swift @@ -102,7 +102,7 @@ struct RaceTimeView: View { ForEach(SpeedUnit.allCases, id: \.self) { u in Button { withAnimation(.snappy(duration: 0.25)) { - selectedUnit = u + selectUnit(u) } } label: { Text(u.paceLabel) @@ -242,6 +242,15 @@ struct RaceTimeView: View { .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) .sensoryFeedback(.impact(flexibility: .soft), trigger: finishTimeText) } + + private func selectUnit(_ unit: SpeedUnit) { + let previousUnit = selectedUnit + guard previousUnit != unit else { return } + + paceInput = ConversionEngine.convertPaceInput(paceInput, from: previousUnit, to: unit) + customDistanceInput = ConversionEngine.convertDistanceInput(customDistanceInput, from: previousUnit, to: unit) + selectedUnit = unit + } } #Preview { diff --git a/pace-to-mph/SplitCalculatorView.swift b/pace-to-mph/SplitCalculatorView.swift index 7be1de5..2ea17e3 100644 --- a/pace-to-mph/SplitCalculatorView.swift +++ b/pace-to-mph/SplitCalculatorView.swift @@ -103,7 +103,7 @@ struct SplitCalculatorView: View { ForEach(SpeedUnit.allCases, id: \.self) { u in Button { withAnimation(.snappy(duration: 0.25)) { - selectedUnit = u + selectUnit(u) } } label: { Text(u == .mph ? "Mile" : "KM") @@ -234,6 +234,14 @@ struct SplitCalculatorView: View { .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) .sensoryFeedback(.impact(flexibility: .soft), trigger: paceText) } + + private func selectUnit(_ unit: SpeedUnit) { + let previousUnit = selectedUnit + guard previousUnit != unit else { return } + + customDistanceInput = ConversionEngine.convertDistanceInput(customDistanceInput, from: previousUnit, to: unit) + selectedUnit = unit + } } #Preview { diff --git a/pace-to-mphTests/pace_to_mphTests.swift b/pace-to-mphTests/pace_to_mphTests.swift index e46abe8..6034e16 100644 --- a/pace-to-mphTests/pace_to_mphTests.swift +++ b/pace-to-mphTests/pace_to_mphTests.swift @@ -104,6 +104,23 @@ struct ConversionEngineTests { #expect(ConversionEngine.convertPaceBetweenUnits(8.0, from: .kph, to: .kph) == 8.0) } + @Test func convertPaceInputBetweenUnitsReformatsEquivalentPace() { + #expect(ConversionEngine.convertPaceInput("8:00", from: .mph, to: .kph) == "4:58") + #expect(ConversionEngine.convertPaceInput("4:58", from: .kph, to: .mph) == "8:00") + } + + @Test func convertPaceComponentsBetweenUnitsPreservesValue() { + let converted = ConversionEngine.convertPaceComponents(minutes: 8, seconds: 0, from: .mph, to: .kph) + #expect(converted != nil) + #expect(converted?.minutes == 4) + #expect(converted?.seconds == 58) + } + + @Test func convertDistanceInputBetweenUnitsReformatsEquivalentDistance() { + #expect(ConversionEngine.convertDistanceInput("3.11", from: .mph, to: .kph) == "5") + #expect(ConversionEngine.convertDistanceInput("5", from: .kph, to: .mph) == "3.11") + } + // MARK: - Full Conversion @Test func fullPaceToSpeed() { From 199662378b50bb388dc6578af5cf80494668280b Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 17:17:31 -0500 Subject: [PATCH 43/53] test(ui): fix stale expectations --- pace-to-mphTests/pace_to_mphTests.swift | 2 +- pace-to-mphUITests/pace_to_mphUITests.swift | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pace-to-mphTests/pace_to_mphTests.swift b/pace-to-mphTests/pace_to_mphTests.swift index 6034e16..35d96eb 100644 --- a/pace-to-mphTests/pace_to_mphTests.swift +++ b/pace-to-mphTests/pace_to_mphTests.swift @@ -117,7 +117,7 @@ struct ConversionEngineTests { } @Test func convertDistanceInputBetweenUnitsReformatsEquivalentDistance() { - #expect(ConversionEngine.convertDistanceInput("3.11", from: .mph, to: .kph) == "5") + #expect(ConversionEngine.convertDistanceInput("3.11", from: .mph, to: .kph) == "5.01") #expect(ConversionEngine.convertDistanceInput("5", from: .kph, to: .mph) == "3.11") } diff --git a/pace-to-mphUITests/pace_to_mphUITests.swift b/pace-to-mphUITests/pace_to_mphUITests.swift index bf2e291..c7f1f3f 100644 --- a/pace-to-mphUITests/pace_to_mphUITests.swift +++ b/pace-to-mphUITests/pace_to_mphUITests.swift @@ -35,8 +35,14 @@ final class pace_to_mphUITests: XCTestCase { paceAttachment.lifetime = .keepAlways add(paceAttachment) - // Navigate to reference table - app.buttons["Pace reference table"].tap() + // Navigate to reference table via toolbar menu + let toolsMenu = app.buttons["Tools menu"] + XCTAssertTrue(toolsMenu.waitForExistence(timeout: 3)) + toolsMenu.tap() + + let referenceTable = app.buttons["Reference Table"] + XCTAssertTrue(referenceTable.waitForExistence(timeout: 3)) + referenceTable.tap() sleep(1) // Screenshot 3: Reference table From ac1db4db9787c95b78f7bb9a1205fc86732a694a Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 17:17:35 -0500 Subject: [PATCH 44/53] fix(app): lock iphone to portrait --- pace-to-mph.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pace-to-mph.xcodeproj/project.pbxproj b/pace-to-mph.xcodeproj/project.pbxproj index 8e034fa..c6492ae 100644 --- a/pace-to-mph.xcodeproj/project.pbxproj +++ b/pace-to-mph.xcodeproj/project.pbxproj @@ -467,7 +467,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -499,7 +499,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", From 08887152ad8926afb0174b1859513d2def90652a Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 17:33:54 -0500 Subject: [PATCH 45/53] fix(race): preserve valid negative splits --- Shared/RaceCalculator.swift | 19 +++++++++++++------ pace-to-mphTests/RaceCalculatorTests.swift | 13 +++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/Shared/RaceCalculator.swift b/Shared/RaceCalculator.swift index 0551362..a48e5ca 100644 --- a/Shared/RaceCalculator.swift +++ b/Shared/RaceCalculator.swift @@ -151,12 +151,19 @@ enum RaceCalculator { // Add rounding remainder to the first (slowest) split to preserve non-increasing order splitTimes[0] += difference } else if difference < 0 { - // Remove excess from the first (slowest) split to preserve strictly-decreasing order - splitTimes[0] += difference - guard splitTimes[0] >= 1 else { return [] } - // If reducing the first split made it strictly less than the second, splits are impossible; - // equal is acceptable — it's a rounding artifact, not an invalid case. - if splitTimes.count > 1 && splitTimes[0] < splitTimes[1] { return [] } + // Remove rounding overshoot from the end back toward the start. Each split can be + // reduced only down to the next split (or 1s for the final split) so the result + // stays non-increasing even when rounding produced equal adjacent splits. + var overshoot = -difference + for index in stride(from: splitTimes.count - 1, through: 0, by: -1) where overshoot > 0 { + let minimum = index == splitTimes.count - 1 ? 1 : splitTimes[index + 1] + let reducible = splitTimes[index] - minimum + guard reducible >= 0 else { return [] } + let adjustment = min(reducible, overshoot) + splitTimes[index] -= adjustment + overshoot -= adjustment + } + guard overshoot == 0 else { return [] } } return zip(distances, splitTimes).map { (distance: $0.0, seconds: $0.1) } diff --git a/pace-to-mphTests/RaceCalculatorTests.swift b/pace-to-mphTests/RaceCalculatorTests.swift index c9c5315..37d38a8 100644 --- a/pace-to-mphTests/RaceCalculatorTests.swift +++ b/pace-to-mphTests/RaceCalculatorTests.swift @@ -138,4 +138,17 @@ struct RaceCalculatorTests { "split \(i) (\(splits[i].seconds)s) must be <= split \(i-1) (\(splits[i-1].seconds)s)") } } + + // Regression: when rounded splits overshoot the requested total, removing all excess + // from the first split can make it faster than the next split and incorrectly return + // no result. The correction should be spread across the earlier splits instead. + @Test func negativeSplitsDistributesOvershootAcrossEarlierSplits() { + let splits = RaceCalculator.negativeSplits(totalSeconds: 900, distanceInUnits: 3.10686, dropSeconds: 0.0) + #expect(splits.count == 4) + #expect(splits.reduce(0) { $0 + $1.seconds } == 900) + for i in 1.. Date: Sun, 1 Mar 2026 17:44:02 -0500 Subject: [PATCH 46/53] fix(watchkit): add Embed Watch Content build phase and dependencies --- pace-to-mph.xcodeproj/project.pbxproj | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/pace-to-mph.xcodeproj/project.pbxproj b/pace-to-mph.xcodeproj/project.pbxproj index c6492ae..ba9b24e 100644 --- a/pace-to-mph.xcodeproj/project.pbxproj +++ b/pace-to-mph.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + C0FFEE120000000000000012 /* pace-to-mph WatchKit App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = C0FFEE010000000000000001 /* pace-to-mph WatchKit App.app */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 939A2E6A2F539928000B40F9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -21,8 +25,29 @@ remoteGlobalIDString = 939A2E5B2F539927000B40F9; remoteInfo = "pace-to-mph"; }; + C0FFEE140000000000000014 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 939A2E542F539927000B40F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C0FFEE060000000000000006; + remoteInfo = "pace-to-mph WatchKit App"; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + C0FFEE130000000000000013 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 16; + files = ( + C0FFEE120000000000000012 /* pace-to-mph WatchKit App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 939A2E5C2F539927000B40F9 /* pace-to-mph.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "pace-to-mph.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 939A2E692F539928000B40F9 /* pace-to-mphTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "pace-to-mphTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -123,10 +148,12 @@ 939A2E582F539927000B40F9 /* Sources */, 939A2E592F539927000B40F9 /* Frameworks */, 939A2E5A2F539927000B40F9 /* Resources */, + C0FFEE130000000000000013 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( + C0FFEE150000000000000015 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 939A2E5E2F539927000B40F9 /* pace-to-mph */, @@ -329,6 +356,11 @@ target = 939A2E5B2F539927000B40F9 /* pace-to-mph */; targetProxy = 939A2E742F539928000B40F9 /* PBXContainerItemProxy */; }; + C0FFEE150000000000000015 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C0FFEE060000000000000006 /* pace-to-mph WatchKit App */; + targetProxy = C0FFEE140000000000000014 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ From c2106d1b43ea3050dae3b97deccc0586f2ac6048 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 17:45:14 -0500 Subject: [PATCH 47/53] doc updates --- docs/privacy.html | 18 +++++++++++------- docs/support.html | 39 ++++++++++++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/docs/privacy.html b/docs/privacy.html index dd4ab64..ebb96e9 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -24,12 +24,12 @@

Privacy Policy

-

Last updated: February 28, 2025

+

Last updated: March 1, 2026

- RunPace – Speed Converter ("the app") is a simple utility for - converting running pace to speed and back. This policy explains what data we - collect — which is none. + RunPace ("the app") is a utility for converting running pace to + speed, calculating race finish times, and planning split strategies. This policy + explains what data we collect — which is none.

No Data Collection

@@ -41,10 +41,14 @@

No Data Collection

Local Storage Only

- Your preferred unit (MPH or KM/H) and conversion direction are saved locally - on your device using Apple's standard UserDefaults API. This data - never leaves your device. + The following data is saved locally on your device using Apple's standard + UserDefaults API and never leaves your device:

+
    +
  • Your preferred unit (MPH or KM/H) and conversion direction
  • +
  • Conversion history
  • +
  • Pinned favorite conversions
  • +

No Network Access

diff --git a/docs/support.html b/docs/support.html index 8582fd4..6c95f04 100644 --- a/docs/support.html +++ b/docs/support.html @@ -33,7 +33,7 @@

RunPace Support

-

Speed Converter for Runners

+

Pace Converter & Race Tools for Runners

Frequently Asked Questions

@@ -46,14 +46,43 @@

How do I convert pace to speed?

How do I switch between miles and kilometers?

- Use the MPH / KM/H toggle in the control panel at the bottom. - Your input will automatically be converted to the new unit. + Use the Mile / KM toggle in the converter. Your input will + automatically be converted to the new unit.

Where is the reference table?

- Tap the table icon in the top-right corner to open a full reference chart of - common paces and their equivalent speeds in both MPH and KM/H. + Tap the menu button in the top-right corner and choose + Reference Table to open a full chart of common paces and + their equivalent speeds in both MPH and KM/H. +

+ +

How do I save a conversion as a favorite?

+

+ While a result is showing in the converter, tap the ★ star + button to pin it. View all your pinned conversions any time by opening the + menu and choosing Favorites. +

+ +

How do I calculate my finish time for a race?

+

+ Open the menu and tap Race Calculator. + Enter your target pace, choose a distance (5K, 10K, Half, Full, or a custom + distance), and the app will instantly show your projected finish time. +

+ +

What is the Negative Split Calculator?

+

+ A negative split strategy means running each mile (or km) a little faster than + the previous one. Open Negative Splits from the + menu, enter your total target time, distance, and how many seconds per split + you want to drop, and the app will generate a complete split plan for you. +

+ +

Is there an Apple Watch app?

+

+ Yes! RunPace includes a companion Watch app with a pace ↔ speed converter + and a race finish-time calculator, controllable via the Digital Crown.

Does the app require an internet connection?

From 3eecf916d4df88e870286497cf44930382bcf303 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 17:58:07 -0500 Subject: [PATCH 48/53] fix(app): address review regressions --- pace-to-mph.xcodeproj/project.pbxproj | 13 ------ pace-to-mph/ContentView.swift | 28 +++++------ pace-to-mphTests/pace_to_mphTests.swift | 62 +++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/pace-to-mph.xcodeproj/project.pbxproj b/pace-to-mph.xcodeproj/project.pbxproj index ba9b24e..3f73dab 100644 --- a/pace-to-mph.xcodeproj/project.pbxproj +++ b/pace-to-mph.xcodeproj/project.pbxproj @@ -25,13 +25,6 @@ remoteGlobalIDString = 939A2E5B2F539927000B40F9; remoteInfo = "pace-to-mph"; }; - C0FFEE140000000000000014 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 939A2E542F539927000B40F9 /* Project object */; - proxyType = 1; - remoteGlobalIDString = C0FFEE060000000000000006; - remoteInfo = "pace-to-mph WatchKit App"; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -153,7 +146,6 @@ buildRules = ( ); dependencies = ( - C0FFEE150000000000000015 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 939A2E5E2F539927000B40F9 /* pace-to-mph */, @@ -356,11 +348,6 @@ target = 939A2E5B2F539927000B40F9 /* pace-to-mph */; targetProxy = 939A2E742F539928000B40F9 /* PBXContainerItemProxy */; }; - C0FFEE150000000000000015 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = C0FFEE060000000000000006 /* pace-to-mph WatchKit App */; - targetProxy = C0FFEE140000000000000014 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ diff --git a/pace-to-mph/ContentView.swift b/pace-to-mph/ContentView.swift index e6aad40..5d4ac6a 100644 --- a/pace-to-mph/ContentView.swift +++ b/pace-to-mph/ContentView.swift @@ -152,17 +152,21 @@ struct ContentView: View { // Result VStack(spacing: 6) { - Text(viewModel.result.isEmpty ? "–" : viewModel.result) - .font(.system(size: 48, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(viewModel.result.isEmpty ? .tertiary : .primary) - .contentTransition(.numericText()) - .animation(.snappy(duration: 0.2), value: viewModel.result) + VStack(spacing: 6) { + Text(viewModel.result.isEmpty ? "–" : viewModel.result) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(viewModel.result.isEmpty ? .tertiary : .primary) + .contentTransition(.numericText()) + .animation(.snappy(duration: 0.2), value: viewModel.result) - Text(viewModel.resultSuffix) - .font(.system(size: 18, weight: .semibold)) - .tracking(2) - .foregroundStyle(.secondary) + Text(viewModel.resultSuffix) + .font(.system(size: 18, weight: .semibold)) + .tracking(2) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(viewModel.result.isEmpty ? "No result" : "\(viewModel.result) \(viewModel.resultSuffix)") if !viewModel.result.isEmpty { Button { @@ -181,7 +185,7 @@ struct ContentView: View { result: viewModel.result, resultSuffix: viewModel.resultSuffix ) - Image(systemName: isFav ? "star.fill" : "star") + Image(systemName: isFav ? "star.fill" : "star") .font(.system(size: 20)) .foregroundStyle(isFav ? .yellow : .secondary) } @@ -190,8 +194,6 @@ struct ContentView: View { .accessibilityLabel("Toggle favorite") } } - .accessibilityElement(children: .combine) - .accessibilityLabel(viewModel.result.isEmpty ? "No result" : "\(viewModel.result) \(viewModel.resultSuffix)") .sensoryFeedback(.impact(flexibility: .soft), trigger: viewModel.result) } .padding(24) diff --git a/pace-to-mphTests/pace_to_mphTests.swift b/pace-to-mphTests/pace_to_mphTests.swift index 35d96eb..bc232fd 100644 --- a/pace-to-mphTests/pace_to_mphTests.swift +++ b/pace-to-mphTests/pace_to_mphTests.swift @@ -150,3 +150,65 @@ struct ConversionEngineTests { #expect(result == "10.5") } } + +struct ReviewRegressionTests { + + @Test func iosTargetDoesNotHardDependOnWatchTarget() throws { + let project = try testFileContents( + "pace-to-mph.xcodeproj", + "project.pbxproj" + ) + + let appTargetBlock = try #require( + slice( + in: project, + from: "939A2E5B2F539927000B40F9 /* pace-to-mph */ = {", + to: "939A2E682F539928000B40F9 /* pace-to-mphTests */ = {" + ) + ) + + #expect(!appTargetBlock.contains("C0FFEE150000000000000015 /* PBXTargetDependency */")) + } + + @Test func favoriteButtonRemainsOutsideCombinedAccessibilityElement() throws { + let contentView = try testFileContents("pace-to-mph", "ContentView.swift") + let resultSection = try #require( + slice( + in: contentView, + from: "// Result", + to: ".sensoryFeedback(.impact(flexibility: .soft), trigger: viewModel.result)" + ) + ) + + let combineIndex = try #require( + resultSection.range(of: ".accessibilityElement(children: .combine)")?.lowerBound + ) + let favoriteIndex = try #require( + resultSection.range(of: "if !viewModel.result.isEmpty {")?.lowerBound + ) + + #expect(combineIndex < favoriteIndex) + } +} + +private func testFileContents(_ pathComponents: String...) throws -> String { + let fileURL = repoRootURL().appending(path: pathComponents.joined(separator: "/")) + return try String(contentsOf: fileURL, encoding: .utf8) +} + +private func repoRootURL() -> URL { + URL(filePath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() +} + +private func slice(in source: String, from start: String, to end: String) -> String? { + guard + let startRange = source.range(of: start), + let endRange = source.range(of: end, range: startRange.lowerBound.. Date: Sun, 1 Mar 2026 19:27:31 -0500 Subject: [PATCH 49/53] fix(a11y): restore favorite actions --- pace-to-mph/ContentView.swift | 14 ++++---- pace-to-mph/FavoritesView.swift | 4 +-- pace-to-mph/HistoryView.swift | 7 ++-- pace-to-mphTests/pace_to_mphTests.swift | 46 +++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/pace-to-mph/ContentView.swift b/pace-to-mph/ContentView.swift index 5d4ac6a..032bc8d 100644 --- a/pace-to-mph/ContentView.swift +++ b/pace-to-mph/ContentView.swift @@ -169,6 +169,12 @@ struct ContentView: View { .accessibilityLabel(viewModel.result.isEmpty ? "No result" : "\(viewModel.result) \(viewModel.resultSuffix)") if !viewModel.result.isEmpty { + let isFav = favoritesStore.isFavorited( + input: viewModel.inputText, + inputSuffix: viewModel.inputSuffix, + result: viewModel.result, + resultSuffix: viewModel.resultSuffix + ) Button { withAnimation(.snappy(duration: 0.25)) { favoritesStore.toggle( @@ -179,19 +185,13 @@ struct ContentView: View { ) } } label: { - let isFav = favoritesStore.isFavorited( - input: viewModel.inputText, - inputSuffix: viewModel.inputSuffix, - result: viewModel.result, - resultSuffix: viewModel.resultSuffix - ) Image(systemName: isFav ? "star.fill" : "star") .font(.system(size: 20)) .foregroundStyle(isFav ? .yellow : .secondary) } .buttonStyle(.plain) .padding(.top, 8) - .accessibilityLabel("Toggle favorite") + .accessibilityLabel(isFav ? "Remove from favorites" : "Add to favorites") } } .sensoryFeedback(.impact(flexibility: .soft), trigger: viewModel.result) diff --git a/pace-to-mph/FavoritesView.swift b/pace-to-mph/FavoritesView.swift index b617010..9e22fb9 100644 --- a/pace-to-mph/FavoritesView.swift +++ b/pace-to-mph/FavoritesView.swift @@ -70,6 +70,8 @@ struct FavoritesView: View { .foregroundStyle(.secondary) } } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(fav.input) \(fav.inputSuffix) equals \(fav.result) \(fav.resultSuffix)") Spacer() @@ -87,8 +89,6 @@ struct FavoritesView: View { } .padding(16) .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(fav.input) \(fav.inputSuffix) equals \(fav.result) \(fav.resultSuffix)") } } diff --git a/pace-to-mph/HistoryView.swift b/pace-to-mph/HistoryView.swift index 5f944ee..606c32e 100644 --- a/pace-to-mph/HistoryView.swift +++ b/pace-to-mph/HistoryView.swift @@ -75,6 +75,9 @@ struct HistoryView: View { .font(.caption) .foregroundStyle(.tertiary) } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(record.input) \(record.inputSuffix) equals \(record.result) \(record.resultSuffix)") + .accessibilityValue("Converted \(record.date.formatted(.relative(presentation: .named)))") Spacer() Button { @@ -92,11 +95,9 @@ struct HistoryView: View { .foregroundStyle(isFav ? Color.yellow : Color.gray.opacity(0.4)) } .buttonStyle(.plain) - .accessibilityLabel("Toggle favorite") + .accessibilityLabel(isFav ? "Remove from favorites" : "Add to favorites") } .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(record.input) \(record.inputSuffix) equals \(record.result) \(record.resultSuffix)") } } diff --git a/pace-to-mphTests/pace_to_mphTests.swift b/pace-to-mphTests/pace_to_mphTests.swift index bc232fd..611561e 100644 --- a/pace-to-mphTests/pace_to_mphTests.swift +++ b/pace-to-mphTests/pace_to_mphTests.swift @@ -1,4 +1,5 @@ import Testing +import Foundation @testable import pace_to_mph struct ConversionEngineTests { @@ -189,6 +190,51 @@ struct ReviewRegressionTests { #expect(combineIndex < favoriteIndex) } + + @Test func favoritesRowKeepsRemoveButtonFocusable() throws { + let favoritesView = try testFileContents("pace-to-mph", "FavoritesView.swift") + let rowSection = try #require( + slice( + in: favoritesView, + from: "private func favoriteCard(_ fav: FavoriteConversion) -> some View {", + to: "#Preview {" + ) + ) + + #expect(!rowSection.contains(""" + .padding(16) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) + .accessibilityElement(children: .combine) + """)) + } + + @Test func historyRowKeepsFavoriteButtonFocusable() throws { + let historyView = try testFileContents("pace-to-mph", "HistoryView.swift") + let rowSection = try #require( + slice( + in: historyView, + from: "private func recordRow(_ record: ConversionRecord) -> some View {", + to: "#Preview {" + ) + ) + + #expect(!rowSection.contains(""" + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + """)) + } + + @Test func favoriteButtonsUseActionBasedLabels() throws { + let contentView = try testFileContents("pace-to-mph", "ContentView.swift") + let historyView = try testFileContents("pace-to-mph", "HistoryView.swift") + + #expect(!contentView.contains("Toggle favorite")) + #expect(!historyView.contains("Toggle favorite")) + #expect(contentView.contains("Add to favorites")) + #expect(contentView.contains("Remove from favorites")) + #expect(historyView.contains("Add to favorites")) + #expect(historyView.contains("Remove from favorites")) + } } private func testFileContents(_ pathComponents: String...) throws -> String { From 5e6f2b528748c31de410b2c0f7320e30e20007b0 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 19:27:54 -0500 Subject: [PATCH 50/53] fix(build): restore watch dependency --- pace-to-mph.xcodeproj/project.pbxproj | 13 +++++++++++++ pace-to-mphTests/pace_to_mphTests.swift | 5 +++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pace-to-mph.xcodeproj/project.pbxproj b/pace-to-mph.xcodeproj/project.pbxproj index 3f73dab..5414ac3 100644 --- a/pace-to-mph.xcodeproj/project.pbxproj +++ b/pace-to-mph.xcodeproj/project.pbxproj @@ -11,6 +11,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + C0FFEE140000000000000014 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 939A2E542F539927000B40F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C0FFEE060000000000000006; + remoteInfo = "pace-to-mph WatchKit App"; + }; 939A2E6A2F539928000B40F9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 939A2E542F539927000B40F9 /* Project object */; @@ -146,6 +153,7 @@ buildRules = ( ); dependencies = ( + C0FFEE150000000000000015 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 939A2E5E2F539927000B40F9 /* pace-to-mph */, @@ -338,6 +346,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + C0FFEE150000000000000015 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C0FFEE060000000000000006 /* pace-to-mph WatchKit App */; + targetProxy = C0FFEE140000000000000014 /* PBXContainerItemProxy */; + }; 939A2E6B2F539928000B40F9 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 939A2E5B2F539927000B40F9 /* pace-to-mph */; diff --git a/pace-to-mphTests/pace_to_mphTests.swift b/pace-to-mphTests/pace_to_mphTests.swift index 611561e..33a69a9 100644 --- a/pace-to-mphTests/pace_to_mphTests.swift +++ b/pace-to-mphTests/pace_to_mphTests.swift @@ -154,7 +154,7 @@ struct ConversionEngineTests { struct ReviewRegressionTests { - @Test func iosTargetDoesNotHardDependOnWatchTarget() throws { + @Test func iOSAppBuildsWatchTargetWhenEmbeddingWatchContent() throws { let project = try testFileContents( "pace-to-mph.xcodeproj", "project.pbxproj" @@ -168,7 +168,8 @@ struct ReviewRegressionTests { ) ) - #expect(!appTargetBlock.contains("C0FFEE150000000000000015 /* PBXTargetDependency */")) + #expect(appTargetBlock.contains("C0FFEE130000000000000013 /* Embed Watch Content */")) + #expect(appTargetBlock.contains("C0FFEE150000000000000015 /* PBXTargetDependency */")) } @Test func favoriteButtonRemainsOutsideCombinedAccessibilityElement() throws { From c57c7149491879a83e20b22ebcb3929064f15144 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 19:48:25 -0500 Subject: [PATCH 51/53] fix(parse): reject empty segments in parseDuration --- Shared/RaceCalculator.swift | 6 ++++-- pace-to-mphTests/RaceCalculatorTests.swift | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Shared/RaceCalculator.swift b/Shared/RaceCalculator.swift index a48e5ca..ea96999 100644 --- a/Shared/RaceCalculator.swift +++ b/Shared/RaceCalculator.swift @@ -78,8 +78,10 @@ enum RaceCalculator { /// Parse "h:mm:ss" or "mm:ss" into total seconds, returns nil if invalid. static func parseDuration(_ input: String) -> Int? { let trimmed = input.trimmingCharacters(in: .whitespaces) - let parts = trimmed.split(separator: ":") - guard !parts.isEmpty, parts.count <= 3 else { return nil } + guard !trimmed.isEmpty else { return nil } + + let parts = trimmed.split(separator: ":", omittingEmptySubsequences: false) + guard !parts.isEmpty, parts.count <= 3, parts.allSatisfy({ !$0.isEmpty }) else { return nil } let ints = parts.compactMap { Int($0) } guard ints.count == parts.count else { return nil } diff --git a/pace-to-mphTests/RaceCalculatorTests.swift b/pace-to-mphTests/RaceCalculatorTests.swift index 37d38a8..96bdd61 100644 --- a/pace-to-mphTests/RaceCalculatorTests.swift +++ b/pace-to-mphTests/RaceCalculatorTests.swift @@ -53,6 +53,12 @@ struct RaceCalculatorTests { #expect(RaceCalculator.parseDuration("1:2:3:4") == nil) } + @Test func parseDurationRejectsMissingComponents() { + #expect(RaceCalculator.parseDuration("1:") == nil) + #expect(RaceCalculator.parseDuration("1::30") == nil) + #expect(RaceCalculator.parseDuration(":30") == nil) + } + // MARK: - Negative Splits @Test func negativeSplitsEvenDistance() { From 1090ac87dbbe36d5a78c279dd80182d5158de3f2 Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 19:48:29 -0500 Subject: [PATCH 52/53] fix(units): add convertDropSecondsBetweenUnits, fix semantic misuse in NegativeSplitView --- Shared/ConversionEngine.swift | 6 ++++++ pace-to-mph/NegativeSplitView.swift | 2 +- pace-to-mphTests/pace_to_mphTests.swift | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Shared/ConversionEngine.swift b/Shared/ConversionEngine.swift index bb0897a..811f1db 100644 --- a/Shared/ConversionEngine.swift +++ b/Shared/ConversionEngine.swift @@ -124,6 +124,12 @@ nonisolated enum ConversionEngine { return from == .mph ? speed * kmPerMile : speed / kmPerMile } + /// Convert a time-per-distance delta (e.g. drop seconds per split) between unit systems. + static func convertDropSecondsBetweenUnits(_ seconds: Double, from: SpeedUnit, to: SpeedUnit) -> Double { + guard from != to else { return seconds } + return from == .mph ? seconds / kmPerMile : seconds * kmPerMile + } + static func convertPaceInput(_ paceInput: String, from: SpeedUnit, to: SpeedUnit) -> String { guard from != to, let pace = parsePace(paceInput) else { return paceInput } let converted = convertPaceBetweenUnits(pace, from: from, to: to) diff --git a/pace-to-mph/NegativeSplitView.swift b/pace-to-mph/NegativeSplitView.swift index c73bb98..7497e8a 100644 --- a/pace-to-mph/NegativeSplitView.swift +++ b/pace-to-mph/NegativeSplitView.swift @@ -289,7 +289,7 @@ struct NegativeSplitView: View { customDistanceInput = ConversionEngine.convertDistanceInput(customDistanceInput, from: previousUnit, to: unit) if let dropSeconds = Double(dropSecondsInput), dropSeconds >= 0 { - let convertedDrop = ConversionEngine.convertPaceBetweenUnits(dropSeconds, from: previousUnit, to: unit) + let convertedDrop = ConversionEngine.convertDropSecondsBetweenUnits(dropSeconds, from: previousUnit, to: unit) dropSecondsInput = String(Int(convertedDrop.rounded())) } diff --git a/pace-to-mphTests/pace_to_mphTests.swift b/pace-to-mphTests/pace_to_mphTests.swift index 33a69a9..e88374c 100644 --- a/pace-to-mphTests/pace_to_mphTests.swift +++ b/pace-to-mphTests/pace_to_mphTests.swift @@ -117,6 +117,21 @@ struct ConversionEngineTests { #expect(converted?.seconds == 58) } + // Regression: dropSeconds in NegativeSplitView is a time-per-distance rate (seconds), + // not a pace in minutes. Dedicated helper must convert it correctly. + @Test func convertDropSecondsBetweenUnits() { + // 5 sec/mi → ~3 sec/km (5 / 1.60934) + let toKph = ConversionEngine.convertDropSecondsBetweenUnits(5.0, from: .mph, to: .kph) + #expect(abs(toKph - 5.0 / 1.60934) < 0.01) + + // 3 sec/km → ~4.83 sec/mi (3 * 1.60934) + let toMph = ConversionEngine.convertDropSecondsBetweenUnits(3.0, from: .kph, to: .mph) + #expect(abs(toMph - 3.0 * 1.60934) < 0.01) + + // Same unit → unchanged + #expect(ConversionEngine.convertDropSecondsBetweenUnits(5.0, from: .mph, to: .mph) == 5.0) + } + @Test func convertDistanceInputBetweenUnitsReformatsEquivalentDistance() { #expect(ConversionEngine.convertDistanceInput("3.11", from: .mph, to: .kph) == "5.01") #expect(ConversionEngine.convertDistanceInput("5", from: .kph, to: .mph) == "3.11") From 31367f0b6b01ea614417b6e4adb9ff7ff02c276d Mon Sep 17 00:00:00 2001 From: Saad Bash Date: Sun, 1 Mar 2026 19:48:34 -0500 Subject: [PATCH 53/53] fix(watch): correct crown range per field, extract shared unit binding --- pace-to-mph WatchKit App/ContentView.swift | 77 ++++++++++------------ 1 file changed, 35 insertions(+), 42 deletions(-) diff --git a/pace-to-mph WatchKit App/ContentView.swift b/pace-to-mph WatchKit App/ContentView.swift index e00b835..661689d 100644 --- a/pace-to-mph WatchKit App/ContentView.swift +++ b/pace-to-mph WatchKit App/ContentView.swift @@ -20,6 +20,9 @@ private struct PaceInputRow: View { @State private var editingSeconds = false @State private var crownValue: Double = 0 + private var crownMax: Double { editingSeconds ? 59 : 30 } + private var crownMin: Double { editingSeconds ? 0 : 1 } + var body: some View { HStack(spacing: 4) { Text("\(paceMinutes)") @@ -60,7 +63,7 @@ private struct PaceInputRow: View { .focusable() .digitalCrownRotation( detent: $crownValue, - from: 0, through: 59, by: 1, + from: crownMin, through: crownMax, by: 1, sensitivity: .low, isContinuous: false ) @@ -68,9 +71,7 @@ private struct PaceInputRow: View { if editingSeconds { paceSeconds = Int(newValue) } else { - let clamped = max(1, min(30, Int(newValue))) - paceMinutes = clamped - if Int(newValue) != clamped { crownValue = Double(clamped) } + paceMinutes = Int(newValue) } } .onAppear { crownValue = Double(paceMinutes) } @@ -223,6 +224,34 @@ private struct ReferenceTab: View { } } +// MARK: - Shared Helpers + +private func makeUnitBinding( + unit: Binding, + paceMinutes: Binding, + paceSeconds: Binding +) -> Binding { + Binding( + get: { unit.wrappedValue }, + set: { newUnit in + let previousUnit = unit.wrappedValue + guard previousUnit != newUnit else { return } + + if let converted = ConversionEngine.convertPaceComponents( + minutes: paceMinutes.wrappedValue, + seconds: paceSeconds.wrappedValue, + from: previousUnit, + to: newUnit + ) { + paceMinutes.wrappedValue = converted.minutes + paceSeconds.wrappedValue = converted.seconds + } + + unit.wrappedValue = newUnit + } + ) +} + // MARK: - Converter Tab private struct ConverterTab: View { @@ -231,25 +260,7 @@ private struct ConverterTab: View { @State private var paceSeconds: Int = 0 private var selectedUnitBinding: Binding { - Binding( - get: { selectedUnit }, - set: { newUnit in - let previousUnit = selectedUnit - guard previousUnit != newUnit else { return } - - if let converted = ConversionEngine.convertPaceComponents( - minutes: paceMinutes, - seconds: paceSeconds, - from: previousUnit, - to: newUnit - ) { - paceMinutes = converted.minutes - paceSeconds = converted.seconds - } - - selectedUnit = newUnit - } - ) + makeUnitBinding(unit: $selectedUnit, paceMinutes: $paceMinutes, paceSeconds: $paceSeconds) } private var paceValue: Double { @@ -334,25 +345,7 @@ private struct RaceCalcTab: View { @State private var paceSeconds: Int = 0 private var selectedUnitBinding: Binding { - Binding( - get: { selectedUnit }, - set: { newUnit in - let previousUnit = selectedUnit - guard previousUnit != newUnit else { return } - - if let converted = ConversionEngine.convertPaceComponents( - minutes: paceMinutes, - seconds: paceSeconds, - from: previousUnit, - to: newUnit - ) { - paceMinutes = converted.minutes - paceSeconds = converted.seconds - } - - selectedUnit = newUnit - } - ) + makeUnitBinding(unit: $selectedUnit, paceMinutes: $paceMinutes, paceSeconds: $paceSeconds) } private var paceValue: Double {