diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f181469 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# 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 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. + +# 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. diff --git a/pace-to-mph/Models/ConversionEngine.swift b/Shared/ConversionEngine.swift similarity index 66% rename from pace-to-mph/Models/ConversionEngine.swift rename to Shared/ConversionEngine.swift index 10bbbfd..811f1db 100644 --- a/pace-to-mph/Models/ConversionEngine.swift +++ b/Shared/ConversionEngine.swift @@ -33,7 +33,7 @@ enum SpeedUnit: String, CaseIterable { } } -enum ConversionEngine { +nonisolated enum ConversionEngine { static let kmPerMile: Double = 1.60934 // MARK: - Parsing @@ -56,7 +56,8 @@ 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 @@ -113,11 +114,46 @@ 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 } + /// 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) + 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 { @@ -147,4 +183,18 @@ 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/Shared/RaceCalculator.swift b/Shared/RaceCalculator.swift new file mode 100644 index 0000000..ea96999 --- /dev/null +++ b/Shared/RaceCalculator.swift @@ -0,0 +1,173 @@ +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 + } + } + + var shortLabel: String { + switch self { + case .halfMarathon: return "Half" + case .marathon: return "Full" + 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. + 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) + 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 } + + 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 + } + } + + /// 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, dropSeconds >= 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 + guard basePace - Double(splitCount - 1) * dropSeconds > 0 else { return [] } + + var distances: [Double] = [] + var splitTimes: [Int] = [] + for i in 0.. 0.001 { + dist = partial + } else { + dist = 1.0 + } + let splitTime = max(Int(round(splitPace * dist)), 1) + distances.append(dist) + splitTimes.append(splitTime) + } + + let difference = totalSeconds - splitTimes.reduce(0, +) + if difference > 0 { + // Add rounding remainder to the first (slowest) split to preserve non-increasing order + splitTimes[0] += difference + } else if difference < 0 { + // 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/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:

+

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?

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/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 0000000..fc44c09 Binary files /dev/null and b/pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ 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..9fc2aaa --- /dev/null +++ b/pace-to-mph WatchKit App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "App-Icon-1024x1024@1x.png", + "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..661689d --- /dev/null +++ b/pace-to-mph WatchKit App/ContentView.swift @@ -0,0 +1,412 @@ +import SwiftUI + +// 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: - 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 + + private var crownMax: Double { editingSeconds ? 59 : 30 } + private var crownMin: Double { editingSeconds ? 0 : 1 } + + 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: crownMin, through: crownMax, by: 1, + sensitivity: .low, + isContinuous: false + ) + .onChange(of: crownValue) { _, newValue in + if editingSeconds { + paceSeconds = Int(newValue) + } else { + paceMinutes = Int(newValue) + } + } + .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 { + 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 { + var body: some View { + ConverterTab() + } +} + +// MARK: - Reference Tab + +private struct ReferenceTab: View { + @State private var selectedUnit: SpeedUnit = .mph + + 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 + }() + + 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 { + List { + UnitToggle(selectedUnit: $selectedUnit) + .listRowBackground(Color.clear) + + ForEach(activePaces) { pace in + let speed = ConversionEngine.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(ConversionEngine.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 \(ConversionEngine.formatSpeed(speed)) \(selectedUnit.speedLabel)") + } + } + .navigationTitle("Reference") + } +} + +// 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 { + @State private var selectedUnit: SpeedUnit = .mph + @State private var paceMinutes: Int = 8 + @State private var paceSeconds: Int = 0 + + private var selectedUnitBinding: Binding { + makeUnitBinding(unit: $selectedUnit, paceMinutes: $paceMinutes, paceSeconds: $paceSeconds) + } + + private var paceValue: Double { + Double(paceMinutes) + Double(paceSeconds) / 60.0 + } + + private var speed: Double { + guard paceValue > 0 else { return 0 } + return ConversionEngine.paceToSpeed(paceValue) + } + + var body: some View { + NavigationStack { + 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) { + UnitToggle(selectedUnit: selectedUnitBinding) + + PaceInputRow( + paceMinutes: $paceMinutes, + paceSeconds: $paceSeconds, + paceLabel: selectedUnit.paceLabel + ) + + Divider() + + // Speed result + VStack(spacing: 4) { + Text(ConversionEngine.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() + + VStack(spacing: 8) { + NavigationLink("Reference Table") { + ReferenceTab() + } + .watchGlassButton() + + NavigationLink("Race Calculator") { + RaceCalcTab() + } + .watchGlassButton() + } + } + .padding(.horizontal) + } + } +} + +// MARK: - Race Calculator Tab + +private struct RaceCalcTab: View { + @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 + + private var selectedUnitBinding: Binding { + makeUnitBinding(unit: $selectedUnit, paceMinutes: $paceMinutes, paceSeconds: $paceSeconds) + } + + private var paceValue: Double { + Double(paceMinutes) + Double(paceSeconds) / 60.0 + } + + private var finishTimeSeconds: Int { + guard paceValue > 0, let distance = selectedDistance.distance(unit: selectedUnit) else { return 0 } + return RaceCalculator.finishTime(paceMinutes: paceValue, distanceInUnits: distance) + } + + 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) { + UnitToggle(selectedUnit: selectedUnitBinding) + + DistanceSelector(selected: $selectedDistance) + + PaceInputRow( + paceMinutes: $paceMinutes, + paceSeconds: $paceSeconds, + paceLabel: selectedUnit.paceLabel + ) + + Divider() + + // Finish time result + VStack(spacing: 4) { + Text("Finish Time") + .font(.caption2) + .foregroundStyle(.secondary) + Text(RaceCalculator.formatDuration(finishTimeSeconds)) + .font(.system(.title, design: .rounded, weight: .bold)) + .monospacedDigit() + .foregroundStyle(.green) + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .watchGlassCard() + } + .padding(.horizontal) + } + } +} + +#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() + } + } +} diff --git a/pace-to-mph.xcodeproj/project.pbxproj b/pace-to-mph.xcodeproj/project.pbxproj index 46a3f96..5414ac3 100644 --- a/pace-to-mph.xcodeproj/project.pbxproj +++ b/pace-to-mph.xcodeproj/project.pbxproj @@ -6,7 +6,18 @@ 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 */ + 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 */; @@ -23,10 +34,25 @@ }; /* 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; }; 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; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -45,6 +71,16 @@ path = "pace-to-mphUITests"; sourceTree = ""; }; + C0FFEE020000000000000002 /* pace-to-mph WatchKit App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "pace-to-mph WatchKit App"; + sourceTree = ""; + }; + C0FFEE110000000000000011 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -69,6 +105,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C0FFEE040000000000000004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -76,6 +119,8 @@ isa = PBXGroup; children = ( 939A2E5E2F539927000B40F9 /* pace-to-mph */, + C0FFEE020000000000000002 /* pace-to-mph WatchKit App */, + C0FFEE110000000000000011 /* Shared */, 939A2E6C2F539928000B40F9 /* pace-to-mphTests */, 939A2E762F539928000B40F9 /* pace-to-mphUITests */, 939A2E5D2F539927000B40F9 /* Products */, @@ -88,6 +133,7 @@ 939A2E5C2F539927000B40F9 /* pace-to-mph.app */, 939A2E692F539928000B40F9 /* pace-to-mphTests.xctest */, 939A2E732F539928000B40F9 /* pace-to-mphUITests.xctest */, + C0FFEE010000000000000001 /* pace-to-mph WatchKit App.app */, ); name = Products; sourceTree = ""; @@ -102,13 +148,16 @@ 939A2E582F539927000B40F9 /* Sources */, 939A2E592F539927000B40F9 /* Frameworks */, 939A2E5A2F539927000B40F9 /* Resources */, + C0FFEE130000000000000013 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( + C0FFEE150000000000000015 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 939A2E5E2F539927000B40F9 /* pace-to-mph */, + C0FFEE110000000000000011 /* Shared */, ); name = "pace-to-mph"; packageProductDependencies = ( @@ -163,6 +212,29 @@ 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 */, + C0FFEE110000000000000011 /* Shared */, + ); + 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"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -184,6 +256,9 @@ CreatedOnToolsVersion = 26.0.1; TestTargetID = 939A2E5B2F539927000B40F9; }; + C0FFEE060000000000000006 = { + CreatedOnToolsVersion = 26.0.1; + }; }; }; buildConfigurationList = 939A2E572F539927000B40F9 /* Build configuration list for PBXProject "pace-to-mph" */; @@ -203,6 +278,7 @@ 939A2E5B2F539927000B40F9 /* pace-to-mph */, 939A2E682F539928000B40F9 /* pace-to-mphTests */, 939A2E722F539928000B40F9 /* pace-to-mphUITests */, + C0FFEE060000000000000006 /* pace-to-mph WatchKit App */, ); }; /* End PBXProject section */ @@ -229,6 +305,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C0FFEE050000000000000005 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -253,9 +336,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C0FFEE030000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* 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 */; @@ -404,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", @@ -436,7 +531,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", @@ -538,6 +633,68 @@ }; 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; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -577,6 +734,15 @@ 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; + }; /* 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 deleted file mode 100644 index df6fe5a..0000000 --- a/pace-to-mph.xcodeproj/xcuserdata/saad.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - pace-to-mph.xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/pace-to-mph/ContentView.swift b/pace-to-mph/ContentView.swift index 76390af..032bc8d 100644 --- a/pace-to-mph/ContentView.swift +++ b/pace-to-mph/ContentView.swift @@ -2,15 +2,12 @@ import SwiftUI struct ContentView: View { @State private var viewModel = ConverterViewModel() + @State private var favoritesStore = FavoritesStore() @FocusState private var isInputFocused: Bool var body: some View { NavigationStack { - ZStack { - Color(.systemGroupedBackground) - .ignoresSafeArea() - .onTapGesture { isInputFocused = false } - + GlassEffectContainer { VStack(spacing: 0) { // Header headerSection @@ -29,17 +26,62 @@ 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: .topBarTrailing) { - NavigationLink { - ReferenceView() + ToolbarItem(placement: .topBarLeading) { + Menu { + NavigationLink { + RaceTimeView() + } label: { + Label("Race Calculator", systemImage: "flag.checkered") + } + + NavigationLink { + SplitCalculatorView() + } label: { + 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") + } + + NavigationLink { + ReferenceView() + } label: { + Label("Reference Table", systemImage: "table") + } } label: { - Image(systemName: "table") + Image(systemName: "line.3.horizontal") .font(.system(size: 15, weight: .semibold)) } - .accessibilityLabel("Pace reference table") + .menuStyle(.button) + .accessibilityLabel("Tools menu") } } } @@ -86,6 +128,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) @@ -109,30 +152,52 @@ 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 { + let isFav = favoritesStore.isFavorited( + input: viewModel.inputText, + inputSuffix: viewModel.inputSuffix, + result: viewModel.result, + resultSuffix: viewModel.resultSuffix + ) + Button { + withAnimation(.snappy(duration: 0.25)) { + favoritesStore.toggle( + input: viewModel.inputText, + inputSuffix: viewModel.inputSuffix, + result: viewModel.result, + resultSuffix: viewModel.resultSuffix + ) + } + } label: { + Image(systemName: isFav ? "star.fill" : "star") + .font(.system(size: 20)) + .foregroundStyle(isFav ? .yellow : .secondary) + } + .buttonStyle(.plain) + .padding(.top, 8) + .accessibilityLabel(isFav ? "Remove from favorites" : "Add to favorites") + } } - .accessibilityElement(children: .combine) - .accessibilityLabel(viewModel.result.isEmpty ? "No result" : "\(viewModel.result) \(viewModel.resultSuffix)") + .sensoryFeedback(.impact(flexibility: .soft), trigger: viewModel.result) } .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 @@ -161,14 +226,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 { @@ -184,31 +242,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") } @@ -225,18 +265,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 : []) } diff --git a/pace-to-mph/FavoritesView.swift b/pace-to-mph/FavoritesView.swift new file mode 100644 index 0000000..9e22fb9 --- /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) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(fav.input) \(fav.inputSuffix) equals \(fav.result) \(fav.resultSuffix)") + + 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)) + } +} + +#Preview { + NavigationStack { + FavoritesView(store: FavoritesStore()) + } +} diff --git a/pace-to-mph/HistoryView.swift b/pace-to-mph/HistoryView.swift new file mode 100644 index 0000000..606c32e --- /dev/null +++ b/pace-to-mph/HistoryView.swift @@ -0,0 +1,108 @@ +import SwiftUI + +struct HistoryView: View { + @Bindable var history: ConversionHistory + var favoritesStore: FavoritesStore + @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 + recordRow(record) + } + } + .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?") + } + } + // 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) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(record.input) \(record.inputSuffix) equals \(record.result) \(record.resultSuffix)") + .accessibilityValue("Converted \(record.date.formatted(.relative(presentation: .named)))") + 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(isFav ? "Remove from favorites" : "Add to favorites") + } + .padding(.vertical, 4) + } +} + +#Preview { + NavigationStack { + HistoryView(history: ConversionHistory(), favoritesStore: FavoritesStore()) + } +} diff --git a/pace-to-mph/Intents/PaceToSpeedIntent.swift b/pace-to-mph/Intents/PaceToSpeedIntent.swift new file mode 100644 index 0000000..5461592 --- /dev/null +++ b/pace-to-mph/Intents/PaceToSpeedIntent.swift @@ -0,0 +1,26 @@ +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") + } + + 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)" + + 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..4dc24eb --- /dev/null +++ b/pace-to-mph/Intents/SpeedToPaceIntent.swift @@ -0,0 +1,29 @@ +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") + } + + let paceMinutes = ConversionEngine.speedToPace(speed) + + 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 + } + } +} diff --git a/pace-to-mph/Models/ConversionHistory.swift b/pace-to-mph/Models/ConversionHistory.swift new file mode 100644 index 0000000..75e873e --- /dev/null +++ b/pace-to-mph/Models/ConversionHistory.swift @@ -0,0 +1,63 @@ +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 } + + // 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, + 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/Models/FavoritesStore.swift b/pace-to-mph/Models/FavoritesStore.swift new file mode 100644 index 0000000..00cf295 --- /dev/null +++ b/pace-to-mph/Models/FavoritesStore.swift @@ -0,0 +1,73 @@ +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: String + private let userDefaults: UserDefaults + + 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) { + // 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.set(data, forKey: storageKey) + } + + private func load() { + 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/NegativeSplitView.swift b/pace-to-mph/NegativeSplitView.swift new file mode 100644 index 0000000..7497e8a --- /dev/null +++ b/pace-to-mph/NegativeSplitView.swift @@ -0,0 +1,304 @@ +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) + } + + 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 { + 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)) { + selectUnit(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) + } + + 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 = cumulativeSeconds[index] + 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: 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.convertDropSecondsBetweenUnits(dropSeconds, from: previousUnit, to: unit) + dropSecondsInput = String(Int(convertedDrop.rounded())) + } + + selectedUnit = unit + } +} + +#Preview { + NavigationStack { + NegativeSplitView() + } +} diff --git a/pace-to-mph/RaceTimeView.swift b/pace-to-mph/RaceTimeView.swift new file mode 100644 index 0000000..7c13e7d --- /dev/null +++ b/pace-to-mph/RaceTimeView.swift @@ -0,0 +1,260 @@ +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) + } + + 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 + 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) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24)) + + // 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) + } + } + .onTapGesture { + isPaceFocused = false + isDistanceFocused = false + } + .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)) { + selectUnit(u) + } + } label: { + Text(u.paceLabel) + .font(.system(size: 14, weight: .bold)) + .tracking(1.5) + } + .buttonStyle(.glass) + .tint(selectedUnit == u ? .green : nil) + } + } + } + + // 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)) + } + .buttonStyle(.glass) + .tint(selectedDistance == d ? .green : nil) + } + } + } + } + .padding(16) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) + } + + // 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) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) + } + + // MARK: - Result + + private var resultCard: some View { + 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) + + 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) + .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 { + NavigationStack { + RaceTimeView() + } +} diff --git a/pace-to-mph/SplitCalculatorView.swift b/pace-to-mph/SplitCalculatorView.swift new file mode 100644 index 0000000..2ea17e3 --- /dev/null +++ b/pace-to-mph/SplitCalculatorView.swift @@ -0,0 +1,251 @@ +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 + } + + 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") + .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 + unitPicker + + // Distance picker + distancePicker + + // Custom distance input + if selectedDistance == .custom { + customDistanceField + } + + // Result card + resultCard + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 32) + } + } + .onTapGesture { + isTimeFocused = false + isDistanceFocused = false + } + .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)) { + selectUnit(u) + } + } label: { + Text(u == .mph ? "Mile" : "KM") + .font(.system(size: 14, weight: .bold)) + .tracking(1.5) + } + .buttonStyle(.glass) + .tint(selectedUnit == u ? .green : nil) + } + } + } + + // 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)) + } + .buttonStyle(.glass) + .tint(selectedDistance == d ? .green : nil) + } + } + } + } + .padding(16) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) + } + + // 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) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 16)) + } + + // MARK: - Result + + private var resultCard: some View { + 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) + } + } + } + + if !speedText.isEmpty { + Divider() + + 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) + .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 { + NavigationStack { + SplitCalculatorView() + } +} diff --git a/pace-to-mph/ViewModels/ConverterViewModel.swift b/pace-to-mph/ViewModels/ConverterViewModel.swift index cc75f7f..1658108 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 @@ -83,11 +85,24 @@ 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.trimmingCharacters(in: .whitespaces).isEmpty else { return } + history.add( + input: inputText, + inputSuffix: inputSuffix, + result: currentResult, + resultSuffix: resultSuffix + ) + } + func switchUnit(to newUnit: SpeedUnit) { guard newUnit != unit else { return } + recordCurrentConversion() oldUnit = unit unit = newUnit } diff --git a/pace-to-mphTests/FavoritesStoreTests.swift b/pace-to-mphTests/FavoritesStoreTests.swift new file mode 100644 index 0000000..ca24c08 --- /dev/null +++ b/pace-to-mphTests/FavoritesStoreTests.swift @@ -0,0 +1,89 @@ +import Foundation +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, 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") + } + + @Test func addDuplicateSkipped() { + 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) + } + + @Test func removeFavorite() { + 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) + } + + @Test func isFavorited() { + 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")) + } + + @Test func toggleFavorite() { + 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) + } + + @Test func maxFavoritesEnforced() { + 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) + } + + @Test func clearFavorites() { + let (store, cleanup) = makeStore() + defer { cleanup() } + store.add(input: "8:00", inputSuffix: "/mi", result: "7.50", resultSuffix: "MPH") + 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 new file mode 100644 index 0000000..876304f --- /dev/null +++ b/pace-to-mphTests/IntentConversionRegressionTests.swift @@ -0,0 +1,46 @@ +import AppIntents +import Testing +@testable import pace_to_mph + +struct IntentConversionRegressionTests { + 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 { + var intent = PaceToSpeedIntent() + intent.pace = "8:00" + intent.unit = .mph + + let result = try await intent.perform() + assertResult(result, value: "7.50", dialogContains: "per mile") + } + + @Test func paceToSpeedIntentForKph() async throws { + var intent = PaceToSpeedIntent() + intent.pace = "8:00" + intent.unit = .kph + + let result = try await intent.perform() + assertResult(result, value: "7.50", dialogContains: "per kilometer") + } + + @Test func speedToPaceIntentForMph() async throws { + var intent = SpeedToPaceIntent() + intent.speed = 10 + intent.unit = .mph + + let result = try await intent.perform() + assertResult(result, value: "6:00", dialogContains: "/mi") + } + + @Test func speedToPaceIntentForKph() async throws { + var intent = SpeedToPaceIntent() + intent.speed = 12 + intent.unit = .kph + + 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 new file mode 100644 index 0000000..96bdd61 --- /dev/null +++ b/pace-to-mphTests/RaceCalculatorTests.swift @@ -0,0 +1,160 @@ +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) + } + + @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() { + // 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) + } + + @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 }) + } + + // 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.. 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 { + 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..