The no-data states previously floated or centred vertically (Spacer sandwich), so the sheet rendered them halfway down the page while the chart-with-data anchors to the top. All chartArea states now share one top-aligned container that fills the space below the pickers — the empty state sits exactly where the chart will render, and the layout no longer jumps between states. 83 tests, Release build green.
258 lines
9.1 KiB
Swift
258 lines
9.1 KiB
Swift
import SwiftUI
|
|
import Charts
|
|
|
|
/// Trends — the Favourites price-history chart.
|
|
///
|
|
/// Plots one line per favourited station for the active fuel across the
|
|
/// selected range (7/30/90 days), fed by the GitHub price mirror
|
|
/// (`FuelHistoryStore`). Default shows absolute prices; "vs cheapest" rebases
|
|
/// each day to the cheapest favourite (0 baseline, signed pence above it) —
|
|
/// the same delta pattern the list rows already use. Missing days are gaps,
|
|
/// never fabricated. No widget in v1 (one-kind rule).
|
|
struct TrendsView: View {
|
|
/// All favourites (fuel-scoped entries) — the sheet derives the active
|
|
/// fuel's stations and which fuels have favourites.
|
|
let favourites: [FavouriteEntry]
|
|
let selectedFuel: FuelType
|
|
let priceDisplayStyle: PriceDisplayStyle
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var fuel: FuelType = .e10
|
|
@State private var rangeDays: Int = 30
|
|
@State private var mode: TrendsMode = .price
|
|
@State private var series: [StationHistory] = []
|
|
@State private var isLoading = false
|
|
@State private var loadFailed = false
|
|
@State private var firstSnapshot: String?
|
|
|
|
enum TrendsMode: String, CaseIterable, Identifiable {
|
|
case price
|
|
case vsCheapest
|
|
var id: String { rawValue }
|
|
}
|
|
|
|
/// Fuels that currently have at least one favourite — only these tabs show.
|
|
private var availableFuels: [FuelType] {
|
|
FuelType.allCases.filter { fuel in
|
|
favourites.contains { $0.fuel == fuel }
|
|
}
|
|
}
|
|
|
|
/// The active fuel, with the same fallback as the Favourites tab.
|
|
private var activeFuel: FuelType {
|
|
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
|
}
|
|
|
|
/// Stations favourited for the active fuel, in the user's stored order —
|
|
/// the chart keeps this order so line colours are stable.
|
|
private var orderedStations: [(id: String, name: String)] {
|
|
favourites.filter { $0.fuel == activeFuel }.map { ($0.station.id, $0.station.name) }
|
|
}
|
|
|
|
private var displaySeries: [StationHistory] {
|
|
mode == .price ? series : FuelHistoryStore.deltaSeries(series)
|
|
}
|
|
|
|
/// A line needs at least two points to draw; anything less is the
|
|
/// "building up" state, not a broken chart.
|
|
private var hasEnoughData: Bool {
|
|
series.contains { $0.points.count >= 2 }
|
|
}
|
|
|
|
private var hasAnyData: Bool {
|
|
series.contains { !$0.points.isEmpty }
|
|
}
|
|
|
|
private func seriesColor(_ index: Int) -> Color {
|
|
let palette: [Color] = [.blue, .orange, .purple, .pink, .teal, .indigo, .brown, .green]
|
|
return palette[index % palette.count]
|
|
}
|
|
|
|
/// X-axis tick density — a tick per day for short ranges, monthly for the
|
|
/// 90-day view so labels never collide.
|
|
private var xStride: Int {
|
|
switch rangeDays {
|
|
case ...14: return 1
|
|
case 15...60: return 7
|
|
default: return 30
|
|
}
|
|
}
|
|
|
|
private func load() async {
|
|
isLoading = true
|
|
loadFailed = false
|
|
defer { isLoading = false }
|
|
// The pointer is a non-fatal hint for the empty state; history fetch
|
|
// failures surface as the retry state.
|
|
firstSnapshot = await FuelHistoryStore.fetchLatest()?.availableFrom
|
|
let fetched = await FuelHistoryStore.fetchHistory(
|
|
stations: orderedStations,
|
|
fuel: activeFuel,
|
|
days: rangeDays
|
|
)
|
|
if fetched.allSatisfy({ $0.points.isEmpty }), !orderedStations.isEmpty {
|
|
// All days missing — either the mirror is unreachable (retry) or
|
|
// genuinely empty (the building-up state). Distinguish by a quick
|
|
// pointer probe already done above.
|
|
loadFailed = firstSnapshot == nil
|
|
}
|
|
series = fetched
|
|
}
|
|
|
|
private func yLabel(_ pence: Double) -> String {
|
|
switch mode {
|
|
case .price:
|
|
return FuelStore.priceText(pence, style: priceDisplayStyle)
|
|
case .vsCheapest:
|
|
return String(format: "%.1fp", pence)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 14) {
|
|
FuelTypeSegmentedPicker(selection: $fuel, fuels: availableFuels)
|
|
|
|
HStack(spacing: 10) {
|
|
Picker("Range", selection: $rangeDays) {
|
|
ForEach(FuelHistoryStore.rangeOptions, id: \.self) { days in
|
|
Text("\(days) days").tag(days)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
Picker("Mode", selection: $mode) {
|
|
Text("Price").tag(TrendsMode.price)
|
|
Text("vs cheapest").tag(TrendsMode.vsCheapest)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
|
|
chartArea
|
|
}
|
|
.padding()
|
|
.navigationTitle("Trends")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") { dismiss() }
|
|
}
|
|
}
|
|
.task(id: "\(activeFuel.rawValue)-\(rangeDays)") {
|
|
await load()
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var chartArea: some View {
|
|
// Every state anchors to the TOP of the chart slot — the same spot the
|
|
// chart occupies when data exists. The empty/loading/error states must
|
|
// not float or centre in the sheet, or the layout jumps between states.
|
|
Group {
|
|
if isLoading {
|
|
VStack(spacing: 12) {
|
|
ProgressView("Fetching price history…")
|
|
}
|
|
.padding(.top, 24)
|
|
} else if loadFailed {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "wifi.exclamationmark")
|
|
.font(.system(size: 32))
|
|
.foregroundStyle(.secondary)
|
|
Text("Couldn't load price history")
|
|
.font(.headline)
|
|
Button("Retry") { Task { await load() } }
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.padding(.top, 24)
|
|
} else if !hasAnyData {
|
|
emptyState
|
|
} else if !hasEnoughData {
|
|
emptyState // single point — nothing to draw yet
|
|
} else {
|
|
VStack(spacing: 12) {
|
|
chart
|
|
legend
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "chart.xyaxis.line")
|
|
.font(.system(size: 32))
|
|
.foregroundStyle(.secondary)
|
|
Text("No price history yet")
|
|
.font(.headline)
|
|
if let firstSnapshot {
|
|
Text("First snapshot \(firstSnapshot) — a few days are needed to draw a trend.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
} else {
|
|
Text("Prices are recorded each day FuelBoard's relay runs — check back in a few days.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 24)
|
|
}
|
|
|
|
private var chart: some View {
|
|
Chart(displaySeries) { history in
|
|
ForEach(history.points) { point in
|
|
LineMark(
|
|
x: .value("Date", point.date),
|
|
y: .value("Price", point.pence)
|
|
)
|
|
.foregroundStyle(seriesColor(index(of: history.stationID)))
|
|
}
|
|
}
|
|
.chartXAxis {
|
|
AxisMarks(values: .stride(by: .day, count: xStride)) { _ in
|
|
AxisGridLine()
|
|
AxisTick()
|
|
AxisValueLabel(format: .dateTime.month().day())
|
|
}
|
|
}
|
|
.chartYAxis {
|
|
AxisMarks { value in
|
|
AxisGridLine()
|
|
AxisValueLabel {
|
|
if let pence = value.as(Double.self) {
|
|
Text(yLabel(pence))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 260)
|
|
}
|
|
|
|
private func index(of stationID: String) -> Int {
|
|
orderedStations.firstIndex(where: { $0.id == stationID }) ?? 0
|
|
}
|
|
|
|
private var legend: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(Array(displaySeries.enumerated()), id: \.element.stationID) { index, history in
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(seriesColor(index))
|
|
.frame(width: 8, height: 8)
|
|
Text(history.name)
|
|
.font(.caption)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 4)
|
|
}
|
|
}
|