- Reloads (range/fuel switch, Price<->vs-cheapest) now glide the existing lines + y-axis to the new data via withAnimation/.animation(value: mode) instead of snapping. - One-time left->right trace-in on first data appearance by revealing an increasing prefix of each series' points (area + line stay in sync); later switches morph rather than re-trace. - Headline average rolls its digits with .contentTransition(.numericText). - All animation honours .accessibilityReduceMotion (jump-cut to final state). Kept two-pass area/line ordering, explicit series:, bounded fill bands, and tight chartYScale unchanged.
561 lines
22 KiB
Swift
561 lines
22 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]
|
||
/// 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 priceDisplayStyle: PriceDisplayStyle
|
||
|
||
/// Propagated up to ContentView so a price-history fetch that fails with
|
||
/// NO data raises the global connection banner (same red banner as the
|
||
/// stations fetch). `onHistoryRecovered` fires once data loads again.
|
||
var onHistoryUnavailable: (() -> Void)? = nil
|
||
var onHistoryRecovered: (() -> Void)? = nil
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
|
||
@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?
|
||
|
||
/// Lines trace-in left→right on first appearance by revealing an
|
||
/// increasing prefix of each series' points. Stays at the full count after
|
||
/// the first reveal so range/mode switches morph instead of re-tracing.
|
||
@State private var revealCount: Int = 0
|
||
|
||
/// 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 {
|
||
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
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
#if DEBUG
|
||
// QA hook: force the unreachable state for screenshots (same pattern
|
||
// as -showTrends / -forceConnectionProblem). Runs before the fetch so
|
||
// the retry state renders immediately with no spinner flash.
|
||
if ProcessInfo.processInfo.arguments.contains("-forceHistoryFailure") {
|
||
series = []
|
||
loadFailed = true
|
||
onHistoryUnavailable?()
|
||
return
|
||
}
|
||
#endif
|
||
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
|
||
}
|
||
// Morph vs trace-in: a reload (range/fuel switch) glides the existing
|
||
// lines to the new data; the first real draw traces each line in
|
||
// left→right. Reduce Motion jumps straight to the final state.
|
||
let hadData = hasAnyData
|
||
if hadData {
|
||
withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.35)) {
|
||
series = fetched
|
||
}
|
||
} else {
|
||
series = fetched
|
||
let maxPoints = fetched.reduce(0) { max($0, $1.points.count) }
|
||
if maxPoints > 0 {
|
||
if reduceMotion {
|
||
revealCount = maxPoints
|
||
} else {
|
||
revealCount = 0
|
||
withAnimation(.easeOut(duration: 0.5)) {
|
||
revealCount = maxPoints
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// A failure with no data IS a connection problem — raise the global
|
||
// banner so the user isn't stuck with a silent retry state. Success
|
||
// clears it (only if the banner is the connection banner).
|
||
if loadFailed {
|
||
onHistoryUnavailable?()
|
||
} else if hasAnyData {
|
||
onHistoryRecovered?()
|
||
}
|
||
}
|
||
|
||
private func yLabel(_ pence: Double) -> String {
|
||
switch mode {
|
||
case .price:
|
||
return FuelStore.priceText(pence, style: priceDisplayStyle)
|
||
case .vsCheapest:
|
||
return String(format: "%.1fp", pence)
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
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 {
|
||
revenueCatCard
|
||
}
|
||
}
|
||
.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)
|
||
}
|
||
|
||
/// 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)
|
||
// Roll the digits to the new figure on range/mode change
|
||
// (fires inside the animated transaction above).
|
||
.contentTransition(.numericText(value: headAvg))
|
||
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 {
|
||
// NOTE: each LineMark MUST carry an explicit `series:` — without it
|
||
// Swift Charts merges every station's points into ONE polyline
|
||
// (points connect across stations, so only the first station's line
|
||
// is recognisable). The outer ForEach keeps one chart with N series;
|
||
// 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 {
|
||
ForEach(displaySeries) { history in
|
||
ForEach(history.points.prefix(revealCount)) { point in
|
||
areaMark(point, series: history.name,
|
||
color: seriesColor(index(of: history.stationID)))
|
||
}
|
||
}
|
||
ForEach(displaySeries) { history in
|
||
ForEach(history.points.prefix(revealCount)) { point in
|
||
lineMark(point, series: history.name,
|
||
color: seriesColor(index(of: history.stationID)))
|
||
}
|
||
}
|
||
}
|
||
.chartXAxis {
|
||
AxisMarks(values: .stride(by: .day, count: xStride)) { _ in
|
||
AxisGridLine()
|
||
AxisTick()
|
||
AxisValueLabel(format: .dateTime.month().day())
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.chartYScale(domain: yDomain)
|
||
.chartYAxis {
|
||
AxisMarks { value in
|
||
AxisGridLine()
|
||
AxisValueLabel {
|
||
if let pence = value.as(Double.self) {
|
||
Text(yLabel(pence))
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(height: 190)
|
||
// Price ↔ vs-cheapest is a pure view toggle (no network): animate the
|
||
// lines + axis gliding to the rebased series.
|
||
.animation(.easeInOut(duration: 0.35), value: mode)
|
||
}
|
||
|
||
/// 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 {
|
||
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 {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
ForEach(legendSeries, id: \.stationID) { history in
|
||
HStack(spacing: 8) {
|
||
Circle()
|
||
.fill(seriesColor(index(of: history.stationID)))
|
||
.frame(width: 8, height: 8)
|
||
Text(history.name)
|
||
.font(.caption)
|
||
.lineLimit(1)
|
||
if let avg = FuelHistoryStore.averagePence(history.points) {
|
||
Text(legendFigure(avg))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.monospacedDigit()
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 4)
|
||
}
|
||
|
||
/// The bracket figure in the chart key: absolute pence in Price mode,
|
||
/// signed pence above the day's cheapest in vs-cheapest mode — always
|
||
/// pence, matching the list rows (the y-axis follows the display toggle).
|
||
private func legendFigure(_ pence: Double) -> String {
|
||
switch mode {
|
||
case .price:
|
||
return String(format: "%.1fp", pence)
|
||
case .vsCheapest:
|
||
return pence > 0 ? String(format: "+%.1fp", pence) : String(format: "%.1fp", pence)
|
||
}
|
||
}
|
||
|
||
/// One-line descriptor under the key so the brackets are self-explanatory.
|
||
private var legendFooter: some View {
|
||
Group {
|
||
if mode == .price {
|
||
Text("Average price over the days shown")
|
||
} else {
|
||
Text("Average pence above the day's cheapest favourite")
|
||
}
|
||
}
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|