diff --git a/FuelBoard/AlertsView.swift b/FuelBoard/AlertsView.swift index 167f58a..01a9741 100644 --- a/FuelBoard/AlertsView.swift +++ b/FuelBoard/AlertsView.swift @@ -4,10 +4,14 @@ import SwiftUI /// being monitored. struct AlertsView: View { @Binding var enabled: Bool - @Binding var radius: Double + @Binding var radius: Double // stored in km (monitor + storage) + let distanceUnit: DistanceUnit let monitoredCount: Int let lastAlert: String? - var onShowOnboarding: () -> Void = {} + + /// The radius slider works in the user's chosen unit; the stored value + /// stays km so ProximityMonitor and persistence never change. + private var radiusInUnit: Double { distanceUnit.fromKM(radius) } var body: some View { NavigationStack { @@ -24,11 +28,15 @@ struct AlertsView: View { HStack { Text("Radius") Spacer() - Text("\(Int(radius)) km") + Text(String(format: "%.1f %@", radiusInUnit, distanceUnit.shortName)) .foregroundStyle(.secondary) .monospacedDigit() } - Slider(value: $radius, in: 1...10, step: 1) + // 1–10 in the user's unit; convert back to km on change. + Slider(value: Binding( + get: { radiusInUnit }, + set: { radius = distanceUnit.toKM($0) } + ), in: 1...10, step: distanceUnit == .kilometers ? 1 : 0.5) } } @@ -42,7 +50,7 @@ struct AlertsView: View { Text("Your favourites get priority, then the closest stations fill the rest (18 max, iOS region limit).") .font(.caption) .foregroundStyle(.secondary) - Text("Alerts are checked against the cheapest station within \(Int(radius)) km for the selected fuel. Each station alerts at most once per hour.") + Text("Alerts are checked against the cheapest station within the trigger radius for the selected fuel. Each station alerts at most once per hour.") .font(.caption) .foregroundStyle(.secondary) } @@ -55,19 +63,6 @@ struct AlertsView: View { } } } - - // Testing hook — in production onboarding shows once at first - // launch; this button re-opens it to verify the flow. - Section { - Button { - onShowOnboarding() - } label: { - Label("Show onboarding (testing)", systemImage: "flag.fill") - .font(.footnote) - } - } footer: { - Text("Testing only — onboarding normally appears once on first launch.") - } } .navigationTitle("Alerts") } diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 79b171f..bb63582 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -9,6 +9,7 @@ struct ContentView: View { @State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel() @State private var sortMode: SortMode = FuelStore.loadSortMode() @State private var stationLimit: Int = FuelStore.loadStationLimit() + @State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit() @State private var favourites: [FuelStation] = FuelStore.loadFavourites() @State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled() @State private var alertsRadius: Double = FuelStore.loadAlertsRadius() @@ -31,7 +32,7 @@ struct ContentView: View { let selling = stations.filter { $0.prices[selectedFuel] != nil } guard let location else { return selling } if sortMode == .closest { return selling } // radius disabled in Closest - let radiusKM = Double(stationLimit) * 1.60934 // chosen miles → km + let radiusKM = distanceUnit.toKM(Double(stationLimit)) // chosen units → km return selling.filter { $0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM } @@ -47,7 +48,7 @@ struct ContentView: View { private var baselinePrice: Double? { let pool = poolStations if sortMode == .closest, let location { - let radiusKM = Double(stationLimit) * 1.60934 // chosen miles → km + let radiusKM = distanceUnit.toKM(Double(stationLimit)) // chosen units → km let within = pool.filter { $0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM } @@ -111,6 +112,7 @@ struct ContentView: View { selectedFuel: $selectedFuel, sortMode: $sortMode, stationLimit: $stationLimit, + distanceUnit: distanceUnit, baselinePrice: baselinePrice, topStationID: topStationID, location: location, @@ -124,6 +126,7 @@ struct ContentView: View { favourites: refreshedFavourites, selectedFuel: selectedFuel, location: location, + distanceUnit: distanceUnit, favouriteIDs: favouriteIDs, onToggleFavourite: toggleFavourite ) @@ -132,11 +135,17 @@ struct ContentView: View { AlertsView( enabled: $alertsEnabled, radius: $alertsRadius, + distanceUnit: distanceUnit, monitoredCount: monitor.monitoredStationIDs.count, - lastAlert: monitor.lastAlert, - onShowOnboarding: { showOnboarding = true } + lastAlert: monitor.lastAlert ) .tabItem { Label("Alerts", systemImage: "bell.fill") } + + SettingsView( + distanceUnit: $distanceUnit, + onShowOnboarding: { showOnboarding = true } + ) + .tabItem { Label("Settings", systemImage: "gearshape.fill") } } .fullScreenCover(isPresented: $showOnboarding) { OnboardingView { @@ -191,8 +200,9 @@ struct ContentView: View { } .onChange(of: stationLimit) { _, newValue in // Distance filter is LOCAL math now — the cache holds the full-UK - // dump, so changing 5/10/15 miles never needs a network fetch. - // sortedStations/radiusScopedStations recompute on the next render. + // dump, so changing 5/10/15 (miles or km) never needs a network + // fetch. sortedStations/radiusScopedStations recompute on the + // next render. FuelStore.saveStationLimit(newValue) WidgetCenter.shared.reloadAllTimelines() } @@ -259,6 +269,7 @@ struct StationRow: View { let station: FuelStation let fuel: FuelType let location: Coordinate? + let distanceUnit: DistanceUnit let baselinePrice: Double? let isTopResult: Bool let isFavourite: Bool @@ -325,7 +336,7 @@ struct StationRow: View { .lineLimit(1) .truncationMode(.tail) if let location { - Text(String(format: "%.1f mi", station.distanceKM(to: location.lat, lng2: location.lng) * 0.621371)) + Text(distanceUnit.format(station.distanceKM(to: location.lat, lng2: location.lng))) .font(.caption2) .foregroundStyle(.secondary) .monospacedDigit() diff --git a/FuelBoard/FavouritesView.swift b/FuelBoard/FavouritesView.swift index 6bdd130..818a6b4 100644 --- a/FuelBoard/FavouritesView.swift +++ b/FuelBoard/FavouritesView.swift @@ -6,6 +6,7 @@ struct FavouritesView: View { let favourites: [FuelStation] let selectedFuel: FuelType let location: Coordinate? + let distanceUnit: DistanceUnit let favouriteIDs: Set var onToggleFavourite: (FuelStation) -> Void = { _ in } @@ -62,6 +63,7 @@ struct FavouritesView: View { station: station, fuel: selectedFuel, location: location, + distanceUnit: distanceUnit, baselinePrice: cheapestPrice, isTopResult: index == 0, isFavourite: favouriteIDs.contains(station.id), diff --git a/FuelBoard/OnboardingView.swift b/FuelBoard/OnboardingView.swift index fcf202f..e2dc867 100644 --- a/FuelBoard/OnboardingView.swift +++ b/FuelBoard/OnboardingView.swift @@ -94,7 +94,7 @@ struct OnboardingView: View { .padding(.top, 12) featureRow(icon: "globe.europe.africa.fill", text: "England-wide prices — 8,000+ stations, updated twice a day") - featureRow(icon: "scope", text: "Cheapest within 5/10/15 miles, or closest station first") + featureRow(icon: "scope", text: "Cheapest within your chosen radius, or closest station first") featureRow(icon: "star.fill", text: "Favourites with instant price comparison") featureRow(icon: "chart.bar.fill", text: "Green/amber/red rating vs the best nearby price") featureRow(icon: "square.grid.2x2.fill", text: "Home-screen widget showing the cheapest nearby") diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index 73aa880..026de85 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -155,14 +155,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca private func fireAlert(for station: FuelStation, price: Double) { let brand = station.brand.isEmpty ? station.name : station.brand + let unit = FuelStore.loadDistanceUnit() let content = UNMutableNotificationContent() content.title = "Cheapest \(fuel.displayName) nearby: \(brand)" - content.body = "\(station.name) is the cheapest within \(Int(radiusKM)) km at \(String(format: "%.1fp", price)). Tap to open." + content.body = "\(station.name) is the cheapest within \(unit.format(radiusKM)) at \(String(format: "%.1fp", price)). Tap to open." content.sound = .default let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) - lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(Int(radiusKM)) km radius" + lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(unit.format(radiusKM)) radius" } } diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift new file mode 100644 index 0000000..2e641d7 --- /dev/null +++ b/FuelBoard/SettingsView.swift @@ -0,0 +1,141 @@ +import SwiftUI +import StoreKit +import WidgetKit + +/// Settings tab — distance units, onboarding replay, and a tip jar. +struct SettingsView: View { + @Binding var distanceUnit: DistanceUnit + var onShowOnboarding: () -> Void = {} + + @StateObject private var tipStore = TipStore() + @State private var showTipAlert = false + @State private var tipAlertMessage = "" + + var body: some View { + NavigationStack { + List { + Section { + Picker("Distance", selection: $distanceUnit) { + ForEach(DistanceUnit.allCases) { unit in + Text(unit.displayName).tag(unit) + } + } + .pickerStyle(.segmented) + .onChange(of: distanceUnit) { _, newValue in + FuelStore.saveDistanceUnit(newValue) + WidgetCenter.shared.reloadAllTimelines() + } + } header: { + Text("Units") + } footer: { + Text("Distances and search radii across the app, widget and alerts are shown in this unit.") + } + + Section { + Button { + onShowOnboarding() + } label: { + Label("Show introduction", systemImage: "sparkles") + } + } footer: { + Text("Replay the welcome screen, including the location and notification permission prompts.") + } + + Section { + Button { + Task { await tipStore.purchase() } + } label: { + HStack { + Label("Leave a tip", systemImage: "heart.fill") + .foregroundStyle(.pink) + Spacer() + Text(tipStore.displayPrice) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + .disabled(tipStore.purchaseInProgress) + } header: { + Text("Support FuelBoard") + } footer: { + Text("A small tip helps keep the data relay and app development going. Thank you!") + } + + if let message = tipStore.message { + Section { + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Settings") + .onAppear { + Task { await tipStore.load() } + } + } + } +} + +/// Loads the £4.99 tip product and drives its purchase. +@MainActor +final class TipStore: ObservableObject { + /// Product ID for the £4.99 tip (App Store Connect — consumable). + static let productID = "com.apt.fuelboard.tip499" + + @Published private(set) var product: Product? + @Published private(set) var purchaseInProgress = false + @Published private(set) var message: String? + + var displayPrice: String { + product?.displayPrice ?? "£4.99" + } + + func load() async { + // Refreshes product state on every visit so a newly-approved product + // (or a restored transaction) is picked up. + do { + let products = try await Product.products(for: [Self.productID]) + product = products.first + } catch { + // No product yet (sideloaded build) — the button still shows the + // intended price and reports the purchase attempt gracefully. + product = nil + } + } + + func purchase() async { + guard !purchaseInProgress else { return } + purchaseInProgress = true + defer { purchaseInProgress = false } + + // If the product hasn't loaded (e.g. not configured in App Store + // Connect yet), still allow the attempt so the user sees a clear + // outcome rather than a dead button. + guard let product else { + message = "The tip isn't available in this build yet — check back after an App Store release." + return + } + + do { + let result = try await product.purchase() + switch result { + case .success(let verification): + switch verification { + case .verified: + message = "Thank you! Your tip has been received. ⛽" + case .unverified: + message = "The purchase couldn't be verified. Please try again." + } + case .userCancelled: + message = nil // silent — the user just closed the sheet + case .pending: + message = "Your tip is pending approval. It'll finish automatically." + @unknown default: + message = nil + } + } catch { + message = "The tip couldn't be completed: \(error.localizedDescription)" + } + } +} diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift index 47f9ca1..b96a631 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -9,6 +9,7 @@ struct StationsView: View { @Binding var selectedFuel: FuelType @Binding var sortMode: SortMode @Binding var stationLimit: Int + let distanceUnit: DistanceUnit let baselinePrice: Double? let topStationID: String? let location: Coordinate? @@ -29,11 +30,11 @@ struct StationsView: View { Section { if let location { if sortMode == .closest { - Text("Closest \(selectedFuel.displayName) stations — nearest first, best value within \(stationLimit) miles.") + Text("Closest \(selectedFuel.displayName) stations — nearest first, best value within \(stationLimit) \(distanceUnit.label).") .font(.footnote) .foregroundStyle(.secondary) } else { - Text("Cheapest \(selectedFuel.displayName) within \(stationLimit) miles — tap a station for directions.") + Text("Cheapest \(selectedFuel.displayName) within \(stationLimit) \(distanceUnit.label) — tap a station for directions.") .font(.footnote) .foregroundStyle(.secondary) } @@ -60,7 +61,7 @@ struct StationsView: View { Section("Distance") { Picker("Distance", selection: $stationLimit) { ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in - Text("\(miles) miles").tag(miles) + Text("\(miles) \(distanceUnit.label)").tag(miles) } } .pickerStyle(.segmented) @@ -79,7 +80,7 @@ struct StationsView: View { .font(.caption2) .foregroundStyle(.secondary) } else { - Text("\(totalCount) stations within \(stationLimit) miles") + Text("\(totalCount) stations within \(stationLimit) \(distanceUnit.label)") .font(.caption2) .foregroundStyle(.secondary) } @@ -111,6 +112,7 @@ struct StationsView: View { station: station, fuel: selectedFuel, location: location, + distanceUnit: distanceUnit, baselinePrice: baselinePrice, isTopResult: station.id == topStationID, isFavourite: favouriteIDs.contains(station.id), @@ -193,12 +195,12 @@ struct StationsView: View { // MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only) extension FuelType { - /// Short segment label ("Unleaded (E10)" / "Premium (E5)" / "Diesel") for + /// Short segment label ("Unleaded" / "Premium" / "Diesel") for /// the picker and description. var shortName: String { switch self { - case .e10: return "Unleaded (E10)" - case .e5: return "Premium (E5)" + case .e10: return "Unleaded" + case .e5: return "Premium" case .diesel: return "Diesel" } } diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 015443c..dd13e21 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -196,10 +196,23 @@ final class BrandTests: XCTestCase { final class FuelTypeLabelTests: XCTestCase { func testDisplayNames() { - XCTAssertEqual(FuelType.e10.displayName, "Unleaded (E10)") - XCTAssertEqual(FuelType.e5.displayName, "Premium (E5)") + // E10/E5 grades are intentionally not part of user-facing labels. + XCTAssertEqual(FuelType.e10.displayName, "Unleaded") + XCTAssertEqual(FuelType.e5.displayName, "Premium") XCTAssertEqual(FuelType.diesel.displayName, "Diesel") } + + func testDistanceUnitConversion() { + XCTAssertEqual(DistanceUnit.miles.toKM(1), 1.60934, accuracy: 0.00001) + XCTAssertEqual(DistanceUnit.kilometers.toKM(5), 5) + XCTAssertEqual(DistanceUnit.miles.fromKM(1.60934), 1, accuracy: 0.00001) + XCTAssertEqual(DistanceUnit.kilometers.fromKM(2.5), 2.5) + } + + func testDistanceUnitFormat() { + XCTAssertEqual(DistanceUnit.miles.format(1.60934), "1.0 mi") + XCTAssertEqual(DistanceUnit.kilometers.format(3.4), "3.4 km") + } } final class FavouriteRefreshTests: XCTestCase { diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index 8c26d88..c7d56a0 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -36,15 +36,17 @@ struct FuelPriceEntry: TimelineEntry { let fuel: FuelType let location: Coordinate? let locationSource: String // "live" | "cached" | "none" + let unit: DistanceUnit // user's display unit for distances } struct FuelPriceTimelineProvider: TimelineProvider { func placeholder(in context: Context) -> FuelPriceEntry { let fuel = FuelStore.loadSelectedFuel() + let unit = FuelStore.loadDistanceUnit() let sample = SampleFuelProvider.sampleStations .filter { $0.prices[fuel] != nil } .sorted { $0.prices[fuel]! < $1.prices[fuel]! } - return FuelPriceEntry(date: Date(), stations: Array(sample.prefix(4)), fuel: fuel, location: nil, locationSource: "none") + return FuelPriceEntry(date: Date(), stations: Array(sample.prefix(4)), fuel: fuel, location: nil, locationSource: "none", unit: unit) } func getSnapshot(in context: Context, completion: @escaping (FuelPriceEntry) -> Void) { @@ -85,11 +87,12 @@ struct FuelPriceTimelineProvider: TimelineProvider { } // 3) Load stations, then sort by price (distance tiebreak), scoped to - // the chosen search radius (miles) so the widget's "cheapest" matches + // the chosen search radius so the widget's "cheapest" matches // the app's list. var stations = FuelStore.loadStations() if stations.isEmpty { stations = SampleFuelProvider.sampleStations } - let radiusKM = Double(FuelStore.loadStationLimit()) * 1.60934 // miles → km + let unit = FuelStore.loadDistanceUnit() + let radiusKM = unit.toKM(Double(FuelStore.loadStationLimit())) // chosen units → km if let location { // STRICT: cached data fetched around another location must never // leak out-of-radius stations into the widget. @@ -112,7 +115,7 @@ struct FuelPriceTimelineProvider: TimelineProvider { sorted = filtered.sorted { $0.prices[fuel]! < $1.prices[fuel]! } } - return FuelPriceEntry(date: Date(), stations: Array(sorted.prefix(4)), fuel: fuel, location: location, locationSource: source) + return FuelPriceEntry(date: Date(), stations: Array(sorted.prefix(4)), fuel: fuel, location: location, locationSource: source, unit: FuelStore.loadDistanceUnit()) } } @@ -158,7 +161,7 @@ struct FuelPriceWidgetView: View { .foregroundStyle(.green) } if let location = entry.location { - Text(String(format: "%.1f mi away", station.distanceKM(to: location.lat, lng2: location.lng) * 0.621371)) + Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away") .font(.caption2) .foregroundStyle(.secondary) } else { @@ -192,7 +195,7 @@ struct FuelPriceWidgetView: View { .font(.caption.weight(.semibold)) .lineLimit(1) if let location = entry.location { - Text(String(format: "%.1f km", station.distanceKM(to: location.lat, lng2: location.lng))) + Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng))) .font(.caption2) .foregroundStyle(.secondary) } diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index eb29f5f..0315ef6 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -48,21 +48,75 @@ enum RAGRating: Int, Codable { } enum FuelType: String, Codable, CaseIterable, Identifiable { - case e10 // Unleaded 95 (E10) - case e5 // Premium 97/98 (E5) + case e10 // Unleaded (E10) + case e5 // Premium (E5) case diesel // B7 diesel var id: String { rawValue } var displayName: String { switch self { - case .e10: return "Unleaded (E10)" - case .e5: return "Premium (E5)" + case .e10: return "Unleaded" + case .e5: return "Premium" case .diesel: return "Diesel" } } } +/// Display unit for all distances in the app + widget. Internally distances +/// are always stored/computed in km; conversion happens at the display and +/// filter boundary so nothing else needs to know the unit. +enum DistanceUnit: String, Codable, CaseIterable, Identifiable { + case miles + case kilometers + + var id: String { rawValue } + + var displayName: String { + switch self { + case .miles: return "Miles" + case .kilometers: return "Kilometres" + } + } + + /// Short suffix for values ("5 mi", "3.2 km"). + var shortName: String { + switch self { + case .miles: return "mi" + case .kilometers: return "km" + } + } + + /// Full word for narrative text ("within 5 miles", "within 8 km"). + var label: String { + switch self { + case .miles: return "miles" + case .kilometers: return "km" + } + } + + /// Convert a value expressed in this unit to km. + func toKM(_ value: Double) -> Double { + switch self { + case .miles: return value * 1.60934 + case .kilometers: return value + } + } + + /// Convert a km value to this unit. + func fromKM(_ km: Double) -> Double { + switch self { + case .miles: return km * 0.621371 + case .kilometers: return km + } + } + + /// Format a km distance in this unit ("1.2 mi", "3.4 km"). + func format(_ km: Double) -> String { + String(format: "%.1f %@", fromKM(km), shortName) + } +} + // MARK: - Station model struct FuelStation: Identifiable, Codable, Equatable { @@ -179,6 +233,7 @@ struct FuelStore { static let fuelKey = "fuelboard.selectedFuel" // FuelType raw value static let sortModeKey = "fuelboard.sortMode" // SortMode raw value static let stationLimitKey = "fuelboard.stationLimitMiles" // Int miles (5/10/15) + static let distanceUnitKey = "fuelboard.distanceUnit" // DistanceUnit raw value static let favouritesKey = "fuelboard.favourites" // [FuelStation] JSON static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km @@ -277,6 +332,20 @@ struct FuelStore { saveString(String(miles), service: stationLimitKey) } + // MARK: Distance unit — miles or kilometres. Stored raw value; default + // miles for backward compatibility with pre-toggle installs. + + static func loadDistanceUnit() -> DistanceUnit { + if let raw = loadString(service: distanceUnitKey), let unit = DistanceUnit(rawValue: raw) { + return unit + } + return .miles + } + + static func saveDistanceUnit(_ unit: DistanceUnit) { + saveString(unit.rawValue, service: distanceUnitKey) + } + // MARK: Favourites static func loadFavourites() -> [FuelStation] {