From 7a84364238de6c82ca433e29fedb68e841fc1794 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Mon, 31 Aug 2026 07:48:20 +0100 Subject: [PATCH] watch: fix snapshot sync and polish favourites UX --- FuelBoard/WatchSyncManager.swift | 8 +- FuelBoardWatch Watch App/ContentView.swift | 197 +++++++++++------- .../WatchSyncManager.swift | 3 - 3 files changed, 132 insertions(+), 76 deletions(-) diff --git a/FuelBoard/WatchSyncManager.swift b/FuelBoard/WatchSyncManager.swift index 596b9d2..5053eb0 100644 --- a/FuelBoard/WatchSyncManager.swift +++ b/FuelBoard/WatchSyncManager.swift @@ -46,9 +46,11 @@ final class WatchSyncManager: NSObject, WCSessionDelegate { if let favourites = try? JSONEncoder().encode(FuelStore.loadFavourites()) { context[FuelStore.favouritesKey] = favourites } - if let stations = try? JSONEncoder().encode(FuelStore.loadStations()) { - context[FuelStore.stationsKey] = stations - } + // Do NOT send the full station dump to watchOS via WatchConnectivity. + // The UK dataset is megabytes large and exceeds application-context + // payload limits, which prevents any snapshot from arriving. The + // favourites payload already carries station snapshots + prices, so the + // watch can render favourites without the full list. context[FuelStore.fuelKey] = FuelStore.loadSelectedFuel().rawValue context[FuelStore.distanceUnitKey] = FuelStore.loadDistanceUnit().rawValue diff --git a/FuelBoardWatch Watch App/ContentView.swift b/FuelBoardWatch Watch App/ContentView.swift index c75de3d..d8f75c3 100644 --- a/FuelBoardWatch Watch App/ContentView.swift +++ b/FuelBoardWatch Watch App/ContentView.swift @@ -1,5 +1,6 @@ import SwiftUI import Foundation +import WatchKit private enum WatchFuelType: String, CaseIterable, Identifiable, Codable { case e10 @@ -102,6 +103,8 @@ private struct WatchFavouriteRow: Identifiable { let priceText: String let distanceText: String? let isCheapest: Bool + let latitude: Double + let longitude: Double } private enum WatchRefreshState { @@ -283,14 +286,30 @@ struct ContentView: View { @State private var distanceUnit: WatchDistanceUnit = WatchFuelCache.loadDistanceUnit() @State private var priceStyle: WatchPriceStyle = WatchFuelCache.loadPriceStyle() + private var displayedFuels: [WatchFuelType] { + let fuels = Set(favourites.map(\.fuel)) + let sorted = fuels.sorted { $0.sortRank < $1.sortRank } + return sorted.isEmpty ? [fuel] : sorted + } + + private var hasAnyFavourites: Bool { + !favourites.isEmpty + } + var body: some View { - TabView(selection: $fuel) { - ForEach(WatchFuelType.allCases) { fuelCase in - fuelPage(for: fuelCase) - .tag(fuelCase) + Group { + if hasAnyFavourites { + TabView(selection: $fuel) { + ForEach(displayedFuels) { fuelCase in + fuelPage(for: fuelCase) + .tag(fuelCase) + } + } + .tabViewStyle(.verticalPage) + } else { + emptyStateView } } - .tabViewStyle(.verticalPage) .onAppear { reloadFromCache() WatchSyncManager.shared.requestSnapshot() @@ -301,6 +320,7 @@ struct ContentView: View { .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { reloadFromCache() + WatchSyncManager.shared.requestSnapshot() } } } @@ -317,8 +337,6 @@ struct ContentView: View { if let cheapest { cheapestCard(for: fuelCase, favourite: cheapest) - } else { - emptyCheapestCard(for: fuelCase) } statusRow @@ -340,14 +358,8 @@ struct ContentView: View { Text("Favourites") .font(.headline) - if rows.isEmpty { - Text("No favourites for \(fuelCase.displayName.lowercased()) yet.") - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(rows) { row in - favouriteRow(row, fuel: fuelCase) - } + ForEach(rows) { row in + favouriteRow(row, fuel: fuelCase) } } } @@ -372,7 +384,9 @@ struct ContentView: View { name: favourite.station.name, priceText: priceText, distanceText: distanceText, - isCheapest: favourite.id == cheapest?.id + isCheapest: favourite.id == cheapest?.id, + latitude: favourite.station.lat, + longitude: favourite.station.lng ) } } @@ -392,44 +406,76 @@ struct ContentView: View { } } - private func cheapestCard(for fuel: WatchFuelType, favourite: WatchFavouriteEntry) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text("Cheapest favourite") - .font(.caption2) - .foregroundStyle(.secondary) - Text(favourite.station.name) - .font(.headline) - .lineLimit(2) - HStack(alignment: .firstTextBaseline) { - Text(favourite.station.prices[fuel].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—") - .font(.title3) - .fontWeight(.bold) - Spacer() - if let location = lastLocation { - Text(distanceUnit.format(km: favourite.station.distanceKM(to: location))) + private var emptyStateView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "fuelpump.fill") + .foregroundStyle(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0)) + Text("FuelBoard") + .font(.headline) + Spacer() + } + + statusRow + + Button { + runCheckNow() + } label: { + HStack { + Image(systemName: refreshState.icon) + Text("Check now") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0)) + + VStack(alignment: .leading, spacing: 6) { + Text("No favourites yet") + .font(.headline) + Text("Add favourites on iPhone and they’ll appear here.") .font(.caption) .foregroundStyle(.secondary) } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } + .padding(.horizontal, 10) + .padding(.vertical, 12) } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } - private func emptyCheapestCard(for fuel: WatchFuelType) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text("Cheapest favourite") - .font(.caption2) - .foregroundStyle(.secondary) - Text("No favourite saved") - .font(.headline) - Text("Add favourites on iPhone to see them here.") - .font(.caption) - .foregroundStyle(.secondary) + private func cheapestCard(for fuel: WatchFuelType, favourite: WatchFavouriteEntry) -> some View { + Button { + openMaps(latitude: favourite.station.lat, longitude: favourite.station.lng) + } label: { + VStack(alignment: .leading, spacing: 6) { + Text("Cheapest favourite") + .font(.caption2) + .foregroundStyle(.secondary) + Text(favourite.station.name) + .font(.headline) + .lineLimit(2) + HStack(alignment: .firstTextBaseline) { + Text(favourite.station.prices[fuel].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—") + .font(.title3) + .fontWeight(.bold) + Spacer() + if let location = lastLocation { + Text(distanceUnit.format(km: favourite.station.distanceKM(to: location))) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) + .buttonStyle(.plain) .background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) } @@ -441,34 +487,40 @@ struct ContentView: View { } private func favouriteRow(_ favourite: WatchFavouriteRow, fuel: WatchFuelType) -> some View { - HStack(spacing: 8) { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(favourite.name) - .font(.caption) - .fontWeight(favourite.isCheapest ? .semibold : .regular) - .lineLimit(1) - if favourite.isCheapest { - Text("TOP") - .font(.system(size: 9, weight: .bold)) - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background(fuel.color.opacity(0.18), in: Capsule()) + Button { + openMaps(latitude: favourite.latitude, longitude: favourite.longitude) + } label: { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(favourite.name) + .font(.caption) + .fontWeight(favourite.isCheapest ? .semibold : .regular) + .lineLimit(1) + if favourite.isCheapest { + Text("TOP") + .font(.system(size: 9, weight: .bold)) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(fuel.color.opacity(0.18), in: Capsule()) + } + } + if let distanceText = favourite.distanceText { + Text(distanceText) + .font(.caption2) + .foregroundStyle(.secondary) } } - if let distanceText = favourite.distanceText { - Text(distanceText) - .font(.caption2) - .foregroundStyle(.secondary) - } + Spacer() + Text(favourite.priceText) + .font(.callout.monospacedDigit()) + .fontWeight(.semibold) } - Spacer() - Text(favourite.priceText) - .font(.callout.monospacedDigit()) - .fontWeight(.semibold) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } - .padding(.horizontal, 10) - .padding(.vertical, 8) + .buttonStyle(.plain) .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } @@ -526,6 +578,11 @@ struct ContentView: View { formatter.unitsStyle = .short return formatter.localizedString(for: date, relativeTo: Date()) } + + private func openMaps(latitude: Double, longitude: Double) { + guard let url = URL(string: "maps://?daddr=\(latitude),\(longitude)&t=d") else { return } + WKExtension.shared().openSystemURL(url) + } } #Preview { diff --git a/FuelBoardWatch Watch App/WatchSyncManager.swift b/FuelBoardWatch Watch App/WatchSyncManager.swift index eb205da..ed6b500 100644 --- a/FuelBoardWatch Watch App/WatchSyncManager.swift +++ b/FuelBoardWatch Watch App/WatchSyncManager.swift @@ -59,9 +59,6 @@ final class WatchSyncManager: NSObject, WCSessionDelegate { if let favourites = payload["fuelboard.favourites"] as? Data { defaults.set(favourites, forKey: "fuelboard.favourites") } - if let stations = payload["fuelboard.stations"] as? Data { - defaults.set(stations, forKey: "fuelboard.stations") - } if let fuel = payload["fuelboard.selectedFuel"] as? String { defaults.set(fuel, forKey: "fuelboard.selectedFuel") }