diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 2fa4a740b..b19f57edb 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -41,6 +41,7 @@ import 'package:dpip/features/sponsor/presentation/pages/sponsor_page.dart'; import 'package:dpip/features/bug_tracker/presentation/pages/bug_list_page.dart'; import 'package:dpip/features/bug_tracker/presentation/pages/bug_thread_page.dart'; import 'package:dpip/features/status/presentation/pages/server_status_page.dart'; +import 'package:dpip/features/weather/domain/weather_ranking.dart'; import 'package:dpip/features/weather/presentation/pages/weather_ranking_page.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; @@ -148,11 +149,15 @@ final GoRouter appRouter = GoRouter( GoRoute( path: AppRoutes.weatherRankingPath, name: AppRoutes.weatherRanking, - builder: (_, state) => WeatherRankingPage( - initialTab: WeatherRankingTab.parse( - state.uri.queryParameters['tab'], - ), - ), + builder: (_, state) { + final rawTab = state.uri.queryParameters['tab']; + return WeatherRankingPage( + initialTab: WeatherRankingTab.parse(rawTab), + initialTempMetric: TempRankMetric.parse( + state.uri.queryParameters['metric'] ?? rawTab, + ), + ); + }, ), GoRoute( path: AppRoutes.moonPath, diff --git a/lib/features/weather/domain/weather_ranking.dart b/lib/features/weather/domain/weather_ranking.dart index 6e03d02c8..0c9d7a01c 100644 --- a/lib/features/weather/domain/weather_ranking.dart +++ b/lib/features/weather/domain/weather_ranking.dart @@ -18,6 +18,37 @@ enum RankingMerge { county, } +/// Which temperature figure a ranking shows: the observation itself (at +/// whichever snapshot the list is drawn from) or one of the day's recorded +/// extremes. +/// +/// Temperature is the only metric whose extremes the v5 snapshot carries +/// (`hi` / `lo` with their occurrence times), so it is the only one the app can +/// rank as an extreme without inventing one. +enum TempRankMetric { + /// The temperature recorded at the snapshot. + current, + + /// The day's recorded maximum (`hi`). + high, + + /// The day's recorded minimum (`lo`). + low, + + /// Diurnal range (`hi − lo`). + range; + + /// Parses the deep-link values the Data hub links use: the current metric, + /// and the aliases the standalone daily-extremes tab had before it became a + /// chip inside the temperature tab (`?tab=tempExtremes`, `?tab=low`, …). + static TempRankMetric parse(String? raw) => switch (raw) { + 'high' || 'tempHigh' || 'tempExtremes' || 'extremes' => TempRankMetric.high, + 'low' || 'tempLow' => TempRankMetric.low, + 'range' => TempRankMetric.range, + _ => TempRankMetric.current, + }; +} + /// Which daily-temperature extreme to rank by. enum TempExtremeMetric { /// Recorded daily maximum (`hi`). @@ -177,6 +208,38 @@ List rankTempExtremes({ ); } +/// Ranks temperature by [metric]: the reading in the snapshot itself, or one of +/// the day's recorded extremes. One entry point so the temperature tab does not +/// have to know which of the two implementations backs the chip it is on. +List rankTemperature({ + required Map stations, + required WeatherSnapshot snapshot, + required TempRankMetric metric, + required bool ascending, + required RankingMerge merge, +}) => switch (metric) { + TempRankMetric.current => rankWeather( + stations: stations, + snapshot: snapshot, + valueOf: (o) => o.temperature, + ascending: ascending, + merge: merge, + ), + TempRankMetric.high || + TempRankMetric.low || + TempRankMetric.range => rankTempExtremes( + stations: stations, + snapshot: snapshot, + metric: switch (metric) { + TempRankMetric.high => TempExtremeMetric.high, + TempRankMetric.low => TempExtremeMetric.low, + _ => TempExtremeMetric.range, + }, + ascending: ascending, + merge: merge, + ), +}; + class RankInput { const RankInput({ required this.id, diff --git a/lib/features/weather/presentation/pages/weather_ranking_page.dart b/lib/features/weather/presentation/pages/weather_ranking_page.dart index c5ca597d2..88187fdd5 100644 --- a/lib/features/weather/presentation/pages/weather_ranking_page.dart +++ b/lib/features/weather/presentation/pages/weather_ranking_page.dart @@ -2,8 +2,10 @@ library; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/geo/geo_math.dart'; +import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/features/weather/domain/meteor_rain_repository.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; @@ -12,6 +14,7 @@ import 'package:dpip/features/weather/domain/rain_snapshot.dart'; import 'package:dpip/features/weather/domain/weather_ranking.dart'; import 'package:dpip/features/weather/domain/weather_snapshot.dart'; import 'package:dpip/features/weather/domain/weather_station.dart'; +import 'package:dpip/features/weather/presentation/widgets/ranking_time_sheet.dart'; import 'package:dpip/features/weather/presentation/widgets/weather_ranking_row.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_station_handoff.dart'; @@ -26,28 +29,30 @@ import 'package:provider/provider.dart'; /// Which metric tab to open (from the Data hub deep-link `?tab=`). /// -/// - [temperature] ranks **current** `temp` (當下氣溫). -/// - [tempExtremes] ranks recorded daily `hi` / `lo` / `hi−lo` with occurrence -/// times (`hit` / `lot`) and a full analysis line. +/// - [temperature] ranks the reading itself or, through [TempRankMetric], one +/// of the day's recorded extremes (`hi` / `lo` / `hi−lo`) with occurrence +/// times (`hit` / `lot`). The standalone daily-extremes tab was folded in +/// here: two tabs over the same field made it look as though only +/// temperature had extremes. /// - Other tabs cover rain / wind / gust / humidity / pressure. enum WeatherRankingTab { rain, temperature, - tempExtremes, wind, gust, humidity, pressure; static WeatherRankingTab parse(String? raw) => switch (raw) { - 'temperature' || 'temp' => WeatherRankingTab.temperature, + 'temperature' || + 'temp' || 'tempExtremes' || 'extremes' || 'tempHigh' || 'high' || 'tempLow' || 'low' || - 'range' => WeatherRankingTab.tempExtremes, + 'range' => WeatherRankingTab.temperature, 'wind' => WeatherRankingTab.wind, 'gust' => WeatherRankingTab.gust, 'pressure' || 'pres' => WeatherRankingTab.pressure, @@ -61,7 +66,6 @@ enum WeatherRankingTab { String label(AppLocalizations l10n) => switch (this) { WeatherRankingTab.rain => l10n.mapLayerRain, WeatherRankingTab.temperature => l10n.mapLayerTemperature, - WeatherRankingTab.tempExtremes => l10n.weatherRankingTempExtremes, WeatherRankingTab.wind => l10n.weatherRankingWind, WeatherRankingTab.gust => l10n.weatherRankingGust, WeatherRankingTab.humidity => l10n.mapLayerHumidity, @@ -71,7 +75,6 @@ enum WeatherRankingTab { IconData get icon => switch (this) { WeatherRankingTab.rain => Icons.umbrella_outlined, WeatherRankingTab.temperature => Icons.thermostat_outlined, - WeatherRankingTab.tempExtremes => Icons.thermostat_auto_outlined, WeatherRankingTab.wind => Icons.air_outlined, WeatherRankingTab.gust => Icons.storm_outlined, WeatherRankingTab.humidity => Icons.water_drop_outlined, @@ -82,14 +85,26 @@ enum WeatherRankingTab { /// Gust has no dedicated layer — land on wind. String get mapLayerId => switch (this) { WeatherRankingTab.rain => 'rain', - WeatherRankingTab.temperature || - WeatherRankingTab.tempExtremes => 'temperature', + WeatherRankingTab.temperature => 'temperature', WeatherRankingTab.wind || WeatherRankingTab.gust => 'wind', WeatherRankingTab.humidity => 'humidity', WeatherRankingTab.pressure => 'pressure', }; } +/// Localised labels for [TempRankMetric] (domain stays Flutter-free). +extension on TempRankMetric { + String label(AppLocalizations l10n) => switch (this) { + TempRankMetric.current => l10n.weatherRankingCurrent, + TempRankMetric.high => l10n.weatherRankingExtremeHigh, + TempRankMetric.low => l10n.weatherRankingExtremeLow, + TempRankMetric.range => l10n.weatherRankingExtremeRange, + }; + + /// Coldest-first for the daily low, hottest/widest otherwise. + bool get ascendingByDefault => this == TempRankMetric.low; +} + /// Localised labels for [RainInterval] (domain stays Flutter-free). extension on RainInterval { String label(AppLocalizations l10n) => switch (this) { @@ -117,7 +132,9 @@ class _RankingData { final Map weatherStations; final WeatherSnapshot weather; final Map rainStations; - final RainSnapshot rain; + + /// `null` when a picked time has no rain snapshot at or before it. + final RainSnapshot? rain; } /// Tabbed station ranking under the Data hub. @@ -125,10 +142,16 @@ class WeatherRankingPage extends StatefulWidget { const WeatherRankingPage({ super.key, this.initialTab = WeatherRankingTab.rain, + this.initialTempMetric = TempRankMetric.current, }); final WeatherRankingTab initialTab; + /// Which temperature figure the temperature tab opens on. The Data hub's + /// 「溫度極值」 tile lands here with [TempRankMetric.high] instead of opening a + /// tab of its own. + final TempRankMetric initialTempMetric; + @override State createState() => _WeatherRankingPageState(); } @@ -139,6 +162,26 @@ class _WeatherRankingPageState extends State late Future> _future; _RankingData? _loaded; + /// The chosen historical snapshot, or `null` for the newest one — which is + /// fetched through `latest()` (not pinned to a timestamp), so a refresh keeps + /// the default list live instead of freezing it an hour back. + int? _selectedSecond; + + /// Snapshot times each dataset can still serve, ascending. Empty until the + /// history endpoints answer — and when they fail, which only costs the + /// picker: the newest ranking is still worth showing. + List _weatherTimes = const []; + List _rainTimes = const []; + + /// Time of the newest weather snapshot last loaded, for the picker's + /// 「最新」 row. Null until the first load lands. + int? _latestWeatherTime; + + /// Snapshots already fetched for a chosen time. History snapshots are + /// immutable and addressed by their second, so re-picking one costs nothing. + final Map _weatherAt = {}; + final Map _rainAt = {}; + static const _order = WeatherRankingTab.values; @override @@ -152,6 +195,9 @@ class _WeatherRankingPageState extends State .clamp(0, _order.length - 1), ); _future = _load(); + // Independent of the ranking itself: the list must not wait for a picker + // that may never be opened. + _loadHistory().ignore(); } @override @@ -160,7 +206,7 @@ class _WeatherRankingPageState extends State super.dispose(); } - Future> _load() async { + Future> _load({int? second}) async { final weatherRepo = context.read(); final rainRepo = context.read(); @@ -168,7 +214,7 @@ class _WeatherRankingPageState extends State final weatherStationsErr = weatherStations.failureOrNull; if (weatherStationsErr != null) return Err(weatherStationsErr); - final weather = await weatherRepo.latest(); + final weather = await _weatherSnapshot(weatherRepo, second); final weatherErr = weather.failureOrNull; if (weatherErr != null) return Err(weatherErr); @@ -176,22 +222,136 @@ class _WeatherRankingPageState extends State final rainStationsErr = rainStations.failureOrNull; if (rainStationsErr != null) return Err(rainStationsErr); - final rain = await rainRepo.latest(); + final rain = await _rainSnapshot(rainRepo, second); final rainErr = rain.failureOrNull; if (rainErr != null) return Err(rainErr); - return Ok( - _RankingData( - weatherStations: weatherStations.valueOrNull!, - weather: weather.valueOrNull!, - rainStations: rainStations.valueOrNull!, - rain: rain.valueOrNull!, - ), + final data = _RankingData( + weatherStations: weatherStations.valueOrNull!, + weather: weather.valueOrNull!, + rainStations: rainStations.valueOrNull!, + rain: rain.valueOrNull, + ); + if (second == null) _latestWeatherTime = data.weather.time; + return Ok(data); + } + + /// The weather snapshot for [second]: `latest()` while the list is live, + /// otherwise the newest history time at or before it. + Future> _weatherSnapshot( + MeteorWeatherRepository repo, + int? second, + ) async { + if (second == null) return repo.latest(); + final cached = _weatherAt[second]; + if (cached != null) return Ok(cached); + final resolved = _atOrBefore(_weatherTimes, second); + // The picker only offers weather times, so this is a history that has + // since gone away. Drawing `latest()` here would put the newest readings + // under a past time in the app bar. + if (resolved == null) { + return Err(NoDataFailure('no weather snapshot at or before $second')); + } + final result = await repo.at(resolved); + final snapshot = result.valueOrNull; + if (snapshot != null) _weatherAt[second] = snapshot; + return result; + } + + /// The rain counterpart of [_weatherSnapshot] — rain keeps a finer history + /// (10-minute steps), so the same chosen time usually resolves to its own + /// nearest snapshot. + /// + /// `Ok(null)` when rain has nothing at or before [second]: its history + /// failed, or it does not reach back that far. The rain tab says so; the + /// weather tabs for that time are still good. + Future> _rainSnapshot( + MeteorRainRepository repo, + int? second, + ) async { + if (second == null) return repo.latest(); + final cached = _rainAt[second]; + if (cached != null) return Ok(cached); + final resolved = _atOrBefore(_rainTimes, second); + if (resolved == null) return const Ok(null); + final result = await repo.at(resolved); + final snapshot = result.valueOrNull; + if (snapshot != null) _rainAt[second] = snapshot; + return result; + } + + /// The newest time in [times] at or before [second], or `null` when there + /// is none. Never a later time: that would be a snapshot from after the + /// one the user asked for, drawn under the time they picked. + static int? _atOrBefore(List times, int second) { + int? best; + for (final time in times) { + if (time <= second && (best == null || time > best)) best = time; + } + return best; + } + + /// Loads the snapshot-time lists the picker offers. Failures are logged and + /// swallowed: the newest ranking does not depend on them, and the next + /// refresh tries again. + Future _loadHistory() async { + final weatherRepo = context.read(); + final rainRepo = context.read(); + final weather = await _timesFrom(weatherRepo.history()); + final rain = await _timesFrom(rainRepo.history()); + if (!mounted) return; + setState(() { + _weatherTimes = weather; + _rainTimes = rain; + }); + } + + Future> _timesFrom(Future>> pending) async { + try { + final result = await pending; + final times = result.valueOrNull; + return times == null ? const [] : (List.of(times)..sort()); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'ranking history'); + return const []; + } + } + + /// Weather drives the picker: it is the coarser of the two histories, and + /// five of the six tabs read it. Offering rain's times when weather has none + /// would pick seconds the weather tabs cannot draw, so no weather history + /// means no picker. + List get _pickerTimes => _weatherTimes; + + int? get _newestTime => + _latestWeatherTime ?? + (_weatherTimes.isNotEmpty ? _weatherTimes.last : null); + + Future _pickTime() async { + final times = _pickerTimes; + final newest = _newestTime; + if (times.isEmpty || newest == null) return; + final choice = await showRankingTimeSheet( + context: context, + times: times, + latest: newest, + selected: _selectedSecond, ); + if (!mounted || choice == null) return; + final second = choice.second; + if (second == _selectedSecond) return; + final next = _load(second: second); + setState(() { + _selectedSecond = second; + _future = next; + }); + // The FutureBuilder owns the outcome; the error surface already shows a + // failed pick, so awaiting here would only rethrow it into the zone. + next.ignore(); } Future _reload() async { - final next = _load(); + final next = _load(second: _selectedSecond); // A block, not an arrow: the assignment expression's value is the future, // and `setState` rejects a callback that returns one — in debug that throw // lands before the frame is even marked dirty, so pull-to-refresh never @@ -199,7 +359,7 @@ class _WeatherRankingPageState extends State setState(() { _future = next; }); - await next; + await Future.wait([next, _loadHistory()]); } Widget _tabsFor(_RankingData value) => TabBarView( @@ -216,19 +376,10 @@ class _WeatherRankingPageState extends State mapLayerId: tab.mapLayerId, onRefresh: _reload, ), - WeatherRankingTab.temperature => _WeatherMetricPanel( - stations: value.weatherStations, - snapshot: value.weather, - valueOf: (o) => o.temperature, - unit: '°C', - fractionDigits: 1, - highLowSort: true, - mapLayerId: tab.mapLayerId, - onRefresh: _reload, - ), - WeatherRankingTab.tempExtremes => _TempExtremePanel( + WeatherRankingTab.temperature => _TemperaturePanel( stations: value.weatherStations, snapshot: value.weather, + initialMetric: widget.initialTempMetric, mapLayerId: tab.mapLayerId, onRefresh: _reload, ), @@ -278,9 +429,30 @@ class _WeatherRankingPageState extends State @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); + final selected = _selectedSecond; return Scaffold( appBar: AppBar( title: Text(l10n.weatherRankingTitle), + actions: [ + if (_pickerTimes.isNotEmpty) + Padding( + padding: const EdgeInsets.only(right: AppSpacing.sm), + child: Tooltip( + message: l10n.weatherRankingPickTime, + child: TextButton.icon( + onPressed: _pickTime, + icon: const Icon(Icons.schedule, size: 18), + // A past snapshot is not the current weather, so the label + // stops saying 「最新」 the moment the user leaves it. + label: Text( + selected == null + ? l10n.weatherRankingLatest + : rankingTimeLabel(selected), + ), + ), + ), + ), + ], bottom: TabBar( controller: _tabs, isScrollable: true, @@ -385,7 +557,9 @@ class _RainRankingPanel extends StatefulWidget { }); final Map stations; - final RainSnapshot snapshot; + + /// `null` when the picked time has no rain snapshot at or before it. + final RainSnapshot? snapshot; final String mapLayerId; final Future Function() onRefresh; @@ -405,21 +579,21 @@ class _RainRankingPanelState extends State<_RainRankingPanel> { RainSnapshot? _rankedSnapshot; RainInterval? _rankedInterval; - List _ranked() { + List _ranked(RainSnapshot snapshot) { final cached = _rankedCache; if (cached != null && identical(_rankedStations, widget.stations) && - identical(_rankedSnapshot, widget.snapshot) && + identical(_rankedSnapshot, snapshot) && _rankedInterval == _interval) { return cached; } final ranked = rankRain( stations: widget.stations, - snapshot: widget.snapshot, + snapshot: snapshot, interval: _interval, ); _rankedStations = widget.stations; - _rankedSnapshot = widget.snapshot; + _rankedSnapshot = snapshot; _rankedInterval = _interval; return _rankedCache = ranked; } @@ -427,9 +601,29 @@ class _RainRankingPanelState extends State<_RainRankingPanel> { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); + final snapshot = widget.snapshot; + if (snapshot == null) { + // No snapshot at or before the picked time. Saying so beats drawing a + // later one under the time in the app bar. + return RefreshIndicator( + onRefresh: widget.onRefresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: 240, + child: EmptyView( + icon: Icons.umbrella_outlined, + message: l10n.weatherRankingRainMissingAt, + ), + ), + ], + ), + ); + } final colors = Theme.of(context).colorScheme; - final ranked = _ranked(); - final time = _formatSnapshotTime(widget.snapshot.time); + final ranked = _ranked(snapshot); + final time = _formatSnapshotTime(snapshot.time); return RefreshIndicator( onRefresh: widget.onRefresh, @@ -523,7 +717,6 @@ class _WeatherMetricPanel extends StatefulWidget { required this.onRefresh, this.windDirectionOf, this.eventTimeOf, - this.highLowSort = false, this.requirePositive = false, }); @@ -536,7 +729,6 @@ class _WeatherMetricPanel extends StatefulWidget { final int fractionDigits; final String mapLayerId; final Future Function() onRefresh; - final bool highLowSort; final bool requirePositive; @override @@ -625,11 +817,7 @@ class _WeatherMetricPanelState extends State<_WeatherMetricPanel> { Padding( padding: const EdgeInsets.only(right: AppSpacing.sm), child: ChoiceChip( - label: Text( - widget.highLowSort - ? l10n.weatherRankingHighest - : l10n.reportFilterOrderDesc, - ), + label: Text(l10n.reportFilterOrderDesc), selected: !_ascending, onSelected: (_) => setState(() => _ascending = false), ), @@ -637,11 +825,7 @@ class _WeatherMetricPanelState extends State<_WeatherMetricPanel> { Padding( padding: const EdgeInsets.only(right: AppSpacing.sm), child: ChoiceChip( - label: Text( - widget.highLowSort - ? l10n.weatherRankingLowest - : l10n.reportFilterOrderAsc, - ), + label: Text(l10n.reportFilterOrderAsc), selected: _ascending, onSelected: (_) => setState(() => _ascending = true), ), @@ -755,46 +939,82 @@ class _WeatherMetricPanelState extends State<_WeatherMetricPanel> { } } -/// Daily temperature extremes: rank by recorded high / low / diurnal range. -class _TempExtremePanel extends StatefulWidget { - const _TempExtremePanel({ +/// Temperature: the reading in the snapshot, or one of the day's recorded +/// extremes (high / low / diurnal range) with its occurrence time. +/// +/// The extremes used to be a tab of their own, which read as "only temperature +/// has extremes". They are one field with the reading, so they share one tab +/// and one chip strip. +class _TemperaturePanel extends StatefulWidget { + const _TemperaturePanel({ required this.stations, required this.snapshot, + required this.initialMetric, required this.mapLayerId, required this.onRefresh, }); final Map stations; final WeatherSnapshot snapshot; + final TempRankMetric initialMetric; final String mapLayerId; final Future Function() onRefresh; @override - State<_TempExtremePanel> createState() => _TempExtremePanelState(); + State<_TemperaturePanel> createState() => _TemperaturePanelState(); } -class _TempExtremePanelState extends State<_TempExtremePanel> { - TempExtremeMetric _metric = TempExtremeMetric.high; - bool _ascending = false; +class _TemperaturePanelState extends State<_TemperaturePanel> { + late TempRankMetric _metric = widget.initialMetric; + late bool _ascending = widget.initialMetric.ascendingByDefault; RankingMerge _merge = RankingMerge.none; + /// Same identity cache as [_WeatherMetricPanelState._ranked]: a tab swipe + /// rebuilds this panel, and re-sorting several hundred stations for the same + /// answer is the stutter. + List? _rankedCache; + Map? _rankedStations; + WeatherSnapshot? _rankedSnapshot; + TempRankMetric _rankedMetric = TempRankMetric.current; + bool _rankedAscending = false; + RankingMerge _rankedMerge = RankingMerge.none; + void _setMerge(RankingMerge next) { setState(() { _merge = _merge == next ? RankingMerge.none : next; }); } - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final colors = Theme.of(context).colorScheme; - final ranked = rankTempExtremes( + List _ranked() { + final cached = _rankedCache; + if (cached != null && + identical(_rankedStations, widget.stations) && + identical(_rankedSnapshot, widget.snapshot) && + _rankedMetric == _metric && + _rankedAscending == _ascending && + _rankedMerge == _merge) { + return cached; + } + final ranked = rankTemperature( stations: widget.stations, snapshot: widget.snapshot, metric: _metric, ascending: _ascending, merge: _merge, ); + _rankedStations = widget.stations; + _rankedSnapshot = widget.snapshot; + _rankedMetric = _metric; + _rankedAscending = _ascending; + _rankedMerge = _merge; + return _rankedCache = ranked; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = Theme.of(context).colorScheme; + final ranked = _ranked(); final time = _formatSnapshotTime(widget.snapshot.time); return RefreshIndicator( @@ -808,22 +1028,17 @@ class _TempExtremePanelState extends State<_TempExtremePanel> { scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), children: [ - for (final metric in TempExtremeMetric.values) + for (final metric in TempRankMetric.values) Padding( padding: const EdgeInsets.only(right: AppSpacing.sm), child: ChoiceChip( - label: Text(switch (metric) { - TempExtremeMetric.high => - l10n.weatherRankingExtremeHigh, - TempExtremeMetric.low => l10n.weatherRankingExtremeLow, - TempExtremeMetric.range => - l10n.weatherRankingExtremeRange, - }), + label: Text(metric.label(l10n)), selected: _metric == metric, onSelected: (_) => setState(() { _metric = metric; - // Low: default coldest-first; high/range: hottest/widest. - _ascending = metric == TempExtremeMetric.low; + // Daily low reads coldest-first; everything else reads + // hottest / widest first. + _ascending = metric.ascendingByDefault; }), ), ), @@ -922,7 +1137,8 @@ class _TempExtremePanelState extends State<_TempExtremePanel> { index: index, ascending: _ascending, ), - // Clock only — no "當下/最高/最低/溫差" dump. + // Only the extremes happened at a time; the reading + // itself is the snapshot's own clock, shown above. eventTimeLabel: item.eventTime == null ? null : _formatClock(item.eventTime!), diff --git a/lib/features/weather/presentation/widgets/ranking_time_sheet.dart b/lib/features/weather/presentation/widgets/ranking_time_sheet.dart new file mode 100644 index 000000000..081d54da2 --- /dev/null +++ b/lib/features/weather/presentation/widgets/ranking_time_sheet.dart @@ -0,0 +1,158 @@ +/// Snapshot picker shared by every observation ranking tab. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// One choice from [showRankingTimeSheet]. +/// +/// [second] is `null` for the newest snapshot, which is fetched through +/// `latest()` instead of being pinned to a timestamp — that is what keeps the +/// default list live across a refresh. A dismissed sheet returns `null`, so the +/// two cases cannot be confused. +typedef RankingTimeChoice = ({int? second}); + +final DateFormat _timeFormat = DateFormat('MM/dd HH:mm'); + +/// Taiwan wall-clock label for a snapshot second (`10/27 03:00`). +String rankingTimeLabel(int unixSeconds) => _timeFormat.format( + AppTime.taipei( + DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000, isUtc: true), + ), +); + +/// Shows the snapshot picker and resolves to the chosen time. +/// +/// [times] are the Unix seconds the history endpoint lists, ascending. Only +/// times the API still serves are offered: the v5 snapshots are immutable and +/// addressed by their exact second, so a picked time is always fetchable. +/// [latest] is the snapshot the page is showing for "newest" (the history list +/// usually ends there), and [selected] is the page's current choice — `null` +/// when it is on the newest snapshot. +Future showRankingTimeSheet({ + required BuildContext context, + required List times, + required int latest, + int? selected, +}) => showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => + _RankingTimeSheet(times: times, latest: latest, selected: selected), +); + +class _RankingTimeSheet extends StatelessWidget { + const _RankingTimeSheet({ + required this.times, + required this.latest, + required this.selected, + }); + + final List times; + final int latest; + final int? selected; + + /// Everything strictly older than the newest snapshot, newest first. The + /// newest time is left out because the 「最新」 row already offers it, and + /// listing it twice would make the sheet look like it disagrees with itself. + List get _older => [ + for (final second in times.reversed) + if (second < latest) second, + ]; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final older = _older; + + return SafeArea( + top: false, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.sm, + ), + child: Text( + l10n.weatherRankingPickTime, + style: theme.textTheme.titleMedium, + ), + ), + Flexible( + child: ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: AppSpacing.lg), + itemCount: older.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return _TimeRow( + title: l10n.weatherRankingLatest, + subtitle: rankingTimeLabel(latest), + selected: selected == null, + onTap: () { + Navigator.of(context) + .pop((second: null)); + }, + ); + } + final second = older[index - 1]; + return _TimeRow( + title: rankingTimeLabel(second), + selected: selected == second, + onTap: () { + Navigator.of(context) + .pop((second: second)); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +/// One row of the picker — the newest snapshot says so in words, the rest are +/// their own timestamp. +class _TimeRow extends StatelessWidget { + const _TimeRow({ + required this.title, + required this.selected, + required this.onTap, + this.subtitle, + }); + + final String title; + final String? subtitle; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return ListTile( + dense: true, + title: Text(title), + subtitle: subtitle == null ? null : Text(subtitle!), + trailing: selected ? Icon(Icons.check, color: colors.primary) : null, + selected: selected, + onTap: onTap, + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f9a66e84b..d25d04983 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -448,9 +448,6 @@ "description": "Hint under the county-border toggle in the radar overlay menu." }, "meshtasticLayerOptions": "Node options", - "@weatherRankingLowest": { - "description": "Chip to rank temperature ascending" - }, "onboardingAgreeContinue": "Agree and continue", "commonRetry": "Retry", "@restroomGradePoor": { @@ -1052,6 +1049,13 @@ "@typhoonOverlaySectionStorm": { "description": "Section header for L7/L10 storm-band choices in the overlay menu" }, + "weatherRankingCurrent": "Current", + "weatherRankingLatest": "Latest", + "weatherRankingPickTime": "Choose data time", + "weatherRankingRainMissingAt": "No rain data at this time", + "@weatherRankingRainMissingAt": { + "description": "Rain tab empty state when the picked data time has no rain snapshot at or before it" + }, "weatherRankingMeta": "Data time: {time}\n{count} stations", "onboardingTermsAgree": "I have read and agree to the Terms of Service", "mapLayerSatelliteTransparentNoVegetation": "Below 0.1 = transparent (no vegetation)", @@ -1643,7 +1647,6 @@ "@typhoonOverlaySectionWeather": { "description": "Overlay-menu section for radar / IR under the typhoon vectors" }, - "weatherRankingHighest": "Highest", "@aedCategory": { "description": "AED venue category row label" }, @@ -2316,9 +2319,6 @@ "description": "Short Map-tab bottom-nav / default-layer picker label for typhoon" }, "meshtasticUtilization": "Airtime (24h)", - "@weatherRankingHighest": { - "description": "Chip to rank temperature descending" - }, "restroomTypeMixed": "Mixed", "restroomGradeGood": "Good", "notifyTsunami": "Tsunami information", @@ -2517,7 +2517,6 @@ "@weatherPrecipitation": { "description": "Label for the precipitation metric in the home weather header" }, - "weatherRankingLowest": "Lowest", "@onboardingPermLocation": { "description": "Permission row: location" }, diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index d4122bbd3..1992d4ab9 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -355,6 +355,10 @@ "mapLayerSatelliteB02": "Himawari Green (B02)", "weatherRankingEmpty": "Walang obserbasyon na iraranggo", "notifySectionOther": "Iba pa", + "weatherRankingCurrent": "Kasalukuyan", + "weatherRankingLatest": "Pinakabago", + "weatherRankingPickTime": "Pumili ng oras ng datos", + "weatherRankingRainMissingAt": "Walang datos ng ulan sa oras na ito", "weatherRankingMeta": "Oras ng datos: {time}\n{count} istasyon", "onboardingTermsAgree": "Nabasa ko na at sumasang-ayon ako sa Mga Tuntunin ng Serbisyo", "mapLayerSatelliteTransparentNoVegetation": "Sa ibaba ng 0.1 = transparent (walang vegetation)", @@ -562,7 +566,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "Pinakamataas", "reportDetailReplay": "I-replay", "mapLayerRestroom": "Pampublikong Palikuran", "restroomCategoryWelfare": "Institusyon ng kapakanan", @@ -900,7 +903,6 @@ "description": "Why two clients on one radio is a problem" }, "openSourceLicenses": "Mga lisensya ng open-source", - "weatherRankingLowest": "Pinakamababa", "@meshtasticConnectAnyway": { "description": "Connect despite the other app" }, diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 10507b645..363d4e242 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -355,6 +355,10 @@ "mapLayerSatelliteB02": "Himawari Green (B02)", "weatherRankingEmpty": "Tidak ada observasi untuk diurutkan", "notifySectionOther": "Lainnya", + "weatherRankingCurrent": "Saat ini", + "weatherRankingLatest": "Terbaru", + "weatherRankingPickTime": "Pilih waktu data", + "weatherRankingRainMissingAt": "Tidak ada data hujan pada waktu ini", "weatherRankingMeta": "Waktu data: {time}\n{count} stasiun", "onboardingTermsAgree": "Saya telah membaca dan menyetujui Ketentuan Layanan", "mapLayerSatelliteTransparentNoVegetation": "Below 0.1 = transparent (no vegetation)", @@ -562,7 +566,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "Tertinggi", "reportDetailReplay": "Putar ulang", "mapLayerRestroom": "Toilet Umum", "restroomCategoryWelfare": "Lembaga kesejahteraan", @@ -900,7 +903,6 @@ "description": "Why two clients on one radio is a problem" }, "openSourceLicenses": "Lisensi sumber terbuka", - "weatherRankingLowest": "Terendah", "@meshtasticConnectAnyway": { "description": "Connect despite the other app" }, diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 16da7ed47..ccf68e033 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -355,6 +355,10 @@ "mapLayerSatelliteB02": "ひまわり 可視緑(B02)", "weatherRankingEmpty": "並べ替え可能な観測がありません", "notifySectionOther": "その他", + "weatherRankingCurrent": "現在", + "weatherRankingLatest": "最新", + "weatherRankingPickTime": "データ時刻を選択", + "weatherRankingRainMissingAt": "この時刻の雨量データはありません", "weatherRankingMeta": "データ時刻:{time}\n観測点 {count}", "onboardingTermsAgree": "サービス利用規約を読み、同意します", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(植生なし)", @@ -562,7 +566,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "最高", "reportDetailReplay": "リプレイ", "mapLayerRestroom": "トイレ", "restroomCategoryWelfare": "社会福祉施設・集会所", @@ -900,7 +903,6 @@ "description": "Why two clients on one radio is a problem" }, "openSourceLicenses": "オープンソースライセンス", - "weatherRankingLowest": "最低", "@meshtasticConnectAnyway": { "description": "Connect despite the other app" }, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 1ab9cef8b..3dc77a78d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -355,6 +355,10 @@ "mapLayerSatelliteB02": "히마와리 가시 녹색(B02)", "weatherRankingEmpty": "정렬할 관측이 없습니다", "notifySectionOther": "기타", + "weatherRankingCurrent": "현재", + "weatherRankingLatest": "최신", + "weatherRankingPickTime": "자료 시각 선택", + "weatherRankingRainMissingAt": "이 시각의 강수 자료가 없습니다", "weatherRankingMeta": "자료 시각: {time}\n관측점 {count}", "onboardingTermsAgree": "서비스 약관을 읽었으며 이에 동의합니다", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 투명(식생 없음)", @@ -562,7 +566,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "최고", "reportDetailReplay": "다시 보기", "mapLayerRestroom": "공중화장실", "restroomCategoryWelfare": "사회복지 기관·집회 시설", @@ -900,7 +903,6 @@ "description": "Why two clients on one radio is a problem" }, "openSourceLicenses": "오픈소스 라이선스", - "weatherRankingLowest": "최저", "@meshtasticConnectAnyway": { "description": "Connect despite the other app" }, diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 06ddb9ab2..3e9b8d828 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -355,6 +355,10 @@ "mapLayerSatelliteB02": "Himawari Green (B02)", "weatherRankingEmpty": "ไม่มีข้อมูลให้จัดอันดับ", "notifySectionOther": "อื่น ๆ", + "weatherRankingCurrent": "ปัจจุบัน", + "weatherRankingLatest": "ล่าสุด", + "weatherRankingPickTime": "เลือกเวลาข้อมูล", + "weatherRankingRainMissingAt": "ไม่มีข้อมูลฝนในเวลานี้", "weatherRankingMeta": "เวลาข้อมูล: {time}\n{count} สถานี", "onboardingTermsAgree": "ฉันได้อ่านและยอมรับข้อกำหนดการให้บริการแล้ว", "mapLayerSatelliteTransparentNoVegetation": "Below 0.1 = transparent (no vegetation)", @@ -562,7 +566,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "สูงสุด", "reportDetailReplay": "เล่นย้อนหลัง", "mapLayerRestroom": "ห้องน้ำสาธารณะ", "restroomCategoryWelfare": "สถานสงเคราะห์", @@ -900,7 +903,6 @@ "description": "Why two clients on one radio is a problem" }, "openSourceLicenses": "ใบอนุญาตโอเพนซอร์ส", - "weatherRankingLowest": "ต่ำสุด", "@meshtasticConnectAnyway": { "description": "Connect despite the other app" }, diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index ff1f12830..ff4215d0f 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -355,6 +355,10 @@ "mapLayerSatelliteB02": "Himawari Green (B02)", "weatherRankingEmpty": "Không có quan trắc để xếp hạng", "notifySectionOther": "Khác", + "weatherRankingCurrent": "Hiện tại", + "weatherRankingLatest": "Mới nhất", + "weatherRankingPickTime": "Chọn thời gian dữ liệu", + "weatherRankingRainMissingAt": "Không có dữ liệu mưa tại thời điểm này", "weatherRankingMeta": "Thời gian: {time}\n{count} trạm", "onboardingTermsAgree": "Tôi đã đọc và đồng ý với Điều khoản Dịch vụ", "mapLayerSatelliteTransparentNoVegetation": "Below 0.1 = transparent (no vegetation)", @@ -562,7 +566,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "Cao nhất", "reportDetailReplay": "Phát lại", "mapLayerRestroom": "Nhà vệ sinh công cộng", "restroomCategoryWelfare": "Cơ sở phúc lợi", @@ -900,7 +903,6 @@ "description": "Why two clients on one radio is a problem" }, "openSourceLicenses": "Giấy phép mã nguồn mở", - "weatherRankingLowest": "Thấp nhất", "@meshtasticConnectAnyway": { "description": "Connect despite the other app" }, diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index a6abdaf87..6e56e73e8 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -357,6 +357,10 @@ "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", "weatherRankingEmpty": "而家冇可排序嘅觀測", "notifySectionOther": "其他", + "weatherRankingCurrent": "而家", + "weatherRankingLatest": "最新", + "weatherRankingPickTime": "選擇資料時間", + "weatherRankingRainMissingAt": "呢個時間冇雨量資料", "weatherRankingMeta": "資料時間:{time}\n共 {count} 觀測點", "onboardingTermsAgree": "我已閱讀並同意服務條款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", @@ -564,7 +568,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "最高", "reportDetailReplay": "重播", "mapLayerRestroom": "公廁", "restroomCategoryWelfare": "社福機構、集會場所", @@ -900,7 +903,6 @@ "description": "Why two clients on one radio is a problem" }, "openSourceLicenses": "引用套件", - "weatherRankingLowest": "最低", "@meshtasticConnectAnyway": { "description": "Connect despite the other app" }, diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d8cda19a3..4cdc24d8e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -355,6 +355,10 @@ "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", "weatherRankingEmpty": "目前沒有可排序的觀測", "notifySectionOther": "其他", + "weatherRankingCurrent": "當下", + "weatherRankingLatest": "最新", + "weatherRankingPickTime": "選擇資料時間", + "weatherRankingRainMissingAt": "此時間沒有雨量資料", "weatherRankingMeta": "資料時間:{time}\n共 {count} 觀測點", "onboardingTermsAgree": "我已閱讀並同意服務條款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", @@ -562,7 +566,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "最高", "reportDetailReplay": "重播", "mapLayerRestroom": "公廁", "restroomCategoryWelfare": "社福機構、集會場所", @@ -900,7 +903,6 @@ "description": "Why two clients on one radio is a problem" }, "openSourceLicenses": "引用套件", - "weatherRankingLowest": "最低", "@meshtasticConnectAnyway": { "description": "Connect despite the other app" }, diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 5df33f9d1..d374dbd61 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -355,6 +355,10 @@ "mapLayerSatelliteB02": "ひまわり 可见光-绿(B02)", "weatherRankingEmpty": "目前没有可排序的观测", "notifySectionOther": "其他", + "weatherRankingCurrent": "当前", + "weatherRankingLatest": "最新", + "weatherRankingPickTime": "选择资料时间", + "weatherRankingRainMissingAt": "此时间没有雨量资料", "weatherRankingMeta": "资料时间:{time}\n共 {count} 观测点", "onboardingTermsAgree": "我已阅读并同意服务条款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(无植被)", @@ -562,7 +566,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "最高", "reportDetailReplay": "重播", "mapLayerRestroom": "公厕", "restroomCategoryWelfare": "社福机构、集会场所", @@ -900,7 +903,6 @@ "description": "Why two clients on one radio is a problem" }, "openSourceLicenses": "开源许可", - "weatherRankingLowest": "最低", "@meshtasticConnectAnyway": { "description": "Connect despite the other app" }, diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 9f2ae0379..4d1e807e8 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -355,6 +355,10 @@ "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", "weatherRankingEmpty": "目前沒有可排序的觀測", "notifySectionOther": "其他", + "weatherRankingCurrent": "當下", + "weatherRankingLatest": "最新", + "weatherRankingPickTime": "選擇資料時間", + "weatherRankingRainMissingAt": "此時間沒有雨量資料", "weatherRankingMeta": "資料時間:{time}\n共 {count} 觀測點", "onboardingTermsAgree": "我已閱讀並同意服務條款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", @@ -562,7 +566,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "最高", "reportDetailReplay": "重播", "mapLayerRestroom": "公廁", "restroomCategoryWelfare": "社福機構、集會場所", @@ -900,7 +903,6 @@ "description": "Why two clients on one radio is a problem" }, "openSourceLicenses": "引用套件", - "weatherRankingLowest": "最低", "@meshtasticConnectAnyway": { "description": "Connect despite the other app" }, diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 01d92a72b..3d2463435 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -355,6 +355,10 @@ "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", "weatherRankingEmpty": "目前沒有可排序的觀測", "notifySectionOther": "其他", + "weatherRankingCurrent": "當下", + "weatherRankingLatest": "最新", + "weatherRankingPickTime": "選擇資料時間", + "weatherRankingRainMissingAt": "此時間沒有雨量資料", "weatherRankingMeta": "資料時間:{time}\n共 {count} 觀測點", "onboardingTermsAgree": "我已閱讀並同意服務條款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", @@ -562,7 +566,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "最高", "reportDetailReplay": "重播", "mapLayerRestroom": "公廁", "restroomCategoryWelfare": "社福機構、集會場所", @@ -900,7 +903,6 @@ "description": "Why two clients on one radio is a problem" }, "openSourceLicenses": "引用套件", - "weatherRankingLowest": "最低", "@meshtasticConnectAnyway": { "description": "Connect despite the other app" }, diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 9b63f7f12..cd838b19e 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1542,6 +1542,30 @@ abstract class AppLocalizations { /// **'Other'** String get notifySectionOther; + /// No description provided for @weatherRankingCurrent. + /// + /// In en, this message translates to: + /// **'Current'** + String get weatherRankingCurrent; + + /// No description provided for @weatherRankingLatest. + /// + /// In en, this message translates to: + /// **'Latest'** + String get weatherRankingLatest; + + /// No description provided for @weatherRankingPickTime. + /// + /// In en, this message translates to: + /// **'Choose data time'** + String get weatherRankingPickTime; + + /// Rain tab empty state when the picked data time has no rain snapshot at or before it + /// + /// In en, this message translates to: + /// **'No rain data at this time'** + String get weatherRankingRainMissingAt; + /// Snapshot time and station count above a ranking list /// /// In en, this message translates to: @@ -2358,12 +2382,6 @@ abstract class AppLocalizations { /// **'Report image not available'** String get reportDetailImageUnavailable; - /// Chip to rank temperature descending - /// - /// In en, this message translates to: - /// **'Highest'** - String get weatherRankingHighest; - /// Button that opens the RTS/EEW replay starting from this report's origin time /// /// In en, this message translates to: @@ -3546,12 +3564,6 @@ abstract class AppLocalizations { /// **'Open-source licenses'** String get openSourceLicenses; - /// Chip to rank temperature ascending - /// - /// In en, this message translates to: - /// **'Lowest'** - String get weatherRankingLowest; - /// Sort reports by hypocentral depth /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 0f81b86ce..0b0e36a9f 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -804,6 +804,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notifySectionOther => 'Other'; + @override + String get weatherRankingCurrent => 'Current'; + + @override + String get weatherRankingLatest => 'Latest'; + + @override + String get weatherRankingPickTime => 'Choose data time'; + + @override + String get weatherRankingRainMissingAt => 'No rain data at this time'; + @override String weatherRankingMeta(String time, int count) { return 'Data time: $time\n$count stations'; @@ -1242,9 +1254,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get reportDetailImageUnavailable => 'Report image not available'; - @override - String get weatherRankingHighest => 'Highest'; - @override String get reportDetailReplay => 'Replay'; @@ -1876,9 +1885,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get openSourceLicenses => 'Open-source licenses'; - @override - String get weatherRankingLowest => 'Lowest'; - @override String get reportFilterSortDepth => 'Depth'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 199fb8250..5a9248eb9 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -809,6 +809,19 @@ class AppLocalizationsFil extends AppLocalizations { @override String get notifySectionOther => 'Iba pa'; + @override + String get weatherRankingCurrent => 'Kasalukuyan'; + + @override + String get weatherRankingLatest => 'Pinakabago'; + + @override + String get weatherRankingPickTime => 'Pumili ng oras ng datos'; + + @override + String get weatherRankingRainMissingAt => + 'Walang datos ng ulan sa oras na ito'; + @override String weatherRankingMeta(String time, int count) { return 'Oras ng datos: $time\n$count istasyon'; @@ -1251,9 +1264,6 @@ class AppLocalizationsFil extends AppLocalizations { String get reportDetailImageUnavailable => 'Wala pang available na larawan ng ulat'; - @override - String get weatherRankingHighest => 'Pinakamataas'; - @override String get reportDetailReplay => 'I-replay'; @@ -1887,9 +1897,6 @@ class AppLocalizationsFil extends AppLocalizations { @override String get openSourceLicenses => 'Mga lisensya ng open-source'; - @override - String get weatherRankingLowest => 'Pinakamababa'; - @override String get reportFilterSortDepth => 'Lalim'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 0c0977fe3..500d4e64a 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -803,6 +803,19 @@ class AppLocalizationsId extends AppLocalizations { @override String get notifySectionOther => 'Lainnya'; + @override + String get weatherRankingCurrent => 'Saat ini'; + + @override + String get weatherRankingLatest => 'Terbaru'; + + @override + String get weatherRankingPickTime => 'Pilih waktu data'; + + @override + String get weatherRankingRainMissingAt => + 'Tidak ada data hujan pada waktu ini'; + @override String weatherRankingMeta(String time, int count) { return 'Waktu data: $time\n$count stasiun'; @@ -1242,9 +1255,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get reportDetailImageUnavailable => 'Gambar laporan belum tersedia'; - @override - String get weatherRankingHighest => 'Tertinggi'; - @override String get reportDetailReplay => 'Putar ulang'; @@ -1878,9 +1888,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get openSourceLicenses => 'Lisensi sumber terbuka'; - @override - String get weatherRankingLowest => 'Terendah'; - @override String get reportFilterSortDepth => 'Kedalaman'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 375fbb167..578f2ffe8 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -789,6 +789,18 @@ class AppLocalizationsJa extends AppLocalizations { @override String get notifySectionOther => 'その他'; + @override + String get weatherRankingCurrent => '現在'; + + @override + String get weatherRankingLatest => '最新'; + + @override + String get weatherRankingPickTime => 'データ時刻を選択'; + + @override + String get weatherRankingRainMissingAt => 'この時刻の雨量データはありません'; + @override String weatherRankingMeta(String time, int count) { return 'データ時刻:$time\n観測点 $count'; @@ -1219,9 +1231,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get reportDetailImageUnavailable => 'レポート画像はまだありません'; - @override - String get weatherRankingHighest => '最高'; - @override String get reportDetailReplay => 'リプレイ'; @@ -1842,9 +1851,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get openSourceLicenses => 'オープンソースライセンス'; - @override - String get weatherRankingLowest => '最低'; - @override String get reportFilterSortDepth => '深さ'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 97221f89a..c52d95a72 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -788,6 +788,18 @@ class AppLocalizationsKo extends AppLocalizations { @override String get notifySectionOther => '기타'; + @override + String get weatherRankingCurrent => '현재'; + + @override + String get weatherRankingLatest => '최신'; + + @override + String get weatherRankingPickTime => '자료 시각 선택'; + + @override + String get weatherRankingRainMissingAt => '이 시각의 강수 자료가 없습니다'; + @override String weatherRankingMeta(String time, int count) { return '자료 시각: $time\n관측점 $count'; @@ -1219,9 +1231,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get reportDetailImageUnavailable => '보고서 이미지가 아직 없습니다'; - @override - String get weatherRankingHighest => '최고'; - @override String get reportDetailReplay => '다시 보기'; @@ -1842,9 +1851,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get openSourceLicenses => '오픈소스 라이선스'; - @override - String get weatherRankingLowest => '최저'; - @override String get reportFilterSortDepth => '깊이'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 9b68d9135..dcb9d2621 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -799,6 +799,18 @@ class AppLocalizationsTh extends AppLocalizations { @override String get notifySectionOther => 'อื่น ๆ'; + @override + String get weatherRankingCurrent => 'ปัจจุบัน'; + + @override + String get weatherRankingLatest => 'ล่าสุด'; + + @override + String get weatherRankingPickTime => 'เลือกเวลาข้อมูล'; + + @override + String get weatherRankingRainMissingAt => 'ไม่มีข้อมูลฝนในเวลานี้'; + @override String weatherRankingMeta(String time, int count) { return 'เวลาข้อมูล: $time\n$count สถานี'; @@ -1237,9 +1249,6 @@ class AppLocalizationsTh extends AppLocalizations { @override String get reportDetailImageUnavailable => 'ยังไม่มีภาพรายงาน'; - @override - String get weatherRankingHighest => 'สูงสุด'; - @override String get reportDetailReplay => 'เล่นย้อนหลัง'; @@ -1869,9 +1878,6 @@ class AppLocalizationsTh extends AppLocalizations { @override String get openSourceLicenses => 'ใบอนุญาตโอเพนซอร์ส'; - @override - String get weatherRankingLowest => 'ต่ำสุด'; - @override String get reportFilterSortDepth => 'ความลึก'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 6bd54c51e..ef8494130 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -800,6 +800,19 @@ class AppLocalizationsVi extends AppLocalizations { @override String get notifySectionOther => 'Khác'; + @override + String get weatherRankingCurrent => 'Hiện tại'; + + @override + String get weatherRankingLatest => 'Mới nhất'; + + @override + String get weatherRankingPickTime => 'Chọn thời gian dữ liệu'; + + @override + String get weatherRankingRainMissingAt => + 'Không có dữ liệu mưa tại thời điểm này'; + @override String weatherRankingMeta(String time, int count) { return 'Thời gian: $time\n$count trạm'; @@ -1239,9 +1252,6 @@ class AppLocalizationsVi extends AppLocalizations { @override String get reportDetailImageUnavailable => 'Hình ảnh báo cáo chưa có sẵn'; - @override - String get weatherRankingHighest => 'Cao nhất'; - @override String get reportDetailReplay => 'Phát lại'; @@ -1874,9 +1884,6 @@ class AppLocalizationsVi extends AppLocalizations { @override String get openSourceLicenses => 'Giấy phép mã nguồn mở'; - @override - String get weatherRankingLowest => 'Thấp nhất'; - @override String get reportFilterSortDepth => 'Độ sâu'; diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 90bee844e..5b65778af 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -784,6 +784,18 @@ class AppLocalizationsYue extends AppLocalizations { @override String get notifySectionOther => '其他'; + @override + String get weatherRankingCurrent => '而家'; + + @override + String get weatherRankingLatest => '最新'; + + @override + String get weatherRankingPickTime => '選擇資料時間'; + + @override + String get weatherRankingRainMissingAt => '呢個時間冇雨量資料'; + @override String weatherRankingMeta(String time, int count) { return '資料時間:$time\n共 $count 觀測點'; @@ -1210,9 +1222,6 @@ class AppLocalizationsYue extends AppLocalizations { @override String get reportDetailImageUnavailable => '報告圖尚未提供'; - @override - String get weatherRankingHighest => '最高'; - @override String get reportDetailReplay => '重播'; @@ -1830,9 +1839,6 @@ class AppLocalizationsYue extends AppLocalizations { @override String get openSourceLicenses => '引用套件'; - @override - String get weatherRankingLowest => '最低'; - @override String get reportFilterSortDepth => '深度'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 1aa4fcda5..a8abe4261 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -784,6 +784,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get notifySectionOther => '其他'; + @override + String get weatherRankingCurrent => '當下'; + + @override + String get weatherRankingLatest => '最新'; + + @override + String get weatherRankingPickTime => '選擇資料時間'; + + @override + String get weatherRankingRainMissingAt => '此時間沒有雨量資料'; + @override String weatherRankingMeta(String time, int count) { return '資料時間:$time\n共 $count 觀測點'; @@ -1210,9 +1222,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get reportDetailImageUnavailable => '報告圖尚未提供'; - @override - String get weatherRankingHighest => '最高'; - @override String get reportDetailReplay => '重播'; @@ -1830,9 +1839,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get openSourceLicenses => '引用套件'; - @override - String get weatherRankingLowest => '最低'; - @override String get reportFilterSortDepth => '深度'; @@ -4321,6 +4327,18 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get notifySectionOther => '其他'; + @override + String get weatherRankingCurrent => '当前'; + + @override + String get weatherRankingLatest => '最新'; + + @override + String get weatherRankingPickTime => '选择资料时间'; + + @override + String get weatherRankingRainMissingAt => '此时间没有雨量资料'; + @override String weatherRankingMeta(String time, int count) { return '资料时间:$time\n共 $count 观测点'; @@ -4747,9 +4765,6 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get reportDetailImageUnavailable => '报告图尚未提供'; - @override - String get weatherRankingHighest => '最高'; - @override String get reportDetailReplay => '重播'; @@ -5367,9 +5382,6 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get openSourceLicenses => '开源许可'; - @override - String get weatherRankingLowest => '最低'; - @override String get reportFilterSortDepth => '深度'; @@ -7857,6 +7869,18 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get notifySectionOther => '其他'; + @override + String get weatherRankingCurrent => '當下'; + + @override + String get weatherRankingLatest => '最新'; + + @override + String get weatherRankingPickTime => '選擇資料時間'; + + @override + String get weatherRankingRainMissingAt => '此時間沒有雨量資料'; + @override String weatherRankingMeta(String time, int count) { return '資料時間:$time\n共 $count 觀測點'; @@ -8283,9 +8307,6 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get reportDetailImageUnavailable => '報告圖尚未提供'; - @override - String get weatherRankingHighest => '最高'; - @override String get reportDetailReplay => '重播'; @@ -8903,9 +8924,6 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get openSourceLicenses => '引用套件'; - @override - String get weatherRankingLowest => '最低'; - @override String get reportFilterSortDepth => '深度'; @@ -11394,6 +11412,18 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get notifySectionOther => '其他'; + @override + String get weatherRankingCurrent => '當下'; + + @override + String get weatherRankingLatest => '最新'; + + @override + String get weatherRankingPickTime => '選擇資料時間'; + + @override + String get weatherRankingRainMissingAt => '此時間沒有雨量資料'; + @override String weatherRankingMeta(String time, int count) { return '資料時間:$time\n共 $count 觀測點'; @@ -11820,9 +11850,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get reportDetailImageUnavailable => '報告圖尚未提供'; - @override - String get weatherRankingHighest => '最高'; - @override String get reportDetailReplay => '重播'; @@ -12440,9 +12467,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get openSourceLicenses => '引用套件'; - @override - String get weatherRankingLowest => '最低'; - @override String get reportFilterSortDepth => '深度'; diff --git a/test/features/weather/domain/weather_ranking_test.dart b/test/features/weather/domain/weather_ranking_test.dart index 6bfd9f4e8..4809d4e7d 100644 --- a/test/features/weather/domain/weather_ranking_test.dart +++ b/test/features/weather/domain/weather_ranking_test.dart @@ -137,4 +137,81 @@ void main() { expect(ranked.first.eventTime, isNull); // range has no single event time }); }); + + group('rankTemperature', () { + const snapshot = WeatherSnapshot( + time: 0, + stations: [ + WeatherObservation( + id: 'A', + weatherCode: 0, + temperature: 28, + high: 32, + highTime: 100, + low: 24, + lowTime: 50, + ), + WeatherObservation( + id: 'B', + weatherCode: 0, + temperature: 30, + high: 31, + highTime: 110, + low: 29, + lowTime: 60, + ), + WeatherObservation( + id: 'C', + weatherCode: 0, + temperature: 33, + high: 33, + highTime: 120, + // no low → skipped by the extreme metrics, still ranked as current + ), + ], + ); + + List rank( + TempRankMetric metric, { + bool ascending = false, + }) => rankTemperature( + stations: stations, + snapshot: snapshot, + metric: metric, + ascending: ascending, + merge: RankingMerge.none, + ); + + test('current ranks the reading in the snapshot, with no clock', () { + final ranked = rank(TempRankMetric.current); + expect(ranked.map((e) => e.id).toList(), ['C', 'B', 'A']); + expect(ranked.first.value, 33); + expect(ranked.first.eventTime, isNull); + }); + + // The chip strip is one entry point over the old separate tab, so each + // metric must behave exactly as its own list used to. + test('high ranks the recorded maximum with its occurrence time', () { + // C has no recorded low, so the extremes cannot place it — the snapshot's + // `hi` and `lo` always come as a pair. + final ranked = rank(TempRankMetric.high); + expect(ranked.map((e) => e.id).toList(), ['A', 'B']); + expect(ranked.first.value, 32); + expect(ranked.first.eventTime, 100); + }); + + test('low ranks coldest-first and skips stations without one', () { + final ranked = rank(TempRankMetric.low, ascending: true); + expect(ranked.map((e) => e.id).toList(), ['A', 'B']); + expect(ranked.first.value, 24); + expect(ranked.first.eventTime, 50); + }); + + test('range ranks the diurnal spread, which has no single time', () { + final ranked = rank(TempRankMetric.range); + expect(ranked.map((e) => e.id).toList(), ['A', 'B']); + expect(ranked.first.value, 8); + expect(ranked.first.eventTime, isNull); + }); + }); } diff --git a/test/features/weather/presentation/pages/weather_ranking_page_test.dart b/test/features/weather/presentation/pages/weather_ranking_page_test.dart index 2cbe5c1b2..37c639652 100644 --- a/test/features/weather/presentation/pages/weather_ranking_page_test.dart +++ b/test/features/weather/presentation/pages/weather_ranking_page_test.dart @@ -16,7 +16,9 @@ import 'package:dpip/features/weather/domain/weather_realtime.dart'; import 'package:dpip/features/weather/domain/weather_snapshot.dart'; import 'package:dpip/features/weather/domain/weather_station.dart'; import 'package:dpip/features/weather/domain/weather_trend.dart'; +import 'package:dpip/features/weather/domain/weather_ranking.dart'; import 'package:dpip/features/weather/presentation/pages/weather_ranking_page.dart'; +import 'package:dpip/features/weather/presentation/widgets/ranking_time_sheet.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_station_handoff.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; @@ -69,13 +71,23 @@ RainObservation _rain(String id) => RainObservation( ); class _Weather implements MeteorWeatherRepository { - _Weather(this.stationsResult, this.latestResult); + _Weather( + this.stationsResult, + this.latestResult, { + this.historyResult = const Ok([]), + Map? past, + }) : past = past ?? {}; Result> stationsResult; Result latestResult; + Result> historyResult; + final Map past; Completer? holdStations; bool explode = false; + /// Every second the page actually asked the API for. + final List requested = []; + @override Future>> stations() async { final hold = holdStations; @@ -88,14 +100,19 @@ class _Weather implements MeteorWeatherRepository { Future> latest() async => latestResult; @override - Future> at(int second) => throw UnimplementedError(); + Future> at(int second) async { + requested.add(second); + final snapshot = past[second]; + if (snapshot != null) return Ok(snapshot); + return Err(const NetworkFailure('no such snapshot')); + } @override Future> forecast(String code) => throw UnimplementedError(); @override - Future>> history() => throw UnimplementedError(); + Future>> history() async => historyResult; @override Future> realtime( @@ -109,10 +126,20 @@ class _Weather implements MeteorWeatherRepository { } class _Rain implements MeteorRainRepository { - _Rain(this.stationsResult, this.latestResult); + _Rain( + this.stationsResult, + this.latestResult, { + this.historyResult = const Ok([]), + Map? past, + }) : past = past ?? {}; Result> stationsResult; Result latestResult; + Result> historyResult; + final Map past; + + /// Every second the page actually asked the API for. + final List requested = []; @override Future>> stations() async => @@ -122,30 +149,80 @@ class _Rain implements MeteorRainRepository { Future> latest() async => latestResult; @override - Future> at(int second) => throw UnimplementedError(); + Future> at(int second) async { + requested.add(second); + final snapshot = past[second]; + if (snapshot != null) return Ok(snapshot); + return Err(const NetworkFailure('no such snapshot')); + } @override - Future>> history() => throw UnimplementedError(); + Future>> history() async => historyResult; @override Future> trend(String id, {String range = '24h'}) => throw UnimplementedError(); } +/// The newest snapshot the fakes serve, and the one an hour before it — the +/// second is what the picker hands to `at()`. +const int _now = 1_700_000_000; +const int _earlier = _now - 3600; + Result> _catalogue() => Ok({'A': _station('大同區'), 'B': _station('信義區')}); Result _weatherSnap() => - Ok(WeatherSnapshot(time: 1_700_000_000, stations: [_obs('A'), _obs('B')])); + Ok(WeatherSnapshot(time: _now, stations: [_obs('A'), _obs('B')])); Result _rainSnap() => - Ok(RainSnapshot(time: 1_700_000_000, stations: [_rain('A'), _rain('B')])); + Ok(RainSnapshot(time: _now, stations: [_rain('A'), _rain('B')])); + +/// The snapshot an hour back: different readings, so a list that ignored the +/// picked time cannot accidentally look right. +WeatherObservation _pastObs(String id) => WeatherObservation( + id: id, + weatherCode: 100, + temperature: id == 'A' ? 41.5 : 9, + humidity: 55, + pressure: 990, + windSpeed: 2, + gustSpeed: 3, + high: 42, + low: 39, + highTime: _earlier + 600, + lowTime: _earlier, +); + +Result _pastWeatherSnap() => Ok( + WeatherSnapshot(time: _earlier, stations: [_pastObs('A'), _pastObs('B')]), +); + +/// Rain an hour back: `now` accumulation 21 mm, so the rain tab's default +/// interval proves it ranked the historical snapshot. +RainObservation _pastRain(String id) => RainObservation( + id: id, + now: id == 'A' ? 21 : 2, + min10: 1, + hour1: 2, + hour3: 3, + hour6: 4, + hour12: 5, + hour24: 6, + day2: 7, + day3: 8, +); + +Result _pastRainSnap() => Ok( + RainSnapshot(time: _earlier, stations: [_pastRain('A'), _pastRain('B')]), +); Future _pump( WidgetTester tester, { required MeteorWeatherRepository weather, required MeteorRainRepository rain, WeatherRankingTab tab = WeatherRankingTab.rain, + TempRankMetric tempMetric = TempRankMetric.current, }) async { tester.view.physicalSize = _tall; tester.view.devicePixelRatio = 1; @@ -156,7 +233,8 @@ Future _pump( routes: [ GoRoute( path: '/', - builder: (_, _) => WeatherRankingPage(initialTab: tab), + builder: (_, _) => + WeatherRankingPage(initialTab: tab, initialTempMetric: tempMetric), ), GoRoute( name: AppRoutes.map, @@ -198,15 +276,30 @@ void main() { expect(tab.mapLayerId, isNotEmpty); } expect(WeatherRankingTab.parse('temp'), WeatherRankingTab.temperature); - expect(WeatherRankingTab.parse('high'), WeatherRankingTab.tempExtremes); - expect(WeatherRankingTab.parse('low'), WeatherRankingTab.tempExtremes); - expect(WeatherRankingTab.parse('range'), WeatherRankingTab.tempExtremes); expect(WeatherRankingTab.parse('pres'), WeatherRankingTab.pressure); expect(WeatherRankingTab.parse('rh'), WeatherRankingTab.humidity); expect(WeatherRankingTab.parse(null), WeatherRankingTab.rain); expect(WeatherRankingTab.parse('nope'), WeatherRankingTab.rain); }); + test( + 'the old daily-extremes tab is now a chip inside the temperature tab', + () { + // The value still has to resolve, so a link shared before the merge does + // not land on rain. + expect( + WeatherRankingTab.parse('tempExtremes'), + WeatherRankingTab.temperature, + ); + expect(TempRankMetric.parse('tempExtremes'), TempRankMetric.high); + expect(TempRankMetric.parse('high'), TempRankMetric.high); + expect(TempRankMetric.parse('low'), TempRankMetric.low); + expect(TempRankMetric.parse('range'), TempRankMetric.range); + expect(TempRankMetric.parse('temperature'), TempRankMetric.current); + expect(TempRankMetric.parse(null), TempRankMetric.current); + }, + ); + testWidgets('lists rain, switches intervals, and opens the station', ( tester, ) async { @@ -287,17 +380,25 @@ void main() { final l10n = await AppLocalizations.delegate.load(const Locale('en')); expect(find.text('32.4 °C'), findsOneWidget); - await tester.tap(find.text(l10n.weatherRankingLowest)); + + // The four figure chips push the sort chips past the screen edge. + await tester.drag( + find.text(l10n.weatherRankingCurrent).hitTestable(), + const Offset(-400, 0), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.reportFilterOrderAsc).hitTestable()); await tester.pumpAndSettle(); expect(find.text('18.0 °C'), findsOneWidget); - await tester.tap(find.text(l10n.weatherRankingHighest)); + await tester.tap(find.text(l10n.reportFilterOrderDesc).hitTestable()); await tester.pumpAndSettle(); expect(find.text('32.4 °C'), findsOneWidget); - await tester.ensureVisible(find.text(l10n.weatherRankingMergeCounty)); - await tester.tap(find.text(l10n.weatherRankingMergeCounty)); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.weatherRankingMergeCounty)); + // Back to the figure chips. + await tester.drag( + find.text(l10n.reportFilterOrderDesc).hitTestable(), + const Offset(400, 0), + ); await tester.pumpAndSettle(); await _openTab(tester, l10n.weatherRankingWind); @@ -315,30 +416,37 @@ void main() { await _openTab(tester, l10n.mapLayerPressure); expect(find.text('1013.2 hPa'), findsOneWidget); - await _openTab(tester, l10n.weatherRankingTempExtremes); + // The daily extremes are chips on the temperature tab now, not a tab. + await _openTab(tester, l10n.mapLayerTemperature); expect(find.textContaining('°C'), findsWidgets); + expect(find.text(l10n.weatherRankingTempExtremes), findsNothing); + + await tester.tap(find.text(l10n.weatherRankingExtremeHigh).hitTestable()); + await tester.pumpAndSettle(); + expect(find.text('33.0 °C'), findsOneWidget); + await tester.tap(find.text(l10n.weatherRankingExtremeLow).hitTestable()); await tester.pumpAndSettle(); + expect(find.text('16.0 °C'), findsOneWidget); + await tester.tap(find.text(l10n.weatherRankingExtremeRange).hitTestable()); await tester.pumpAndSettle(); - await tester.drag( - find.text(l10n.weatherRankingExtremeHigh).hitTestable(), - const Offset(-700, 0), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.reportFilterOrderAsc).hitTestable()); + expect(find.text('17.0 °C'), findsOneWidget); + + await tester.tap(find.text(l10n.weatherRankingCurrent).hitTestable()); await tester.pumpAndSettle(); + expect(find.text('32.4 °C'), findsOneWidget); + + // Merge chips sit at the far end of the same strip. await tester.drag( - find.text(l10n.reportFilterOrderAsc).hitTestable(), - const Offset(-500, 0), + find.text(l10n.weatherRankingCurrent).hitTestable(), + const Offset(-800, 0), ); await tester.pumpAndSettle(); await tester.tap(find.text(l10n.weatherRankingMergeTown).hitTestable()); await tester.pumpAndSettle(); await tester.tap(find.text(l10n.weatherRankingMergeCounty).hitTestable()); await tester.pumpAndSettle(); - await tester.tap(find.text(l10n.reportFilterOrderDesc).hitTestable()); - await tester.pumpAndSettle(); }); testWidgets('a reload keeps the list up until the next snapshot arrives', ( @@ -401,7 +509,182 @@ void main() { final l10n = await AppLocalizations.delegate.load(const Locale('en')); expect(find.text(l10n.weatherRankingEmpty), findsOneWidget); - await _openTab(tester, l10n.weatherRankingTempExtremes); + await _openTab(tester, l10n.mapLayerTemperature); + expect(find.text('20.0 °C'), findsWidgets); + + // The stations have a reading but no recorded extremes, so the chip that + // needs them is the empty one. + await tester.tap(find.text(l10n.weatherRankingExtremeHigh).hitTestable()); + await tester.pumpAndSettle(); expect(find.text(l10n.weatherRankingEmpty), findsOneWidget); }); + + testWidgets('picking a past time re-ranks the list at that snapshot', ( + tester, + ) async { + final weather = _Weather( + _catalogue(), + _weatherSnap(), + historyResult: const Ok([_earlier, _now]), + past: {_earlier: _pastWeatherSnap().valueOrNull!}, + ); + final rain = _Rain( + _catalogue(), + _rainSnap(), + historyResult: const Ok([_earlier, _now]), + past: {_earlier: _pastRainSnap().valueOrNull!}, + ); + await _pump(tester, weather: weather, rain: rain); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text('12.5 mm'), findsOneWidget); + + await tester.tap(find.text(l10n.weatherRankingLatest)); + await tester.pumpAndSettle(); + expect(find.text(l10n.weatherRankingPickTime), findsOneWidget); + + await tester.tap(find.text(rankingTimeLabel(_earlier))); + await tester.pumpAndSettle(); + + // One `at()` per dataset, both for the chosen second. + expect(weather.requested, [_earlier]); + expect(rain.requested, [_earlier]); + expect(find.text('21 mm'), findsOneWidget); + expect(find.text('12.5 mm'), findsNothing); + // The bar says where the list is instead of claiming to be live. + expect(find.text(l10n.weatherRankingLatest), findsNothing); + expect(find.text(rankingTimeLabel(_earlier)), findsOneWidget); + }); + + testWidgets('the picker walks back to the newest snapshot', (tester) async { + final weather = _Weather( + _catalogue(), + _weatherSnap(), + historyResult: const Ok([_earlier, _now]), + past: {_earlier: _pastWeatherSnap().valueOrNull!}, + ); + final rain = _Rain( + _catalogue(), + _rainSnap(), + historyResult: const Ok([_earlier, _now]), + past: {_earlier: _pastRainSnap().valueOrNull!}, + ); + await _pump(tester, weather: weather, rain: rain); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + await tester.tap(find.text(l10n.weatherRankingLatest)); + await tester.pumpAndSettle(); + await tester.tap(find.text(rankingTimeLabel(_earlier))); + await tester.pumpAndSettle(); + expect(find.text('21 mm'), findsOneWidget); + + await tester.tap(find.text(rankingTimeLabel(_earlier))); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.weatherRankingLatest).last); + await tester.pumpAndSettle(); + + expect(find.text('12.5 mm'), findsOneWidget); + expect(weather.requested, [_earlier]); + }); + + testWidgets('rain lands on its own nearest snapshot, not the weather one', ( + tester, + ) async { + final rainSecond = _earlier - 600; + final weather = _Weather( + _catalogue(), + _weatherSnap(), + historyResult: const Ok([_earlier, _now]), + past: {_earlier: _pastWeatherSnap().valueOrNull!}, + ); + final rain = _Rain( + _catalogue(), + _rainSnap(), + // The rain history runs on finer steps than the weather one, with no + // snapshot on the picked second. The one just after is as close as the + // one just before, but it is from after the time the user asked for. + historyResult: const Ok([_earlier - 600, _earlier + 600, _now]), + past: {rainSecond: _pastRainSnap().valueOrNull!}, + ); + await _pump(tester, weather: weather, rain: rain); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + await tester.tap(find.text(l10n.weatherRankingLatest)); + await tester.pumpAndSettle(); + await tester.tap(find.text(rankingTimeLabel(_earlier))); + await tester.pumpAndSettle(); + + expect(weather.requested, [_earlier]); + expect(rain.requested, [rainSecond]); + expect(find.text('21 mm'), findsOneWidget); + }); + + testWidgets('a time older than the rain history shows no rain, not a later ' + 'snapshot', (tester) async { + final weather = _Weather( + _catalogue(), + _weatherSnap(), + historyResult: const Ok([_earlier, _now]), + past: {_earlier: _pastWeatherSnap().valueOrNull!}, + ); + final rain = _Rain( + _catalogue(), + _rainSnap(), + // Rain keeps less history than weather: its oldest snapshot is ten + // minutes after the hour the user is about to pick. + historyResult: const Ok([_earlier + 600, _now]), + past: {_earlier + 600: _pastRainSnap().valueOrNull!}, + ); + await _pump(tester, weather: weather, rain: rain); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + + await tester.tap(find.text(l10n.weatherRankingLatest)); + await tester.pumpAndSettle(); + await tester.tap(find.text(rankingTimeLabel(_earlier))); + await tester.pumpAndSettle(); + + expect(find.text(rankingTimeLabel(_earlier)), findsOneWidget); + expect(rain.requested, isEmpty, reason: 'nothing at or before the pick'); + expect(find.text('21 mm'), findsNothing, reason: 'a later snapshot'); + expect(find.text('12.5 mm'), findsNothing, reason: 'the newest snapshot'); + }); + + testWidgets('no rain history does not pass the newest rain off as the ' + 'picked time', (tester) async { + final weather = _Weather( + _catalogue(), + _weatherSnap(), + historyResult: const Ok([_earlier, _now]), + past: {_earlier: _pastWeatherSnap().valueOrNull!}, + ); + final rain = _Rain( + _catalogue(), + _rainSnap(), + historyResult: Err>(const NetworkFailure('offline')), + ); + await _pump(tester, weather: weather, rain: rain); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text('12.5 mm'), findsOneWidget); + + await tester.tap(find.text(l10n.weatherRankingLatest)); + await tester.pumpAndSettle(); + await tester.tap(find.text(rankingTimeLabel(_earlier))); + await tester.pumpAndSettle(); + + expect(find.text(rankingTimeLabel(_earlier)), findsOneWidget); + expect(find.text('12.5 mm'), findsNothing, reason: 'the newest snapshot'); + }); + + testWidgets('no history means no picker, and the newest list still works', ( + tester, + ) async { + await _pump( + tester, + weather: _Weather(_catalogue(), _weatherSnap()), + rain: _Rain(_catalogue(), _rainSnap()), + ); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.byIcon(Icons.schedule), findsNothing); + expect(find.text(l10n.weatherRankingLatest), findsNothing); + expect(find.text('12.5 mm'), findsOneWidget); + }); }