diff --git a/FuelBoard/TrendsView.swift b/FuelBoard/TrendsView.swift index b8303a5..c6b747c 100644 --- a/FuelBoard/TrendsView.swift +++ b/FuelBoard/TrendsView.swift @@ -25,6 +25,7 @@ struct TrendsView: View { 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 @@ -34,6 +35,11 @@ struct TrendsView: View { @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], @@ -157,7 +163,28 @@ struct TrendsView: View { // pointer probe already done above. loadFailed = firstSnapshot == nil } - series = fetched + // 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). @@ -330,6 +357,9 @@ struct TrendsView: View { .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", @@ -389,13 +419,13 @@ struct TrendsView: View { // type-checker's budget.) Chart { ForEach(displaySeries) { history in - ForEach(history.points) { point 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) { point in + ForEach(history.points.prefix(revealCount)) { point in lineMark(point, series: history.name, color: seriesColor(index(of: history.stationID))) } @@ -424,6 +454,9 @@ struct TrendsView: View { } } .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.