From 0183fdf9f4d9790677a14425af4c2c15a55dad46 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Thu, 27 Aug 2026 03:24:51 +0800 Subject: [PATCH 1/3] fix(home): stop the township outline from covering the location dot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Fix(zh-Hant): 修正選取的鄉鎮框線會蓋住定位圓點 Fix(en-US): the selected township's outline no longer covers the location dot --- .../widgets/home_map_backdrop.dart | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/lib/features/home/presentation/widgets/home_map_backdrop.dart b/lib/features/home/presentation/widgets/home_map_backdrop.dart index 243d4ef6c..f2c79c8f3 100644 --- a/lib/features/home/presentation/widgets/home_map_backdrop.dart +++ b/lib/features/home/presentation/widgets/home_map_backdrop.dart @@ -68,6 +68,49 @@ class _HomeMapBackdropState extends State static const String _radarLayer = 'home-radar-lyr'; static const double _radarOpacity = 0.85; + /// Finds the lowest layer belonging to MapLibre Android's native location + /// puck in the *live* style, or null if it isn't there. + /// + /// Android and iOS need opposite handling here, and this one query gives + /// both the right answer without a platform branch: + /// + /// - **Android**: the puck is not an overlay drawn above the map — it is + /// itself a handful of runtime style layers (ids like + /// `mapbox-location-shadow-layer`, `…-foreground-layer`, …), so it does + /// not automatically stay above whatever this backdrop adds afterward. + /// Hardcoding one of those names is fragile: the SDK picks between a + /// legacy multi-layer renderer and a newer single "location-indicator" + /// layer internally, not through anything this plugin's Dart API + /// exposes, so a name that matches today's build can silently stop + /// matching after an SDK bump. Querying the live stack for whichever id + /// actually contains "location" survives that. [style.getLayers()][1] — + /// what `getLayerIds()` wraps — orders bottom to top, so the first match + /// is the bottom of the puck's own layer group; anchoring + /// [_addSelectedLayers] below *that* keeps it under every sub-layer the + /// puck is made of, however many there are. + /// - **iOS**: this fork's `MapLibreMapController.swift` just flips + /// `MLNMapView.showsUserLocation` — the puck is a native + /// `MLNUserLocationAnnotationView`, composited over the rendered map by + /// the SDK itself, never a style layer. No id here will ever contain + /// "location", so this correctly returns null and [_addSelectedLayers] + /// falls back to the plain top-of-stack add — which is already right on + /// iOS, since nothing in the style's layer order can cover an annotation + /// that isn't part of that stack in the first place. + /// + /// [1]: MapLibre Android's `Style.getLayers()`. + Future _locationPuckLayerId(MapLibreMapController controller) async { + try { + final ids = await controller.getLayerIds(); + for (final id in ids) { + if (id is String && id.toLowerCase().contains('location')) return id; + } + Log.debug('Home map: no location layer found in $ids'); + } catch (e, st) { + Log.handle(e, st, 'Home map: getLayerIds failed'); + } + return null; + } + /// How far the selected township's bounds are pushed outward before fitting — /// a fraction of the box's span added to every side — so it frames with /// surrounding context instead of edge-to-edge. Fitting the (expanded) bounds @@ -311,6 +354,7 @@ class _HomeMapBackdropState extends State List filter, ) async { await _removeLayerQuietly(controller, _selectedLineLayer); + final belowLayerId = await _locationPuckLayerId(controller); await controller.addLineLayer( 'exptech', _selectedLineLayer, @@ -319,6 +363,7 @@ class _HomeMapBackdropState extends State sourceLayer: 'town', filter: filter, enableInteraction: false, + belowLayerId: belowLayerId, ); } From 23622eaaf698692fc8b4940a137a9ee8c349798b Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Thu, 27 Aug 2026 06:21:05 +0800 Subject: [PATCH 2/3] fix(map): draw the rain legend as a gradient matching the forecast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 雨量圖例改為漸層顯示,與雷達預報圖例一致 Fix(en-US): the rain legend now draws as a gradient, matching the radar forecast legend --- .../map/presentation/layers/rain_layer.dart | 6 + .../layers/weather_station_layer.dart | 15 +- test/features/map/rain_legend_test.dart | 181 ++++++++++++------ 3 files changed, 140 insertions(+), 62 deletions(-) diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart index 8db87a124..a0f265f31 100644 --- a/lib/features/map/presentation/layers/rain_layer.dart +++ b/lib/features/map/presentation/layers/rain_layer.dart @@ -95,6 +95,12 @@ class RainMapLayer @override bool get bandedColors => true; + /// The legend draws as a gradient, unlike the dots and sheet reading above — + /// matching the QPESUMS forecast legend's look, since both are precipitation + /// scales shown on the same map. + @override + bool get legendBanded => false; + @override double? valueOf(RainObservation observation) => interval.value.valueOf(observation); diff --git a/lib/features/map/presentation/layers/weather_station_layer.dart b/lib/features/map/presentation/layers/weather_station_layer.dart index c16d18a7c..22b875986 100644 --- a/lib/features/map/presentation/layers/weather_station_layer.dart +++ b/lib/features/map/presentation/layers/weather_station_layer.dart @@ -68,10 +68,19 @@ abstract class WeatherStationLayer< /// value between two stops genuinely lies between two colours. Accumulations /// do not — the published rainfall scale is a table of categories, and a dot /// blended halfway between the 70 mm and 90 mm bands claims a precision the - /// band structure denies. Banded layers get a MapLibre `step`, [stepColor] - /// in the sheet, and a hard-edged legend, so all three agree. + /// band structure denies. Banded layers get a MapLibre `step` and + /// [stepColor] in the sheet — see [legendBanded] for the legend, which does + /// not always follow this. bool get bandedColors => false; + /// Whether the legend draws hard bands rather than a gradient. + /// + /// Defaults to [bandedColors], but a subclass may split the two: rain keeps + /// its dots and sheet reading banded (the CWA scale is genuinely + /// categorical) while drawing its legend as a gradient, to read + /// consistently with the other precipitation layers on the map. + bool get legendBanded => bandedColors; + /// Whether to draw the value-coloured dot. A subclass may replace it with its /// own symbology (e.g. wind arrows) by returning false. @protected @@ -231,7 +240,7 @@ abstract class WeatherStationLayer< final scale = ColorScaleLegend( stops: colorStops, unit: unit, - banded: bandedColors, + banded: legendBanded, ); final child = header == null ? scale diff --git a/test/features/map/rain_legend_test.dart b/test/features/map/rain_legend_test.dart index 29adca5ce..978c02ad7 100644 --- a/test/features/map/rain_legend_test.dart +++ b/test/features/map/rain_legend_test.dart @@ -2,16 +2,23 @@ /// inside the collapsed chip, whose `AnimatedSize` lays out with unbounded /// width. /// -/// Guards two regressions that together made the 雨量 legend useless: +/// Two things guarded here: /// -/// 1. The banded label column (`_BandBoundaryLabels`) was a bare `Stack` in a -/// Row, and a Stack refuses unbounded width — expanding the legend threw a -/// layout assertion every frame and it never appeared. The column now -/// measures the widest boundary label and carries its own width. -/// 2. The band strip's `ColoredBox` cells have no intrinsic width, so under -/// the default `Column` cross axis they collapsed to zero and the strip -/// was invisible — the boundary numbers had no colours beside them. The -/// band column now stretches across the swatch width. +/// 1. [RainMapLayer]'s legend draws as a gradient — like the QPESUMS forecast +/// legend — even though the dots and sheet reading underneath stay banded +/// (the CWA scale is genuinely categorical; only the legend's look +/// changed, via `WeatherStationLayer.legendBanded`). +/// 2. `ColorScaleLegend(banded: true)` — the hard-edged rendering rain no +/// longer uses for its own legend, but which the widget still supports for +/// a genuinely categorical scale — must keep working inside this same +/// unbounded-width chip. This guards two regressions that together once +/// made a banded legend useless: the boundary-label column is a `Stack` in +/// a `Row`, and a `Stack` refuses unbounded width, so expanding the legend +/// threw a layout assertion every frame and it never appeared; and the +/// band strip's `ColoredBox` cells have no intrinsic width, so under the +/// default `Column` cross axis they collapsed to zero and the strip was +/// invisible while the boundary numbers kept rendering with no colour +/// beside them. library; import 'package:dpip/core/error/result.dart'; @@ -46,65 +53,122 @@ class _StubRainRepository implements MeteorRainRepository { Ok(RainTrend(id: id, range: range, times: const [], rain: const [])); } -void main() { - testWidgets( - 'the banded legend expands inside the chip without layout exceptions', - (tester) async { - final layer = RainMapLayer(_StubRainRepository()); - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - locale: const Locale('zh'), - home: Scaffold( - body: Builder( - builder: (context) => Stack( - children: [ - Positioned( - top: 0, - left: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: CollapsibleMapLegend( - key: ValueKey(layer.id), - legend: layer.buildLegend(context), - ), - ), +/// Pumps the legend built by [legendBuilder] inside the same collapsed-chip / +/// `AnimatedSize` context the map actually uses, then expands it. +Future _pumpExpanded( + WidgetTester tester, + Widget Function(BuildContext) legendBuilder, +) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('zh'), + home: Scaffold( + body: Builder( + builder: (context) => Stack( + children: [ + Positioned( + top: 0, + left: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: CollapsibleMapLegend( + key: const ValueKey('legend-under-test'), + legend: legendBuilder(context), ), ), - ], + ), ), - ), + ], ), ), - ); + ), + ), + ); - // The chip should be visible. - expect(find.byIcon(Icons.legend_toggle), findsOneWidget); + final semantics = tester.ensureSemantics(); + await tester.pump(); + await tester.tap(find.byIcon(Icons.legend_toggle)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + semantics.dispose(); +} - // The map's legend chip lays out in an unbounded-width context - // (AnimatedSize) — the banded label column must carry its own width. - // Semantics on, like the running app under VoiceOver: the broken layout - // also wedged the semantics flush, so cover that path too. - final semantics = tester.ensureSemantics(); - await tester.pump(); +void main() { + testWidgets('the rain legend draws a gradient, not hard bands', ( + tester, + ) async { + final layer = RainMapLayer(_StubRainRepository()); + await _pumpExpanded(tester, layer.buildLegend); - // Expand it. - await tester.tap(find.byIcon(Icons.legend_toggle)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 500)); - await tester.pump(const Duration(milliseconds: 500)); + expect(tester.takeException(), isNull); + expect(find.byType(ColorScaleLegend), findsOneWidget); + expect(find.byType(MapLegendCard), findsOneWidget); - final errors = tester.takeException(); - expect(errors, isNull, reason: 'no exception while rendering legend'); + // No banded swatch cells — the gradient path paints the scale as a single + // `Container` with a `LinearGradient`, not a column of opaque + // `ColoredBox`es. (An incidental translucent `ColoredBox` from the + // surrounding chip chrome is not one of those.) + final opaqueBandCells = tester + .widgetList( + find.descendant( + of: find.byType(MapLegendCard), + matching: find.byType(ColoredBox), + ), + ) + .where((cb) => cb.color.a == 1.0); + expect(opaqueBandCells, isEmpty); + + final gradientContainers = tester + .widgetList( + find.descendant( + of: find.byType(MapLegendCard), + matching: find.byType(Container), + ), + ) + .where((c) => (c.decoration as BoxDecoration?)?.gradient != null) + .toList(); + expect(gradientContainers, hasLength(1)); + final gradient = gradientContainers.single.decoration as BoxDecoration; + expect((gradient.gradient as LinearGradient).colors, hasLength(17)); + }); + + + testWidgets( + 'ColorScaleLegend(banded: true) still expands inside the chip without ' + 'layout exceptions', + (tester) async { + await _pumpExpanded( + tester, + (_) => const MapLegendCard( + child: ColorScaleLegend( + unit: 'mm', + banded: true, + stops: [ + (0, '#c2c2c2'), + (1, '#a0fffa'), + (2, '#00cdff'), + (6, '#0096ff'), + ], + ), + ), + ); + + expect( + tester.takeException(), + isNull, + reason: 'no exception while rendering legend', + ); expect(find.byType(ColorScaleLegend), findsOneWidget); - expect(find.byType(MapLegendCard), findsOneWidget); - // Every one of the 17 CWA bands must actually paint: the band cells are - // plain ColoredBoxes (no intrinsic width), so a non-stretched column - // collapses them to zero width and the strip vanishes while the numbers - // stay. Assert the opaque cells inside the card are 17, each 8 px wide. + // Every band must actually paint: the band cells are plain + // `ColoredBox`es (no intrinsic width), so a non-stretched column + // collapses them to zero width and the strip vanishes while the + // numbers stay. Assert the opaque cells inside the card are all 4, + // each 8 px wide. final swatchFinder = find.descendant( of: find.byType(MapLegendCard), matching: find.byType(ColoredBox), @@ -113,10 +177,9 @@ void main() { .widgetList(swatchFinder) .where((cb) => cb.color.a == 1.0) .toList(); - expect(opaque, hasLength(17)); + expect(opaque, hasLength(4)); final firstSwatch = tester.renderObject(swatchFinder.at(1)); expect(firstSwatch.size.width, 8); - semantics.dispose(); }, ); } From 065a8e50b853c816c3523c33918e720c5d4ed5ac Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Thu, 27 Aug 2026 06:22:09 +0800 Subject: [PATCH 3/3] fix(map): rebuild the rain legend when the observation window changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正切換觀測時段後雨量圖例不會跟著更新 Fix(en-US): the rain legend now follows the observation window you switch to --- .../layers/weather_station_layer.dart | 23 ++++++++++++++---- test/features/map/rain_legend_test.dart | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/lib/features/map/presentation/layers/weather_station_layer.dart b/lib/features/map/presentation/layers/weather_station_layer.dart index 22b875986..124f1d37d 100644 --- a/lib/features/map/presentation/layers/weather_station_layer.dart +++ b/lib/features/map/presentation/layers/weather_station_layer.dart @@ -234,8 +234,14 @@ abstract class WeatherStationLayer< } /// Colour scale from [colorStops] + [unit], with an optional [legendHeader]. - @override - Widget buildLegend(BuildContext context) { + /// + /// Built inside the [ListenableBuilder]'s callback, not before it: rain's + /// header and scale both read live state ([legendHeader]'s interval, + /// [colorStops]' colour scale), so building them once and handing + /// [ListenableBuilder] the finished card would freeze the legend at + /// whichever window was selected when it first appeared — every later + /// `chromeListenable` notify would just replay that same stale widget. + Widget _buildLegendCard(BuildContext context) { final header = legendHeader(context); final scale = ColorScaleLegend( stops: colorStops, @@ -253,11 +259,18 @@ abstract class WeatherStationLayer< scale, ], ); - final card = MapLegendCard(child: child); + return MapLegendCard(child: child); + } + + @override + Widget buildLegend(BuildContext context) { final listenable = chromeListenable; return listenable == null - ? card - : ListenableBuilder(listenable: listenable, builder: (_, _) => card); + ? _buildLegendCard(context) + : ListenableBuilder( + listenable: listenable, + builder: (context, _) => _buildLegendCard(context), + ); } @override diff --git a/test/features/map/rain_legend_test.dart b/test/features/map/rain_legend_test.dart index 978c02ad7..901887728 100644 --- a/test/features/map/rain_legend_test.dart +++ b/test/features/map/rain_legend_test.dart @@ -24,6 +24,7 @@ library; import 'package:dpip/core/error/result.dart'; import 'package:dpip/features/map/presentation/layers/rain_layer.dart'; import 'package:dpip/features/weather/domain/meteor_rain_repository.dart'; +import 'package:dpip/features/weather/domain/rain_interval.dart'; import 'package:dpip/features/weather/domain/rain_snapshot.dart'; import 'package:dpip/features/weather/domain/rain_trend.dart'; import 'package:dpip/features/weather/domain/weather_station.dart'; @@ -136,6 +137,29 @@ void main() { expect((gradient.gradient as LinearGradient).colors, hasLength(17)); }); + testWidgets( + "the legend's header follows a later interval change, not just its " + 'first build', + (tester) async { + final layer = RainMapLayer(_StubRainRepository()); + await _pumpExpanded(tester, layer.buildLegend); + + // Default window is 1 時 (see RainMapLayer.interval's doc). + expect(find.text('1 時'), findsOneWidget); + expect(find.text('今日'), findsNothing); + + // buildLegend wraps its header + scale in a ListenableBuilder driven by + // chromeListenable (interval + colorScale). Building that header and + // scale *before* handing them to the builder — rather than inside its + // callback — would freeze the legend at whatever was selected when it + // first appeared, silently ignoring every later interval change. + await layer.setInterval(RainInterval.now); + await tester.pump(); + + expect(find.text('今日'), findsOneWidget); + expect(find.text('1 時'), findsNothing); + }, + ); testWidgets( 'ColorScaleLegend(banded: true) still expands inside the chip without '