From d07aaa211bb48a9c8e1719d8324c1c3cb8eecf9c Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Wed, 2 Sep 2026 12:02:51 +0800 Subject: [PATCH 1/5] fix(earthquake): show the date beside the today/yesterday tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 地震報告的「今天/昨天」標籤旁會顯示該日日期 Fix(en-US): the earthquake report now shows the date next to its today/yesterday tag --- .../presentation/pages/report_list_page.dart | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/features/earthquake/presentation/pages/report_list_page.dart b/lib/features/earthquake/presentation/pages/report_list_page.dart index 675be6016..15f8fafe4 100644 --- a/lib/features/earthquake/presentation/pages/report_list_page.dart +++ b/lib/features/earthquake/presentation/pages/report_list_page.dart @@ -251,11 +251,13 @@ class _DaySection extends StatelessWidget { ), child: Row( children: [ - Text( - _dayLabel(day, l10n, locale), - style: theme.textTheme.titleSmall?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, + Flexible( + child: Text( + _dayLabel(day, l10n, locale), + style: theme.textTheme.titleSmall?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), ), ), const SizedBox(width: AppSpacing.sm), @@ -302,9 +304,17 @@ class _DaySection extends StatelessWidget { static String _dayLabel(DateTime day, AppLocalizations l10n, String locale) { final today = taipeiCalendarDay(AppTime.utc); - if (day == today) return l10n.reportListToday; - if (day == today.subtract(const Duration(days: 1))) { - return l10n.reportListYesterday; + String? relative; + if (day == today) { + relative = l10n.reportListToday; + } else if (day == today.subtract(const Duration(days: 1))) { + relative = l10n.reportListYesterday; + } + if (relative != null) { + final date = _relativeDayFormats + .putIfAbsent(locale, () => DateFormat.yMMMd(locale)) + .format(day); + return '$relative ($date)'; } // Parsing a locale's pattern is not free — memoised per locale. return _dayFormats @@ -313,6 +323,7 @@ class _DaySection extends StatelessWidget { } static final Map _dayFormats = {}; + static final Map _relativeDayFormats = {}; } class _ReportTile extends StatelessWidget { From 05d6af44d8180115e60fd912637d026a00802dae Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Wed, 2 Sep 2026 12:03:00 +0800 Subject: [PATCH 2/5] fix(weather): keep the last ranking rows clear of the bottom nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正觀測排行最末幾筆被下方導覽列蓋住 Fix(en-US): the last observation-ranking rows are no longer hidden by the bottom nav bar --- .../pages/weather_ranking_page.dart | 93 ++++++++++--------- 1 file changed, 49 insertions(+), 44 deletions(-) diff --git a/lib/features/weather/presentation/pages/weather_ranking_page.dart b/lib/features/weather/presentation/pages/weather_ranking_page.dart index fe6aff58f..2c0314764 100644 --- a/lib/features/weather/presentation/pages/weather_ranking_page.dart +++ b/lib/features/weather/presentation/pages/weather_ranking_page.dart @@ -348,6 +348,13 @@ void _openStationOnMap( context.goNamed(AppRoutes.map); } +/// Bottom inset so the last ranked rows aren't hidden by the shell's +/// bottom nav bar (extendBody scaffolds). Same value every nested /data page +/// uses. With `extendBody: true` the bar floats over the page body, so its +/// own height plus the safe-area inset has to be padded away by the list. +double _rankingBottomPad(BuildContext context) => + AppSpacing.xl + MediaQuery.paddingOf(context).bottom; + double _fillFraction({ required List ranked, required int index, @@ -426,46 +433,46 @@ class _RainRankingPanelState extends State<_RainRankingPanel> { ), ), Expanded( - child: ranked.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox( - height: 240, - child: EmptyView( - icon: Icons.umbrella_outlined, - message: l10n.weatherRankingEmpty, - ), - ), - ], - ) - : ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.xs, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB( + 0, + AppSpacing.xs, + 0, + _rankingBottomPad(context), + ), + children: [ + if (ranked.isEmpty) + SizedBox( + height: 240, + child: EmptyView( + icon: Icons.umbrella_outlined, + message: l10n.weatherRankingEmpty, ), - itemCount: ranked.length, - itemBuilder: (context, index) { - final item = ranked[index]; - final label = item.value == item.value.roundToDouble() - ? '${item.value.toStringAsFixed(0)} mm' - : '${item.value.toStringAsFixed(1)} mm'; - return WeatherRankingRow( - rank: index + 1, + ) + else + ...List.generate(ranked.length, (index) { + final item = ranked[index]; + final label = item.value == item.value.roundToDouble() + ? '${item.value.toStringAsFixed(0)} mm' + : '${item.value.toStringAsFixed(1)} mm'; + return WeatherRankingRow( + rank: index + 1, + item: item, + merge: RankingMerge.none, + valueLabel: label, + fraction: ranked.first.value == 0 + ? 0 + : item.value / ranked.first.value, + onTap: () => _openStationOnMap( + context, + layerId: widget.mapLayerId, item: item, - merge: RankingMerge.none, - valueLabel: label, - fraction: ranked.first.value == 0 - ? 0 - : item.value / ranked.first.value, - onTap: () => _openStationOnMap( - context, - layerId: widget.mapLayerId, - item: item, - ), - ); - }, - ), + ), + ); + }), + ], + ), ), ], ), @@ -633,9 +640,8 @@ class _WeatherMetricPanelState extends State<_WeatherMetricPanel> { ) : ListView.builder( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.xs, - ), + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs) + .copyWith(bottom: _rankingBottomPad(context)), itemCount: ranked.length, itemBuilder: (context, index) { final item = ranked[index]; @@ -838,9 +844,8 @@ class _TempExtremePanelState extends State<_TempExtremePanel> { ) : ListView.builder( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.xs, - ), + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs) + .copyWith(bottom: _rankingBottomPad(context)), itemCount: ranked.length, itemBuilder: (context, index) { final item = ranked[index]; From 4f58d733e30ccde9aa81480ab7a829076150fb03 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Wed, 2 Sep 2026 14:51:26 +0800 Subject: [PATCH 3/5] fix(home): fit forecast and data grid on wide screens and large text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 大字體與平板螢幕下,首頁天氣與資料網格不再被拉寬或截斷 Fix(en-US): the home forecast and data grid no longer stretch or clip on wide screens and large text --- .../data/presentation/pages/data_page.dart | 46 +++- .../presentation/widgets/home_content.dart | 140 +++++++++--- .../widgets/home_forecast_section.dart | 203 ++++++++++-------- .../widgets/home_sheet_header.dart | 29 ++- .../widgets/onboarding_scaffold.dart | 27 ++- test/features/data/data_page_test.dart | 31 +++ .../widgets/home_content_test.dart | 196 ++++++++++++++++- .../widgets/home_sheet_header_test.dart | 40 +++- .../widgets/onboarding_scaffold_test.dart | 31 +++ 9 files changed, 602 insertions(+), 141 deletions(-) diff --git a/lib/features/data/presentation/pages/data_page.dart b/lib/features/data/presentation/pages/data_page.dart index ba047cd9b..5e7b0b133 100644 --- a/lib/features/data/presentation/pages/data_page.dart +++ b/lib/features/data/presentation/pages/data_page.dart @@ -2,6 +2,8 @@ /// weather observation rankings, …). library; +import 'dart:math' as math; + import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; @@ -73,8 +75,9 @@ class DataPage extends StatelessWidget { ), ), SectionHeader(l10n.dataSectionWeather), - GridView.count( - crossAxisCount: 2, + // Wide and short: icon left, label right, one glance per tile. + GridView( + gridDelegate: _rankingGrid(context), shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB( @@ -83,10 +86,6 @@ class DataPage extends StatelessWidget { AppSpacing.lg, AppSpacing.sm, ), - crossAxisSpacing: AppSpacing.sm, - mainAxisSpacing: AppSpacing.sm, - // Wide and short: icon left, label right, one glance per tile. - childAspectRatio: 2.4, children: [ for (final (tab, icon) in _weatherRankingEntries) _RankingGridTile( @@ -105,8 +104,8 @@ class DataPage extends StatelessWidget { ], ), SectionHeader(l10n.dataSectionAstronomy), - GridView.count( - crossAxisCount: 2, + GridView( + gridDelegate: _rankingGrid(context), shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB( @@ -115,9 +114,6 @@ class DataPage extends StatelessWidget { AppSpacing.lg, AppSpacing.sm, ), - crossAxisSpacing: AppSpacing.sm, - mainAxisSpacing: AppSpacing.sm, - childAspectRatio: 2.4, children: [ for (final (route, icon, label, accent) in <(String, IconData, String, Color)>[ @@ -240,6 +236,34 @@ class _SeismicCard extends StatelessWidget { } } +/// The ranking tiles' geometry, shared by both grids so 氣象 and 天文 stay the +/// same shape. +/// +/// Width-driven, not count-driven: two columns on a phone, four on a tablet, +/// and a tile of the same size either way. `crossAxisCount: 2` with a +/// `childAspectRatio` tied the tile's *height* to the screen's width — on a +/// 1366 pt iPad the two columns are 668 pt wide, so every tile came out 278 pt +/// tall and its single line of label floated in the middle of a sea of tonal +/// grey. +/// +/// The height is stated outright instead, and follows the text scale rather +/// than the window: the tile holds a 34 pt icon badge beside a label of at most +/// two lines, so that is what it is tall enough for at any text size. The 80 +/// floor is the height a phone drew before this, kept so the phone layout is +/// untouched. +SliverGridDelegate _rankingGrid(BuildContext context) { + const twoLabelLines = 44.0; + return SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 340, + crossAxisSpacing: AppSpacing.sm, + mainAxisSpacing: AppSpacing.sm, + mainAxisExtent: math.max( + 80, + AppSpacing.md * 2 + MediaQuery.textScalerOf(context).scale(twoLabelLines), + ), + ); +} + /// A metric card in a grid — tonal surface, leading icon badge, label, and a /// forward affordance. /// diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index aa7d499d3..be115adb2 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -170,9 +170,32 @@ class HomeContent extends StatelessWidget { /// the fully expanded dry forecast scrollable instead of clipping them. static const double _minimumWeatherHeroHeight = 760; - /// Current growth of the hero forecast card for [offset]. - static double _forecastExpansion(double offset) => - (offset / _forecastExpandExtent).clamp(0.0, 1.0); + /// Current growth of the hero forecast card for [offset], over whichever is + /// shorter: [_forecastExpandExtent] or the scroll [reach] the list actually + /// has. + /// + /// The ramp has to be normalised because the distance it asks for is not a + /// distance the list is guaranteed to own. The hero block fills the viewport + /// exactly, so everything the sheet can scroll is what sits *after* it — on + /// a dry hour that is the grab handle, one gap and the (usually empty) + /// active-events card, about 140 px. A fixed 200 px ramp then tops out near + /// 0.7 at the very bottom of the list: the card could never finish opening + /// on a tall phone, no matter how hard it was pulled. Short phones hid this, + /// because [_minimumWeatherHeroHeight] hands them a hero taller than their + /// own viewport and the leftover is scroll distance; so did a larger text + /// size, which grows the cards below and with them the reach — which is why + /// this reads as a per-device, per-text-size bug rather than a constant. + /// + /// [reach] of 0 means the list cannot scroll at all, so no gesture is left + /// to reveal anything with: the card opens rather than sitting forever at + /// its summary. + static double _forecastExpansion(double offset, double reach) { + if (reach <= 0) return 1; + final extent = reach < _forecastExpandExtent + ? reach + : _forecastExpandExtent; + return (offset / extent).clamp(0.0, 1.0); + } /// How wet the rain-trend card gets for a given backdrop. /// @@ -258,7 +281,8 @@ class HomeContent extends StatelessWidget { // rebuild the whole panel for identical output. Keep this the // max of the ramp extents if a longer ramp is ever added. saturation: _forecastExpandExtent, - builder: (context, offset) { + builder: (context, reading) { + final offset = reading.offset; // The cards read as a pane of the sky only while the sky is // the point (hero showing). Once the list scrolls, they // solidify back into solid plates and their ink back onto @@ -266,7 +290,10 @@ class HomeContent extends StatelessWidget { // sky-tuned ink is exactly what makes scrolled content hard // to read, no matter how dimmed the backdrop behind it is. final reveal = this.reveal * (1 - _focus(offset)); - final forecastExpansion = _forecastExpansion(offset); + final forecastExpansion = _forecastExpansion( + offset, + reading.reach, + ); final heroLayoutHeight = heroHeight == null ? null : heroHeight < _minimumWeatherHeroHeight @@ -285,13 +312,36 @@ class HomeContent extends StatelessWidget { // SizedBox > Padding > Column > RainOnCard) never // changes shape, so it never needs to be torn down and // rebuilt when the sheet opens or closes. - SizedBox( - height: heroLayoutHeight, + // A floor, not a fixed height: the block is + // [heroLayoutHeight] tall whenever its own content fits + // in that, and taller when it does not. At the largest + // text step the header and the fully opened forecast + // card together want ~60 px more than the viewport, and + // a fixed height turned that into a RenderFlex overflow + // — the bottom of the card, silently cut. Growing + // instead hands the surplus to the list as scroll + // distance, which is where a block taller than the + // screen belongs. + // + // The floor is a `minHeight` and the column below sizes + // to its children (`MainAxisSize.min`), which is why the + // gap between header and card is `spaceBetween` rather + // than the [Expanded] it used to be: a flex reads its + // *constrained* size, so it still fills the floor and + // still parks the card against the bottom edge, but it + // is now free to exceed it instead of overflowing. An + // [IntrinsicHeight] would express the same thing and + // cannot be used — the header holds a [LayoutBuilder], + // which refuses to answer intrinsic queries. + ConstrainedBox( + constraints: BoxConstraints( + minHeight: heroLayoutHeight ?? 0, + ), // Only the trailing gap depends on scroll offset, so // that is all this block's Padding re-reads per tick. child: Padding( - // Deflates the tight SizedBox height so the Expanded - // gap between header and trend card shrinks by exactly + // Deflates the block height so the gap between + // header and trend card shrinks by exactly // this much and heroHeight itself — and with it the // forecast/events fold below — never moves. Collapses // to 0 as the sheet scrolls — see @@ -303,6 +353,8 @@ class HomeContent extends StatelessWidget { : _heroBottomGap(offset, bottomSafeArea), ), child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ RainOnCard( @@ -335,11 +387,12 @@ class HomeContent extends StatelessWidget { ), ), if (heroHeight != null) ...[ - // The gap is the point — open sky between the - // two fixed edges, not a forgotten card. - // `HomeSheet` blurs it back in once the scroll - // below carries the trend card past the top. - const Expanded(child: SizedBox.shrink()), + // The gap between this card and the header — + // the column's `spaceBetween` — is the point: + // open sky between the two fixed edges, not a + // forgotten card. `HomeSheet` blurs it back in + // once the scroll below carries the trend card + // past the top. if (dryTrend) // A dry hour has no rain chart, so the forecast // card itself takes the hero slot — a one-glance @@ -582,14 +635,19 @@ class HomeSheetHandle extends StatelessWidget { } } -/// Rebuilds [builder] only when the scroll offset, clamped to -/// `[0, saturation]`, actually changes. +/// How far the list is scrolled and how far it *can* be scrolled: the offset +/// clamped to `[0, saturation]`, and the position's own max scroll extent. +typedef _ScrollReading = ({double offset, double reach}); + +/// Rebuilds [builder] only when the scroll reading — offset clamped to +/// `[0, saturation]`, plus the reach the ramps are measured against — +/// actually changes. /// /// A [ScrollController] notifies on every scrolled pixel. The panel this /// drives derives everything from clamping ramps that saturate early, so most /// of those notifications produce byte-identical output — the clamp runs -/// here, in the listener, and [ValueNotifier]'s own equality drops the -/// duplicates before any widget rebuilds. +/// here, in the listener, and the record's own equality drops the duplicates +/// before any widget rebuilds. class _SaturatingOffsetBuilder extends StatefulWidget { const _SaturatingOffsetBuilder({ required this.controller, @@ -599,7 +657,7 @@ class _SaturatingOffsetBuilder extends StatefulWidget { final ScrollController controller; final double saturation; - final Widget Function(BuildContext context, double offset) builder; + final Widget Function(BuildContext context, _ScrollReading reading) builder; @override State<_SaturatingOffsetBuilder> createState() => @@ -607,13 +665,37 @@ class _SaturatingOffsetBuilder extends StatefulWidget { } class _SaturatingOffsetBuilderState extends State<_SaturatingOffsetBuilder> { - late final ValueNotifier _offset = ValueNotifier(_read()); - - double _read() => widget.controller.hasClients - ? widget.controller.offset.clamp(0.0, widget.saturation) - : 0.0; + late final ValueNotifier<_ScrollReading> _reading = ValueNotifier(_read()); + + /// [ScrollPosition] announces a changed *offset* to its listeners, but a + /// changed content extent only as a `ScrollMetricsNotification`, which + /// bubbles up past this builder (it is a child of the list). Re-reading the + /// reach on every scroll tick is what keeps it current instead: any content + /// change the user has not yet scrolled through leaves the offset at 0, + /// where the reach does not affect a single ramp. + /// + /// Before first layout there is no position to read. The reach is reported + /// as the full [HomeContent._forecastExpandExtent] rather than 0 for that + /// frame — 0 means "cannot scroll", and answering that before the list has + /// ever been measured would pop the forecast card open on the first frame. + _ScrollReading _read() { + if (!widget.controller.hasClients) { + return (offset: 0.0, reach: widget.saturation); + } + final position = widget.controller.position; + return ( + offset: position.hasPixels + ? position.pixels.clamp(0.0, widget.saturation) + : 0.0, + // Both are still unset while this builds inside the list's very first + // layout, and reading either one there throws. + reach: position.hasContentDimensions + ? position.maxScrollExtent + : widget.saturation, + ); + } - void _onScroll() => _offset.value = _read(); + void _onScroll() => _reading.value = _read(); @override void initState() { @@ -634,13 +716,13 @@ class _SaturatingOffsetBuilderState extends State<_SaturatingOffsetBuilder> { @override void dispose() { widget.controller.removeListener(_onScroll); - _offset.dispose(); + _reading.dispose(); super.dispose(); } @override - Widget build(BuildContext context) => ValueListenableBuilder( - valueListenable: _offset, - builder: (context, offset, _) => widget.builder(context, offset), + Widget build(BuildContext context) => ValueListenableBuilder<_ScrollReading>( + valueListenable: _reading, + builder: (context, reading, _) => widget.builder(context, reading), ); } diff --git a/lib/features/home/presentation/widgets/home_forecast_section.dart b/lib/features/home/presentation/widgets/home_forecast_section.dart index edbf7fd90..4328ebbf4 100644 --- a/lib/features/home/presentation/widgets/home_forecast_section.dart +++ b/lib/features/home/presentation/widgets/home_forecast_section.dart @@ -66,6 +66,17 @@ class HomeForecastSection extends StatefulWidget { } class _HomeForecastSectionState extends State { + /// Height of the fully grown temperature curve. Not text-scaled — it is a + /// chart, and its readable size comes from the width it is drawn across. + static const double _sparklineHeight = 36; + + /// Expansion at which the detail band snaps open. Halfway through the + /// reveal, so the same pull that grows the curve carries the band with it, + /// and the band never sits on a threshold the scroll cannot cross — + /// `HomeContent` normalises [HomeForecastSection.expansion] against the + /// scroll range that actually exists, so 0.5 is always half a pull away. + static const double _detailOpenAt = 0.5; + int _selected = 0; WeatherForecast? _seriesForecast; _ForecastTemperatureSeries? _series; @@ -184,104 +195,128 @@ class _HomeForecastSectionState extends State { ), ), ), - Text( - l10n.homeForecastHighLow( - maxTemp.round().toString(), - minTemp.round().toString(), + // Flexible, not a bare [Text]: a Row lays its non-flex children + // out unbounded, so at a large text step this one grew past the + // width left over and the whole title row overflowed to the + // right. Sharing the row with the title lets it wrap onto a + // second line instead — the high and the low are numbers, and a + // number that is ellipsised is worse than a number on its own + // line. At every ordinary text size it still fits on one. + Flexible( + child: Text( + l10n.homeForecastHighLow( + maxTemp.round().toString(), + minTemp.round().toString(), + ), + textAlign: TextAlign.end, + style: theme.textTheme.labelLarge?.copyWith(color: secondary), ), - style: theme.textTheme.labelLarge?.copyWith(color: secondary), ), ], ), const SizedBox(height: AppSpacing.md), - // The sparkline and detail band reveal with [expansion] — the summary - // card shows only the title + hour chips, and pulling the sheet up - // grows this same card into its full height. Clipped so the not-yet- - // revealed parts never bleed over the hour chips below. - ClipRect( - child: Align( - alignment: Alignment.topCenter, - heightFactor: expansion, - child: Opacity( - opacity: expansion, - child: SizedBox( - height: 36, - width: double.infinity, - child: CustomPaint( - painter: _TempSparklinePainter( - temps: temps, - min: minTemp, - max: maxTemp, - selected: selected, - line: colors.primary, - fill: colors.primary.withValues(alpha: 0.18), - mark: foreground, - ), - child: const SizedBox.expand(), - ), + // The sparkline reveals with [expansion] — the summary card shows + // only the title + hour chips, and pulling the sheet up grows this + // same card into its full height. Grown by handing the painter a + // shorter box, never by clipping a full-height one: the painter maps + // the series onto whatever height it is given, so every fraction is + // a whole curve. The scroll can rest at any fraction, and a clipped + // chart parked at 0.7 reads as a chart with its bottom sliced off. + Opacity( + opacity: expansion, + child: SizedBox( + height: _sparklineHeight * expansion, + width: double.infinity, + child: CustomPaint( + painter: _TempSparklinePainter( + temps: temps, + min: minTemp, + max: maxTemp, + selected: selected, + line: colors.primary, + fill: colors.primary.withValues(alpha: 0.18), + mark: foreground, ), + child: const SizedBox.expand(), ), ), ), SizedBox(height: AppSpacing.md * expansion), - SizedBox( - height: 108, - child: ListView.separated( + // The strip is exactly as tall as the tallest chip wants to be, not + // a fixed height the chips are expected to fit inside. Every line in + // a chip grows with the text-size setting while the icon does not, + // so no constant is right at every step: 108 fit until 特大, where + // the chips ran 16 px over it and the rain chance was cut in half. + // The intrinsic pass costs one extra layout of a row of ~24 chips of + // three short strings each. + IntrinsicHeight( + child: SingleChildScrollView( scrollDirection: Axis.horizontal, - itemCount: points.length, - separatorBuilder: (_, _) => const SizedBox(width: AppSpacing.sm), - itemBuilder: (context, index) { - final p = points[index]; - final hour = _hourNumber(p.time); - final (icon, accent) = weatherVisual( - p.weather, - p.weatherCode, - colors, - // Per hour, not per row: a clear 02:00 chip must show a moon - // while the 14:00 chip beside it shows a sun. - isNight: hour < sunlight.sunrise || hour >= sunlight.sunset, - ); - final isSelected = index == selected; - return _HourChip( - time: l10n.chartHourLabel(hour), - icon: icon, - iconColor: accent ?? secondary, - temp: '${p.temperature.round()}°', - pop: l10n.homeForecastPop(p.pop.toString()), - selected: isSelected, - foreground: foreground, - secondary: secondary, - selectedFill: colors.primary.withValues(alpha: 0.16), - onTap: () => setState(() => _selected = index), - ); - }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.sm, + children: [ + for (final (index, p) in points.indexed) + Builder( + builder: (context) { + final hour = _hourNumber(p.time); + final (icon, accent) = weatherVisual( + p.weather, + p.weatherCode, + colors, + // Per hour, not per row: a clear 02:00 chip must show + // a moon while the 14:00 chip beside it shows a sun. + isNight: + hour < sunlight.sunrise || + hour >= sunlight.sunset, + ); + return _HourChip( + time: l10n.chartHourLabel(hour), + icon: icon, + iconColor: accent ?? secondary, + temp: '${p.temperature.round()}°', + pop: l10n.homeForecastPop(p.pop.toString()), + selected: index == selected, + foreground: foreground, + secondary: secondary, + selectedFill: colors.primary.withValues(alpha: 0.16), + onTap: () => setState(() => _selected = index), + ); + }, + ), + ], + ), ), ), - ClipRect( - child: Align( - alignment: Alignment.topCenter, - heightFactor: expansion, - child: Opacity( - opacity: expansion, - child: _DetailBand( - weather: point.weather, - time: point.time, - feelsLike: l10n.homeForecastFeelsLike( - point.apparentTemp.round().toString(), - ), - humidity: l10n.homeForecastHumidity( - point.humidity.toString(), - ), - wind: l10n.homeForecastWind( - point.wind.direction, - point.wind.beaufort.toString(), + // Snapped open, not scroll-linked like the sparkline above: this band + // is text, and a fraction of a line of text is a line cut in half. + // The scroll rests wherever the finger leaves it, so a scroll-linked + // clip here parks a sliced line on screen for as long as the user + // stays — which is exactly what it used to do. It crosses + // [_detailOpenAt] once and animates to its own full height. + AnimatedSize( + duration: AppMotion.medium, + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: expansion < _detailOpenAt + ? const SizedBox(width: double.infinity) + : _DetailBand( + weather: point.weather, + time: point.time, + feelsLike: l10n.homeForecastFeelsLike( + point.apparentTemp.round().toString(), + ), + humidity: l10n.homeForecastHumidity( + point.humidity.toString(), + ), + wind: l10n.homeForecastWind( + point.wind.direction, + point.wind.beaufort.toString(), + ), + foreground: foreground, + secondary: secondary, + divider: secondary.withValues(alpha: 0.35), ), - foreground: foreground, - secondary: secondary, - divider: secondary.withValues(alpha: 0.35), - ), - ), - ), ), ], ), diff --git a/lib/features/home/presentation/widgets/home_sheet_header.dart b/lib/features/home/presentation/widgets/home_sheet_header.dart index 9080299e1..b89636842 100644 --- a/lib/features/home/presentation/widgets/home_sheet_header.dart +++ b/lib/features/home/presentation/widgets/home_sheet_header.dart @@ -234,15 +234,30 @@ class HomeSheetHeader extends StatelessWidget { color: conditionAccent, ), const SizedBox(width: AppSpacing.md), - Text.rich( - TextSpan( - children: [ + // The reading is one unbreakable token — `28.7°C` has + // nowhere to wrap — beside an icon that does not scale + // with text, so on a narrow phone at a large accessibility + // size it grew past the row and the last digits were cut + // off. Scaled down to the width that is left instead: + // shrinking only when it must, and the temperature stays + // the largest thing on the screen either way. Truncating + // it was never an option — half a temperature reads as a + // different temperature. + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerStart, + child: Text.rich( TextSpan( - text: temp?.toStringAsFixed(1) ?? '—', - style: tempStyle, + children: [ + TextSpan( + text: temp?.toStringAsFixed(1) ?? '—', + style: tempStyle, + ), + TextSpan(text: '°C', style: unitStyle), + ], ), - TextSpan(text: '°C', style: unitStyle), - ], + ), ), ), ], diff --git a/lib/features/onboarding/presentation/widgets/onboarding_scaffold.dart b/lib/features/onboarding/presentation/widgets/onboarding_scaffold.dart index f04987ce6..1ff2fce3d 100644 --- a/lib/features/onboarding/presentation/widgets/onboarding_scaffold.dart +++ b/lib/features/onboarding/presentation/widgets/onboarding_scaffold.dart @@ -26,6 +26,15 @@ class OnboardingScaffold extends StatefulWidget { } class _OnboardingScaffoldState extends State { + /// The widest the step's column is allowed to get. A tablet is wide enough + /// that an unconstrained onboarding stretches every row to the screen's + /// edges: the body becomes a single line of text a foot long, each + /// permission card strands its 授權 button half a screen away from the + /// sentence explaining it, and the final call to action is a button wider + /// than any thumb travels. Same measure as the settings sheet, so the two + /// permission surfaces are the same shape. + static const double _maxContentWidth = 560; + final ScrollController _controller = ScrollController(); bool _atEnd = false; bool _checkScheduled = false; @@ -96,7 +105,14 @@ class _OnboardingScaffoldState extends State { child: SingleChildScrollView( controller: _controller, padding: const EdgeInsets.all(AppSpacing.lg), - child: widget.child, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: _maxContentWidth, + ), + child: widget.child, + ), + ), ), ), ), @@ -110,7 +126,14 @@ class _OnboardingScaffoldState extends State { AppSpacing.lg, AppSpacing.lg, ), - child: widget.actionBuilder(context, atEnd), + // Same measure as the body above it, so the action sits under the + // content it acts on rather than under the whole window. + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _maxContentWidth), + child: widget.actionBuilder(context, atEnd), + ), + ), ), ), ], diff --git a/test/features/data/data_page_test.dart b/test/features/data/data_page_test.dart index 4fc9625b8..29ca65389 100644 --- a/test/features/data/data_page_test.dart +++ b/test/features/data/data_page_test.dart @@ -91,6 +91,37 @@ void main() { expect(astronomy, greaterThan(weather)); }); + testWidgets('a tablet gets more columns, not taller tiles', (tester) async { + // An iPad in landscape. The grid used to be two fixed columns whose height + // came from a `childAspectRatio`, so the wider the screen the taller the + // tile: 668 pt columns gave 278 pt cells, each holding one line of label + // in the middle of an otherwise empty card. + tester.view.physicalSize = const Size(2752, 2064); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: _router([]), + ), + ); + await tester.pump(); + + final tile = tester.getRect( + find + .ancestor(of: find.text('Moon'), matching: find.byType(InkWell)) + .first, + ); + expect( + tile.height, + lessThan(120), + reason: 'tile height, not a screen slice', + ); + // Four columns of ~330, not two of ~670. + expect(tile.width, lessThan(400)); + }); + for (final (route, label) in _astronomyTiles) { testWidgets('the $label tile navigates to $route', (tester) async { // A fresh router per tile: a tile wired to the wrong route would diff --git a/test/features/home/presentation/widgets/home_content_test.dart b/test/features/home/presentation/widgets/home_content_test.dart index 1544b4e64..71cdd5eec 100644 --- a/test/features/home/presentation/widgets/home_content_test.dart +++ b/test/features/home/presentation/widgets/home_content_test.dart @@ -120,6 +120,8 @@ Widget _wrap( RegionStore store, { bool expanded = false, double topInset = 0, + double textScale = 1, + ScrollController? controller, RainHourTrendRepository? hourTrend, WeatherForecast? forecast, TownDirectory directory = const TownDirectory({}), @@ -158,11 +160,17 @@ Widget _wrap( ), ), ], - child: Scaffold( - body: HomeContent( - scrollController: ScrollController(), - expanded: expanded, - topInset: topInset, + child: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: Scaffold( + body: HomeContent( + scrollController: controller ?? ScrollController(), + expanded: expanded, + topInset: topInset, + ), + ), ), ), ), @@ -179,6 +187,52 @@ Future _store() async { ); } +/// A located township on a dry hour: the hero block carries the one forecast +/// card, and everything below it is the (empty) events card — the shape whose +/// scroll range is shorter than the reveal ramp used to assume. +Future _dryHeroApp({ + required double textScale, + required ScrollController controller, +}) async { + final store = await _store(); + store + ..select(1) + ..setCurrentCode('100'); + const point = WeatherForecastPoint( + time: '14:00', + temperature: 30, + apparentTemp: 33, + humidity: 70, + weather: 'Clear', + weatherCode: 100, + pop: 0, + wind: ForecastWind(direction: 'NE', speed: 2, beaufort: 2), + ); + return _wrap( + store, + expanded: true, + topInset: 88, + textScale: textScale, + controller: controller, + hourTrend: _FakeHourTrendRepository(dry: true), + forecast: const WeatherForecast( + updateTime: 0, + forecast: [point, point, point], + ), + directory: const TownDirectory({ + '100': Town( + code: '100', + city: 'Test', + town: 'North', + lat: 25.0, + lng: 121.5, + cityLevel: 'City', + townLevel: 'District', + ), + }), + ); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -466,6 +520,138 @@ void main() { expect(tester.takeException(), isNull); }); + // A tall phone is the case the fixed 200 px reveal ramp could not serve: its + // hero block fills the viewport exactly, so the whole scroll range is the + // handle, one gap and the empty events card — about 140 px. The card used to + // top out near 0.7 at the very bottom of the list and sit there with its last + // line of text cut in half. A short phone never showed it, because the 760 px + // hero floor hands it scroll distance its own viewport does not have. + testWidgets('a tall phone can open the forecast card all the way', ( + tester, + ) async { + tester.view.physicalSize = const Size(1284, 2778); // 428 × 926 at 3× + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + await _dryHeroApp(textScale: 1, controller: controller), + ); + await tester.pumpAndSettle(); + + final reach = controller.position.maxScrollExtent; + expect( + reach, + lessThan(200), + reason: 'the case under test: less scroll than the ramp asks for', + ); + + controller.jumpTo(reach); + await tester.pumpAndSettle(); + + expect( + tester + .widget(find.byType(HomeForecastSection)) + .expansion, + 1, + ); + expect(find.text('Feels like 33°').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + // The scroll rests wherever the finger leaves it, so every offset in between + // is a state someone sits and reads. The detail band is text: a fraction of + // a line of text is a line cut in half, which is why it snaps open rather + // than tracking the scroll like the curve above it does. + testWidgets('no resting scroll position leaves the detail band sliced', ( + tester, + ) async { + tester.view.physicalSize = const Size(1284, 2778); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + await _dryHeroApp(textScale: 1, controller: controller), + ); + await tester.pumpAndSettle(); + + final reach = controller.position.maxScrollExtent; + for (final fraction in const [0.0, 0.2, 0.4, 0.55, 0.7, 0.9, 1.0]) { + controller.jumpTo(reach * fraction); + await tester.pumpAndSettle(); + + final wind = find.text('NE · Force 2'); // the band's last line + if (wind.evaluate().isEmpty) continue; // not open yet at this offset + expect( + tester.getRect(wind).bottom, + lessThanOrEqualTo( + tester.getRect(find.byType(HomeForecastSection)).bottom, + ), + reason: 'band drawn past the card edge at $fraction of the scroll', + ); + } + expect(tester.takeException(), isNull); + }); + + // Every line in an hour chip grows with the text-size setting while its icon + // does not, so the strip's old fixed 108 px height ran 16 px short at 特大 and + // cut the rain chance off the bottom of every chip. + for (final scale in const [1.2, 1.45]) { + testWidgets('the hour chips fit at text scale $scale', (tester) async { + tester.view.physicalSize = const Size(1284, 2778); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + await _dryHeroApp(textScale: scale, controller: controller), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: 'summary card'); + + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: 'fully opened card'); + expect( + find.text('0%'), + findsWidgets, + ); // the chip line that used to be cut + }); + } + + // The narrow phone at the largest in-app text step: the card's title row laid + // its high/low out unbounded, so the row overflowed 35 px to the right and + // the reading was cut off at the card's edge. 320 pt is the smallest screen + // the app ships to, and 1.45 is 特大 with the system size left alone. + testWidgets('the forecast card fits a narrow phone at text scale 1.45', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + await _dryHeroApp(textScale: 1.45, controller: controller), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: 'summary card'); + + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: 'fully opened card'); + expect(find.text('H 30° · L 30°'), findsOneWidget); + }); + testWidgets('全國 keeps its events card (it is not a missing location)', ( tester, ) async { diff --git a/test/features/home/presentation/widgets/home_sheet_header_test.dart b/test/features/home/presentation/widgets/home_sheet_header_test.dart index f5ea059e4..7b197c682 100644 --- a/test/features/home/presentation/widgets/home_sheet_header_test.dart +++ b/test/features/home/presentation/widgets/home_sheet_header_test.dart @@ -119,6 +119,7 @@ Widget _wrap( MapStationHandoff handoff, { Future<({double lat, double lng})?> Function()? gpsFix, bool expanded = false, + double textScale = 1, }) { final router = GoRouter( initialLocation: '/', @@ -140,9 +141,15 @@ Widget _wrap( ), ), ], - child: Scaffold( - body: SingleChildScrollView( - child: HomeSheetHeader(expanded: expanded), + child: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: Scaffold( + body: SingleChildScrollView( + child: HomeSheetHeader(expanded: expanded), + ), + ), ), ), ), @@ -164,6 +171,33 @@ Widget _wrap( } void main() { + // The full-screen reading is one unbreakable token beside an icon that does + // not scale with text: at a large accessibility size on a narrow phone the + // row overflowed and the last digits of the temperature were cut off. 2.0 is + // the system's own maximum on Android, before the app's own step is applied. + testWidgets('expanded: the hero temperature fits a narrow phone at 2x text', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final store = await _store() + ..select(2); // '100' + final repo = _GatedWeatherRepository(); + final handoff = MapStationHandoff(); + await tester.pumpWidget( + _wrap(store, repo, handoff, expanded: true, textScale: 2), + ); + + repo.complete(25.0, 121.5, _realtime('信義', 28.7)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.textContaining('28.7'), findsOneWidget); + }); + testWidgets('collapsed: station data time shows, view-on-map link does not', ( tester, ) async { diff --git a/test/features/onboarding/presentation/widgets/onboarding_scaffold_test.dart b/test/features/onboarding/presentation/widgets/onboarding_scaffold_test.dart index c59d86f98..1fb80cdc9 100644 --- a/test/features/onboarding/presentation/widgets/onboarding_scaffold_test.dart +++ b/test/features/onboarding/presentation/widgets/onboarding_scaffold_test.dart @@ -94,6 +94,37 @@ void main() { ); }); + testWidgets('a tablet keeps the body and the action to one measure', ( + tester, + ) async { + // An iPad in portrait. Unconstrained, the step's column and the call to + // action each stretched the full 1032 pt: the permission cards put their + // 授權 button half a screen from the sentence explaining it, and 開始使用 + // became a button wider than any thumb travels. + tester.view.devicePixelRatio = 2; + tester.view.physicalSize = const Size(2064, 2752); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + _wrap( + OnboardingScaffold( + child: const Text('body'), + actionBuilder: (context, atEnd) => + FilledButton(onPressed: () {}, child: const Text('go')), + ), + ), + ); + await tester.pumpAndSettle(); + + final screen = tester.getSize(find.byType(OnboardingScaffold)).width; + final action = tester.getRect(find.byType(FilledButton)); + expect(screen, greaterThan(1000), reason: 'the tablet width under test'); + expect(action.width, lessThanOrEqualTo(560)); + // Centred, not left-aligned against a sea of empty space. + expect(action.center.dx, closeTo(screen / 2, 0.5)); + }); + testWidgets('re-checks when new window metrics make the content fit', ( tester, ) async { From d7cc7dea19e42b3ac64cd05816bf440046b25ea2 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Thu, 3 Sep 2026 03:41:49 +0800 Subject: [PATCH 4/5] fix(map): keep the OSM ground visible under the township wash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): OSM 詳細底圖會留在震度洗底下,無震時不再整片變灰 Fix(en-US): the OSM detailed map stays visible under the township wash instead of turning the island flat grey when shaking is zero --- .../pages/report_replay_page.dart | 13 ++++- .../map/presentation/layers/rts_layer.dart | 13 ++++- lib/shared/map/basemap_overlay_sync.dart | 1 + lib/shared/map/map_gsi_overlay.dart | 16 ++++++- lib/shared/map/map_style.dart | 14 +++++- .../features/map/raster_timeline_harness.dart | 22 ++++++++- test/shared/map/map_gsi_overlay_test.dart | 48 ++++++++++++++++++- test/shared/map/map_style_test.dart | 27 +++++++++++ 8 files changed, 144 insertions(+), 10 deletions(-) diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 61d6daae3..9e347d8ac 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -942,9 +942,13 @@ class _ReplayMapState extends State<_ReplayMap> { countyFillLayerId, FillLayerProperties(fillColor: baseFill, fillOpacity: 1), ); + // Back to the baked default. This layer is the wash and nothing else: + // with no alert up it paints nothing, so the OSM detailed ground — + // which mounts directly beneath it — keeps showing. The grey island is + // [countyFillLayerId]'s job, restored just above. await controller.setLayerProperties( townFillLayerId, - FillLayerProperties(fillColor: baseFill, fillOpacity: 1), + const FillLayerProperties(fillColor: '#00000000', fillOpacity: 0), ); return; } @@ -980,7 +984,12 @@ class _ReplayMapState extends State<_ReplayMap> { 'match', ['get', 'CODE'], ...entries, - baseFill, + // Transparent, not the palette grey: a township the estimate puts + // at 0 has to leave whatever is under it showing — the OSM + // detailed ground when that layer is on, the grey `land` fill when + // it is not. Falling back to grey painted a flat sheet over the + // detailed map everywhere the shaking was 0. + 'rgba(0, 0, 0, 0)', ], fillOpacity: 1, ), diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index 247a199a0..a52aa32b9 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -779,9 +779,13 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { countyFillLayerId, FillLayerProperties(fillColor: baseFill, fillOpacity: 1), ); + // Back to the baked default. This layer is the wash and nothing else: + // with no alert up it paints nothing, so the OSM detailed ground — + // which mounts directly beneath it — keeps showing. The grey island is + // [countyFillLayerId]'s job, restored just above. await controller.setLayerProperties( townFillLayerId, - FillLayerProperties(fillColor: baseFill, fillOpacity: 1), + const FillLayerProperties(fillColor: '#00000000', fillOpacity: 0), ); return; } @@ -817,7 +821,12 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { 'match', ['get', 'CODE'], ...entries, - baseFill, + // Transparent, not the palette grey: a township the estimate puts + // at 0 has to leave whatever is under it showing — the OSM + // detailed ground when that layer is on, the grey `land` fill when + // it is not. Falling back to grey painted a flat sheet over the + // detailed map everywhere the shaking was 0. + 'rgba(0, 0, 0, 0)', ], fillOpacity: 1, ), diff --git a/lib/shared/map/basemap_overlay_sync.dart b/lib/shared/map/basemap_overlay_sync.dart index 57b9d6bba..3ccbd7490 100644 --- a/lib/shared/map/basemap_overlay_sync.dart +++ b/lib/shared/map/basemap_overlay_sync.dart @@ -100,6 +100,7 @@ class BasemapOverlaySync { brightness: brightness, selection: gsi, belowLayerId: townOutlineLayerId, + groundBelowLayerId: townFillLayerId, ); if (!stillCurrent()) return; _gsiOnMap = true; diff --git a/lib/shared/map/map_gsi_overlay.dart b/lib/shared/map/map_gsi_overlay.dart index 5955e717e..dc55d718d 100644 --- a/lib/shared/map/map_gsi_overlay.dart +++ b/lib/shared/map/map_gsi_overlay.dart @@ -688,11 +688,25 @@ const List _nameExpression = [ ['get', 'name_int'], ]; +/// Mounts the OSM detailed overlay at two anchors, because the overlay is two +/// different things. +/// +/// [groundBelowLayerId] takes the opaque *ground* — landcover, land use, +/// parks, water, buildings. It has to go under the township shaking wash: at +/// one anchor for everything, the 0.9-opacity landcover painted straight over +/// the wash, and 強震監視器 showed a fully tinted island only where OSM +/// happened to have no polygon — a township with no colour reads as a township +/// with no shaking. +/// +/// [belowLayerId] takes the roads, place names and POI. Those stay *above* the +/// wash: they are thin, they do not hide a colour, and they are the only thing +/// left to navigate by once the island is tinted. Future addGsiOverlay( MapLibreMapController controller, { required Brightness brightness, required GsiOverlayController selection, required String belowLayerId, + required String groundBelowLayerId, }) async { final layers = gsiStyleLayers(brightness); final added = []; @@ -708,7 +722,7 @@ Future addGsiOverlay( gsiSourceId, layer.id, properties as FillLayerProperties, - belowLayerId: belowLayerId, + belowLayerId: groundBelowLayerId, sourceLayer: layer.sourceLayer, minzoom: layer.minZoom, filter: layer.filter, diff --git a/lib/shared/map/map_style.dart b/lib/shared/map/map_style.dart index 2bc445eb0..8db54d310 100644 --- a/lib/shared/map/map_style.dart +++ b/lib/shared/map/map_style.dart @@ -125,6 +125,18 @@ const String countyFillLayerId = 'county'; /// Id of the base township-fill layer (`town` source-layer) — recoloured the /// same way, keyed per `CODE`, when an EEW wants the whole island tinted by /// estimated shaking (legacy monitor behaviour). +/// +/// Mounted at `fill-opacity: 0`: this layer paints the shaking wash and +/// nothing else. The grey landmass under it is [countyFillLayerId]'s job — +/// the two cover the same island in the same [MapPalette.fill], which is why +/// the wash can already hide the county fill outright and still leave a whole +/// grey island where the estimate is 0. +/// +/// It has to start invisible so that something can be drawn *between* the +/// landmass and the wash: the OSM detailed ground mounts here (see +/// `basemap_overlay_sync`), and an opaque grey township would bury it. That +/// is the same reason the wash's own `match` falls back to transparent rather +/// than to the palette's grey. const String townFillLayerId = 'town'; /// Id of the faint township-outline layer (below the county borders). @@ -297,7 +309,7 @@ String exptechVectorStyle( { "id": "bg", "type": "background", "paint": { "background-color": "$background" } }, { "id": "$landLayerId", "type": "fill", "source": "exptech", "source-layer": "global", "paint": { "fill-color": "$fill" } }, { "id": "county", "type": "fill", "source": "exptech", "source-layer": "city", "paint": { "fill-color": "$fill" } }, - { "id": "town", "type": "fill", "source": "exptech", "source-layer": "town", "paint": { "fill-color": "$fill" } }$hillshade, + { "id": "town", "type": "fill", "source": "exptech", "source-layer": "town", "paint": { "fill-color": "$fill", "fill-opacity": 0 } }$hillshade, { "id": "$townOutlineLayerId", "type": "line", "source": "exptech", "source-layer": "town", "paint": { "line-color": "$townOutline", "line-width": 0.4, "line-opacity": 0.7 } }, { "id": "$outlineLayerId", "type": "line", "source": "exptech", "source-layer": "city", "paint": { "line-color": "$outline", "line-width": 1.0 } }, { "id": "$townLabelLayerId", "type": "symbol", "source": "$townLabelSourceId", "minzoom": $townLabelMinZoom, "layout": { diff --git a/test/features/map/raster_timeline_harness.dart b/test/features/map/raster_timeline_harness.dart index 1c7c9e547..85456e280 100644 --- a/test/features/map/raster_timeline_harness.dart +++ b/test/features/map/raster_timeline_harness.dart @@ -7,7 +7,13 @@ import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/shared/map/map_style.dart' - show landLayerId, outlineLayerId, townLabelLayerId; + show + countyFillLayerId, + landLayerId, + outlineLayerId, + townFillLayerId, + townLabelLayerId, + townOutlineLayerId; import 'package:dpip/shared/map/raster_frame_source.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -236,7 +242,19 @@ class RecordingMapController implements MapLibreMapController { /// anchor and so the most recently added one ends up highest. That is exactly /// how a timeline scrub used to bury the county borders under the echo. So /// this models the real insertion, seeded with the base style's own layers. - final List order = [landLayerId, outlineLayerId, townLabelLayerId]; + /// + /// The seed mirrors `exptechVectorStyle`'s own layer list bottom-up, not just + /// the anchors overlays quote: the OSM overlay now mounts its opaque ground + /// *below* the township fill and its roads and labels *above* it, and the + /// difference between those two only exists if the fills are in the model. + final List order = [ + landLayerId, + countyFillLayerId, + townFillLayerId, + townOutlineLayerId, + outlineLayerId, + townLabelLayerId, + ]; void _insert(String layerId, String? belowLayerId) { order.remove(layerId); diff --git a/test/shared/map/map_gsi_overlay_test.dart b/test/shared/map/map_gsi_overlay_test.dart index e768b6c70..4e7cdce9e 100644 --- a/test/shared/map/map_gsi_overlay_test.dart +++ b/test/shared/map/map_gsi_overlay_test.dart @@ -3,6 +3,7 @@ import 'package:dpip/core/network/etag_interceptor.dart'; import 'package:dpip/core/settings/setting_keys.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/shared/map/map_gsi_overlay.dart'; +import 'package:dpip/shared/map/map_style.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -197,7 +198,8 @@ void main() { map, brightness: Brightness.dark, selection: selection, - belowLayerId: 'town-outline', + belowLayerId: townOutlineLayerId, + groundBelowLayerId: townFillLayerId, ); expect(map.calls.first, 'addSource:$gsiSourceId'); @@ -208,7 +210,13 @@ void main() { hasLength(19), ); for (final layer in gsiStyleLayers(Brightness.dark)) { - expect(map.belowOf(layer.id), 'town-outline', reason: layer.id); + // The ground goes under the township fill (which carries the shaking + // wash); everything thin stays above it. + expect( + map.belowOf(layer.id), + layer.kind == GsiLayerKind.fill ? townFillLayerId : townOutlineLayerId, + reason: layer.id, + ); } selection.setGroupEnabled(GsiLayerGroup.roads, false); @@ -227,6 +235,42 @@ void main() { ); }); + test('the shaking wash sits over OSM ground, under OSM roads', () async { + // 強震監視器 and 重播 tint the island by recolouring the baked `town` fill. + // With every OSM layer anchored at one place, the overlay's 0.9-opacity + // landcover painted straight over that wash, and a township with no OSM + // polygon under it was the only one that still showed its colour — a map + // whose blank patches read as "no shaking here". + final map = RecordingMapController(); + final selection = GsiOverlayController(SettingsStore.inMemory({})); + addTearDown(selection.dispose); + + await addGsiOverlay( + map, + brightness: Brightness.dark, + selection: selection, + belowLayerId: townOutlineLayerId, + groundBelowLayerId: townFillLayerId, + ); + + for (final layer in gsiStyleLayers(Brightness.dark)) { + final ground = layer.kind == GsiLayerKind.fill; + expect( + map.isAbove(townFillLayerId, layer.id), + ground, + reason: '${layer.id} vs the wash', + ); + } + // Named outright, so a layer changing kind cannot quietly move sides. + expect(map.isAbove(townFillLayerId, 'gsi-landcover'), isTrue); + expect(map.isAbove(townFillLayerId, 'gsi-building'), isTrue); + expect(map.isAbove('gsi-transportation', townFillLayerId), isTrue); + expect(map.isAbove('gsi-place', townFillLayerId), isTrue); + // …and the base map's own borders still end up over all of it. + expect(map.isAbove(townOutlineLayerId, 'gsi-place'), isTrue); + expect(map.isAbove(outlineLayerId, 'gsi-transportation'), isTrue); + }); + test('OSM PBFs share the immutable map-tile cache path', () { final uri = Uri.parse( 'https://static.lb.exptech.dev/api/v1/map/gsi/14/13703/7034.pbf', diff --git a/test/shared/map/map_style_test.dart b/test/shared/map/map_style_test.dart index c9b541407..c09aa9493 100644 --- a/test/shared/map/map_style_test.dart +++ b/test/shared/map/map_style_test.dart @@ -31,6 +31,33 @@ void main() { ]); }); + test('the township fill is mounted invisible — it is only ever the wash', () { + final style = jsonDecode( + exptechVectorStyle( + MapColors.dark, + basemapTileUrl: 'https://example.com/{z}/{x}/{y}.pbf', + glyphsUrl: 'https://example.com/{fontstack}/{range}.pbf', + ), + ) as Map; + final layers = style['layers'] as List; + final byId = {for (final l in layers) (l as Map)['id']: l}; + + // The grey island is the county fill's; the township fill exists only so + // an EEW can recolour it per CODE. It starts at zero opacity so the OSM + // detailed ground, which anchors directly beneath it, is not buried under + // a second sheet of the same grey. + final town = byId[townFillLayerId] as Map; + expect(town['paint']['fill-opacity'], 0); + expect( + town['paint']['fill-color'], + MapColors.dark.fill, + reason: 'still the palette grey, so a wash can restore it', + ); + final county = byId[countyFillLayerId] as Map; + expect(county['paint'].containsKey('fill-opacity'), isFalse); + expect(county['paint']['fill-color'], MapColors.dark.fill); + }); + test('terrain adds a mapbox-encoded raster-dem source and hillshade between fills and borders', () { final style = jsonDecode( exptechVectorStyle( From 0da855d0c5778f6785ae7345cf7d9f275571d6d8 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Fri, 4 Sep 2026 00:07:06 +0800 Subject: [PATCH 5/5] fix(home): show the EEW card only in the full-screen dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 首頁收合時不重複顯示地震速報卡片,展開後才顯示完整內容 Fix(en-US): the home no longer duplicates the earthquake alert card while collapsed and shows it when expanded --- .../presentation/widgets/home_content.dart | 22 ++-- .../widgets/home_eew_section.dart | 76 ++++++------- .../widgets/home_content_test.dart | 101 ++++++++++++++++-- .../widgets/home_eew_section_test.dart | 22 ++++ 4 files changed, 167 insertions(+), 54 deletions(-) diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index be115adb2..7422c5804 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -438,13 +438,21 @@ class HomeContent extends StatelessWidget { // thing on the dashboard that matters in the seconds it // exists. Nothing renders when calm, so the sheet's // ordinary layout is untouched outside an earthquake. - const HomeEewSection(), - // The section renders nothing when calm — only reserve - // the gap below it while an alert is actually showing, - // or a calm dashboard gains an extra empty lg here on - // top of the one already before this section. - if (HomeEewSection.isActive(context)) - const SizedBox(height: AppSpacing.lg), + // + // Full-screen only: while the sheet rests half-open, + // `HomeMonitorBanner` is still on screen carrying the same + // alert (it only slides away as the sheet rises), and two + // copies of one warning in one view is one too many. The + // card takes over exactly where the banner leaves off. + if (expanded) ...[ + const HomeEewSection(), + // The section renders nothing when calm — only reserve + // the gap below it while an alert is actually showing, + // or a calm dashboard gains an extra empty lg here on + // top of the one already before this section. + if (HomeEewSection.isActive(context)) + const SizedBox(height: AppSpacing.lg), + ], // Collapsed, or nothing to anchor a hero to: active events // only. Full-screen township: the hero above, then forecast // → events reached by scrolling past it. 全國: events only diff --git a/lib/features/home/presentation/widgets/home_eew_section.dart b/lib/features/home/presentation/widgets/home_eew_section.dart index d83c52787..f260a1132 100644 --- a/lib/features/home/presentation/widgets/home_eew_section.dart +++ b/lib/features/home/presentation/widgets/home_eew_section.dart @@ -80,43 +80,19 @@ class _EewSectionBody extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colors = theme.colorScheme; - final l10n = AppLocalizations.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Icon(Icons.warning_amber_outlined, size: 18, color: colors.error), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - l10n.eewTitle, - style: theme.textTheme.titleSmall?.copyWith( - color: colors.onSurface, - fontWeight: FontWeight.w700, - ), - ), - ), - Text( - l10n.eewSerial(alert.serial), - style: theme.textTheme.labelSmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => context.pushNamed(AppRoutes.eew), - child: _EewAlertCard(alert: alert), - ), - ), - ], + // No section header outside the card: the space between the sheet's cards + // is the scroll-dimmed weather sky (`_ScrollBlurredWeather`), not a + // surface, and it is dimmed precisely so the solid plates carry the + // reading. Bare theme-ink text there is unreadable in the light theme — + // near-black `onSurface` on a 45 %-dimmed sky. The title lives inside the + // card instead, exactly like every sibling section's. + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => context.pushNamed(AppRoutes.eew), + child: _EewAlertCard(alert: alert), + ), ); } } @@ -211,6 +187,32 @@ class _EewAlertCardState extends State<_EewAlertCard> with SecondTicker { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Row( + children: [ + Icon( + Icons.warning_amber_outlined, + size: 18, + color: colors.error, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + l10n.eewTitle, + style: theme.textTheme.titleSmall?.copyWith( + color: colors.onSurface, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + l10n.eewSerial(widget.alert.serial), + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), Row( children: [ Expanded( diff --git a/test/features/home/presentation/widgets/home_content_test.dart b/test/features/home/presentation/widgets/home_content_test.dart index 71cdd5eec..bf279ed87 100644 --- a/test/features/home/presentation/widgets/home_content_test.dart +++ b/test/features/home/presentation/widgets/home_content_test.dart @@ -1,4 +1,6 @@ import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/geo/location_service.dart'; +import 'package:dpip/core/geo/location_status.dart'; import 'package:dpip/core/geo/town.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/realtime/clock.dart'; @@ -11,12 +13,14 @@ import 'package:dpip/core/realtime/ticker.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; import 'package:dpip/features/events/domain/event.dart'; import 'package:dpip/features/events/domain/event_repository.dart'; import 'package:dpip/features/home/presentation/home_active_events_controller.dart'; import 'package:dpip/features/home/presentation/home_weather_controller.dart'; import 'package:dpip/features/home/presentation/widgets/home_active_events_section.dart'; import 'package:dpip/features/home/presentation/widgets/home_content.dart'; +import 'package:dpip/features/home/presentation/widgets/home_eew_section.dart'; import 'package:dpip/features/home/presentation/widgets/home_forecast_section.dart'; import 'package:dpip/features/home/presentation/widgets/home_rain_trend_section.dart'; import 'package:dpip/features/home/presentation/widgets/home_sheet_header.dart'; @@ -114,11 +118,43 @@ class _StaticEewSource extends RealtimeSource> { bool sameData(List? a, List? b) => listEquals(a, b); } +/// A refreshed EEW channel carrying one live alert, ready to hand to [_wrap]. +Future>> _liveEew() async { + final channel = RealtimeChannel>( + source: _StaticEewSource([ + Eew( + agency: 'CWA', + id: 'test', + serial: 34, + status: 0, + isFinal: false, + info: const EewInfo( + time: 1786362600000, + longitude: 121.5, + latitude: 23.5, + depth: 10, + magnitude: 7.5, + location: '臺東縣', + max: 6, + ), + ), + ]), + clock: _FakeClock(), + elapsed: _FakeElapsed(), + ticker: _FakeTicker(), + config: RealtimeConfig.eew, + label: 'test-eew', + ); + await channel.refreshNow(); + return RealtimeNotifier>(channel); +} + /// Pumps [HomeContent] with everything it reads: a [RegionStore] to switch on /// and localizations for the body. Widget _wrap( RegionStore store, { bool expanded = false, + RealtimeNotifier>? eew, double topInset = 0, double textScale = 1, ScrollController? controller, @@ -147,18 +183,36 @@ Widget _wrap( ChangeNotifierProvider( create: (_) => HomeActiveEventsController(events, store), ), - ChangeNotifierProvider>>( - create: (_) => RealtimeNotifier>( - RealtimeChannel>( - source: _StaticEewSource(const []), - clock: _FakeClock(), - elapsed: _FakeElapsed(), - ticker: _FakeTicker(), - config: RealtimeConfig.eew, - label: 'test-eew', - ), + // Read by the EEW alert card for its 所在地預估 countdown. + Provider>.value( + value: Future.value( + const SeismicTravelTimeTable({}), ), ), + Provider.value( + value: LocationService( + directory, + isAvailable: () async => false, + fix: () async => null, + lastKnown: () async => null, + status: () async => LocationStatus.denied, + ), + ), + if (eew != null) + ChangeNotifierProvider>>.value(value: eew) + else + ChangeNotifierProvider>>( + create: (_) => RealtimeNotifier>( + RealtimeChannel>( + source: _StaticEewSource(const []), + clock: _FakeClock(), + elapsed: _FakeElapsed(), + ticker: _FakeTicker(), + config: RealtimeConfig.eew, + label: 'test-eew', + ), + ), + ), ], child: Builder( builder: (context) => MediaQuery( @@ -364,6 +418,33 @@ void main() { expect(find.byType(HomeActiveEventsSection), findsOneWidget); }); + testWidgets('the EEW card is full-screen only, never at rest', ( + tester, + ) async { + final store = await _store(); + store + ..select(1) + ..setCurrentCode('100'); + + // At rest `HomeMonitorBanner` is still on screen with the same alert, so + // the in-sheet card would be a second copy of one warning. + await tester.pumpWidget(_wrap(store, eew: await _liveEew())); + await tester.pumpAndSettle(); + expect(find.byType(HomeEewSection), findsNothing); + expect(find.text('臺東縣'), findsNothing); + + // Full-screen the banner has slid away, so the card carries the alert. + await tester.pumpWidget( + _wrap(store, expanded: true, eew: await _liveEew()), + ); + await tester.pumpAndSettle(); + expect(find.byType(HomeEewSection), findsOneWidget); + expect(find.text('臺東縣'), findsOneWidget); + + // Tear down so the card's countdown timer is cancelled. + await tester.pumpWidget(const SizedBox()); + }); + testWidgets('a dry hour hides the rain trend and raises a compact forecast', ( tester, ) async { diff --git a/test/features/home/presentation/widgets/home_eew_section_test.dart b/test/features/home/presentation/widgets/home_eew_section_test.dart index 4475aa925..e2a3ae487 100644 --- a/test/features/home/presentation/widgets/home_eew_section_test.dart +++ b/test/features/home/presentation/widgets/home_eew_section_test.dart @@ -106,6 +106,7 @@ Widget _wrap(RealtimeNotifier> notifier, RegionStore store) => localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, locale: const Locale('en'), + theme: ThemeData(brightness: Brightness.light), home: MultiProvider( providers: [ ChangeNotifierProvider>>.value( @@ -155,6 +156,27 @@ void main() { await tester.pumpWidget(const SizedBox()); }); + testWidgets('title and serial sit inside the card, not on the backdrop', ( + tester, + ) async { + final setup = await _liveNotifier([_alert()]); + final store = await _store(); + await tester.pumpWidget(_wrap(setup.notifier, store)); + + // The gap between the home sheet's cards is the scroll-dimmed weather sky, + // not a surface — theme on-surface ink is unreadable there in the light + // theme. Both header texts must be on the card's opaque plate. + for (final label in ['Earthquake early warning', 'Report 2']) { + expect( + find.ancestor(of: find.text(label), matching: find.byType(Card)), + findsOneWidget, + reason: '"$label" must render inside the alert card', + ); + } + + await tester.pumpWidget(const SizedBox()); + }); + testWidgets('renders nothing when the feed is calm (no alerts)', ( tester, ) async {