Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions lib/features/home/presentation/widgets/home_map_backdrop.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,49 @@ class _HomeMapBackdropState extends State<HomeMapBackdrop>
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<String?> _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
Expand Down Expand Up @@ -311,6 +354,7 @@ class _HomeMapBackdropState extends State<HomeMapBackdrop>
List<Object> filter,
) async {
await _removeLayerQuietly(controller, _selectedLineLayer);
final belowLayerId = await _locationPuckLayerId(controller);
await controller.addLineLayer(
'exptech',
_selectedLineLayer,
Expand All @@ -319,6 +363,7 @@ class _HomeMapBackdropState extends State<HomeMapBackdrop>
sourceLayer: 'town',
filter: filter,
enableInteraction: false,
belowLayerId: belowLayerId,
);
}

Expand Down
6 changes: 6 additions & 0 deletions lib/features/map/presentation/layers/rain_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
38 changes: 30 additions & 8 deletions lib/features/map/presentation/layers/weather_station_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -225,13 +234,19 @@ 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,
unit: unit,
banded: bandedColors,
banded: legendBanded,
);
final child = header == null
? scale
Expand All @@ -244,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
Expand Down
203 changes: 145 additions & 58 deletions test/features/map/rain_legend_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,29 @@
/// 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';
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';
Expand Down Expand Up @@ -46,65 +54,145 @@ 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<void> _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);

expect(tester.takeException(), isNull);
expect(find.byType(ColorScaleLegend), findsOneWidget);
expect(find.byType(MapLegendCard), findsOneWidget);

// 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<ColoredBox>(
find.descendant(
of: find.byType(MapLegendCard),
matching: find.byType(ColoredBox),
),
)
.where((cb) => cb.color.a == 1.0);
expect(opaqueBandCells, isEmpty);

// Expand it.
await tester.tap(find.byIcon(Icons.legend_toggle));
final gradientContainers = tester
.widgetList<Container>(
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(
"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();
await tester.pump(const Duration(milliseconds: 500));
await tester.pump(const Duration(milliseconds: 500));

final errors = tester.takeException();
expect(errors, isNull, reason: 'no exception while rendering legend');
expect(find.text('今日'), findsOneWidget);
expect(find.text('1 時'), findsNothing);
},
);

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),
Expand All @@ -113,10 +201,9 @@ void main() {
.widgetList<ColoredBox>(swatchFinder)
.where((cb) => cb.color.a == 1.0)
.toList();
expect(opaque, hasLength(17));
expect(opaque, hasLength(4));
final firstSwatch = tester.renderObject<RenderBox>(swatchFinder.at(1));
expect(firstSwatch.size.width, 8);
semantics.dispose();
},
);
}
Loading