Settings: move test notifications into hidden Debug section + dev flag

- Test-alert buttons moved out of the Alerts section into a Debug section.
- Debug section only renders when the debug flag is on; completely hidden
  otherwise (no user-facing toggle).
- Hidden toggle: tap Settings → About → Version five times to flip the
  flag (keychain + app-group storage, survives reinstall).
- About section added with version/build number.
- 35 tests pass.
This commit is contained in:
FuelBoard Contributor
2026-08-12 14:10:07 +01:00
parent 5246f41c48
commit 07521b1c98
2 changed files with 94 additions and 28 deletions
+77 -28
View File
@@ -3,10 +3,13 @@ import StoreKit
import UserNotifications import UserNotifications
import WidgetKit import WidgetKit
/// Settings tab distance units, onboarding replay, a tip jar, and /// Settings tab distance units, onboarding replay, a tip jar, and an About
/// test-alert buttons. The first test button uses REAL data (the actual /// section. Test-notification buttons live in a Debug section that is hidden
/// cheapest station for the monitored fuel within the radius, exactly like a /// unless the developer debug flag is on (tap the About Version row five
/// live alert) via `onTestAlert`; the second fires with no criteria at all. /// 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 { struct SettingsView: View {
@Binding var distanceUnit: DistanceUnit @Binding var distanceUnit: DistanceUnit
/// The fuel + radius currently configured for alerts (mirrors the Alerts /// 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 showTipAlert = false
@State private var tipAlertMessage = "" @State private var tipAlertMessage = ""
@State private var testAlertMessage: String? @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 { var body: some View {
NavigationStack { NavigationStack {
@@ -58,31 +66,33 @@ struct SettingsView: View {
Text("Replay the welcome screen, including the location and notification permission prompts.") Text("Replay the welcome screen, including the location and notification permission prompts.")
} }
Section { if debugMode {
Button { Section {
sendTestAlert() Button {
} label: { sendTestAlert()
Label("Test alert notification (real data)", systemImage: "bell.badge.fill") } 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 { Section {
@@ -105,6 +115,22 @@ struct SettingsView: View {
Text("A small tip helps keep the data relay and app development going. Thank you!") 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 { if let message = tipStore.message {
Section { Section {
Text(message) 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() { private func sendTestAlert() {
UNUserNotificationCenter.current().getNotificationSettings { settings in UNUserNotificationCenter.current().getNotificationSettings { settings in
Task { @MainActor in Task { @MainActor in
+17
View File
@@ -261,6 +261,7 @@ struct FuelStore {
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value 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 onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
@@ -401,6 +402,22 @@ struct FuelStore {
saveString(unit.rawValue, service: distanceUnitKey) 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 // MARK: Favourites
/// A favourite pins a station FOR ONE fuel type. Starring a row while /// A favourite pins a station FOR ONE fuel type. Starring a row while