diff --git a/lib/features/earthquake/presentation/pages/report_detail_page.dart b/lib/features/earthquake/presentation/pages/report_detail_page.dart index de642886a..c4ed13d32 100644 --- a/lib/features/earthquake/presentation/pages/report_detail_page.dart +++ b/lib/features/earthquake/presentation/pages/report_detail_page.dart @@ -17,14 +17,20 @@ import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/features/earthquake/domain/earthquake_report.dart'; import 'package:dpip/shared/seismic/intensity.dart'; import 'package:dpip/features/earthquake/domain/report_repository.dart'; import 'package:dpip/shared/seismic/intensity_icon_renderer.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/base_map.dart'; +import 'package:dpip/shared/map/basemap_overlay_sync.dart'; import 'package:dpip/shared/map/camera_fit.dart'; import 'package:dpip/shared/map/map_compass.dart'; +import 'package:dpip/shared/map/map_gsi_overlay.dart'; +import 'package:dpip/shared/map/map_style.dart'; +import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/seismic/report_colors.dart'; @@ -218,18 +224,109 @@ class _ReportMapDetailState extends State<_ReportMapDetail> { MapLibreMapController? _controller; bool _iconsLoaded = false; + bool _styleLoaded = false; /// Feeds the Flutter [MapCompass] needle — camera heading, ° clockwise from /// north. Kept in sync from [BaseMap.onCameraMove] so the needle tracks /// rotation live, matching the map tab's compass. final ValueNotifier _bearing = ValueNotifier(0); + /// The three base-map toggles — OSM detailed map, terrain relief, township + /// names — persisted through the same [SettingKeys] the map tab writes, so a + /// choice made here is in force on every surface (and survives an app + /// restart, the settings table being sqlite-backed). + final ValueNotifier _showTerrain = ValueNotifier(true); + final ValueNotifier _showTownLabels = ValueNotifier(true); + late final GsiOverlayController _gsi; + late final SettingsStore _settings; + final BasemapOverlaySync _basemapSync = BasemapOverlaySync(); + + /// Whether the initial style bakes terrain — mirrors the persisted OSM + /// state at mount (OSM-first styles omit the DEM, see [BaseMap]). + late final bool _initialOsmEnabled; + + /// Serialises basemap-overlay syncs so two rapid toggles cannot interleave + /// native add/remove calls on the same controller. + Future _syncChain = Future.value(); + + @override + void initState() { + super.initState(); + _settings = context.read(); + _showTerrain.value = _settings.getBool(SettingKeys.mapShowTerrain) ?? true; + _showTownLabels.value = + _settings.getBool(SettingKeys.mapShowTownLabels) ?? true; + _initialOsmEnabled = _settings.getBool(SettingKeys.mapGsiEnabled) ?? false; + _gsi = GsiOverlayController( + _settings, + mutuallyExclusiveTerrain: _showTerrain, + ); + _gsi.addListener(_onGsiChanged); + } + @override void dispose() { + _gsi.removeListener(_onGsiChanged); + _gsi.dispose(); + _showTerrain.dispose(); + _showTownLabels.dispose(); _bearing.dispose(); super.dispose(); } + void _onGsiChanged() { + // The controller cleared [_showTerrain] itself when OSM turned on (the + // vector overlay brings its own land surface); mirror the result. + _syncBasemapOverlays(); + } + + void _setShowTerrain(bool value) { + // Inverse edge of [GsiOverlayController.setEnabled]: terrain on → OSM off. + if (value && _gsi.enabled) _gsi.setEnabled(false); + if (_showTerrain.value == value) return; + _showTerrain.value = value; + unawaited(_settings.setBool(SettingKeys.mapShowTerrain, value)); + _syncBasemapOverlays(); + } + + void _setShowTownLabels(bool value) { + if (_showTownLabels.value == value) return; + _showTownLabels.value = value; + unawaited(_settings.setBool(SettingKeys.mapShowTownLabels, value)); + _applyTownLabelVisibility(); + } + + /// Pushes the township-label choice onto a live map. The base style's + /// `town-label` layer survives style reloads, which reset it to visible, so + /// this also runs after every [_onStyleLoaded] to re-assert the choice. + void _applyTownLabelVisibility() { + final controller = _controller; + if (controller == null) return; + unawaited( + controller + .setLayerVisibility(townLabelLayerId, _showTownLabels.value) + .catchError((Object e, StackTrace st) { + Log.handle(e, st, 'Failed to sync the township labels'); + }), + ); + } + + void _syncBasemapOverlays() { + final controller = _controller; + if (!mounted || controller == null || !_styleLoaded) return; + final brightness = Theme.of(context).brightness; + _syncChain = _syncChain.then( + (_) => _basemapSync.sync( + controller, + showTerrain: () => _showTerrain.value, + gsi: _gsi, + brightness: brightness, + stillCurrent: () => + mounted && identical(controller, _controller) && _styleLoaded, + ), + ); + } + void _onMapCreated(MapLibreMapController controller) { _controller = controller; } @@ -260,6 +357,7 @@ class _ReportMapDetailState extends State<_ReportMapDetail> { Future _onStyleLoaded() async { final controller = _controller; if (controller == null) return; + _styleLoaded = true; final dark = Theme.of(context).brightness == Brightness.dark; try { if (!_iconsLoaded) { @@ -284,6 +382,11 @@ class _ReportMapDetailState extends State<_ReportMapDetail> { } catch (e, st) { Log.handle(e, st, 'report detail map render failed'); } + // A style (re)load wipes every runtime overlay and resets the base + // style's township-label layer to visible — re-assert the saved choices. + _applyTownLabelVisibility(); + _basemapSync.onStyleLoaded(bakedTerrain: !_initialOsmEnabled); + _syncBasemapOverlays(); _frame(); } @@ -353,36 +456,62 @@ class _ReportMapDetailState extends State<_ReportMapDetail> { @override Widget build(BuildContext context) { - return Stack( - children: [ - Positioned.fill( - child: BaseMap( - showUserLocation: false, - compassEnabled: false, - onMapCreated: _onMapCreated, - onStyleLoaded: () => unawaited(_onStyleLoaded()), - onCameraMove: (position) => _bearing.value = position.bearing, + return GsiOverlayScope( + controller: _gsi, + child: Stack( + children: [ + Positioned.fill( + child: BaseMap( + showUserLocation: false, + compassEnabled: false, + includeTerrainInStyle: !_initialOsmEnabled, + onMapCreated: _onMapCreated, + onStyleLoaded: () => unawaited(_onStyleLoaded()), + onCameraMove: (position) => _bearing.value = position.bearing, + ), ), - ), - Positioned.fill( - child: _ReportSheet( - report: widget.report, - expandedNotifier: widget.sheetExpanded, + Positioned.fill( + child: _ReportSheet( + report: widget.report, + expandedNotifier: widget.sheetExpanded, + ), ), - ), - // North indicator above the sheet so a dragged-up sheet can never hide - // it — same Flutter [MapCompass] the map tab uses, parked at top-right. - Positioned( - top: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: MapCompass(bearing: _bearing, onPressed: _resetNorth), + // Base-map options (OSM detailed map / terrain relief / township + // names) above the compass — the same chrome the 強震監視器 carries in + // the map tab, persisted to the shared settings store. Above the + // sheet so a dragged-up sheet can never hide the controls — but like + // the floating back button top-left, both disappear once the sheet + // is fully expanded, leaving the reading surface unobstructed. + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: ValueListenableBuilder( + valueListenable: widget.sheetExpanded, + builder: (context, expanded, child) => + expanded ? const SizedBox.shrink() : child!, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + MapBasemapMenu( + showTownLabels: _showTownLabels, + onShowTownLabelsChanged: _setShowTownLabels, + showTerrain: _showTerrain, + onShowTerrainChanged: _setShowTerrain, + ), + const SizedBox(height: AppSpacing.sm), + MapCompass(bearing: _bearing, onPressed: _resetNorth), + ], + ), + ), + ), ), ), - ), - ], + ], + ), ); } } diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 62bd1addf..61d6daae3 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -21,6 +21,8 @@ import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/models/lat_lng.dart' as geo; import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/settings/eew_cwa_only_settings.dart'; +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/realtime/realtime_service.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; @@ -42,10 +44,13 @@ import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/color_hex.dart'; import 'package:dpip/shared/widgets/alert_cycle_chip.dart'; import 'package:dpip/shared/map/base_map.dart'; +import 'package:dpip/shared/map/basemap_overlay_sync.dart'; import 'package:dpip/shared/map/camera_fit.dart'; import 'package:dpip/shared/map/geo_circle.dart'; import 'package:dpip/shared/map/map_compass.dart'; +import 'package:dpip/shared/map/map_gsi_overlay.dart'; import 'package:dpip/shared/map/map_station_labels.dart'; +import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/map/map_style.dart' show MapColors, @@ -409,9 +414,39 @@ class _ReplayMapState extends State<_ReplayMap> { /// north, kept in sync from [BaseMap.onCameraMove]. final ValueNotifier _bearing = ValueNotifier(0); + /// The three base-map toggles — OSM detailed map, terrain relief, township + /// names — persisted through the same [SettingKeys] the map tab writes, so + /// a choice made here is in force on every surface (and survives an app + /// restart, the settings table being sqlite-backed). + final ValueNotifier _showTerrain = ValueNotifier(true); + final ValueNotifier _showTownLabels = ValueNotifier(true); + late final GsiOverlayController _gsi; + late final SettingsStore _settings; + final BasemapOverlaySync _basemapSync = BasemapOverlaySync(); + + /// Whether the initial style bakes terrain — mirrors the persisted OSM + /// state at mount (OSM-first styles omit the DEM, see [BaseMap]). + late final bool _initialOsmEnabled; + + bool _styleLoaded = false; + + /// Serialises basemap-overlay syncs so two rapid toggles cannot interleave + /// native add/remove calls on the same controller. + Future _syncChain = Future.value(); + @override void initState() { super.initState(); + _settings = context.read(); + _showTerrain.value = _settings.getBool(SettingKeys.mapShowTerrain) ?? true; + _showTownLabels.value = + _settings.getBool(SettingKeys.mapShowTownLabels) ?? true; + _initialOsmEnabled = _settings.getBool(SettingKeys.mapGsiEnabled) ?? false; + _gsi = GsiOverlayController( + _settings, + mutuallyExclusiveTerrain: _showTerrain, + ); + _gsi.addListener(_onGsiChanged); widget.rts.addListener(_onRts); widget.tick.addListener(_onTick); widget.travelTimeTable.then((table) { @@ -452,6 +487,10 @@ class _ReplayMapState extends State<_ReplayMap> { widget.tick.removeListener(_onTick); _blinkTimer?.cancel(); _wavefrontTicker?.cancel(); + _gsi.removeListener(_onGsiChanged); + _gsi.dispose(); + _showTerrain.dispose(); + _showTownLabels.dispose(); _bearing.dispose(); super.dispose(); } @@ -503,6 +542,59 @@ class _ReplayMapState extends State<_ReplayMap> { _controller = controller; } + void _onGsiChanged() { + // The controller cleared [_showTerrain] itself when OSM turned on (the + // vector overlay brings its own land surface); mirror the result. + _syncBasemapOverlays(); + } + + void _setShowTerrain(bool value) { + // Inverse edge of [GsiOverlayController.setEnabled]: terrain on → OSM off. + if (value && _gsi.enabled) _gsi.setEnabled(false); + if (_showTerrain.value == value) return; + _showTerrain.value = value; + unawaited(_settings.setBool(SettingKeys.mapShowTerrain, value)); + _syncBasemapOverlays(); + } + + void _setShowTownLabels(bool value) { + if (_showTownLabels.value == value) return; + _showTownLabels.value = value; + unawaited(_settings.setBool(SettingKeys.mapShowTownLabels, value)); + _applyTownLabelVisibility(); + } + + /// Pushes the township-label choice onto a live map. The base style's + /// `town-label` layer survives style reloads, which reset it to visible, so + /// this also runs after every [_onStyleLoaded] to re-assert the choice. + void _applyTownLabelVisibility() { + final controller = _controller; + if (controller == null) return; + unawaited( + controller + .setLayerVisibility(townLabelLayerId, _showTownLabels.value) + .catchError((Object e, StackTrace st) { + Log.handle(e, st, 'Failed to sync the township labels'); + }), + ); + } + + void _syncBasemapOverlays() { + final controller = _controller; + if (!mounted || controller == null || !_styleLoaded) return; + final brightness = Theme.of(context).brightness; + _syncChain = _syncChain.then( + (_) => _basemapSync.sync( + controller, + showTerrain: () => _showTerrain.value, + gsi: _gsi, + brightness: brightness, + stillCurrent: () => + mounted && identical(controller, _controller) && _styleLoaded, + ), + ); + } + /// Re-points the camera north, keeping centre / zoom. Mirrors /// [MapScaffold._resetNorth]: the needle is settled directly because a /// programmatic move may not emit a final north-up camera event. @@ -529,6 +621,7 @@ class _ReplayMapState extends State<_ReplayMap> { Future _onStyleLoaded() async { final controller = _controller; if (controller == null) return; + _styleLoaded = true; try { final data = await IntensityIconRenderer.render('cross'); await controller.addImage(_crossIcon, data); @@ -716,6 +809,11 @@ class _ReplayMapState extends State<_ReplayMap> { _setupBlink(); _startWavefrontTicker(); _frameTaiwan(); + // A style (re)load wipes every runtime overlay and resets the base + // style's township-label layer to visible — re-assert the saved choices. + _applyTownLabelVisibility(); + _basemapSync.onStyleLoaded(bakedTerrain: !_initialOsmEnabled); + _syncBasemapOverlays(); } /// Loads the station directory once; the RTS feed carries only per-id @@ -1075,33 +1173,52 @@ class _ReplayMapState extends State<_ReplayMap> { @override Widget build(BuildContext context) { - return Stack( - children: [ - Positioned.fill( - child: BaseMap( - // GPS on: the map shows the user's position, and the EEW cards' - // local-intensity tiles resolve against the current location the - // same way the legacy monitor's did. - showUserLocation: true, - compassEnabled: false, - onMapCreated: _onMapCreated, - onStyleLoaded: () => unawaited(_onStyleLoaded()), - onCameraMove: (position) => _bearing.value = position.bearing, + return GsiOverlayScope( + controller: _gsi, + child: Stack( + children: [ + Positioned.fill( + child: BaseMap( + // GPS on: the map shows the user's position, and the EEW cards' + // local-intensity tiles resolve against the current location the + // same way the legacy monitor's did. + showUserLocation: true, + compassEnabled: false, + includeTerrainInStyle: !_initialOsmEnabled, + onMapCreated: _onMapCreated, + onStyleLoaded: () => unawaited(_onStyleLoaded()), + onCameraMove: (position) => _bearing.value = position.bearing, + ), ), - ), - // North indicator, matching the map tab's Flutter [MapCompass] — - // parked at top-right, level with the page's back button. - Positioned( - top: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: MapCompass(bearing: _bearing, onPressed: _resetNorth), + // Base-map options (OSM detailed map / terrain relief / township + // names) above the compass — the same chrome the 強震監視器 carries in + // the map tab, persisted to the shared settings store. No sheet + // here, so the controls stay up for the whole replay. + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + MapBasemapMenu( + showTownLabels: _showTownLabels, + onShowTownLabelsChanged: _setShowTownLabels, + showTerrain: _showTerrain, + onShowTerrainChanged: _setShowTerrain, + ), + const SizedBox(height: AppSpacing.sm), + MapCompass(bearing: _bearing, onPressed: _resetNorth), + ], + ), + ), ), ), - ), - ], + ], + ), ); } } diff --git a/lib/shared/map/basemap_overlay_sync.dart b/lib/shared/map/basemap_overlay_sync.dart new file mode 100644 index 000000000..59369e700 --- /dev/null +++ b/lib/shared/map/basemap_overlay_sync.dart @@ -0,0 +1,128 @@ +/// Applying a surface's base-map options — terrain relief, the OSM detailed +/// overlay, and (via the caller) township labels — to a live MapLibre +/// controller, one instance per map surface. +library; + +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/shared/map/map_gsi_overlay.dart'; +import 'package:dpip/shared/map/map_style.dart'; +import 'package:flutter/material.dart' show Brightness; +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// Runtime DEM source — identical to what the baked style declares (see +/// [exptechVectorStyle]), so a surface can rebuild the relief after removing +/// it, with no drift between the two descriptions. +const RasterDemSourceProperties terrainSourceProps = RasterDemSourceProperties( + tiles: [terrainOriginTileUrl], + bounds: [110.0, 10.0, 132.0, 35.0], + minzoom: 0, + maxzoom: 12, + tileSize: 512, + encoding: 'mapbox', +); + +/// Runtime hillshade layer — same id, same paint as the baked style. +const HillshadeLayerProperties terrainLayerProps = HillshadeLayerProperties( + hillshadeIlluminationDirection: terrainIlluminationDirection, + hillshadeExaggeration: terrainExaggeration, +); + +/// The on-map bookkeeping behind the base-map toggles every interactive +/// surface shares (map tab, report detail). A style reload wipes every runtime +/// layer, so [onStyleLoaded] forgets what was mounted and [sync] reconciles +/// against the current settings. +/// +/// OSM and terrain are mutually exclusive (the vector overlay brings its own +/// land surface), so [sync] unmounts whichever is no longer selected before +/// mounting its replacement — that way the OSM PBF and DEM tile bursts never +/// overlap. The exclusivity itself is enforced by the caller's controllers +/// ([GsiOverlayController] flips its `mutuallyExclusiveTerrain` notifier); +/// this class only reflects the resulting state. +class BasemapOverlaySync { + bool _terrainOnMap = false; + bool _gsiOnMap = false; + int _gsiAppliedRevision = -1; + + /// The style was (re)loaded: runtime overlays are gone, and the baked + /// terrain is present on the map iff [bakedTerrain]. + void onStyleLoaded({required bool bakedTerrain}) { + _terrainOnMap = bakedTerrain; + _gsiOnMap = false; + _gsiAppliedRevision = -1; + } + + /// Reconciles the live map with the current settings, re-reading them after + /// every native call so a toggle made mid-sync wins. [stillCurrent] guards + /// every await — the surface may have swapped controllers (a platform-view + /// recreate) or gone away while a call was in flight. + Future sync( + MapLibreMapController controller, { + required bool Function() showTerrain, + required GsiOverlayController gsi, + required Brightness brightness, + required bool Function() stillCurrent, + }) async { + while (stillCurrent()) { + final showGsi = gsi.enabled; + final effectiveTerrain = showTerrain() && !showGsi; + final revision = gsi.revision; + try { + // Unmount everything no longer selected before mounting its + // replacement, so the two tile bursts cannot overlap. + if (!effectiveTerrain && _terrainOnMap) { + await controller.removeLayer(terrainHillshadeLayerId); + if (!stillCurrent()) return; + await controller.removeSource(terrainSourceId); + if (!stillCurrent()) return; + _terrainOnMap = false; + } + if (!showGsi && _gsiOnMap) { + await removeGsiOverlay(controller); + if (!stillCurrent()) return; + _gsiOnMap = false; + _gsiAppliedRevision = revision; + } + + if (effectiveTerrain && !_terrainOnMap) { + await controller.addSource(terrainSourceId, terrainSourceProps); + if (!stillCurrent()) return; + await controller.addHillshadeLayer( + terrainSourceId, + terrainHillshadeLayerId, + terrainLayerProps, + belowLayerId: townOutlineLayerId, + ); + if (!stillCurrent()) return; + _terrainOnMap = true; + } else if (showGsi && !_gsiOnMap) { + await addGsiOverlay( + controller, + brightness: brightness, + selection: gsi, + belowLayerId: townOutlineLayerId, + ); + if (!stillCurrent()) return; + _gsiOnMap = true; + _gsiAppliedRevision = revision; + } else if (showGsi && _gsiAppliedRevision != revision) { + await applyGsiLayerVisibility(controller, gsi, brightness); + if (!stillCurrent()) return; + _gsiAppliedRevision = revision; + } + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'base-map overlay sync'); + return; + } + // A toggle landed during the awaits — restart against the new state. + // Compare the *recomputed* effective terrain (live value minus the OSM + // exclusion) rather than the raw live value: with OSM on, the raw value + // is legitimately true while nothing is on the map, and comparing it to + // the effective false would loop forever. + if (gsi.enabled == showGsi && + (showTerrain() && !gsi.enabled) == effectiveTerrain && + gsi.revision == revision) { + return; + } + } + } +} diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index 4c3a5eb18..b79c119a5 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -20,6 +20,7 @@ import 'package:dpip/shared/map/map_station_handoff.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_layer_switcher.dart'; import 'package:dpip/shared/map/map_compass.dart'; +import 'package:dpip/shared/map/basemap_overlay_sync.dart'; import 'package:dpip/shared/map/map_gsi_overlay.dart'; import 'package:dpip/shared/map/map_style.dart'; import 'package:dpip/shared/map/map_timeline.dart'; @@ -38,24 +39,6 @@ import 'package:provider/provider.dart'; /// memory on camera idle. LB has no ETag; the store keys these by URL hash. const String _basemapTileUrl = basemapOriginTileUrl; -/// The runtime DEM source — identical to what the baked style declares (see -/// [exptechVectorStyle]), so [MapScaffold] can rebuild the relief after -/// removing it, with no drift between the two descriptions. -const RasterDemSourceProperties _terrainSourceProps = RasterDemSourceProperties( - tiles: [terrainOriginTileUrl], - bounds: [110.0, 10.0, 132.0, 35.0], - minzoom: 0, - maxzoom: 12, - tileSize: 512, - encoding: 'mapbox', -); - -/// The runtime hillshade layer — same id, same paint as the baked style. -const HillshadeLayerProperties _terrainLayerProps = HillshadeLayerProperties( - hillshadeIlluminationDirection: terrainIlluminationDirection, - hillshadeExaggeration: terrainExaggeration, -); - /// The reusable map surface — a base map with a switchable, time-scrubbable /// overlay layer. /// @@ -213,15 +196,10 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { /// react to a settings change made elsewhere while this surface is open. late final SettingsStore _settings; - /// Whether the terrain source + hillshade layer are actually on the map. - /// - /// This mirrors the native state so the toggle can add/remove instead of - /// flipping visibility (see [_syncBasemapOverlays]). A regular style reload - /// re-bakes both terrain pieces; an OSM-first style omits them entirely. - bool _terrainOnMap = false; + /// Applies the base-map toggles to the live controller (see + /// [_syncBasemapOverlays]); shared with the report-detail page. + final BasemapOverlaySync _basemapSync = BasemapOverlaySync(); - bool _gsiOnMap = false; - int _gsiAppliedRevision = -1; bool _gsiZoomEnabled = false; /// The geography the map is framed on, kept across layer switches so each @@ -670,9 +648,6 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { _controllerEpoch++; _showQueued = false; _styleLoaded = false; - _terrainOnMap = false; - _gsiOnMap = false; - _gsiAppliedRevision = -1; _controller = null; _basemapWarmer?.cancel(); _restoreSurfaceAfterRecreate = true; @@ -783,76 +758,18 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { if (!mounted || controller == null || !_styleLoaded) return; final brightness = Theme.of(context).brightness; _queue( - () => _syncBasemapOverlayLoop(controller, brightness), + () => _basemapSync.sync( + controller, + showTerrain: () => _showTerrain.value, + gsi: _gsi, + brightness: brightness, + stillCurrent: () => + mounted && identical(controller, _controller) && _styleLoaded, + ), label: 'basemap-overlays', ); } - Future _syncBasemapOverlayLoop( - MapLibreMapController controller, - Brightness brightness, - ) async { - while (mounted && identical(controller, _controller) && _styleLoaded) { - // OSM wins only as a defensive fallback. Normal interactions enforce - // this invariant synchronously in the two setters above. - final showGsi = _gsi.enabled; - final showTerrain = _showTerrain.value && !showGsi; - final revision = _gsi.revision; - try { - // Unmount everything no longer selected before mounting its - // replacement. This prevents simultaneous OSM PBF and DEM tile bursts. - if (!showTerrain && _terrainOnMap) { - await controller.removeLayer(terrainHillshadeLayerId); - if (!identical(controller, _controller)) return; - await controller.removeSource(terrainSourceId); - if (!identical(controller, _controller)) return; - _terrainOnMap = false; - } - if (!showGsi && _gsiOnMap) { - await removeGsiOverlay(controller); - if (!identical(controller, _controller)) return; - _gsiOnMap = false; - _gsiAppliedRevision = revision; - } - - if (showTerrain && !_terrainOnMap) { - await controller.addSource(terrainSourceId, _terrainSourceProps); - if (!identical(controller, _controller)) return; - await controller.addHillshadeLayer( - terrainSourceId, - terrainHillshadeLayerId, - _terrainLayerProps, - belowLayerId: townOutlineLayerId, - ); - if (!identical(controller, _controller)) return; - _terrainOnMap = true; - } else if (showGsi && !_gsiOnMap) { - await addGsiOverlay( - controller, - brightness: brightness, - selection: _gsi, - belowLayerId: townOutlineLayerId, - ); - if (!identical(controller, _controller)) return; - _gsiOnMap = true; - _gsiAppliedRevision = revision; - } else if (showGsi && _gsiAppliedRevision != revision) { - await applyGsiLayerVisibility(controller, _gsi, brightness); - if (!identical(controller, _controller)) return; - _gsiAppliedRevision = revision; - } - } catch (error, stackTrace) { - Log.handle(error, stackTrace, 'base-map overlay sync'); - return; - } - if (_gsi.enabled == showGsi && - _showTerrain.value == showTerrain && - _gsi.revision == revision) { - return; - } - } - } - /// Pushes the township-label setting onto a live map. The base style's /// `town-label` layer survives style reloads, which reset it to visible, so /// this also runs after every [_onStyleLoaded] to re-assert the choice. @@ -901,8 +818,8 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { // Only while the relief is on: with it off the DEM source is removed // from the style, MapLibre will never request these tiles, and warming // them was a viewport of downloads per camera settle for pixels that - // cannot be drawn. Gated on the toggle (the user's intent), not on - // `_terrainOnMap`, which is transiently wrong mid style-reload. + // cannot be drawn. Gated on the toggle (the user's intent), not on the + // applier's on-map flag, which is transiently wrong mid style-reload. if (_showTerrain.value) { await warmer.warmViewportAbsolute( urlFor: (z, x, y) => terrainOriginTileUrl @@ -1024,11 +941,9 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { unawaited(_applyCameraHandoff()); // A reload resets the base style's township-label layer to visible. _applyTownLabelVisibility(); - // A regular surface bakes terrain into the base style. OSM-first surfaces - // omit it, so their native mirror must start false before reconciliation. - _terrainOnMap = !widget.initialOsmEnabled; - _gsiOnMap = false; - _gsiAppliedRevision = -1; + // A regular surface bakes terrain into the base style; OSM-first surfaces + // omit it, so the native mirror starts on whichever the style declares. + _basemapSync.onStyleLoaded(bakedTerrain: !widget.initialOsmEnabled); _syncBasemapOverlays(); } diff --git a/lib/shared/widgets/map_color_legend.dart b/lib/shared/widgets/map_color_legend.dart index 0d6218c98..c5fead3e6 100644 --- a/lib/shared/widgets/map_color_legend.dart +++ b/lib/shared/widgets/map_color_legend.dart @@ -122,6 +122,13 @@ class ColorScaleLegend extends StatelessWidget { ), child: banded ? Column( + // ColoredBox has no intrinsic width, and the default + // cross axis is center — under the Container's loose + // (0.._swatch) width the band column would collapse to + // zero and the whole strip would vanish, leaving the + // boundary numbers with no colour beside them. Stretch + // forces every band to fill the swatch width. + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final color in swatchColors) Expanded(child: ColoredBox(color: color)), @@ -380,20 +387,54 @@ class _BandBoundaryLabels extends StatelessWidget { @override Widget build(BuildContext context) { const cell = ColorScaleLegend._cell; - return Stack( - clipBehavior: Clip.none, - children: [ - for (var i = 0; i < rows.length - 1; i++) - Positioned( - top: (i + 1) * cell - cell / 2, - left: 0, - height: cell, - child: Align( - alignment: Alignment.centerLeft, - child: Text(ColorScaleLegend._label(rows[i].$1), style: style), + final labels = [ + for (var i = 0; i < rows.length - 1; i++) + ColorScaleLegend._label(rows[i].$1), + ]; + // The label column sits in a Row, which hands children unbounded width — + // and a Stack refuses that. Measure the widest boundary label so this + // column carries its own bounded width (text scaling included) instead of + // asking the map's collapsed-legend chip for one; without it, expanding + // the 雨量 legend throws a layout assertion every frame and the legend + // never appears. + final width = _widestLabel(labels, style, MediaQuery.textScalerOf(context)); + return SizedBox( + width: width, + height: cell * rows.length, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < labels.length; i++) + Positioned( + top: (i + 1) * cell - cell / 2, + left: 0, + height: cell, + child: Align( + alignment: Alignment.centerLeft, + child: Text(labels[i], style: style), + ), ), - ), - ], + ], + ), ); } + + static double _widestLabel( + List labels, + TextStyle? style, + TextScaler textScaler, + ) { + var widest = 0.0; + for (final label in labels) { + final painter = TextPainter( + text: TextSpan(text: label, style: style), + textDirection: TextDirection.ltr, + textScaler: textScaler, + maxLines: 1, + )..layout(); + if (painter.width > widest) widest = painter.width; + painter.dispose(); + } + return widest; + } } diff --git a/test/features/map/basemap_overlay_sync_test.dart b/test/features/map/basemap_overlay_sync_test.dart new file mode 100644 index 000000000..c4e1cd117 --- /dev/null +++ b/test/features/map/basemap_overlay_sync_test.dart @@ -0,0 +1,139 @@ +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:dpip/shared/map/basemap_overlay_sync.dart'; +import 'package:dpip/shared/map/map_gsi_overlay.dart'; +import 'package:dpip/shared/map/map_style.dart'; +import 'package:flutter/material.dart' show Brightness; +import 'package:flutter_test/flutter_test.dart'; + +import 'raster_timeline_harness.dart'; + +void main() { + group('BasemapOverlaySync', () { + late RecordingMapController controller; + late GsiOverlayController gsi; + late BasemapOverlaySync sync; + var showTerrain = true; + var current = true; + + setUp(() { + controller = RecordingMapController(); + gsi = GsiOverlayController(SettingsStore.inMemory({})); + sync = BasemapOverlaySync(); + showTerrain = true; + current = true; + }); + + Future run() => sync.sync( + controller, + showTerrain: () => showTerrain, + gsi: gsi, + brightness: Brightness.dark, + stillCurrent: () => current, + ); + + test( + 'a fresh style with baked terrain needs no calls when nothing changed', + () async { + sync.onStyleLoaded(bakedTerrain: true); + await run(); + expect(controller.calls, isEmpty); + }, + ); + + test('terrain off removes the baked DEM and hillshade', () async { + sync.onStyleLoaded(bakedTerrain: true); + showTerrain = false; + await run(); + expect(controller.calls, [ + 'removeLayer:$terrainHillshadeLayerId', + 'removeSource:$terrainSourceId', + ]); + // Idempotent — a second sync makes no further calls. + await run(); + expect(controller.calls, hasLength(2)); + }); + + test( + 'terrain on adds the runtime DEM and hillshade below the town outline', + () async { + sync.onStyleLoaded(bakedTerrain: false); + await run(); + expect(controller.calls, [ + 'addSource:$terrainSourceId', + 'addHillshadeLayer:$terrainHillshadeLayerId', + ]); + expect(controller.sourceProperties[terrainSourceId], isNotNull); + expect(controller.belowOf(terrainHillshadeLayerId), townOutlineLayerId); + }, + ); + + test('OSM on mounts the GSI overlay instead of terrain', () async { + sync.onStyleLoaded(bakedTerrain: true); + showTerrain = false; + gsi.setEnabled(true); + await run(); + expect(controller.calls, [ + 'removeLayer:$terrainHillshadeLayerId', + 'removeSource:$terrainSourceId', + 'addSource:$gsiSourceId', + // Every GSI group is enabled by default, so all layers mount. + for (final l in gsiStyleLayers(Brightness.dark)) + switch (l.kind) { + GsiLayerKind.fill => 'addFillLayer:${l.id}', + GsiLayerKind.line => 'addLineLayer:${l.id}', + GsiLayerKind.symbol => 'addSymbolLayer:${l.id}', + }, + ]); + }); + + test('OSM off tears the overlay down again', () async { + sync.onStyleLoaded(bakedTerrain: false); + gsi.setEnabled(true); + await run(); + controller.calls.clear(); + gsi.setEnabled(false); + await run(); + expect(controller.calls, [ + for (final l in gsiStyleLayers(Brightness.dark).reversed) + 'removeLayer:${l.id}', + 'removeSource:$gsiSourceId', + // Terrain comes back once OSM is off (the mutual exclusion releases). + 'addSource:$terrainSourceId', + 'addHillshadeLayer:$terrainHillshadeLayerId', + ]); + }); + + test('a group change re-applies visibility without remounting', () async { + sync.onStyleLoaded(bakedTerrain: false); + gsi.setEnabled(true); + await run(); + controller.calls.clear(); + gsi.setGroupEnabled(GsiLayerGroup.poi, false); + await run(); + expect(controller.propertyBatches, 1, reason: 'one visibility batch'); + expect(controller.calls, [ + for (final l in gsiStyleLayers(Brightness.dark)) 'set:${l.id}:null', + ]); + // No re-add of the source or layers. + expect(controller.calls.where((c) => c.startsWith('add')), isEmpty); + }); + + test('style reload resets the on-map bookkeeping', () async { + sync.onStyleLoaded(bakedTerrain: false); + await run(); + expect(controller.calls, isNotEmpty); + // Reload: baked terrain now present; runtime layers are gone. + controller.calls.clear(); + sync.onStyleLoaded(bakedTerrain: true); + await run(); + expect(controller.calls, isEmpty); + }); + + test('a controller swap mid-sync stops the reconciliation', () async { + sync.onStyleLoaded(bakedTerrain: false); + current = false; + await run(); + expect(controller.calls, isEmpty); + }); + }); +} diff --git a/test/features/map/rain_legend_test.dart b/test/features/map/rain_legend_test.dart new file mode 100644 index 000000000..29adca5ce --- /dev/null +++ b/test/features/map/rain_legend_test.dart @@ -0,0 +1,122 @@ +/// The rainfall layer's legend, rendered the way [MapScaffold] shows it — +/// inside the collapsed chip, whose `AnimatedSize` lays out with unbounded +/// width. +/// +/// Guards two regressions that together made the 雨量 legend useless: +/// +/// 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. +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_snapshot.dart'; +import 'package:dpip/features/weather/domain/rain_trend.dart'; +import 'package:dpip/features/weather/domain/weather_station.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/collapsible_map_legend.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _StubRainRepository implements MeteorRainRepository { + @override + Future>> stations() async => const Ok({}); + + @override + Future> latest() async => + const Ok(RainSnapshot(time: 0, stations: [])); + + @override + Future>> history() async => const Ok([]); + + @override + Future> at(int second) async => + const Ok(RainSnapshot(time: 0, stations: [])); + + @override + Future> trend(String id, {String range = '24h'}) async => + 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), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + + // The chip should be visible. + expect(find.byIcon(Icons.legend_toggle), findsOneWidget); + + // 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(); + + // 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)); + + final errors = tester.takeException(); + expect(errors, 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. + final swatchFinder = find.descendant( + of: find.byType(MapLegendCard), + matching: find.byType(ColoredBox), + ); + final opaque = tester + .widgetList(swatchFinder) + .where((cb) => cb.color.a == 1.0) + .toList(); + expect(opaque, hasLength(17)); + final firstSwatch = tester.renderObject(swatchFinder.at(1)); + expect(firstSwatch.size.width, 8); + semantics.dispose(); + }, + ); +}