diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index f609af3..2cf2745 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -3,10 +3,13 @@ import StoreKit import UserNotifications import WidgetKit -/// Settings tab — distance units, onboarding replay, a tip jar, and -/// test-alert buttons. The first test button uses REAL data (the actual -/// cheapest station for the monitored fuel within the radius, exactly like a -/// live alert) via `onTestAlert`; the second fires with no criteria at all. +/// Settings tab — distance units, onboarding replay, a tip jar, and an About +/// section. Test-notification buttons live in a Debug section that is hidden +/// unless the developer debug flag is on (tap the About → Version row five +/// times to toggle it — no user-facing switch). The first test button uses +/// REAL data (the actual cheapest station for the monitored fuel within the +/// radius, exactly like a live alert) via `onTestAlert`; the second fires +/// with no criteria at all. struct SettingsView: View { @Binding var distanceUnit: DistanceUnit /// The fuel + radius currently configured for alerts (mirrors the Alerts @@ -27,6 +30,11 @@ struct SettingsView: View { @State private var showTipAlert = false @State private var tipAlertMessage = "" @State private var testAlertMessage: String? + /// Hidden developer flag — the Debug section only appears when on. Toggled + /// by tapping the About → Version row five times (NOT a user-facing switch). + @State private var debugMode: Bool = FuelStore.loadDebugMode() + @State private var versionTapCount = 0 + @State private var lastVersionTap = Date.distantPast var body: some View { NavigationStack { @@ -58,31 +66,33 @@ struct SettingsView: View { Text("Replay the welcome screen, including the location and notification permission prompts.") } - Section { - Button { - sendTestAlert() - } label: { - Label("Test alert notification (real data)", systemImage: "bell.badge.fill") + if debugMode { + Section { + Button { + sendTestAlert() + } label: { + Label("Test alert notification (real data)", systemImage: "bell.badge.fill") + } + if let testAlertMessage { + Text(testAlertMessage) + .font(.footnote) + .foregroundStyle(.secondary) + } + if let testAlertResult { + Label(testAlertResult, systemImage: "checkmark.circle.fill") + .font(.footnote) + .foregroundStyle(.green) + } + Button { + onPlainTestAlert() + } label: { + Label("Send plain test notification", systemImage: "bell.slash.fill") + } + } header: { + Text("Debug") + } footer: { + Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions.") } - if let testAlertMessage { - Text(testAlertMessage) - .font(.footnote) - .foregroundStyle(.secondary) - } - if let testAlertResult { - Label(testAlertResult, systemImage: "checkmark.circle.fill") - .font(.footnote) - .foregroundStyle(.green) - } - Button { - onPlainTestAlert() - } label: { - Label("Send plain test notification", systemImage: "bell.slash.fill") - } - } header: { - Text("Alerts") - } footer: { - Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions.") } Section { @@ -105,6 +115,22 @@ struct SettingsView: View { Text("A small tip helps keep the data relay and app development going. Thank you!") } + Section { + HStack { + Text("Version") + Spacer() + Text(appVersion) + .foregroundStyle(.secondary) + .monospacedDigit() + .contentShape(Rectangle()) + .onTapGesture { handleVersionTap() } + } + } header: { + Text("About") + } footer: { + Text("FuelBoard \(appVersion)") + } + if let message = tipStore.message { Section { Text(message) @@ -120,6 +146,29 @@ struct SettingsView: View { } } + private var appVersion: String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" + return "\(version) (\(build))" + } + + /// Hidden debug-mode toggle: five taps on the Version row flips the flag. + /// Nothing in the UI advertises this — the Debug section simply appears or + /// disappears. Deliberately NOT a visible switch so end users never see it. + private func handleVersionTap() { + let now = Date() + if now.timeIntervalSince(lastVersionTap) > 0.6 { + versionTapCount = 0 + } + lastVersionTap = now + versionTapCount += 1 + if versionTapCount >= 5 { + versionTapCount = 0 + debugMode.toggle() + FuelStore.saveDebugMode(debugMode) + } + } + private func sendTestAlert() { UNUserNotificationCenter.current().getNotificationSettings { settings in Task { @MainActor in diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index c3de72e..738247f 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -261,6 +261,7 @@ struct FuelStore { static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value + static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) @@ -401,6 +402,22 @@ struct FuelStore { saveString(unit.rawValue, service: distanceUnitKey) } + // MARK: Debug mode + + /// Hidden developer flag. NOT exposed in the UI: toggled by tapping the + /// Settings → About → Version row five times. When on, the Settings tab + /// shows the Debug section (test notification buttons); when off, that + /// section is completely hidden from the UI. Stored keychain-first like + /// every other small setting so it survives app deletion during the + /// delete → reinstall test loop. + static func loadDebugMode() -> Bool { + loadString(service: debugModeKey) == "1" + } + + static func saveDebugMode(_ enabled: Bool) { + saveString(enabled ? "1" : "0", service: debugModeKey) + } + // MARK: Favourites /// A favourite pins a station FOR ONE fuel type. Starring a row while