Refine trends and debug harness; clean up warnings
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 747 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 747 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 747 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 208 KiB |
@@ -222,15 +222,36 @@ struct ContentView: View {
|
|||||||
// opens the given tab; `-skipOnboarding` skips onboarding
|
// opens the given tab; `-skipOnboarding` skips onboarding
|
||||||
// without touching the stored flag.
|
// without touching the stored flag.
|
||||||
let args = ProcessInfo.processInfo.arguments
|
let args = ProcessInfo.processInfo.arguments
|
||||||
|
#if DEBUG
|
||||||
showWidgetMock = args.contains("-widgets")
|
showWidgetMock = args.contains("-widgets")
|
||||||
// `-seedFavourite <substring>` pins the first matching station
|
// `-seedFavourite <substring>` pins the first matching station
|
||||||
// for Unleaded via the normal save path (keychain + app group)
|
// for Unleaded via the normal save path (keychain + app group)
|
||||||
// so screenshot captures can show a populated Favourites tab.
|
// so screenshot captures can show a populated Favourites tab.
|
||||||
if let i = args.firstIndex(of: "-seedFavourite"), i + 1 < args.count {
|
// Repeatable: each occurrence adds another favourite (QA temp).
|
||||||
let query = args[i + 1]
|
// Optional ":fuel" suffix (e10|e5|diesel) picks the
|
||||||
if let station = FuelStore.loadStations()
|
// fuel, so the fuel-aware Trends open can be verified (QA temp).
|
||||||
.first(where: { $0.name.localizedCaseInsensitiveContains(query) }) {
|
let seedQueries = args.enumerated()
|
||||||
FuelStore.saveFavourites([FavouriteEntry(station: station, fuel: .e10)])
|
.filter { $0.element == "-seedFavourite" }
|
||||||
|
.compactMap { i, _ in
|
||||||
|
i + 1 < args.count ? args[i + 1] : nil
|
||||||
|
}
|
||||||
|
if !seedQueries.isEmpty {
|
||||||
|
let stations = FuelStore.loadStations()
|
||||||
|
var favs = seedQueries.compactMap { query in
|
||||||
|
let parts = query.split(separator: ":", maxSplits: 1)
|
||||||
|
let name = String(parts[0])
|
||||||
|
let fuel = parts.count > 1
|
||||||
|
? FuelType(rawValue: String(parts[1])) ?? .e10
|
||||||
|
: FuelType.e10
|
||||||
|
return stations.first { $0.name.localizedCaseInsensitiveContains(name) }
|
||||||
|
.map { FavouriteEntry(station: $0, fuel: fuel) }
|
||||||
|
}
|
||||||
|
if favs.isEmpty, let q = seedQueries.first,
|
||||||
|
let s = stations.first(where: { $0.name.localizedCaseInsensitiveContains(q.split(separator: ":").first.map(String.init) ?? q) }) {
|
||||||
|
favs = [FavouriteEntry(station: s, fuel: .e10)]
|
||||||
|
}
|
||||||
|
if !favs.isEmpty {
|
||||||
|
FuelStore.saveFavourites(favs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
|
||||||
@@ -241,6 +262,14 @@ struct ContentView: View {
|
|||||||
default: selectedTab = 0
|
default: selectedTab = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// QA temp: `-fuel <e10|e5|diesel>` sets the app-wide selected
|
||||||
|
// fuel so the fuel-aware Trends open can be verified (simulates
|
||||||
|
// the user having switched to the diesel tab).
|
||||||
|
if let i = args.firstIndex(of: "-fuel"), i + 1 < args.count,
|
||||||
|
let f = FuelType(rawValue: args[i + 1]) {
|
||||||
|
selectedFuel = f
|
||||||
|
FuelStore.saveSelectedFuel(f)
|
||||||
|
}
|
||||||
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
|
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
|
||||||
// failure legs for the screenshot harness. The auto-refresh below
|
// failure legs for the screenshot harness. The auto-refresh below
|
||||||
// is skipped so the banner stays up (a live fetch would clear it).
|
// is skipped so the banner stays up (a live fetch would clear it).
|
||||||
@@ -254,6 +283,9 @@ struct ContentView: View {
|
|||||||
: FuelStore.loadStations()
|
: FuelStore.loadStations()
|
||||||
dataStatus = .connectionProblem
|
dataStatus = .connectionProblem
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
showWidgetMock = false
|
||||||
|
#endif
|
||||||
// Onboarding runs first on a fresh install — it owns the initial
|
// Onboarding runs first on a fresh install — it owns the initial
|
||||||
// permission prompts (location, notifications, and the data/local
|
// permission prompts (location, notifications, and the data/local
|
||||||
// network probe on the Data page). Location tracking and the first
|
// network probe on the Data page). Location tracking and the first
|
||||||
@@ -261,8 +293,13 @@ struct ContentView: View {
|
|||||||
// Launch-arg hook (UI-testing/screenshot harness, same pattern as
|
// Launch-arg hook (UI-testing/screenshot harness, same pattern as
|
||||||
// StationsView's `-showKeySheet`): skip onboarding without
|
// StationsView's `-showKeySheet`): skip onboarding without
|
||||||
// touching the stored flag.
|
// touching the stored flag.
|
||||||
|
#if DEBUG
|
||||||
|
let shouldSkipOnboarding = args.contains("-skipOnboarding")
|
||||||
|
#else
|
||||||
|
let shouldSkipOnboarding = false
|
||||||
|
#endif
|
||||||
if FuelStore.loadHasCompletedOnboarding()
|
if FuelStore.loadHasCompletedOnboarding()
|
||||||
|| args.contains("-skipOnboarding") {
|
|| shouldSkipOnboarding {
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
// Geofences must follow the user even in the background:
|
// Geofences must follow the user even in the background:
|
||||||
// wire the delegate hook (fires on every fix incl. background
|
// wire the delegate hook (fires on every fix incl. background
|
||||||
@@ -284,7 +321,14 @@ struct ContentView: View {
|
|||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
// Refresh only when the cache is stale (twice-a-day policy).
|
// Refresh only when the cache is stale (twice-a-day policy).
|
||||||
// Skipped under the force-* hooks so the banner stays up.
|
// Skipped under the force-* hooks so the banner stays up.
|
||||||
if !args.contains("-forceOfflineDump") && !args.contains("-forceConnectionProblem") && !args.contains("-forceHistoryFailure") {
|
#if DEBUG
|
||||||
|
let shouldSkipInitialRefresh = args.contains("-forceOfflineDump")
|
||||||
|
|| args.contains("-forceConnectionProblem")
|
||||||
|
|| args.contains("-forceHistoryFailure")
|
||||||
|
#else
|
||||||
|
let shouldSkipInitialRefresh = false
|
||||||
|
#endif
|
||||||
|
if !shouldSkipInitialRefresh {
|
||||||
Task { await refresh() }
|
Task { await refresh() }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -172,11 +172,13 @@ struct FavouritesView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
#if DEBUG
|
||||||
// QA hook: launch with `-showTrends` to open the sheet
|
// QA hook: launch with `-showTrends` to open the sheet
|
||||||
// without a tap (same pattern as -showKeySheet).
|
// without a tap (same pattern as -showKeySheet).
|
||||||
if ProcessInfo.processInfo.arguments.contains("-showTrends") {
|
if ProcessInfo.processInfo.arguments.contains("-showTrends") {
|
||||||
showTrends = true
|
showTrends = true
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ struct OnboardingView: View {
|
|||||||
/// Owns the system permission requests during onboarding and publishes
|
/// Owns the system permission requests during onboarding and publishes
|
||||||
/// their outcomes so the pages can reflect them live.
|
/// their outcomes so the pages can reflect them live.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
|
final class OnboardingPermissionPrompter: NSObject, ObservableObject, CLLocationManagerDelegate {
|
||||||
@Published private(set) var locationGranted = false
|
@Published private(set) var locationGranted = false
|
||||||
@Published private(set) var locationDenied = false
|
@Published private(set) var locationDenied = false
|
||||||
@Published private(set) var notificationsGranted = false
|
@Published private(set) var notificationsGranted = false
|
||||||
@@ -358,11 +358,14 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
switch settings.authorizationStatus {
|
switch settings.authorizationStatus {
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
do {
|
||||||
Task { @MainActor in
|
let granted = try await UNUserNotificationCenter.current()
|
||||||
self.notificationsGranted = granted
|
.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
self.notificationsDenied = !granted
|
self.notificationsGranted = granted
|
||||||
}
|
self.notificationsDenied = !granted
|
||||||
|
} catch {
|
||||||
|
self.notificationsGranted = false
|
||||||
|
self.notificationsDenied = true
|
||||||
}
|
}
|
||||||
case .authorized:
|
case .authorized:
|
||||||
self.notificationsGranted = true
|
self.notificationsGranted = true
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ struct SettingsView: View {
|
|||||||
Label("In range", systemImage: "location.circle.fill")
|
Label("In range", systemImage: "location.circle.fill")
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
if let coord = status.coordinate {
|
if status.coordinate != nil {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(inRangeColor(status.inRangeCount))
|
.fill(inRangeColor(status.inRangeCount))
|
||||||
.frame(width: 10, height: 10)
|
.frame(width: 10, height: 10)
|
||||||
@@ -567,13 +567,17 @@ struct SettingsView: View {
|
|||||||
testAlertMessage = NSLocalizedString("Notifications are turned off for FuelBoard. Enable them in Settings → Notifications → FuelBoard, then try again.", comment: "")
|
testAlertMessage = NSLocalizedString("Notifications are turned off for FuelBoard. Enable them in Settings → Notifications → FuelBoard, then try again.", comment: "")
|
||||||
default:
|
default:
|
||||||
// First time — ask, then fire if granted.
|
// First time — ask, then fire if granted.
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
Task { @MainActor in
|
||||||
Task { @MainActor in
|
do {
|
||||||
|
let granted = try await UNUserNotificationCenter.current()
|
||||||
|
.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
if granted {
|
if granted {
|
||||||
onTestAlert()
|
onTestAlert()
|
||||||
} else {
|
} else {
|
||||||
testAlertMessage = NSLocalizedString("Notifications weren't allowed, so no test alert was sent.", comment: "")
|
testAlertMessage = NSLocalizedString("Notifications weren't allowed, so no test alert was sent.", comment: "")
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
testAlertMessage = NSLocalizedString("Couldn't request notification permission right now. Please try again.", comment: "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,18 +22,6 @@ import SwiftUI
|
|||||||
/// "super unleaded") so Siri matches how people actually ask. The app UI's
|
/// "super unleaded") so Siri matches how people actually ask. The app UI's
|
||||||
/// FuelType.displayName is untouched.
|
/// FuelType.displayName is untouched.
|
||||||
|
|
||||||
// MARK: - Fuel parameter
|
|
||||||
|
|
||||||
extension FuelType: AppEnum {
|
|
||||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel"
|
|
||||||
|
|
||||||
static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [
|
|
||||||
.e10: "Unleaded",
|
|
||||||
.e5: "Premium",
|
|
||||||
.diesel: "Diesel",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Intent
|
// MARK: - Intent
|
||||||
|
|
||||||
struct CheapestFuelIntent: AppIntent {
|
struct CheapestFuelIntent: AppIntent {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct StationsView: View {
|
|||||||
} else {
|
} else {
|
||||||
ratio = "\(totalCount)"
|
ratio = "\(totalCount)"
|
||||||
}
|
}
|
||||||
if let location {
|
if location != nil {
|
||||||
if sortMode == .closest {
|
if sortMode == .closest {
|
||||||
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
||||||
}
|
}
|
||||||
@@ -174,11 +174,13 @@ struct StationsView: View {
|
|||||||
.navigationTitle("FuelBoard")
|
.navigationTitle("FuelBoard")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
#if DEBUG
|
||||||
// QA hook: launch with `-showKeySheet` to verify the legend
|
// QA hook: launch with `-showKeySheet` to verify the legend
|
||||||
// sheet without driving a tap (simctl has no tap command).
|
// sheet without driving a tap (simctl has no tap command).
|
||||||
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
||||||
showKey = true
|
showKey = true
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ struct TrendsView: View {
|
|||||||
/// All favourites (fuel-scoped entries) — the sheet derives the active
|
/// All favourites (fuel-scoped entries) — the sheet derives the active
|
||||||
/// fuel's stations and which fuels have favourites.
|
/// fuel's stations and which fuels have favourites.
|
||||||
let favourites: [FavouriteEntry]
|
let favourites: [FavouriteEntry]
|
||||||
|
/// The fuel the Favourites tab is showing when the Trends button is
|
||||||
|
/// tapped — the sheet must open on the same fuel, not always .e10.
|
||||||
let selectedFuel: FuelType
|
let selectedFuel: FuelType
|
||||||
let priceDisplayStyle: PriceDisplayStyle
|
let priceDisplayStyle: PriceDisplayStyle
|
||||||
|
|
||||||
@@ -32,6 +34,21 @@ struct TrendsView: View {
|
|||||||
@State private var loadFailed = false
|
@State private var loadFailed = false
|
||||||
@State private var firstSnapshot: String?
|
@State private var firstSnapshot: String?
|
||||||
|
|
||||||
|
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
|
||||||
|
/// opens where the user was — same pattern as FavouritesView.
|
||||||
|
init(favourites: [FavouriteEntry],
|
||||||
|
selectedFuel: FuelType,
|
||||||
|
priceDisplayStyle: PriceDisplayStyle,
|
||||||
|
onHistoryUnavailable: (() -> Void)? = nil,
|
||||||
|
onHistoryRecovered: (() -> Void)? = nil) {
|
||||||
|
self.favourites = favourites
|
||||||
|
self.selectedFuel = selectedFuel
|
||||||
|
self.priceDisplayStyle = priceDisplayStyle
|
||||||
|
self.onHistoryUnavailable = onHistoryUnavailable
|
||||||
|
self.onHistoryRecovered = onHistoryRecovered
|
||||||
|
_fuel = State(initialValue: selectedFuel)
|
||||||
|
}
|
||||||
|
|
||||||
enum TrendsMode: String, CaseIterable, Identifiable {
|
enum TrendsMode: String, CaseIterable, Identifiable {
|
||||||
case price
|
case price
|
||||||
case vsCheapest
|
case vsCheapest
|
||||||
@@ -85,7 +102,34 @@ struct TrendsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Y-domain hugging the displayed data (RevenueCat-style tight scale).
|
||||||
|
/// Without it Swift Charts pads a sparse series (e.g. 3 days of ~150.7p)
|
||||||
|
/// out to a 0→200 axis, wasting the plot area. Pad by ~4% of the range
|
||||||
|
/// (min 0.5p) so the line doesn't kiss the top edge.
|
||||||
|
private var yDomain: ClosedRange<Double> {
|
||||||
|
let values = displaySeries.flatMap { $0.points.map(\.pence) }
|
||||||
|
guard let lo = values.min(), let hi = values.max() else {
|
||||||
|
return 0...1
|
||||||
|
}
|
||||||
|
let pad = max((hi - lo) * 0.04, 0.5)
|
||||||
|
return (lo - pad)...(hi + pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth of each series' gradient fill band, in pence. Band instead of
|
||||||
|
/// fill-to-axis: with 2+ favourites, full-height unstacked fills overlap
|
||||||
|
/// and wash out the lower lines (observed on device 2026-08-17). A band
|
||||||
|
/// of ~18% of the y-range under each line keeps every area visible and
|
||||||
|
/// gives the RevenueCat "fade under the line" look.
|
||||||
|
private var fillBand: Double {
|
||||||
|
let values = displaySeries.flatMap { $0.points.map(\.pence) }
|
||||||
|
guard let lo = values.min(), let hi = values.max(), hi > lo else {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
return max((hi - lo) * 0.18, 0.8)
|
||||||
|
}
|
||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
|
#if DEBUG
|
||||||
// QA hook: force the unreachable state for screenshots (same pattern
|
// QA hook: force the unreachable state for screenshots (same pattern
|
||||||
// as -showTrends / -forceConnectionProblem). Runs before the fetch so
|
// as -showTrends / -forceConnectionProblem). Runs before the fetch so
|
||||||
// the retry state renders immediately with no spinner flash.
|
// the retry state renders immediately with no spinner flash.
|
||||||
@@ -95,6 +139,7 @@ struct TrendsView: View {
|
|||||||
onHistoryUnavailable?()
|
onHistoryUnavailable?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
isLoading = true
|
isLoading = true
|
||||||
loadFailed = false
|
loadFailed = false
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
@@ -132,6 +177,38 @@ struct TrendsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - RevenueCat-style headline metric
|
||||||
|
|
||||||
|
/// Overall average across displayed series (mean of per-station means) —
|
||||||
|
/// the big figure in the card header, Price and vs-cheapest mode both.
|
||||||
|
private var headlineAverage: Double? {
|
||||||
|
let avgs = displaySeries.compactMap { FuelHistoryStore.averagePence($0.points) }
|
||||||
|
guard !avgs.isEmpty else { return nil }
|
||||||
|
return avgs.reduce(0, +) / Double(avgs.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change over the window: mean of each series' (last − first) point.
|
||||||
|
/// Positive = prices/gaps rose; negative = fell. Hidden while loading or
|
||||||
|
/// when any displayed series lacks two points.
|
||||||
|
private var headlineDelta: Double? {
|
||||||
|
let deltas = displaySeries.compactMap { history -> Double? in
|
||||||
|
guard let first = history.points.first?.pence,
|
||||||
|
let last = history.points.last?.pence else { return nil }
|
||||||
|
return last - first
|
||||||
|
}
|
||||||
|
guard deltas.count == displaySeries.count else { return nil }
|
||||||
|
return deltas.reduce(0, +) / Double(deltas.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Good = prices fell (saving money) or the gap to cheapest narrowed.
|
||||||
|
private var deltaIsGood: Bool {
|
||||||
|
(headlineDelta ?? 0) <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var periodLabel: String {
|
||||||
|
"Last \(rangeDays) days"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
@@ -195,11 +272,7 @@ struct TrendsView: View {
|
|||||||
} else if !hasEnoughData {
|
} else if !hasEnoughData {
|
||||||
emptyState // single point — nothing to draw yet
|
emptyState // single point — nothing to draw yet
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 12) {
|
revenueCatCard
|
||||||
chart
|
|
||||||
legend
|
|
||||||
legendFooter
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
@@ -228,21 +301,103 @@ struct TrendsView: View {
|
|||||||
.padding(.vertical, 24)
|
.padding(.vertical, 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RevenueCat-style card: rounded surface with a headline metric (period
|
||||||
|
/// average + change), the multi-series area chart, then the key. Mirrors
|
||||||
|
/// the mockup approved after the dashboard reference screenshot.
|
||||||
|
private var revenueCatCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
metricHeader
|
||||||
|
metricFooter
|
||||||
|
chart
|
||||||
|
legend
|
||||||
|
legendFooter
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Headline row: big average figure, signed change, right-aligned period.
|
||||||
|
/// Colours stay on the app palette — green when the move is good
|
||||||
|
/// (prices fell / gap narrowed), the accent tint when it rose.
|
||||||
|
@ViewBuilder
|
||||||
|
private var metricHeader: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
if let headAvg = headlineAverage {
|
||||||
|
Text(yLabel(headAvg))
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .default))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if let delta = headlineDelta, delta != 0 {
|
||||||
|
Label(
|
||||||
|
"\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p",
|
||||||
|
systemImage: deltaIsGood ? "arrow.down.right" : "arrow.up.right"
|
||||||
|
)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
|
.foregroundStyle(deltaIsGood ? Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) : Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(periodLabel)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line descriptor under the headline so the big figure (period
|
||||||
|
/// average across favourites) and the arrowed delta (change over the
|
||||||
|
/// window) are self-explanatory.
|
||||||
|
private var metricFooter: some View {
|
||||||
|
Text(chartFooterText)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chartFooterText: String {
|
||||||
|
switch mode {
|
||||||
|
case .price:
|
||||||
|
return "Average across favourites · change since the first day"
|
||||||
|
case .vsCheapest:
|
||||||
|
return "Average gap above the day's cheapest · change since the first day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var chart: some View {
|
private var chart: some View {
|
||||||
// NOTE: each LineMark MUST carry an explicit `series:` — without it
|
// NOTE: each LineMark MUST carry an explicit `series:` — without it
|
||||||
// Swift Charts merges every station's points into ONE polyline
|
// Swift Charts merges every station's points into ONE polyline
|
||||||
// (points connect across stations, so only the first station's line
|
// (points connect across stations, so only the first station's line
|
||||||
// is recognisable). The outer ForEach keeps one chart with N series;
|
// is recognisable). The outer ForEach keeps one chart with N series;
|
||||||
// per-mark foregroundStyle then colours each series from the palette.
|
// per-mark foregroundStyle then colours each series from the palette.
|
||||||
|
// The AreaMark under each LineMark adds the gradient fill.
|
||||||
|
//
|
||||||
|
// stacking: .unstacked is essential — the standard stacking mode
|
||||||
|
// piles each series' area ON TOP of the previous series' area, so
|
||||||
|
// with 2+ favourites the fills land in the wrong place (seen on
|
||||||
|
// device 2026-08-17: fills shifted/overlapping vs the lines).
|
||||||
|
// .unstacked draws each area from its own line down to the axis,
|
||||||
|
// the RevenueCat look.
|
||||||
|
// Two passes: ALL AreaMarks first, then ALL LineMarks. Same-series
|
||||||
|
// marks composite in insertion order — if fills and lines interleave,
|
||||||
|
// a later series' fill paints OVER an earlier series' line (the
|
||||||
|
// bottom favourite's line vanished under the next fill; seen on
|
||||||
|
// device 2026-08-17). Drawing every fill before every line keeps all
|
||||||
|
// lines on top of all fills, RevenueCat style. (Mark builders are
|
||||||
|
// extracted into small helpers — the inline expression grew past the
|
||||||
|
// type-checker's budget.)
|
||||||
Chart {
|
Chart {
|
||||||
ForEach(displaySeries) { history in
|
ForEach(displaySeries) { history in
|
||||||
ForEach(history.points) { point in
|
ForEach(history.points) { point in
|
||||||
LineMark(
|
areaMark(point, series: history.name,
|
||||||
x: .value("Date", point.date),
|
color: seriesColor(index(of: history.stationID)))
|
||||||
y: .value("Price", point.pence),
|
}
|
||||||
series: .value("Station", history.name)
|
}
|
||||||
)
|
ForEach(displaySeries) { history in
|
||||||
.foregroundStyle(seriesColor(index(of: history.stationID)))
|
ForEach(history.points) { point in
|
||||||
|
lineMark(point, series: history.name,
|
||||||
|
color: seriesColor(index(of: history.stationID)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,31 +406,83 @@ struct TrendsView: View {
|
|||||||
AxisGridLine()
|
AxisGridLine()
|
||||||
AxisTick()
|
AxisTick()
|
||||||
AxisValueLabel(format: .dateTime.month().day())
|
AxisValueLabel(format: .dateTime.month().day())
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.chartYScale(domain: yDomain)
|
||||||
.chartYAxis {
|
.chartYAxis {
|
||||||
AxisMarks { value in
|
AxisMarks { value in
|
||||||
AxisGridLine()
|
AxisGridLine()
|
||||||
AxisValueLabel {
|
AxisValueLabel {
|
||||||
if let pence = value.as(Double.self) {
|
if let pence = value.as(Double.self) {
|
||||||
Text(yLabel(pence))
|
Text(yLabel(pence))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 260)
|
.frame(height: 190)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One gradient-filled area band under a single point of a series.
|
||||||
|
/// yStart/yEnd bound the fill to `fillBand` pence under the line
|
||||||
|
/// instead of to the axis: full-height fills overlap and bury lower
|
||||||
|
/// lines when several favourites overlap (observed on device
|
||||||
|
/// 2026-08-17).
|
||||||
|
private func areaMark(_ point: PricePoint, series: String,
|
||||||
|
color: Color) -> some ChartContent {
|
||||||
|
AreaMark(
|
||||||
|
x: .value("Date", point.date),
|
||||||
|
yStart: .value("Price start", point.pence),
|
||||||
|
yEnd: .value("Price end", point.pence - fillBand),
|
||||||
|
series: .value("Station", series)
|
||||||
|
)
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [color.opacity(0.30), color.opacity(0.02)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.interpolationMethod(.monotone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line segment point for a series, drawn above every fill.
|
||||||
|
private func lineMark(_ point: PricePoint, series: String,
|
||||||
|
color: Color) -> some ChartContent {
|
||||||
|
LineMark(
|
||||||
|
x: .value("Date", point.date),
|
||||||
|
y: .value("Price", point.pence),
|
||||||
|
series: .value("Station", series)
|
||||||
|
)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round))
|
||||||
|
.interpolationMethod(.monotone)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func index(of stationID: String) -> Int {
|
private func index(of stationID: String) -> Int {
|
||||||
orderedStations.firstIndex(where: { $0.id == stationID }) ?? 0
|
orderedStations.firstIndex(where: { $0.id == stationID }) ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Key rows in the same vertical order as the chart: most expensive at the
|
||||||
|
/// top working down to cheapest at the bottom (the chart's y-axis puts
|
||||||
|
/// the cheapest line lowest). Sorts by average price — the bracketed
|
||||||
|
/// figure — descending, so the first key row matches the top line.
|
||||||
|
private var legendSeries: [StationHistory] {
|
||||||
|
displaySeries.sorted {
|
||||||
|
(FuelHistoryStore.averagePence($0.points) ?? 0)
|
||||||
|
> (FuelHistoryStore.averagePence($1.points) ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var legend: some View {
|
private var legend: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
ForEach(Array(displaySeries.enumerated()), id: \.element.stationID) { index, history in
|
ForEach(legendSeries, id: \.stationID) { history in
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(seriesColor(index))
|
.fill(seriesColor(index(of: history.stationID)))
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
Text(history.name)
|
Text(history.name)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|||||||
@@ -3,6 +3,17 @@
|
|||||||
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
||||||
// Shows the cheapest station for the pinned fuel within the app's chosen
|
// Shows the cheapest station for the pinned fuel within the app's chosen
|
||||||
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
||||||
|
//
|
||||||
|
// ADAPTIVE LAYOUT: ActivityConfiguration shares ONE content view across the
|
||||||
|
// Lock Screen, banner, and the CarPlay small slot — there is no per-platform
|
||||||
|
// closure. So this view provides two layouts and lets ViewThatFits pick by
|
||||||
|
// available width:
|
||||||
|
// • richBody — the full three-column design (glyph · fuel+station · price),
|
||||||
|
// wins wherever there's Lock Screen width (it carries an
|
||||||
|
// explicit minWidth so it can never be squeezed into the car).
|
||||||
|
// • compactBody — a minimal price-strip (glyph+fuel+price, station caption
|
||||||
|
// below) that wins in the CarPlay/Apple Watch Smart Stack
|
||||||
|
// small slot.
|
||||||
|
|
||||||
import ActivityKit
|
import ActivityKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -17,7 +28,7 @@ import WidgetKit
|
|||||||
struct FuelBoardLiveActivity: Widget {
|
struct FuelBoardLiveActivity: Widget {
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
|
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
|
||||||
// Lock Screen / banner presentation
|
// Lock Screen / banner / CarPlay small slot presentation
|
||||||
FuelBoardLiveActivityView(context: context)
|
FuelBoardLiveActivityView(context: context)
|
||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
@@ -44,43 +55,81 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lock Screen / banner body — the main presentation.
|
/// Lock Screen / banner / CarPlay body — adaptive.
|
||||||
private struct FuelBoardLiveActivityView: View {
|
private struct FuelBoardLiveActivityView: View {
|
||||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Link(destination: context.state.mapsURL) {
|
Link(destination: context.state.mapsURL) {
|
||||||
HStack(spacing: 12) {
|
ViewThatFits(in: .horizontal) {
|
||||||
// LEFT — station brand glyph
|
// rich first — wins on full-width Lock Screen / banner
|
||||||
Image(systemName: "fuelpump.circle.fill")
|
richBody
|
||||||
.font(.system(size: 32))
|
// CarPlay / Watch small slot is far narrower than this,
|
||||||
.foregroundStyle(.green, .white)
|
// so ViewThatFits reliably falls through to compactBody.
|
||||||
.frame(width: 40, height: 40)
|
.frame(minWidth: 280)
|
||||||
|
// compact fallback — the car's small supplemental slot
|
||||||
// MIDDLE — fuel + station
|
compactBody
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Cheapest \(context.state.fuel.displayName)")
|
|
||||||
.font(.headline)
|
|
||||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
|
|
||||||
// RIGHT — price
|
|
||||||
VStack(alignment: .trailing, spacing: 2) {
|
|
||||||
FuelStore.priceTextAttributed(context.state.pricePence,
|
|
||||||
style: context.state.priceDisplayStyle,
|
|
||||||
size: 22, weight: .bold)
|
|
||||||
Text("Tap for directions")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
|
||||||
|
private var richBody: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// LEFT — station brand glyph
|
||||||
|
Image(systemName: "fuelpump.circle.fill")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundStyle(.green, .white)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
// MIDDLE — fuel + station
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Cheapest \(context.state.fuel.displayName)")
|
||||||
|
.font(.headline)
|
||||||
|
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// RIGHT — price
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
|
style: context.state.priceDisplayStyle,
|
||||||
|
size: 22, weight: .bold)
|
||||||
|
Text("Tap for directions")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal strip for the small CarPlay / Watch Smart Stack slot:
|
||||||
|
/// glyph + fuel left, bold price right, truncated station below.
|
||||||
|
private var compactBody: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: "fuelpump.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text(context.state.fuel.displayName)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
|
style: context.state.priceDisplayStyle,
|
||||||
|
size: 15, weight: .bold)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Text(context.state.stationName)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dynamic Island expanded regions + compact trailing — price only.
|
/// Dynamic Island expanded regions + compact trailing — price only.
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)),
|
URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)),
|
||||||
]
|
]
|
||||||
guard let url = components.url else { return }
|
guard let url = components.url else { return }
|
||||||
var request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
|
let request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
|
||||||
Task {
|
Task {
|
||||||
_ = try? await URLSession.shared.data(for: request)
|
_ = try? await URLSession.shared.data(for: request)
|
||||||
}
|
}
|
||||||
@@ -312,7 +312,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
URLQueryItem(name: "radius", value: String(radiusKM)),
|
URLQueryItem(name: "radius", value: String(radiusKM)),
|
||||||
URLQueryItem(name: "limit", value: "500"),
|
URLQueryItem(name: "limit", value: "500"),
|
||||||
]
|
]
|
||||||
var request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
|
let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
|
||||||
do {
|
do {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||||
@@ -340,7 +340,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
URLQueryItem(name: "fuel", value: fuel.rawValue),
|
URLQueryItem(name: "fuel", value: fuel.rawValue),
|
||||||
URLQueryItem(name: "limit", value: String(limit)),
|
URLQueryItem(name: "limit", value: String(limit)),
|
||||||
]
|
]
|
||||||
var request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
|
let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
|
||||||
do {
|
do {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
#if canImport(AppIntents)
|
||||||
|
import AppIntents
|
||||||
|
#endif
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Fuel types
|
// MARK: - Fuel types
|
||||||
@@ -59,6 +62,18 @@ enum RAGRating: Int, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum FuelType: String, Codable, CaseIterable, Identifiable {
|
enum FuelType: String, Codable, CaseIterable, Identifiable {
|
||||||
|
#if canImport(AppIntents)
|
||||||
|
typealias DisplayRepresentation = AppIntents.DisplayRepresentation
|
||||||
|
typealias TypeDisplayRepresentation = AppIntents.TypeDisplayRepresentation
|
||||||
|
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel"
|
||||||
|
|
||||||
|
static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [
|
||||||
|
.e10: "Unleaded",
|
||||||
|
.e5: "Premium",
|
||||||
|
.diesel: "Diesel",
|
||||||
|
]
|
||||||
|
#endif
|
||||||
case e10 // Unleaded (E10)
|
case e10 // Unleaded (E10)
|
||||||
case e5 // Premium (E5)
|
case e5 // Premium (E5)
|
||||||
case diesel // B7 diesel
|
case diesel // B7 diesel
|
||||||
@@ -74,6 +89,10 @@ enum FuelType: String, Codable, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if canImport(AppIntents)
|
||||||
|
extension FuelType: AppEnum {}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Display unit for all distances in the app + widget. Internally distances
|
/// Display unit for all distances in the app + widget. Internally distances
|
||||||
/// are always stored/computed in km; conversion happens at the display and
|
/// are always stored/computed in km; conversion happens at the display and
|
||||||
/// filter boundary so nothing else needs to know the unit.
|
/// filter boundary so nothing else needs to know the unit.
|
||||||
@@ -516,7 +535,7 @@ struct FuelStore {
|
|||||||
let whole = tenths / 1000
|
let whole = tenths / 1000
|
||||||
let major = (tenths % 1000) / 10
|
let major = (tenths % 1000) / 10
|
||||||
let minor = tenths % 10
|
let minor = tenths % 10
|
||||||
let main = Text(String(format: "£%d.%02d", whole, major))
|
let amount = Text(String(format: "£%d.%02d", whole, major))
|
||||||
.font(base)
|
.font(base)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
let sup = Text(String(superscriptDigits[minor]))
|
let sup = Text(String(superscriptDigits[minor]))
|
||||||
@@ -526,7 +545,7 @@ struct FuelStore {
|
|||||||
let perL = Text("/L")
|
let perL = Text("/L")
|
||||||
.font(.system(size: size * 0.5, weight: .regular).monospaced())
|
.font(.system(size: size * 0.5, weight: .regular).monospaced())
|
||||||
.foregroundColor(color.opacity(0.55))
|
.foregroundColor(color.opacity(0.55))
|
||||||
return main + sup + perL
|
return Text("\(amount)\(sup)\(perL)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,7 +844,7 @@ struct FuelStore {
|
|||||||
// MARK: Low-level keychain helpers
|
// MARK: Low-level keychain helpers
|
||||||
|
|
||||||
private static func keychainData(service: String) -> Data? {
|
private static func keychainData(service: String) -> Data? {
|
||||||
var query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecReturnData as String: true,
|
kSecReturnData as String: true,
|
||||||
|
|||||||