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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user