From 723ea38783214f2c3061497f9be0aed534f1e27c Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Tue, 27 Oct 2026 10:47:45 +0800 Subject: [PATCH 1/2] feat(weather): rank observations at any snapshot time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all New(zh-Hant): 觀測排行可以選擇資料時間,溫度分頁也能切換當下與當日極值 New(en-US): rankings can pick their data time, and the temperature tab switches between the reading and the day's extremes --- lib/app/router/app_router.dart | 15 +- .../weather/domain/weather_ranking.dart | 63 ++++ .../pages/weather_ranking_page.dart | 323 ++++++++++++++---- .../widgets/ranking_time_sheet.dart | 158 +++++++++ lib/l10n/app_en.arb | 11 +- lib/l10n/app_fil.arb | 5 +- lib/l10n/app_id.arb | 5 +- lib/l10n/app_ja.arb | 5 +- lib/l10n/app_ko.arb | 5 +- lib/l10n/app_th.arb | 5 +- lib/l10n/app_vi.arb | 5 +- lib/l10n/app_yue.arb | 5 +- lib/l10n/app_zh.arb | 5 +- lib/l10n/app_zh_Hans.arb | 5 +- lib/l10n/app_zh_Hant_HK.arb | 5 +- lib/l10n/app_zh_TW.arb | 5 +- lib/l10n/gen/app_localizations.dart | 30 +- lib/l10n/gen/app_localizations_en.dart | 15 +- lib/l10n/gen/app_localizations_fil.dart | 15 +- lib/l10n/gen/app_localizations_id.dart | 15 +- lib/l10n/gen/app_localizations_ja.dart | 15 +- lib/l10n/gen/app_localizations_ko.dart | 15 +- lib/l10n/gen/app_localizations_th.dart | 15 +- lib/l10n/gen/app_localizations_vi.dart | 15 +- lib/l10n/gen/app_localizations_yue.dart | 15 +- lib/l10n/gen/app_localizations_zh.dart | 60 ++-- .../weather/domain/weather_ranking_test.dart | 77 +++++ .../pages/weather_ranking_page_test.dart | 285 ++++++++++++++-- 28 files changed, 980 insertions(+), 217 deletions(-) create mode 100644 lib/features/weather/presentation/widgets/ranking_time_sheet.dart 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..b2fc48d23 100644 --- a/lib/features/weather/presentation/pages/weather_ranking_page.dart +++ b/lib/features/weather/presentation/pages/weather_ranking_page.dart @@ -4,6 +4,7 @@ library; import 'package:dpip/app/theme/app_spacing.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 +13,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 +28,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 +65,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 +74,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 +84,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) { @@ -125,10 +139,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 +159,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 +192,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 +203,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 +211,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 +219,132 @@ 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 history has not answered (or is empty): the newest snapshot is the + // only one this page can honestly draw. + if (resolved == null) return repo.latest(); + 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. + 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 repo.latest(); + 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]; the oldest one when + /// [second] predates everything the history still keeps. + static int? _atOrBefore(List times, int second) { + if (times.isEmpty) return null; + int? best; + for (final time in times) { + if (time <= second && (best == null || time > best)) best = time; + } + return best ?? times.first; + } + + /// 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 + /// every weather tab reads it. Rain alone answers when weather has none. + List get _pickerTimes => + _weatherTimes.isNotEmpty ? _weatherTimes : _rainTimes; + + int? get _newestTime => + _latestWeatherTime ?? + (_weatherTimes.isNotEmpty + ? _weatherTimes.last + : _rainTimes.isNotEmpty + ? _rainTimes.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 +352,7 @@ class _WeatherRankingPageState extends State setState(() { _future = next; }); - await next; + await Future.wait([next, _loadHistory()]); } Widget _tabsFor(_RankingData value) => TabBarView( @@ -216,19 +369,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 +422,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, @@ -523,7 +688,6 @@ class _WeatherMetricPanel extends StatefulWidget { required this.onRefresh, this.windDirectionOf, this.eventTimeOf, - this.highLowSort = false, this.requirePositive = false, }); @@ -536,7 +700,6 @@ class _WeatherMetricPanel extends StatefulWidget { final int fractionDigits; final String mapLayerId; final Future Function() onRefresh; - final bool highLowSort; final bool requirePositive; @override @@ -625,11 +788,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 +796,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 +910,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 +999,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 +1108,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..9fb4ac8d9 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,9 @@ "@typhoonOverlaySectionStorm": { "description": "Section header for L7/L10 storm-band choices in the overlay menu" }, + "weatherRankingCurrent": "Current", + "weatherRankingLatest": "Latest", + "weatherRankingPickTime": "Choose data time", "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 +1643,6 @@ "@typhoonOverlaySectionWeather": { "description": "Overlay-menu section for radar / IR under the typhoon vectors" }, - "weatherRankingHighest": "Highest", "@aedCategory": { "description": "AED venue category row label" }, @@ -2316,9 +2315,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 +2513,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..ea523f7e6 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -355,6 +355,9 @@ "mapLayerSatelliteB02": "Himawari Green (B02)", "weatherRankingEmpty": "Walang obserbasyon na iraranggo", "notifySectionOther": "Iba pa", + "weatherRankingCurrent": "Kasalukuyan", + "weatherRankingLatest": "Pinakabago", + "weatherRankingPickTime": "Pumili ng oras ng datos", "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 +565,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 +902,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..679481e51 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -355,6 +355,9 @@ "mapLayerSatelliteB02": "Himawari Green (B02)", "weatherRankingEmpty": "Tidak ada observasi untuk diurutkan", "notifySectionOther": "Lainnya", + "weatherRankingCurrent": "Saat ini", + "weatherRankingLatest": "Terbaru", + "weatherRankingPickTime": "Pilih waktu data", "weatherRankingMeta": "Waktu data: {time}\n{count} stasiun", "onboardingTermsAgree": "Saya telah membaca dan menyetujui Ketentuan Layanan", "mapLayerSatelliteTransparentNoVegetation": "Below 0.1 = transparent (no vegetation)", @@ -562,7 +565,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 +902,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..a8adfe966 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -355,6 +355,9 @@ "mapLayerSatelliteB02": "ひまわり 可視緑(B02)", "weatherRankingEmpty": "並べ替え可能な観測がありません", "notifySectionOther": "その他", + "weatherRankingCurrent": "現在", + "weatherRankingLatest": "最新", + "weatherRankingPickTime": "データ時刻を選択", "weatherRankingMeta": "データ時刻:{time}\n観測点 {count}", "onboardingTermsAgree": "サービス利用規約を読み、同意します", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(植生なし)", @@ -562,7 +565,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "最高", "reportDetailReplay": "リプレイ", "mapLayerRestroom": "トイレ", "restroomCategoryWelfare": "社会福祉施設・集会所", @@ -900,7 +902,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..402236dc0 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -355,6 +355,9 @@ "mapLayerSatelliteB02": "히마와리 가시 녹색(B02)", "weatherRankingEmpty": "정렬할 관측이 없습니다", "notifySectionOther": "기타", + "weatherRankingCurrent": "현재", + "weatherRankingLatest": "최신", + "weatherRankingPickTime": "자료 시각 선택", "weatherRankingMeta": "자료 시각: {time}\n관측점 {count}", "onboardingTermsAgree": "서비스 약관을 읽었으며 이에 동의합니다", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 투명(식생 없음)", @@ -562,7 +565,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "최고", "reportDetailReplay": "다시 보기", "mapLayerRestroom": "공중화장실", "restroomCategoryWelfare": "사회복지 기관·집회 시설", @@ -900,7 +902,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..1de292a1b 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -355,6 +355,9 @@ "mapLayerSatelliteB02": "Himawari Green (B02)", "weatherRankingEmpty": "ไม่มีข้อมูลให้จัดอันดับ", "notifySectionOther": "อื่น ๆ", + "weatherRankingCurrent": "ปัจจุบัน", + "weatherRankingLatest": "ล่าสุด", + "weatherRankingPickTime": "เลือกเวลาข้อมูล", "weatherRankingMeta": "เวลาข้อมูล: {time}\n{count} สถานี", "onboardingTermsAgree": "ฉันได้อ่านและยอมรับข้อกำหนดการให้บริการแล้ว", "mapLayerSatelliteTransparentNoVegetation": "Below 0.1 = transparent (no vegetation)", @@ -562,7 +565,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "สูงสุด", "reportDetailReplay": "เล่นย้อนหลัง", "mapLayerRestroom": "ห้องน้ำสาธารณะ", "restroomCategoryWelfare": "สถานสงเคราะห์", @@ -900,7 +902,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..6c8d9aff3 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -355,6 +355,9 @@ "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", "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 +565,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 +902,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..01c55773a 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -357,6 +357,9 @@ "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", "weatherRankingEmpty": "而家冇可排序嘅觀測", "notifySectionOther": "其他", + "weatherRankingCurrent": "而家", + "weatherRankingLatest": "最新", + "weatherRankingPickTime": "選擇資料時間", "weatherRankingMeta": "資料時間:{time}\n共 {count} 觀測點", "onboardingTermsAgree": "我已閱讀並同意服務條款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", @@ -564,7 +567,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "最高", "reportDetailReplay": "重播", "mapLayerRestroom": "公廁", "restroomCategoryWelfare": "社福機構、集會場所", @@ -900,7 +902,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..3764a6728 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -355,6 +355,9 @@ "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", "weatherRankingEmpty": "目前沒有可排序的觀測", "notifySectionOther": "其他", + "weatherRankingCurrent": "當下", + "weatherRankingLatest": "最新", + "weatherRankingPickTime": "選擇資料時間", "weatherRankingMeta": "資料時間:{time}\n共 {count} 觀測點", "onboardingTermsAgree": "我已閱讀並同意服務條款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", @@ -562,7 +565,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "最高", "reportDetailReplay": "重播", "mapLayerRestroom": "公廁", "restroomCategoryWelfare": "社福機構、集會場所", @@ -900,7 +902,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..8aaf98686 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -355,6 +355,9 @@ "mapLayerSatelliteB02": "ひまわり 可见光-绿(B02)", "weatherRankingEmpty": "目前没有可排序的观测", "notifySectionOther": "其他", + "weatherRankingCurrent": "当前", + "weatherRankingLatest": "最新", + "weatherRankingPickTime": "选择资料时间", "weatherRankingMeta": "资料时间:{time}\n共 {count} 观测点", "onboardingTermsAgree": "我已阅读并同意服务条款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(无植被)", @@ -562,7 +565,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "最高", "reportDetailReplay": "重播", "mapLayerRestroom": "公厕", "restroomCategoryWelfare": "社福机构、集会场所", @@ -900,7 +902,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..6dcc75b6a 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -355,6 +355,9 @@ "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", "weatherRankingEmpty": "目前沒有可排序的觀測", "notifySectionOther": "其他", + "weatherRankingCurrent": "當下", + "weatherRankingLatest": "最新", + "weatherRankingPickTime": "選擇資料時間", "weatherRankingMeta": "資料時間:{time}\n共 {count} 觀測點", "onboardingTermsAgree": "我已閱讀並同意服務條款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", @@ -562,7 +565,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "最高", "reportDetailReplay": "重播", "mapLayerRestroom": "公廁", "restroomCategoryWelfare": "社福機構、集會場所", @@ -900,7 +902,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..202840e6f 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -355,6 +355,9 @@ "mapLayerSatelliteB02": "ひまわり 可見光-綠(B02)", "weatherRankingEmpty": "目前沒有可排序的觀測", "notifySectionOther": "其他", + "weatherRankingCurrent": "當下", + "weatherRankingLatest": "最新", + "weatherRankingPickTime": "選擇資料時間", "weatherRankingMeta": "資料時間:{time}\n共 {count} 觀測點", "onboardingTermsAgree": "我已閱讀並同意服務條款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", @@ -562,7 +565,6 @@ "@weatherModeSand": { "description": "Label for the weatherModeSand option in the experimental backdrop settings." }, - "weatherRankingHighest": "最高", "reportDetailReplay": "重播", "mapLayerRestroom": "公廁", "restroomCategoryWelfare": "社福機構、集會場所", @@ -900,7 +902,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..a29d41bf5 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1542,6 +1542,24 @@ 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; + /// Snapshot time and station count above a ranking list /// /// In en, this message translates to: @@ -2358,12 +2376,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 +3558,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..33357a264 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -804,6 +804,15 @@ 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 weatherRankingMeta(String time, int count) { return 'Data time: $time\n$count stations'; @@ -1242,9 +1251,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 +1882,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..5ff8fd4fe 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -809,6 +809,15 @@ 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 weatherRankingMeta(String time, int count) { return 'Oras ng datos: $time\n$count istasyon'; @@ -1251,9 +1260,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 +1893,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..cfb81539b 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -803,6 +803,15 @@ 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 weatherRankingMeta(String time, int count) { return 'Waktu data: $time\n$count stasiun'; @@ -1242,9 +1251,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 +1884,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..25640f86d 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -789,6 +789,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get notifySectionOther => 'その他'; + @override + String get weatherRankingCurrent => '現在'; + + @override + String get weatherRankingLatest => '最新'; + + @override + String get weatherRankingPickTime => 'データ時刻を選択'; + @override String weatherRankingMeta(String time, int count) { return 'データ時刻:$time\n観測点 $count'; @@ -1219,9 +1228,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get reportDetailImageUnavailable => 'レポート画像はまだありません'; - @override - String get weatherRankingHighest => '最高'; - @override String get reportDetailReplay => 'リプレイ'; @@ -1842,9 +1848,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..969fb2431 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -788,6 +788,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get notifySectionOther => '기타'; + @override + String get weatherRankingCurrent => '현재'; + + @override + String get weatherRankingLatest => '최신'; + + @override + String get weatherRankingPickTime => '자료 시각 선택'; + @override String weatherRankingMeta(String time, int count) { return '자료 시각: $time\n관측점 $count'; @@ -1219,9 +1228,6 @@ class AppLocalizationsKo extends AppLocalizations { @override String get reportDetailImageUnavailable => '보고서 이미지가 아직 없습니다'; - @override - String get weatherRankingHighest => '최고'; - @override String get reportDetailReplay => '다시 보기'; @@ -1842,9 +1848,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..60fc059d7 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -799,6 +799,15 @@ class AppLocalizationsTh extends AppLocalizations { @override String get notifySectionOther => 'อื่น ๆ'; + @override + String get weatherRankingCurrent => 'ปัจจุบัน'; + + @override + String get weatherRankingLatest => 'ล่าสุด'; + + @override + String get weatherRankingPickTime => 'เลือกเวลาข้อมูล'; + @override String weatherRankingMeta(String time, int count) { return 'เวลาข้อมูล: $time\n$count สถานี'; @@ -1237,9 +1246,6 @@ class AppLocalizationsTh extends AppLocalizations { @override String get reportDetailImageUnavailable => 'ยังไม่มีภาพรายงาน'; - @override - String get weatherRankingHighest => 'สูงสุด'; - @override String get reportDetailReplay => 'เล่นย้อนหลัง'; @@ -1869,9 +1875,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..43f784d7b 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -800,6 +800,15 @@ 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 weatherRankingMeta(String time, int count) { return 'Thời gian: $time\n$count trạm'; @@ -1239,9 +1248,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 +1880,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..775946ad4 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -784,6 +784,15 @@ class AppLocalizationsYue extends AppLocalizations { @override String get notifySectionOther => '其他'; + @override + String get weatherRankingCurrent => '而家'; + + @override + String get weatherRankingLatest => '最新'; + + @override + String get weatherRankingPickTime => '選擇資料時間'; + @override String weatherRankingMeta(String time, int count) { return '資料時間:$time\n共 $count 觀測點'; @@ -1210,9 +1219,6 @@ class AppLocalizationsYue extends AppLocalizations { @override String get reportDetailImageUnavailable => '報告圖尚未提供'; - @override - String get weatherRankingHighest => '最高'; - @override String get reportDetailReplay => '重播'; @@ -1830,9 +1836,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..bf16c1c6a 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -784,6 +784,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get notifySectionOther => '其他'; + @override + String get weatherRankingCurrent => '當下'; + + @override + String get weatherRankingLatest => '最新'; + + @override + String get weatherRankingPickTime => '選擇資料時間'; + @override String weatherRankingMeta(String time, int count) { return '資料時間:$time\n共 $count 觀測點'; @@ -1210,9 +1219,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get reportDetailImageUnavailable => '報告圖尚未提供'; - @override - String get weatherRankingHighest => '最高'; - @override String get reportDetailReplay => '重播'; @@ -1830,9 +1836,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get openSourceLicenses => '引用套件'; - @override - String get weatherRankingLowest => '最低'; - @override String get reportFilterSortDepth => '深度'; @@ -4321,6 +4324,15 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get notifySectionOther => '其他'; + @override + String get weatherRankingCurrent => '当前'; + + @override + String get weatherRankingLatest => '最新'; + + @override + String get weatherRankingPickTime => '选择资料时间'; + @override String weatherRankingMeta(String time, int count) { return '资料时间:$time\n共 $count 观测点'; @@ -4747,9 +4759,6 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get reportDetailImageUnavailable => '报告图尚未提供'; - @override - String get weatherRankingHighest => '最高'; - @override String get reportDetailReplay => '重播'; @@ -5367,9 +5376,6 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get openSourceLicenses => '开源许可'; - @override - String get weatherRankingLowest => '最低'; - @override String get reportFilterSortDepth => '深度'; @@ -7857,6 +7863,15 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get notifySectionOther => '其他'; + @override + String get weatherRankingCurrent => '當下'; + + @override + String get weatherRankingLatest => '最新'; + + @override + String get weatherRankingPickTime => '選擇資料時間'; + @override String weatherRankingMeta(String time, int count) { return '資料時間:$time\n共 $count 觀測點'; @@ -8283,9 +8298,6 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get reportDetailImageUnavailable => '報告圖尚未提供'; - @override - String get weatherRankingHighest => '最高'; - @override String get reportDetailReplay => '重播'; @@ -8903,9 +8915,6 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get openSourceLicenses => '引用套件'; - @override - String get weatherRankingLowest => '最低'; - @override String get reportFilterSortDepth => '深度'; @@ -11394,6 +11403,15 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get notifySectionOther => '其他'; + @override + String get weatherRankingCurrent => '當下'; + + @override + String get weatherRankingLatest => '最新'; + + @override + String get weatherRankingPickTime => '選擇資料時間'; + @override String weatherRankingMeta(String time, int count) { return '資料時間:$time\n共 $count 觀測點'; @@ -11820,9 +11838,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get reportDetailImageUnavailable => '報告圖尚未提供'; - @override - String get weatherRankingHighest => '最高'; - @override String get reportDetailReplay => '重播'; @@ -12440,9 +12455,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..aac0019b9 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,124 @@ 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. + historyResult: const Ok([_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('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); + }); } From 148499c20f3e39f701f8d3284ec5367c828ff245 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Sat, 31 Oct 2026 02:00:56 +0800 Subject: [PATCH 2/2] fix(weather): never draw a ranking from a time other than the one picked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all A picked time used to fall back to a different snapshot whenever a dataset had nothing at or before it: the newest one when its history failed, or the oldest one it kept, which is later than the pick, when the history did not reach back that far. The app bar kept saying the picked time either way. Rain now shows that it has no data for that time, and the picker only offers the weather history's times, which the weather tabs can always draw. Fix(zh-Hant): 觀測排行選了過去的時間時,雨量不再顯示其他時間的資料 Fix(en-US): a ranking set to a past time no longer shows rain from a different time --- .../pages/weather_ranking_page.dart | 81 +++++++++++++------ lib/l10n/app_en.arb | 4 + lib/l10n/app_fil.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_th.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_yue.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hans.arb | 1 + lib/l10n/app_zh_Hant_HK.arb | 1 + lib/l10n/app_zh_TW.arb | 1 + lib/l10n/gen/app_localizations.dart | 6 ++ lib/l10n/gen/app_localizations_en.dart | 3 + lib/l10n/gen/app_localizations_fil.dart | 4 + lib/l10n/gen/app_localizations_id.dart | 4 + lib/l10n/gen/app_localizations_ja.dart | 3 + lib/l10n/gen/app_localizations_ko.dart | 3 + lib/l10n/gen/app_localizations_th.dart | 3 + lib/l10n/gen/app_localizations_vi.dart | 4 + lib/l10n/gen/app_localizations_yue.dart | 3 + lib/l10n/gen/app_localizations_zh.dart | 12 +++ .../pages/weather_ranking_page_test.dart | 64 ++++++++++++++- 24 files changed, 176 insertions(+), 29 deletions(-) diff --git a/lib/features/weather/presentation/pages/weather_ranking_page.dart b/lib/features/weather/presentation/pages/weather_ranking_page.dart index b2fc48d23..88187fdd5 100644 --- a/lib/features/weather/presentation/pages/weather_ranking_page.dart +++ b/lib/features/weather/presentation/pages/weather_ranking_page.dart @@ -2,6 +2,7 @@ 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'; @@ -131,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. @@ -227,7 +230,7 @@ class _WeatherRankingPageState extends State weatherStations: weatherStations.valueOrNull!, weather: weather.valueOrNull!, rainStations: rainStations.valueOrNull!, - rain: rain.valueOrNull!, + rain: rain.valueOrNull, ); if (second == null) _latestWeatherTime = data.weather.time; return Ok(data); @@ -243,9 +246,12 @@ class _WeatherRankingPageState extends State final cached = _weatherAt[second]; if (cached != null) return Ok(cached); final resolved = _atOrBefore(_weatherTimes, second); - // The history has not answered (or is empty): the newest snapshot is the - // only one this page can honestly draw. - if (resolved == null) return repo.latest(); + // 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; @@ -255,7 +261,11 @@ class _WeatherRankingPageState extends State /// 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. - Future> _rainSnapshot( + /// + /// `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 { @@ -263,22 +273,22 @@ class _WeatherRankingPageState extends State final cached = _rainAt[second]; if (cached != null) return Ok(cached); final resolved = _atOrBefore(_rainTimes, second); - if (resolved == null) return repo.latest(); + 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]; the oldest one when - /// [second] predates everything the history still keeps. + /// 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) { - if (times.isEmpty) return null; int? best; for (final time in times) { if (time <= second && (best == null || time > best)) best = time; } - return best ?? times.first; + return best; } /// Loads the snapshot-time lists the picker offers. Failures are logged and @@ -308,17 +318,14 @@ class _WeatherRankingPageState extends State } /// Weather drives the picker: it is the coarser of the two histories, and - /// every weather tab reads it. Rain alone answers when weather has none. - List get _pickerTimes => - _weatherTimes.isNotEmpty ? _weatherTimes : _rainTimes; + /// 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 - : _rainTimes.isNotEmpty - ? _rainTimes.last - : null); + (_weatherTimes.isNotEmpty ? _weatherTimes.last : null); Future _pickTime() async { final times = _pickerTimes; @@ -550,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; @@ -570,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; } @@ -592,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, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9fb4ac8d9..d25d04983 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1052,6 +1052,10 @@ "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)", diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index ea523f7e6..1992d4ab9 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -358,6 +358,7 @@ "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)", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 679481e51..363d4e242 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -358,6 +358,7 @@ "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)", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index a8adfe966..ccf68e033 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -358,6 +358,7 @@ "weatherRankingCurrent": "現在", "weatherRankingLatest": "最新", "weatherRankingPickTime": "データ時刻を選択", + "weatherRankingRainMissingAt": "この時刻の雨量データはありません", "weatherRankingMeta": "データ時刻:{time}\n観測点 {count}", "onboardingTermsAgree": "サービス利用規約を読み、同意します", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(植生なし)", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 402236dc0..3dc77a78d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -358,6 +358,7 @@ "weatherRankingCurrent": "현재", "weatherRankingLatest": "최신", "weatherRankingPickTime": "자료 시각 선택", + "weatherRankingRainMissingAt": "이 시각의 강수 자료가 없습니다", "weatherRankingMeta": "자료 시각: {time}\n관측점 {count}", "onboardingTermsAgree": "서비스 약관을 읽었으며 이에 동의합니다", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 투명(식생 없음)", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 1de292a1b..3e9b8d828 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -358,6 +358,7 @@ "weatherRankingCurrent": "ปัจจุบัน", "weatherRankingLatest": "ล่าสุด", "weatherRankingPickTime": "เลือกเวลาข้อมูล", + "weatherRankingRainMissingAt": "ไม่มีข้อมูลฝนในเวลานี้", "weatherRankingMeta": "เวลาข้อมูล: {time}\n{count} สถานี", "onboardingTermsAgree": "ฉันได้อ่านและยอมรับข้อกำหนดการให้บริการแล้ว", "mapLayerSatelliteTransparentNoVegetation": "Below 0.1 = transparent (no vegetation)", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 6c8d9aff3..ff4215d0f 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -358,6 +358,7 @@ "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)", diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index 01c55773a..6e56e73e8 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -360,6 +360,7 @@ "weatherRankingCurrent": "而家", "weatherRankingLatest": "最新", "weatherRankingPickTime": "選擇資料時間", + "weatherRankingRainMissingAt": "呢個時間冇雨量資料", "weatherRankingMeta": "資料時間:{time}\n共 {count} 觀測點", "onboardingTermsAgree": "我已閱讀並同意服務條款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3764a6728..4cdc24d8e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -358,6 +358,7 @@ "weatherRankingCurrent": "當下", "weatherRankingLatest": "最新", "weatherRankingPickTime": "選擇資料時間", + "weatherRankingRainMissingAt": "此時間沒有雨量資料", "weatherRankingMeta": "資料時間:{time}\n共 {count} 觀測點", "onboardingTermsAgree": "我已閱讀並同意服務條款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 8aaf98686..d374dbd61 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -358,6 +358,7 @@ "weatherRankingCurrent": "当前", "weatherRankingLatest": "最新", "weatherRankingPickTime": "选择资料时间", + "weatherRankingRainMissingAt": "此时间没有雨量资料", "weatherRankingMeta": "资料时间:{time}\n共 {count} 观测点", "onboardingTermsAgree": "我已阅读并同意服务条款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(无植被)", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 6dcc75b6a..4d1e807e8 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -358,6 +358,7 @@ "weatherRankingCurrent": "當下", "weatherRankingLatest": "最新", "weatherRankingPickTime": "選擇資料時間", + "weatherRankingRainMissingAt": "此時間沒有雨量資料", "weatherRankingMeta": "資料時間:{time}\n共 {count} 觀測點", "onboardingTermsAgree": "我已閱讀並同意服務條款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 202840e6f..3d2463435 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -358,6 +358,7 @@ "weatherRankingCurrent": "當下", "weatherRankingLatest": "最新", "weatherRankingPickTime": "選擇資料時間", + "weatherRankingRainMissingAt": "此時間沒有雨量資料", "weatherRankingMeta": "資料時間:{time}\n共 {count} 觀測點", "onboardingTermsAgree": "我已閱讀並同意服務條款", "mapLayerSatelliteTransparentNoVegetation": "< 0.1 = 透明(無植被)", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index a29d41bf5..cd838b19e 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1560,6 +1560,12 @@ abstract class AppLocalizations { /// **'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: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 33357a264..0b0e36a9f 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -813,6 +813,9 @@ class AppLocalizationsEn extends AppLocalizations { @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'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 5ff8fd4fe..5a9248eb9 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -818,6 +818,10 @@ class AppLocalizationsFil extends AppLocalizations { @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'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index cfb81539b..500d4e64a 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -812,6 +812,10 @@ class AppLocalizationsId extends AppLocalizations { @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'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 25640f86d..578f2ffe8 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -798,6 +798,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get weatherRankingPickTime => 'データ時刻を選択'; + @override + String get weatherRankingRainMissingAt => 'この時刻の雨量データはありません'; + @override String weatherRankingMeta(String time, int count) { return 'データ時刻:$time\n観測点 $count'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 969fb2431..c52d95a72 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -797,6 +797,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get weatherRankingPickTime => '자료 시각 선택'; + @override + String get weatherRankingRainMissingAt => '이 시각의 강수 자료가 없습니다'; + @override String weatherRankingMeta(String time, int count) { return '자료 시각: $time\n관측점 $count'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 60fc059d7..dcb9d2621 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -808,6 +808,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get weatherRankingPickTime => 'เลือกเวลาข้อมูล'; + @override + String get weatherRankingRainMissingAt => 'ไม่มีข้อมูลฝนในเวลานี้'; + @override String weatherRankingMeta(String time, int count) { return 'เวลาข้อมูล: $time\n$count สถานี'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 43f784d7b..ef8494130 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -809,6 +809,10 @@ class AppLocalizationsVi extends AppLocalizations { @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'; diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 775946ad4..5b65778af 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -793,6 +793,9 @@ class AppLocalizationsYue extends AppLocalizations { @override String get weatherRankingPickTime => '選擇資料時間'; + @override + String get weatherRankingRainMissingAt => '呢個時間冇雨量資料'; + @override String weatherRankingMeta(String time, int count) { return '資料時間:$time\n共 $count 觀測點'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index bf16c1c6a..a8abe4261 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -793,6 +793,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get weatherRankingPickTime => '選擇資料時間'; + @override + String get weatherRankingRainMissingAt => '此時間沒有雨量資料'; + @override String weatherRankingMeta(String time, int count) { return '資料時間:$time\n共 $count 觀測點'; @@ -4333,6 +4336,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get weatherRankingPickTime => '选择资料时间'; + @override + String get weatherRankingRainMissingAt => '此时间没有雨量资料'; + @override String weatherRankingMeta(String time, int count) { return '资料时间:$time\n共 $count 观测点'; @@ -7872,6 +7878,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get weatherRankingPickTime => '選擇資料時間'; + @override + String get weatherRankingRainMissingAt => '此時間沒有雨量資料'; + @override String weatherRankingMeta(String time, int count) { return '資料時間:$time\n共 $count 觀測點'; @@ -11412,6 +11421,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get weatherRankingPickTime => '選擇資料時間'; + @override + String get weatherRankingRainMissingAt => '此時間沒有雨量資料'; + @override String weatherRankingMeta(String time, int count) { return '資料時間:$time\n共 $count 觀測點'; 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 aac0019b9..37c639652 100644 --- a/test/features/weather/presentation/pages/weather_ranking_page_test.dart +++ b/test/features/weather/presentation/pages/weather_ranking_page_test.dart @@ -589,7 +589,7 @@ void main() { testWidgets('rain lands on its own nearest snapshot, not the weather one', ( tester, ) async { - final rainSecond = _earlier + 600; + final rainSecond = _earlier - 600; final weather = _Weather( _catalogue(), _weatherSnap(), @@ -599,8 +599,10 @@ void main() { final rain = _Rain( _catalogue(), _rainSnap(), - // The rain history runs on finer steps than the weather one. - historyResult: const Ok([_earlier + 600, _now]), + // 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); @@ -616,6 +618,62 @@ void main() { 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 {