diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index bd251e79b..ddbd38d2b 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -46,6 +46,7 @@ import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/locale_controller.dart'; import 'package:dpip/core/settings/map_layer_order_controller.dart'; import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; +import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/core/astro/tle_store.dart'; import 'package:dpip/core/settings/setting_keys.dart'; @@ -226,6 +227,7 @@ Future bootstrap() async { final defaultMapLayer = DefaultMapLayerController(settings); final mapLayerOrder = MapLayerOrderController(settings); final mapLayerVisibility = MapLayerVisibilityController(settings); + final mapReferenceOutline = MapReferenceOutlineController(settings); final cache = await cacheFuture; final dio = createDio(etagCache: cache?.etag, usage: cache?.usage); final endpointHealth = EndpointHealthMonitor(); @@ -378,6 +380,7 @@ Future bootstrap() async { defaultMapLayer: defaultMapLayer, mapLayerOrder: mapLayerOrder, mapLayerVisibility: mapLayerVisibility, + mapReferenceOutline: mapReferenceOutline, meshtastic: meshtastic, meshLink: meshLink, meshAlerts: meshAlerts, diff --git a/lib/core/di/core_providers.dart b/lib/core/di/core_providers.dart index 1c57522a0..e7d0ca954 100644 --- a/lib/core/di/core_providers.dart +++ b/lib/core/di/core_providers.dart @@ -29,6 +29,7 @@ import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/locale_controller.dart'; import 'package:dpip/core/settings/map_layer_order_controller.dart'; import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; +import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/settings/color_vision_controller.dart'; @@ -60,6 +61,9 @@ List coreProviders(SharedDeps deps) => [ ChangeNotifierProvider.value( value: deps.mapLayerVisibility, ), + ChangeNotifierProvider.value( + value: deps.mapReferenceOutline, + ), Provider.value(value: deps.settings), Provider.value(value: deps.database), Provider.value(value: deps.tleStore), diff --git a/lib/core/di/shared_deps.dart b/lib/core/di/shared_deps.dart index b27fb2e3b..ac38b8a52 100644 --- a/lib/core/di/shared_deps.dart +++ b/lib/core/di/shared_deps.dart @@ -27,6 +27,7 @@ import 'package:dpip/core/settings/experimental_settings.dart'; import 'package:dpip/core/settings/locale_controller.dart'; import 'package:dpip/core/settings/map_layer_order_controller.dart'; import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; +import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/core/settings/region_store.dart'; @@ -70,6 +71,7 @@ class SharedDeps { required this.defaultMapLayer, required this.mapLayerOrder, required this.mapLayerVisibility, + required this.mapReferenceOutline, required this.meshtastic, required this.meshLink, required this.meshAlerts, @@ -158,6 +160,10 @@ class SharedDeps { /// The map layers the user hid (also provided). final MapLayerVisibilityController mapLayerVisibility; + /// The shared reference-chrome toggles every raster layer offers — admin + /// borders and scan range (also provided). + final MapReferenceOutlineController mapReferenceOutline; + /// LoRa mesh (Meshtastic) over BLE — off-grid emergency messaging. final MeshtasticService meshtastic; diff --git a/lib/core/settings/map_reference_outline_controller.dart b/lib/core/settings/map_reference_outline_controller.dart new file mode 100644 index 000000000..2b607d879 --- /dev/null +++ b/lib/core/settings/map_reference_outline_controller.dart @@ -0,0 +1,69 @@ +/// Persisted, shared state for a raster layer's reference chrome — the +/// admin-border outlines (國界 / 縣市 / 鄉鎮) and the scan-range coverage +/// outline every weather raster can redraw over itself. +library; + +import 'dart:async'; + +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:flutter/foundation.dart'; + +/// One shared preference per toggle, used by every raster layer that offers +/// it (radar, QPESUMS, satellite, wind forecast) — see `AdminOutlineChrome` +/// and `ScanRangeOverlayChrome`. A layer instance is rebuilt per map surface, +/// so without one shared, persisted source of truth each rebuild forgot the +/// choice and every layer kept its own copy, out of sync with the others. +/// +/// All four ship on: a blank area reads as "not observed", not "no weather", +/// and an unidentified county is one a reader cannot act on. +class MapReferenceOutlineController extends ChangeNotifier { + MapReferenceOutlineController(this._settings) + : _global = _settings.getBool(SettingKeys.mapShowGlobalOutline) ?? true, + _county = _settings.getBool(SettingKeys.mapShowCountyOutline) ?? true, + _town = _settings.getBool(SettingKeys.mapShowTownOutline) ?? true, + _scanRange = _settings.getBool(SettingKeys.mapShowScanRange) ?? true; + + final SettingsStore _settings; + + bool _global; + bool _county; + bool _town; + bool _scanRange; + + bool get showGlobalOutline => _global; + bool get showCountyOutline => _county; + bool get showTownOutline => _town; + bool get showScanRange => _scanRange; + + void setShowGlobalOutline(bool value) { + if (!_changed(_global, value)) return; + _global = value; + _persist(SettingKeys.mapShowGlobalOutline, value); + } + + void setShowCountyOutline(bool value) { + if (!_changed(_county, value)) return; + _county = value; + _persist(SettingKeys.mapShowCountyOutline, value); + } + + void setShowTownOutline(bool value) { + if (!_changed(_town, value)) return; + _town = value; + _persist(SettingKeys.mapShowTownOutline, value); + } + + void setShowScanRange(bool value) { + if (!_changed(_scanRange, value)) return; + _scanRange = value; + _persist(SettingKeys.mapShowScanRange, value); + } + + bool _changed(bool current, bool next) => current != next; + + void _persist(SettingKey key, bool value) { + unawaited(_settings.setBool(key, value)); + notifyListeners(); + } +} diff --git a/lib/core/settings/setting_keys.dart b/lib/core/settings/setting_keys.dart index 376c9f30d..618dd0857 100644 --- a/lib/core/settings/setting_keys.dart +++ b/lib/core/settings/setting_keys.dart @@ -98,6 +98,47 @@ abstract final class SettingKeys { static const SettingKey> mapLayerHiddenIds = SettingKey>._('map.layerHiddenIds'); + /// Whether the OSM street/building overlay is on (absent = off, except the + /// disaster-prevention layer's own forced default). See + /// `GsiOverlayController`. + static const SettingKey mapGsiEnabled = SettingKey._( + 'map.gsiEnabled', + ); + + /// Enabled OSM overlay sub-layer groups ([GsiLayerGroup] names; absent = + /// every group except the ones [gsiDefaultDisabledGroups] starts off). See + /// `GsiOverlayController`. + static const SettingKey> mapGsiEnabledGroups = + SettingKey>._('map.gsiEnabledGroups'); + + /// Whether the base map's terrain-relief hillshade is shown (absent = + /// true). See `MapScaffold`. + static const SettingKey mapShowTerrain = SettingKey._( + 'map.showTerrain', + ); + + /// Whether the base map's township-name labels are shown (absent = true). + /// See `MapScaffold`. + static const SettingKey mapShowTownLabels = SettingKey._( + 'map.showTownLabels', + ); + + /// The four reference-chrome toggles a raster layer (radar, QPESUMS, + /// satellite, wind forecast) redraws over itself — one shared preference + /// for every layer, all absent = true. See `MapReferenceOutlineController`. + static const SettingKey mapShowGlobalOutline = SettingKey._( + 'map.showGlobalOutline', + ); + static const SettingKey mapShowCountyOutline = SettingKey._( + 'map.showCountyOutline', + ); + static const SettingKey mapShowTownOutline = SettingKey._( + 'map.showTownOutline', + ); + static const SettingKey mapShowScanRange = SettingKey._( + 'map.showScanRange', + ); + /// Saved Home township codes (ordered list). See `RegionStore`. static const SettingKey> savedRegionCodes = SettingKey>._('home.savedRegionCodes'); diff --git a/lib/features/map/presentation/layers/admin_outline_chrome.dart b/lib/features/map/presentation/layers/admin_outline_chrome.dart index 5b3515b31..5e0715fe4 100644 --- a/lib/features/map/presentation/layers/admin_outline_chrome.dart +++ b/lib/features/map/presentation/layers/admin_outline_chrome.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/a11y/color_vision.dart'; import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/color_hex.dart'; import 'package:dpip/shared/map/admin_outline.dart'; @@ -28,22 +29,27 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// consumer; only the ids come from the base style's own source, so a map /// showing two rasters at once draws two independent border sets. mixin AdminOutlineChrome on RasterTimelineLayer { + /// The persisted, shared toggle state — one instance for every raster layer + /// that mixes this in, so a choice made on one layer's menu is already in + /// effect (and already saved) on every other one, live. + MapReferenceOutlineController get referenceOutline; + /// Whether 國界 (world country borders) are redrawn above the raster. /// /// On by default: the base style's own borders are covered by the raster /// everywhere the weather shows, and the world frame is the reference a /// reader keeps when the map zooms past a county mesh — so every weather /// layer ships with it, and a reader who finds it noise can turn it off. - final ValueNotifier showGlobalOutline = ValueNotifier(true); + bool get showGlobalOutline => referenceOutline.showGlobalOutline; /// Whether 縣市 borders are redrawn above the raster. - final ValueNotifier showCountyOutline = ValueNotifier(true); + bool get showCountyOutline => referenceOutline.showCountyOutline; /// Whether 鄉鎮 borders are redrawn above the raster. Separate from the /// county toggle: they are different questions ("which city" vs "which /// township"), and the finer mesh is the first thing a reader wants gone when /// studying a single cell. - final ValueNotifier showTownOutline = ValueNotifier(true); + bool get showTownOutline => referenceOutline.showTownOutline; /// The live map controller, set on attach and cleared on detach. Shared with /// chrome mixins layered above this one, so it is protected rather than @@ -53,49 +59,47 @@ mixin AdminOutlineChrome on RasterTimelineLayer { final Set _boundariesShown = {}; /// All admin-chrome listenables, for a legend that follows the toggles. - Listenable get adminChromeListenable => - Listenable.merge([showGlobalOutline, showCountyOutline, showTownOutline]); + Listenable get adminChromeListenable => referenceOutline; /// Which boundary sets should currently be on the map. Set get _wantedBoundaries => { // Inserted finest → coarsest so the later, coarser frame wins where two // run together along a coastline. - if (showTownOutline.value) AdminBoundary.town, - if (showCountyOutline.value) AdminBoundary.county, - if (showGlobalOutline.value) AdminBoundary.global, + if (showTownOutline) AdminBoundary.town, + if (showCountyOutline) AdminBoundary.county, + if (showGlobalOutline) AdminBoundary.global, }; - /// Turns the 國界 borders on/off, applying it to a live map immediately. - void setShowGlobalOutline(bool value) { - if (showGlobalOutline.value == value) return; - showGlobalOutline.value = value; - unawaited(_syncBoundaries()); - } + /// Turns the 國界 borders on/off, applying it to every attached layer + /// sharing [referenceOutline] immediately. + void setShowGlobalOutline(bool value) => + referenceOutline.setShowGlobalOutline(value); - /// Turns the 縣市 borders on/off, applying it to a live map immediately. - void setShowCountyOutline(bool value) { - if (showCountyOutline.value == value) return; - showCountyOutline.value = value; - unawaited(_syncBoundaries()); - } + /// Turns the 縣市 borders on/off, applying it to every attached layer + /// sharing [referenceOutline] immediately. + void setShowCountyOutline(bool value) => + referenceOutline.setShowCountyOutline(value); - /// Turns the 鄉鎮 borders on/off, applying it to a live map immediately. - void setShowTownOutline(bool value) { - if (showTownOutline.value == value) return; - showTownOutline.value = value; - unawaited(_syncBoundaries()); - } + /// Turns the 鄉鎮 borders on/off, applying it to every attached layer + /// sharing [referenceOutline] immediately. + void setShowTownOutline(bool value) => + referenceOutline.setShowTownOutline(value); @override Future onAttached(MapLibreMapController controller) async { super.onAttached(controller); this.controller = controller; + // A shared preference can change from another attached layer's menu, not + // just this one's — resync whenever it does, for as long as this layer is + // actually on the map to resync. + referenceOutline.addListener(_onReferenceOutlineChanged); await _syncBoundaries(); } @override Future onDetached(MapLibreMapController controller) async { super.onDetached(controller); + referenceOutline.removeListener(_onReferenceOutlineChanged); this.controller = null; // Only undo what was actually added — tearing down an overlay that was // never mounted issues removals the map never asked for. @@ -105,6 +109,10 @@ mixin AdminOutlineChrome on RasterTimelineLayer { } } + void _onReferenceOutlineChanged() { + unawaited(_syncBoundaries()); + } + @override void onStyleReset() { // The style reload dropped every runtime layer, these included; forget them @@ -150,7 +158,7 @@ mixin AdminOutlineChrome on RasterTimelineLayer { List adminLegendItems(BuildContext context) { final l10n = AppLocalizations.of(context); return [ - if (showGlobalOutline.value) + if (showGlobalOutline) SymbolLegendItem( swatch: LineSwatch( color: colorFromHexRgb(AdminOutline.lineColor)!, @@ -162,7 +170,7 @@ mixin AdminOutlineChrome on RasterTimelineLayer { ), label: l10n.radarGlobalOutline, ), - if (showCountyOutline.value) + if (showCountyOutline) SymbolLegendItem( swatch: LineSwatch( color: colorFromHexRgb(AdminOutline.lineColor)!, @@ -174,7 +182,7 @@ mixin AdminOutlineChrome on RasterTimelineLayer { ), label: l10n.radarCountyOutline, ), - if (showTownOutline.value) + if (showTownOutline) SymbolLegendItem( swatch: LineSwatch( color: colorFromHexRgb(AdminOutline.lineColor)!, diff --git a/lib/features/map/presentation/layers/qpesums_layer.dart b/lib/features/map/presentation/layers/qpesums_layer.dart index 631dbc34b..7cb5ccbdb 100644 --- a/lib/features/map/presentation/layers/qpesums_layer.dart +++ b/lib/features/map/presentation/layers/qpesums_layer.dart @@ -1,4 +1,5 @@ import 'package:dpip/core/a11y/color_vision.dart'; +import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart'; import 'package:dpip/features/map/presentation/layers/qpesums_scan_range.dart'; import 'package:dpip/features/map/presentation/layers/scan_range_overlay_chrome.dart'; @@ -25,7 +26,10 @@ import 'package:flutter/material.dart'; /// union of range circles. class QpesumsMapLayer extends RasterTimelineLayer with AdminOutlineChrome, ScanRangeOverlayChrome { - QpesumsMapLayer(QpesumsRepository super.repository); + QpesumsMapLayer(QpesumsRepository super.repository, this.referenceOutline); + + @override + final MapReferenceOutlineController referenceOutline; /// Distinct from radar's ids: both layers can be on the map at once, and each /// draws its own outline instead of clashing over one source/layer pair. diff --git a/lib/features/map/presentation/layers/radar_layer.dart b/lib/features/map/presentation/layers/radar_layer.dart index 4f146e644..2a8f9467a 100644 --- a/lib/features/map/presentation/layers/radar_layer.dart +++ b/lib/features/map/presentation/layers/radar_layer.dart @@ -1,4 +1,5 @@ import 'package:dpip/core/a11y/color_vision.dart'; +import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart'; import 'package:dpip/features/map/presentation/layers/radar_scan_range.dart'; import 'package:dpip/features/map/presentation/layers/scan_range_overlay_chrome.dart'; @@ -24,7 +25,10 @@ import 'package:flutter/material.dart'; /// uninterrupted raster. class RadarMapLayer extends RasterTimelineLayer with AdminOutlineChrome, ScanRangeOverlayChrome { - RadarMapLayer(RadarRepository super.repository); + RadarMapLayer(RadarRepository super.repository, this.referenceOutline); + + @override + final MapReferenceOutlineController referenceOutline; /// The radar composite's own ids — the default geometry and layer naming. @override diff --git a/lib/features/map/presentation/layers/satellite_layer.dart b/lib/features/map/presentation/layers/satellite_layer.dart index b915c3466..995fcce21 100644 --- a/lib/features/map/presentation/layers/satellite_layer.dart +++ b/lib/features/map/presentation/layers/satellite_layer.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/features/map/presentation/widgets/satellite_legend.dart'; import 'package:dpip/features/map/presentation/widgets/satellite_style_menu.dart'; import 'package:dpip/features/weather/domain/satellite_channel.dart'; @@ -22,12 +23,18 @@ class SatelliteMapLayer extends RasterTimelineLayer { SatelliteMapLayer( SatelliteRepository super.repository, { required this.channel, + required this.referenceOutline, }); /// Which view of Himawari this layer renders — sets both the tile URL's /// channel path and the picker label. final SatelliteChannel channel; + /// The shared reference-outline preference — the same one every other + /// raster layer's admin-border chrome reads from. See + /// [AdminOutlineChrome.referenceOutline]. + final MapReferenceOutlineController referenceOutline; + /// Colour rendering of a raw band (JMA grayscale default); ignored for named /// products, which carry their own palette. Non-default ⇒ the settings chip /// shows its marker dot. @@ -39,7 +46,7 @@ class SatelliteMapLayer extends RasterTimelineLayer { /// default — the same default as the radar / wind frame /// ([AdminOutlineChrome.showGlobalOutline]): the neighbours' outlines are /// the frame a reader navigates by on a basin-wide view. - final ValueNotifier showGlobalOutline = ValueNotifier(true); + bool get showGlobalOutline => referenceOutline.showGlobalOutline; bool _globalShown = false; @@ -48,18 +55,16 @@ class SatelliteMapLayer extends RasterTimelineLayer { @protected MapLibreMapController? controller; - /// Turns the 國界 borders on/off, applying it to a live map immediately. - void setShowGlobalOutline(bool value) { - if (showGlobalOutline.value == value) return; - showGlobalOutline.value = value; - unawaited(_syncGlobalOutline()); - } + /// Turns the 國界 borders on/off, applying it to every attached layer + /// sharing [referenceOutline] immediately. + void setShowGlobalOutline(bool value) => + referenceOutline.setShowGlobalOutline(value); /// Adds or removes the 國界 line to match [showGlobalOutline]. Future _syncGlobalOutline() async { final controller = this.controller; if (controller == null) return; - final wanted = showGlobalOutline.value; + final wanted = showGlobalOutline; if (wanted == _globalShown) return; _globalShown = wanted; try { @@ -196,6 +201,7 @@ class SatelliteMapLayer extends RasterTimelineLayer { belowLayerId: townLabelLayerId, enableInteraction: false, ); + referenceOutline.addListener(_onReferenceOutlineChanged); await _syncGlobalOutline(); } catch (_) { // Half-added outlines are worse than none — roll back and carry on. @@ -203,8 +209,13 @@ class SatelliteMapLayer extends RasterTimelineLayer { } } + void _onReferenceOutlineChanged() { + unawaited(_syncGlobalOutline()); + } + @override Future onDetached(MapLibreMapController controller) async { + referenceOutline.removeListener(_onReferenceOutlineChanged); this.controller = null; _globalShown = false; for (final layerId in const [ @@ -222,7 +233,7 @@ class SatelliteMapLayer extends RasterTimelineLayer { Widget buildLegend(BuildContext context) => SatelliteLegend( channel: channel, style: style, - showGlobal: showGlobalOutline, + showGlobal: referenceOutline, ); } diff --git a/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart index 3a8023fbe..e5d950895 100644 --- a/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart +++ b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart @@ -35,7 +35,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// instead of clashing over one. mixin ScanRangeOverlayChrome on AdminOutlineChrome { /// Whether the observed area is outlined. On by default — see the class doc. - final ValueNotifier showScanRange = ValueNotifier(true); + bool get showScanRange => referenceOutline.showScanRange; bool _rangeShown = false; @@ -56,26 +56,26 @@ mixin ScanRangeOverlayChrome on AdminOutlineChrome { /// data covers something else overrides it. Map get scanRangeGeoJson => RadarScanRange.geoJson(); - /// All chrome listenables, for a legend that follows the toggles. - Listenable get chromeListenable => - Listenable.merge([showScanRange, adminChromeListenable]); + /// All chrome listenables, for a legend that follows the toggles — scan + /// range and the admin outlines are all one shared controller now, so this + /// is just it. + Listenable get chromeListenable => adminChromeListenable; - /// Turns the coverage outline on/off, applying it to a live map immediately. - void setShowScanRange(bool value) { - if (showScanRange.value == value) return; - showScanRange.value = value; - unawaited(_syncRange()); - } + /// Turns the coverage outline on/off, applying it to every attached layer + /// sharing [referenceOutline] immediately. + void setShowScanRange(bool value) => referenceOutline.setShowScanRange(value); @override Future onAttached(MapLibreMapController controller) async { super.onAttached(controller); + referenceOutline.addListener(_onReferenceOutlineChanged); await _syncRange(); } @override Future onDetached(MapLibreMapController controller) async { super.onDetached(controller); + referenceOutline.removeListener(_onReferenceOutlineChanged); if (_rangeShown) { _rangeShown = false; await RadarScanRange.remove( @@ -86,6 +86,10 @@ mixin ScanRangeOverlayChrome on AdminOutlineChrome { } } + void _onReferenceOutlineChanged() { + unawaited(_syncRange()); + } + @override void onStyleReset() { // The style reload dropped every runtime layer, these included; forget them @@ -99,7 +103,7 @@ mixin ScanRangeOverlayChrome on AdminOutlineChrome { final controller = this.controller; if (controller == null) return; - final wanted = showScanRange.value; + final wanted = showScanRange; if (wanted == _rangeShown) return; _rangeShown = wanted; @@ -135,7 +139,7 @@ mixin ScanRangeOverlayChrome on AdminOutlineChrome { List chromeLegendItems(BuildContext context) { final l10n = AppLocalizations.of(context); return [ - if (showScanRange.value) + if (showScanRange) SymbolLegendItem( // The same blue-grey the outline is drawn in, and corrected the same // way: the ring is a vector line this app draws, not raster pixels. diff --git a/lib/features/map/presentation/layers/wind_forecast_layer.dart b/lib/features/map/presentation/layers/wind_forecast_layer.dart index 8fba9e27f..8afec3eca 100644 --- a/lib/features/map/presentation/layers/wind_forecast_layer.dart +++ b/lib/features/map/presentation/layers/wind_forecast_layer.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:dpip/core/a11y/color_vision.dart'; import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart'; import 'package:dpip/features/map/presentation/widgets/forecast_overlay_menu.dart'; import 'package:dpip/features/map/presentation/widgets/wind_particle_overlay.dart'; @@ -40,10 +41,14 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome { WindForecastMapLayer( WindForecastRepository super.repository, { required this.model, + required this.referenceOutline, }); final WindForecastModel model; + @override + final MapReferenceOutlineController referenceOutline; + /// The wind grid backing the overlay's particles, loaded for the settled /// frame. It stays null while the timeline is moving and until that frame's /// field arrives; the overlay starts its animation only once this is set. diff --git a/lib/features/map/presentation/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart index 73868904c..9f1cbe997 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -7,6 +7,7 @@ import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/settings/default_map_layer.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; +import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/features/disaster_map/domain/disaster_map_repository.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; import 'package:dpip/features/earthquake/domain/rts.dart'; @@ -74,15 +75,22 @@ class MapPage extends StatefulWidget { class _MapPageState extends State { // Built once so each layer keeps its own MapLibre state across rebuilds. late final List _layers = [ - RadarMapLayer(context.read()), + RadarMapLayer( + context.read(), + context.read(), + ), // The wind-forecast block sits right after radar: the picker groups by // category in declared order, and the numerical-forecast group (QPESUMS // then ECMWF then GFS) is what radar hands off to. - QpesumsMapLayer(context.read()), + QpesumsMapLayer( + context.read(), + context.read(), + ), for (final model in WindForecastModel.values) WindForecastMapLayer( context.read>()[model]!, model: model, + referenceOutline: context.read(), ), // One layer per satellite channel — each fetches its own frame list and // serves its own channel-scoped tile path. @@ -90,6 +98,7 @@ class _MapPageState extends State { SatelliteMapLayer( context.read>()[channel]!, channel: channel, + referenceOutline: context.read(), ), LightningMapLayer(context.read()), TyphoonMapLayer( diff --git a/lib/features/map/presentation/widgets/forecast_overlay_menu.dart b/lib/features/map/presentation/widgets/forecast_overlay_menu.dart index c3b828e33..4290fe413 100644 --- a/lib/features/map/presentation/widgets/forecast_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/forecast_overlay_menu.dart @@ -50,9 +50,9 @@ class ForecastOverlayMenu extends StatelessWidget { showTerrain, ]), builder: (context, _) { - final showGlobal = layer.showGlobalOutline.value; - final showCounty = layer.showCountyOutline.value; - final showTown = layer.showTownOutline.value; + final showGlobal = layer.showGlobalOutline; + final showCounty = layer.showCountyOutline; + final showTown = layer.showTownOutline; final showLabels = showTownLabels.value; final showRelief = showTerrain.value; return MenuAnchor( diff --git a/lib/features/map/presentation/widgets/satellite_legend.dart b/lib/features/map/presentation/widgets/satellite_legend.dart index f84e8b4d9..5485f57e4 100644 --- a/lib/features/map/presentation/widgets/satellite_legend.dart +++ b/lib/features/map/presentation/widgets/satellite_legend.dart @@ -13,6 +13,7 @@ library; import 'package:dpip/core/a11y/color_vision.dart'; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/features/weather/domain/satellite_channel.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; @@ -34,8 +35,9 @@ class SatelliteLegend extends StatelessWidget { final ValueListenable style; /// Whether 國界 borders are currently on the map — null keeps the row shown - /// (a standalone legend with no live toggle). - final ValueListenable? showGlobal; + /// (a standalone legend with no live toggle). Shared across every raster + /// layer, so it also doubles as the listenable this legend rebuilds from. + final MapReferenceOutlineController? showGlobal; static Color get _county => const Color(0xFFFFD400).vision; static Color get _town => const Color(0xFFB79A00).vision; @@ -335,7 +337,7 @@ class SatelliteLegend extends StatelessWidget { final l10n = AppLocalizations.of(context); return SymbolLegend( items: [ - if (showGlobal?.value ?? true) + if (showGlobal?.showGlobalOutline ?? true) SymbolLegendItem( swatch: LineSwatch(color: _county, width: 1.0), label: l10n.mapLayerSatelliteGlobalOutline, diff --git a/lib/features/map/presentation/widgets/satellite_style_menu.dart b/lib/features/map/presentation/widgets/satellite_style_menu.dart index 4b1703b6f..db9f87cbd 100644 --- a/lib/features/map/presentation/widgets/satellite_style_menu.dart +++ b/lib/features/map/presentation/widgets/satellite_style_menu.dart @@ -43,13 +43,13 @@ class SatelliteStyleMenu extends StatelessWidget { return ListenableBuilder( listenable: Listenable.merge([ layer.style, - layer.showGlobalOutline, + layer.referenceOutline, showTownLabels, showTerrain, ]), builder: (context, _) { final style = layer.style.value; - final showGlobal = layer.showGlobalOutline.value; + final showGlobal = layer.showGlobalOutline; final showLabels = showTownLabels.value; final showRelief = showTerrain.value; final active = @@ -161,12 +161,12 @@ class SatelliteReferenceMenu extends StatelessWidget { final l10n = AppLocalizations.of(context); return ListenableBuilder( listenable: Listenable.merge([ - layer.showGlobalOutline, + layer.referenceOutline, showTownLabels, showTerrain, ]), builder: (context, _) { - final showGlobal = layer.showGlobalOutline.value; + final showGlobal = layer.showGlobalOutline; final showLabels = showTownLabels.value; final showRelief = showTerrain.value; return MenuAnchor( diff --git a/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart index 0feda1bad..79f12e384 100644 --- a/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart +++ b/lib/features/map/presentation/widgets/scan_range_overlay_menu.dart @@ -51,10 +51,10 @@ class ScanRangeOverlayMenu extends StatelessWidget { showTerrain, ]), builder: (context, _) { - final showRange = layer.showScanRange.value; - final showGlobal = layer.showGlobalOutline.value; - final showCounty = layer.showCountyOutline.value; - final showTown = layer.showTownOutline.value; + final showRange = layer.showScanRange; + final showGlobal = layer.showGlobalOutline; + final showCounty = layer.showCountyOutline; + final showTown = layer.showTownOutline; final showLabels = showTownLabels.value; final showRelief = showTerrain.value; return MenuAnchor( diff --git a/lib/shared/map/map_gsi_overlay.dart b/lib/shared/map/map_gsi_overlay.dart index 817653363..5955e717e 100644 --- a/lib/shared/map/map_gsi_overlay.dart +++ b/lib/shared/map/map_gsi_overlay.dart @@ -13,6 +13,8 @@ import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/a11y/color_vision.dart'; import 'package:dpip/core/network/api_paths.dart'; +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:dpip/shared/widgets/map_menu_toggle_row.dart'; @@ -95,18 +97,28 @@ const Map> gsiLayerGroupsBySection = { }; /// State shared by the base-map menu and the native style owner. +/// +/// Both the on/off switch and the per-group selection are persisted, so a +/// choice made in the menu is still in effect the next time this surface is +/// opened. [forceEnabled] is a per-entry override on top of that — the +/// disaster-prevention layer forces OSM on for the street/building context it +/// needs — not the saved preference itself; it never overwrites what's stored. class GsiOverlayController extends ChangeNotifier { - GsiOverlayController({ + GsiOverlayController( + this._settings, { this.mutuallyExclusiveTerrain, - bool initiallyEnabled = false, - }) : _enabled = initiallyEnabled { - if (initiallyEnabled) mutuallyExclusiveTerrain?.value = false; + bool forceEnabled = false, + }) : _enabled = + forceEnabled || + (_settings.getBool(SettingKeys.mapGsiEnabled) ?? false), + _groups = _loadGroups(_settings) { + if (_enabled) mutuallyExclusiveTerrain?.value = false; } + final SettingsStore _settings; final ValueNotifier? mutuallyExclusiveTerrain; bool _enabled; - final Set _groups = {...GsiLayerGroup.values} - ..removeAll(gsiDefaultDisabledGroups); + final Set _groups; int _revision = 0; bool get enabled => _enabled; @@ -115,6 +127,21 @@ class GsiOverlayController extends ChangeNotifier { bool groupEnabled(GsiLayerGroup group) => _groups.contains(group); + /// The saved group set, or every group but [gsiDefaultDisabledGroups] on a + /// first run. A stale saved name (a group renamed or removed since) is + /// dropped rather than crashing, matching the tolerance every other + /// id-list setting in the app already gives a saved value that outgrew it. + static Set _loadGroups(SettingsStore settings) { + final saved = settings.getStringList(SettingKeys.mapGsiEnabledGroups); + if (saved == null) { + return {...GsiLayerGroup.values}..removeAll(gsiDefaultDisabledGroups); + } + final byName = { + for (final group in GsiLayerGroup.values) group.name: group, + }; + return {for (final name in saved) ?byName[name]}; + } + void setEnabled(bool value) { // The vector overlay already supplies its own land / road surface. Drawing // hillshade below it both wastes a DEM viewport and muddies that surface, @@ -124,6 +151,7 @@ class GsiOverlayController extends ChangeNotifier { if (_enabled == value) return; _enabled = value; _revision++; + unawaited(_settings.setBool(SettingKeys.mapGsiEnabled, value)); notifyListeners(); } @@ -131,6 +159,7 @@ class GsiOverlayController extends ChangeNotifier { final changed = value ? _groups.add(group) : _groups.remove(group); if (!changed) return; _revision++; + _persistGroups(); notifyListeners(); } @@ -138,8 +167,17 @@ class GsiOverlayController extends ChangeNotifier { if (_groups.length == GsiLayerGroup.values.length) return; _groups.addAll(GsiLayerGroup.values); _revision++; + _persistGroups(); notifyListeners(); } + + void _persistGroups() { + unawaited( + _settings.setStringList(SettingKeys.mapGsiEnabledGroups, [ + for (final group in _groups) group.name, + ]), + ); + } } /// Makes the one scaffold-owned controller available to every layer menu diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index f3ee47659..4c3a5eb18 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -6,6 +6,8 @@ import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/camera_fit.dart'; @@ -206,6 +208,11 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { /// disaster-prevention map. late final GsiOverlayController _gsi; + /// Backs [_gsi] plus the [_showTerrain] / [_showTownLabels] persistence — + /// read once in [initState], since none of these three toggles need to + /// 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 @@ -238,10 +245,14 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { super.initState(); _trace(() => 'init active=${_active.id} timeline=${_active.usesTimeline}'); _scrubBackpressure.addListener(_onScrubBackpressureChanged); - if (widget.initialOsmEnabled) _showTerrain.value = false; + _settings = context.read(); + _showTerrain.value = _settings.getBool(SettingKeys.mapShowTerrain) ?? true; + _showTownLabels.value = + _settings.getBool(SettingKeys.mapShowTownLabels) ?? true; _gsi = GsiOverlayController( + _settings, mutuallyExclusiveTerrain: _showTerrain, - initiallyEnabled: widget.initialOsmEnabled, + forceEnabled: widget.initialOsmEnabled, ); _gsiZoomEnabled = _gsi.enabled; _gsi.addListener(_onGsiChanged); @@ -724,6 +735,7 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { void _setShowTownLabels(bool value) { if (_showTownLabels.value == value) return; _showTownLabels.value = value; + unawaited(_settings.setBool(SettingKeys.mapShowTownLabels, value)); _applyTownLabelVisibility(); } @@ -734,6 +746,7 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { if (value && _gsi.enabled) _gsi.setEnabled(false); if (_showTerrain.value == value) return; _showTerrain.value = value; + unawaited(_settings.setBool(SettingKeys.mapShowTerrain, value)); if (!value) { unawaited(_basemapWarmer?.discardWorkingSet('terrain')); } diff --git a/test/features/map/layer_stacking_test.dart b/test/features/map/layer_stacking_test.dart index f3d9a66a4..2499f0f3a 100644 --- a/test/features/map/layer_stacking_test.dart +++ b/test/features/map/layer_stacking_test.dart @@ -104,7 +104,7 @@ Future<(RecordingMapController, List)> _scrub( void main() { test('a scrub never buries the admin borders under the echo', () async { - final layer = RadarMapLayer(_FakeRadar(_ids(9))); + final layer = RadarMapLayer(_FakeRadar(_ids(9)), testReferenceOutline()); final (controller, ids) = await _scrub(layer); for (final boundary in [AdminBoundary.county, AdminBoundary.town]) { @@ -126,7 +126,7 @@ void main() { }); test('the borders still stay under the township names', () async { - final layer = RadarMapLayer(_FakeRadar(_ids(9))); + final layer = RadarMapLayer(_FakeRadar(_ids(9)), testReferenceOutline()); final (controller, _) = await _scrub(layer); // The labels are the top-most text on every surface: a border line must // never cross a place name. @@ -137,7 +137,7 @@ void main() { }); test('the scan-range circle is drawn over the echo, not under it', () async { - final layer = RadarMapLayer(_FakeRadar(_ids(9))); + final layer = RadarMapLayer(_FakeRadar(_ids(9)), testReferenceOutline()); layer.setShowScanRange(true); final (controller, ids) = await _scrub(layer); @@ -153,7 +153,7 @@ void main() { }); test('the seam sits between the frames and the chrome', () async { - final layer = RadarMapLayer(_FakeRadar(_ids(9))); + final layer = RadarMapLayer(_FakeRadar(_ids(9)), testReferenceOutline()); final (controller, ids) = await _scrub(layer); final seam = layer.frameSeamLayerId; @@ -166,7 +166,7 @@ void main() { }); test('the seam is torn down with the layer', () async { - final layer = RadarMapLayer(_FakeRadar(_ids(9))); + final layer = RadarMapLayer(_FakeRadar(_ids(9)), testReferenceOutline()); final (controller, _) = await _scrub(layer); expect(controller.order, contains(layer.frameSeamLayerId)); @@ -180,9 +180,13 @@ void main() { test('every timeline layer keeps its own chrome above its frames', () async { final layers = [ - RadarMapLayer(_FakeRadar(_ids(9))), - QpesumsMapLayer(_FakeQpesums(_ids(9))), - WindForecastMapLayer(_FakeWind(_ids(9)), model: WindForecastModel.gfs), + RadarMapLayer(_FakeRadar(_ids(9)), testReferenceOutline()), + QpesumsMapLayer(_FakeQpesums(_ids(9)), testReferenceOutline()), + WindForecastMapLayer( + _FakeWind(_ids(9)), + model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), + ), ]; for (final layer in layers) { final (controller, ids) = await _scrub(layer); @@ -204,6 +208,7 @@ void main() { final layer = SatelliteMapLayer( _FakeSatellite(_ids(9)), channel: SatelliteChannel.irClean, + referenceOutline: testReferenceOutline(), ); final (controller, ids) = await _scrub(layer); for (final id in ids) { diff --git a/test/features/map/qpesums_layer_test.dart b/test/features/map/qpesums_layer_test.dart index dae29f2d2..9baa71203 100644 --- a/test/features/map/qpesums_layer_test.dart +++ b/test/features/map/qpesums_layer_test.dart @@ -19,13 +19,17 @@ void main() { test('frames chronological', () async { final layer = QpesumsMapLayer( _FakeQpesumsRepository(['1786209600000', '1786208400000']), + testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; expect(frames.map((f) => f.id), ['1786208400000', '1786209600000']); }); test('a settle mounts the preload ring at forecast opacity', () async { - final layer = QpesumsMapLayer(_FakeQpesumsRepository(_ids(5))); + final layer = QpesumsMapLayer( + _FakeQpesumsRepository(_ids(5)), + testReferenceOutline(), + ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -38,7 +42,10 @@ void main() { test( 'scrubbing inside the ring is two opacity writes, nothing else', () async { - final layer = QpesumsMapLayer(_FakeQpesumsRepository(_ids(9))); + final layer = QpesumsMapLayer( + _FakeQpesumsRepository(_ids(9)), + testReferenceOutline(), + ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -65,7 +72,7 @@ void main() { test('clear releases tiles', () async { final source = _FakeQpesumsRepository(_ids(5)); - final layer = QpesumsMapLayer(source); + final layer = QpesumsMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -77,7 +84,10 @@ void main() { }); testWidgets('timeline caption says forecast, not observed', (tester) async { - final layer = QpesumsMapLayer(_FakeQpesumsRepository(const [])); + final layer = QpesumsMapLayer( + _FakeQpesumsRepository(const []), + testReferenceOutline(), + ); String? caption; await tester.pumpWidget( MaterialApp( @@ -95,7 +105,10 @@ void main() { }); testWidgets('legend renders the QPESUMS mm/h scale', (tester) async { - final layer = QpesumsMapLayer(_FakeQpesumsRepository(const [])); + final layer = QpesumsMapLayer( + _FakeQpesumsRepository(const []), + testReferenceOutline(), + ); await tester.pumpWidget( MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, @@ -115,7 +128,10 @@ void main() { group('overlays', () { /// A layer attached to a live map, ready for the toggles. Future<(QpesumsMapLayer, RecordingMapController)> attached() async { - final layer = QpesumsMapLayer(_FakeQpesumsRepository(_ids(3))); + final layer = QpesumsMapLayer( + _FakeQpesumsRepository(_ids(3)), + testReferenceOutline(), + ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); await layer.prepare(controller, frames); @@ -132,9 +148,9 @@ void main() { test('all three overlays are on by default and drawn on attach', () async { final (layer, controller) = await attached(); - expect(layer.showScanRange.value, isTrue); - expect(layer.showCountyOutline.value, isTrue); - expect(layer.showTownOutline.value, isTrue); + expect(layer.showScanRange, isTrue); + expect(layer.showCountyOutline, isTrue); + expect(layer.showTownOutline, isTrue); // Its own ids, so radar and QPESUMS can both be on the map at once. expect(controller.calls, contains('addSource:qpesums-scan-range')); expect( @@ -206,7 +222,7 @@ void main() { isEmpty, reason: 'dropping the fine mesh must not take the coarse frame with it', ); - expect(layer.showCountyOutline.value, isTrue); + expect(layer.showCountyOutline, isTrue); }); test('clear tears the chrome down with the layer', () async { diff --git a/test/features/map/radar_layer_test.dart b/test/features/map/radar_layer_test.dart index b1dfe811b..cf9616093 100644 --- a/test/features/map/radar_layer_test.dart +++ b/test/features/map/radar_layer_test.dart @@ -99,6 +99,7 @@ void main() { test('frames chronological', () async { final layer = RadarMapLayer( _FakeRadarRepository(['1700000600', '1700000000']), + testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; expect(frames.map((f) => f.id), ['1700000000', '1700000600']); @@ -106,7 +107,7 @@ void main() { test('a settle mounts the preload ring around the target', () async { final source = _FakeRadarRepository(_ids(9)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -139,7 +140,7 @@ void main() { test('a cancelled incomplete frame is recreated before reuse', () async { final source = _FakeRadarRepository(_ids(15)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); final oldFrame = frames[2].id; @@ -168,7 +169,7 @@ void main() { 'a complete retired frame remains reusable without cancellation', () async { final source = _FakeRadarRepository(_ids(15)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); final oldFrame = frames[2].id; @@ -192,7 +193,7 @@ void main() { test('a blocked warm cannot leave two timestamps at full opacity', () async { final source = _BlockingWarmRadarRepository(_ids(9)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -223,7 +224,7 @@ void main() { 'scrubbing inside the ring is two opacity writes, nothing else', () async { final source = _FakeRadarRepository(_ids(9)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -274,7 +275,7 @@ void main() { test('timeline touch cancels preload before the first frame event', () async { final source = _FakeRadarRepository(_ids(9)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -295,7 +296,7 @@ void main() { test('native idle makes an ambient-cache ring scrub-ready', () async { final source = _ControlledReadinessRadarRepository(_ids(5))..ready = false; - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -322,7 +323,7 @@ void main() { test('a late native idle completes a settle after an L1 miss', () async { final source = _ControlledReadinessRadarRepository(_ids(5))..ready = false; - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -340,7 +341,7 @@ void main() { test('a scrub derives the visible region once, not once per frame', () async { final source = _ControlledReadinessRadarRepository(_ids(9))..ready = false; - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -379,7 +380,7 @@ void main() { test('camera movement invalidates native readiness during a scrub', () async { final source = _ControlledReadinessRadarRepository(_ids(5)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -399,7 +400,10 @@ void main() { }); test('frames mount without opacity or per-tile fades', () async { - final layer = RadarMapLayer(_FakeRadarRepository(_ids(9))); + final layer = RadarMapLayer( + _FakeRadarRepository(_ids(9)), + testReferenceOutline(), + ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -425,7 +429,7 @@ void main() { test('an idle-preloaded scrub target restores and flips from L1', () async { final source = _FakeRadarRepository(_ids(9)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -458,7 +462,7 @@ void main() { 'idle settle fills the resident ceiling without extra draw passes', () async { final source = _FakeRadarRepository(_ids(40)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -513,7 +517,7 @@ void main() { () async { final ids = _ids(40); final source = _BlockedNeighboursRadarRepository(ids); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; source.blockedFrames = {frames[23].id, frames[17].id, frames[24].id}; final controller = RecordingMapController(); @@ -550,7 +554,7 @@ void main() { test('hiding a settled map releases decoded preload sources', () async { final source = _FakeRadarRepository(_ids(40)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -588,7 +592,7 @@ void main() { 'a replacement map refreshes repaired L1 before mounting tiles', () async { final source = _FakeRadarRepository(_ids(5)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final oldController = RecordingMapController(); @@ -625,7 +629,7 @@ void main() { 'one gesture cancels warm once and restarts it after settling', () async { final source = _FakeRadarRepository(_ids(12)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -653,7 +657,7 @@ void main() { 'returning to the map restores the cancelled GIF preload window', () async { final source = _BlockingWarmRadarRepository(_ids(12)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -693,7 +697,7 @@ void main() { 'a timeline born off-screen does no warm work before first reveal', () async { final source = _FakeRadarRepository(_ids(12)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -728,7 +732,7 @@ void main() { test('hiding the map cancels all in-flight idle preload lanes', () async { final ids = _ids(40); final source = _BlockedNeighboursRadarRepository(ids); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; source.blockedFrames = {frames[23].id, frames[17].id, frames[24].id}; final controller = RecordingMapController(); @@ -759,7 +763,7 @@ void main() { 'memory pressure releases speculative sources while the map is visible', () async { final source = _FakeRadarRepository(_ids(40)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -804,7 +808,7 @@ void main() { 'a map that is still warming can be trimmed without losing its frame', () async { final source = _BlockingWarmRadarRepository(_ids(12)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -851,7 +855,7 @@ void main() { test('a long cached scrub keeps the resident source set bounded', () async { final source = _FakeRadarRepository(_ids(40)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -892,7 +896,7 @@ void main() { test('a cold fast scrub mounts only the final ring on finger-up', () async { final source = _ControlledReadinessRadarRepository(_ids(12)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -924,7 +928,7 @@ void main() { test('a settle abandons the frames the scrub swept past', () async { final source = _FakeRadarRepository(_ids(9)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -942,7 +946,7 @@ void main() { test('finger-up settles the cold frame held during scrubbing', () async { final source = _ControlledReadinessRadarRepository(_ids(9)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -968,7 +972,7 @@ void main() { test('a settle warms outward from the frame, far beyond the ring', () async { // 25 frames so the ±4 ring is a strict subset of the warm spread. final source = _FakeRadarRepository(_ids(25)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -990,7 +994,7 @@ void main() { test('a settled fill uses the full frame budget at a series edge', () async { final source = _FakeRadarRepository(_ids(700)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -1015,7 +1019,7 @@ void main() { test('scrubbing never launches a whole-history warm scan', () async { final source = _FakeRadarRepository(_ids(25)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -1049,7 +1053,7 @@ void main() { test('a cold scrub target cannot replace the complete frame', () async { final source = _ControlledReadinessRadarRepository(_ids(9)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -1097,7 +1101,7 @@ void main() { test('a held scrub frame can retry after backpressure quiet', () async { final source = _ControlledReadinessRadarRepository(_ids(9)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -1123,7 +1127,7 @@ void main() { 'an older readiness completion cannot overwrite a newer target', () async { final source = _ControlledReadinessRadarRepository(_ids(12)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -1143,7 +1147,7 @@ void main() { test('clear releases tiles and removes every mounted frame', () async { final source = _FakeRadarRepository(_ids(5)); - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -1165,14 +1169,20 @@ void main() { test('history length uncapped', () async { final ids = [for (var i = 0; i < 500; i++) '${1700000000 + i * 600}']; - final layer = RadarMapLayer(_FakeRadarRepository(ids.reversed.toList())); + final layer = RadarMapLayer( + _FakeRadarRepository(ids.reversed.toList()), + testReferenceOutline(), + ); expect((await layer.frames()).valueOrNull!.length, 500); }); group('overlays', () { /// A layer attached to a live map, ready for the toggles. Future<(RadarMapLayer, RecordingMapController)> attached() async { - final layer = RadarMapLayer(_FakeRadarRepository(_ids(3))); + final layer = RadarMapLayer( + _FakeRadarRepository(_ids(3)), + testReferenceOutline(), + ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); await layer.prepare(controller, frames); @@ -1192,9 +1202,9 @@ void main() { // Blank outside the coverage means "not observed", and a county you // cannot identify is a county you cannot act on. Neither should have to // be found in a menu first. - expect(layer.showScanRange.value, isTrue); - expect(layer.showCountyOutline.value, isTrue); - expect(layer.showTownOutline.value, isTrue); + expect(layer.showScanRange, isTrue); + expect(layer.showCountyOutline, isTrue); + expect(layer.showTownOutline, isTrue); expect(controller.calls, contains('addSource:radar-scan-range')); expect( controller.calls, @@ -1259,7 +1269,7 @@ void main() { isEmpty, reason: 'dropping the fine mesh must not take the coarse frame with it', ); - expect(layer.showCountyOutline.value, isTrue); + expect(layer.showCountyOutline, isTrue); }); test('the township mesh is drawn lighter than the county frame', () { diff --git a/test/features/map/radar_overlay_menu_test.dart b/test/features/map/radar_overlay_menu_test.dart index f861ff69b..e315a87b8 100644 --- a/test/features/map/radar_overlay_menu_test.dart +++ b/test/features/map/radar_overlay_menu_test.dart @@ -60,7 +60,7 @@ void main() { tester, ) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository()); + final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); await tester.pumpWidget(_wrap(layer)); final l10n = await _l10n(); @@ -90,7 +90,7 @@ void main() { tester, ) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository()); + final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); final terrain = ValueNotifier(true); final flipped = []; await tester.pumpWidget( @@ -109,25 +109,25 @@ void main() { testWidgets('tapping the national-border row turns it off', (tester) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository()); + final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); await tester.pumpWidget(_wrap(layer)); final l10n = await _l10n(); - expect(layer.showGlobalOutline.value, isTrue); + expect(layer.showGlobalOutline, isTrue); await tester.tap(find.byType(MapChipButton)); await tester.pumpAndSettle(); await tester.tap(find.text(l10n.radarGlobalOutline)); await tester.pumpAndSettle(); - expect(layer.showGlobalOutline.value, isFalse); + expect(layer.showGlobalOutline, isFalse); // Independent controls: one must not drag the others with it. - expect(layer.showCountyOutline.value, isTrue); - expect(layer.showTownOutline.value, isTrue); + expect(layer.showCountyOutline, isTrue); + expect(layer.showTownOutline, isTrue); }); testWidgets('tapping the coverage row turns it off', (tester) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository()); + final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); await tester.pumpWidget(_wrap(layer)); final l10n = await _l10n(); @@ -136,15 +136,15 @@ void main() { await tester.tap(find.text(l10n.radarScanRange)); await tester.pumpAndSettle(); - expect(layer.showScanRange.value, isFalse); + expect(layer.showScanRange, isFalse); // Independent controls: one must not drag the others with it. - expect(layer.showCountyOutline.value, isTrue); - expect(layer.showTownOutline.value, isTrue); + expect(layer.showCountyOutline, isTrue); + expect(layer.showTownOutline, isTrue); }); testWidgets('tapping the county row turns it off', (tester) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository()); + final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); await tester.pumpWidget(_wrap(layer)); final l10n = await _l10n(); @@ -153,14 +153,14 @@ void main() { await tester.tap(find.text(l10n.radarCountyOutline)); await tester.pumpAndSettle(); - expect(layer.showCountyOutline.value, isFalse); - expect(layer.showScanRange.value, isTrue); - expect(layer.showTownOutline.value, isTrue); + expect(layer.showCountyOutline, isFalse); + expect(layer.showScanRange, isTrue); + expect(layer.showTownOutline, isTrue); }); testWidgets('tapping the township row turns only it off', (tester) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository()); + final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); await tester.pumpWidget(_wrap(layer)); final l10n = await _l10n(); @@ -169,15 +169,15 @@ void main() { await tester.tap(find.text(l10n.radarTownOutline)); await tester.pumpAndSettle(); - expect(layer.showTownOutline.value, isFalse); - expect(layer.showCountyOutline.value, isTrue); + expect(layer.showTownOutline, isFalse); + expect(layer.showCountyOutline, isTrue); }); testWidgets('tapping the township-label row reports the flip upward', ( tester, ) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository()); + final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); final labels = ValueNotifier(true); final flipped = []; await tester.pumpWidget( @@ -203,7 +203,7 @@ void main() { tester, ) async { _useTallSurface(tester); - final layer = RadarMapLayer(_FakeRadarRepository()); + final layer = RadarMapLayer(_FakeRadarRepository(), testReferenceOutline()); await tester.pumpWidget(_wrap(layer)); // Both overlays ship on, so at rest the chip is unmarked. diff --git a/test/features/map/raster_source_maxzoom_test.dart b/test/features/map/raster_source_maxzoom_test.dart index 8c16e3852..14c8ce6cf 100644 --- a/test/features/map/raster_source_maxzoom_test.dart +++ b/test/features/map/raster_source_maxzoom_test.dart @@ -30,7 +30,7 @@ class _CappedRadarRepository extends FakeRasterFrameSource void main() { test('mounted radar sources carry the pyramid cap as maxzoom', () async { final source = _CappedRadarRepository(_ids(9))..sourceMaxZoom = 8; - final layer = RadarMapLayer(source); + final layer = RadarMapLayer(source, testReferenceOutline()); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); diff --git a/test/features/map/raster_timeline_harness.dart b/test/features/map/raster_timeline_harness.dart index 964a792df..030f6ad44 100644 --- a/test/features/map/raster_timeline_harness.dart +++ b/test/features/map/raster_timeline_harness.dart @@ -4,11 +4,19 @@ library; import 'dart:math' show Point; 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; import 'package:dpip/shared/map/raster_frame_source.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +/// A fresh, in-memory-backed [MapReferenceOutlineController] — every raster +/// layer test that needs one constructs its own, so a toggle in one test +/// never leaks into another. +MapReferenceOutlineController testReferenceOutline() => + MapReferenceOutlineController(SettingsStore.inMemory({})); + /// A [RasterFrameSource] that records the tile-memory calls a layer makes. /// /// Those calls are the contract that keeps a scrub cheap — which frames were diff --git a/test/features/map/satellite_layer_test.dart b/test/features/map/satellite_layer_test.dart index 0d42e74cb..a50e27c03 100644 --- a/test/features/map/satellite_layer_test.dart +++ b/test/features/map/satellite_layer_test.dart @@ -23,6 +23,7 @@ void main() { final layer = SatelliteMapLayer( _FakeSatelliteRepository(['1700000600', '1700000000']), channel: SatelliteChannel.irClean, + referenceOutline: testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; expect(frames.map((f) => f.id), ['1700000000', '1700000600']); @@ -32,6 +33,7 @@ void main() { final layer = SatelliteMapLayer( _FakeSatelliteRepository(_ids(5)), channel: SatelliteChannel.irClean, + referenceOutline: testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -48,6 +50,7 @@ void main() { final layer = SatelliteMapLayer( _FakeSatelliteRepository(_ids(9)), channel: SatelliteChannel.irClean, + referenceOutline: testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -78,6 +81,7 @@ void main() { final layer = SatelliteMapLayer( _FakeSatelliteRepository(_ids(5)), channel: SatelliteChannel.irClean, + referenceOutline: testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -115,6 +119,7 @@ void main() { final layer = SatelliteMapLayer( _FakeSatelliteRepository(_ids(5)), channel: SatelliteChannel.irClean, + referenceOutline: testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -145,7 +150,11 @@ void main() { test('clear releases tiles', () async { final source = _FakeSatelliteRepository(_ids(5)); - final layer = SatelliteMapLayer(source, channel: SatelliteChannel.irClean); + final layer = SatelliteMapLayer( + source, + channel: SatelliteChannel.irClean, + referenceOutline: testReferenceOutline(), + ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); diff --git a/test/features/map/satellite_style_menu_test.dart b/test/features/map/satellite_style_menu_test.dart index 30a4b2aa1..82ad6b341 100644 --- a/test/features/map/satellite_style_menu_test.dart +++ b/test/features/map/satellite_style_menu_test.dart @@ -53,6 +53,7 @@ void main() { final layer = SatelliteMapLayer( _FakeSatelliteRepository(), channel: SatelliteChannel.irClean, + referenceOutline: testReferenceOutline(), ); await tester.pumpWidget( wrap( @@ -87,6 +88,7 @@ void main() { final layer = SatelliteMapLayer( repository, channel: SatelliteChannel.irClean, + referenceOutline: testReferenceOutline(), ); var reloads = 0; await tester.pumpWidget( @@ -123,6 +125,7 @@ void main() { final layer = SatelliteMapLayer( _FakeSatelliteRepository(), channel: SatelliteChannel.truecolor, + referenceOutline: testReferenceOutline(), ); await tester.pumpWidget(const MaterialApp(home: Scaffold())); final chrome = layer.buildTopTrailingChrome( @@ -143,6 +146,7 @@ void main() { final layer = SatelliteMapLayer( _FakeSatelliteRepository(), channel: SatelliteChannel.visibleBlue, + referenceOutline: testReferenceOutline(), ); await tester.pumpWidget(const MaterialApp(home: Scaffold())); final chrome = layer.buildTopTrailingChrome( @@ -162,6 +166,7 @@ void main() { final layer = SatelliteMapLayer( _FakeSatelliteRepository(), channel: SatelliteChannel.irLong, // B14 — thermal + referenceOutline: testReferenceOutline(), ); await tester.pumpWidget(const MaterialApp(home: Scaffold())); final chrome = layer.buildTopTrailingChrome( @@ -183,6 +188,7 @@ void main() { final layer = SatelliteMapLayer( _FakeSatelliteRepository(), channel: SatelliteChannel.visibleBlue, // B01 — grayscale only + referenceOutline: testReferenceOutline(), ); var reloads = 0; layer.setStyle(SatelliteStyle.jma, onReloadActive: () async => reloads++); diff --git a/test/features/map/wind_forecast_layer_test.dart b/test/features/map/wind_forecast_layer_test.dart index 0016a98e7..2b785e700 100644 --- a/test/features/map/wind_forecast_layer_test.dart +++ b/test/features/map/wind_forecast_layer_test.dart @@ -100,6 +100,7 @@ void main() { final layer = WindForecastMapLayer( _FakeWindRepository(['1700000600', '1700000000']), model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; expect(frames.map((f) => f.id), ['1700000000', '1700000600']); @@ -109,6 +110,7 @@ void main() { final gfs = WindForecastMapLayer( _FakeWindRepository(const []), model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), ); expect(gfs.id, 'wind-gfs'); expect(gfs.icon, Icons.air); @@ -117,6 +119,7 @@ void main() { final ecmwf = WindForecastMapLayer( _FakeWindRepository(const []), model: WindForecastModel.ecmwf, + referenceOutline: testReferenceOutline(), ); expect(ecmwf.id, 'wind-ecmwf'); expect(WindForecastModel.ecmwf.subtitle, '0.25° · 3 h'); @@ -133,6 +136,7 @@ void main() { final layer = WindForecastMapLayer( _FakeWindRepository(_ids(5)), model: WindForecastModel.ecmwf, + referenceOutline: testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -169,6 +173,7 @@ void main() { final layer = WindForecastMapLayer( _FakeWindRepository(_ids(5)), model: WindForecastModel.ecmwf, + referenceOutline: testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -193,6 +198,7 @@ void main() { final layer = WindForecastMapLayer( _FakeWindRepository(_ids(5)), model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -217,7 +223,11 @@ void main() { test('clear releases tiles', () async { final source = _FakeWindRepository(_ids(5)); - final layer = WindForecastMapLayer(source, model: WindForecastModel.gfs); + final layer = WindForecastMapLayer( + source, + model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), + ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -232,6 +242,7 @@ void main() { final layer = WindForecastMapLayer( _FakeWindRepository(_ids(5)), model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -254,7 +265,11 @@ void main() { 'scrubbing clears particles and fetches only the settled frame', () async { final source = _ControlledWindRepository(_ids(3)); - final layer = WindForecastMapLayer(source, model: WindForecastModel.gfs); + final layer = WindForecastMapLayer( + source, + model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), + ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -282,7 +297,11 @@ void main() { test('a late old WND1 response cannot overwrite the latest frame', () async { final source = _ControlledWindRepository(_ids(3)); - final layer = WindForecastMapLayer(source, model: WindForecastModel.gfs); + final layer = WindForecastMapLayer( + source, + model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), + ); final frames = (await layer.frames()).valueOrNull!; final controller = RecordingMapController(); @@ -310,6 +329,7 @@ void main() { final layer = WindForecastMapLayer( _FakeWindRepository(const []), model: WindForecastModel.ecmwf, + referenceOutline: testReferenceOutline(), ); final overlay = layer.buildMapOverlay( tester.element(find.byType(Scaffold)), @@ -323,6 +343,7 @@ void main() { final layer = WindForecastMapLayer( _FakeWindRepository(const []), model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), ); // The harness camera is zoom 7 over Taiwan, so a seeded particle must sit // inside the viewport and the ticker has somewhere to streak it. @@ -376,6 +397,7 @@ void main() { final layer = WindForecastMapLayer( _FakeWindRepository(const []), model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), ); await layer.onAttached(RecordingMapController()); layer.field.value = WindField( @@ -462,6 +484,7 @@ void main() { final layer = WindForecastMapLayer( _FakeWindRepository(const []), model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), ); await layer.onAttached(RecordingMapController()); @@ -538,6 +561,7 @@ void main() { final layer = WindForecastMapLayer( _FakeWindRepository(const []), model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), ); await tester.pumpWidget( const MaterialApp( diff --git a/test/features/map/wind_overlay_resilience_test.dart b/test/features/map/wind_overlay_resilience_test.dart index 15bf97ec7..1fdc437c2 100644 --- a/test/features/map/wind_overlay_resilience_test.dart +++ b/test/features/map/wind_overlay_resilience_test.dart @@ -89,6 +89,7 @@ Future<(WindForecastMapLayer, _FlakyController)> _mount( final layer = WindForecastMapLayer( _Repo(['1700000000']), model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), ); final controller = _FlakyController(); final frames = (await layer.frames()).valueOrNull!; @@ -265,6 +266,7 @@ void main() { final wind = WindForecastMapLayer( _Repo(const ['1']), model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), ); expect(wind.overlayFollowsCamera, isFalse); }); @@ -381,6 +383,7 @@ void main() { final layer = WindForecastMapLayer( _Repo(const ['1']), model: WindForecastModel.gfs, + referenceOutline: testReferenceOutline(), ); expect(layer.interacting.value, isFalse); layer.onMapGestureStart(); diff --git a/test/shared/map/map_gsi_overlay_test.dart b/test/shared/map/map_gsi_overlay_test.dart index c095abf33..e768b6c70 100644 --- a/test/shared/map/map_gsi_overlay_test.dart +++ b/test/shared/map/map_gsi_overlay_test.dart @@ -1,5 +1,7 @@ import 'package:dpip/core/network/api_paths.dart'; 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:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -87,7 +89,7 @@ void main() { }); test('controller starts cold and retains per-group choices', () { - final controller = GsiOverlayController(); + final controller = GsiOverlayController(SettingsStore.inMemory({})); addTearDown(controller.dispose); expect(controller.enabled, isFalse); @@ -115,9 +117,54 @@ void main() { expect(controller.revision, 3); }); + test( + 'a choice persists — a fresh controller on the same store reads it back', + () { + final settings = SettingsStore.inMemory({}); + final first = GsiOverlayController(settings); + addTearDown(first.dispose); + first.setEnabled(true); + first.setGroupEnabled(GsiLayerGroup.parks, true); + first.setGroupEnabled(GsiLayerGroup.buildings, false); + + final second = GsiOverlayController(settings); + addTearDown(second.dispose); + + expect(second.enabled, isTrue); + expect(second.groupEnabled(GsiLayerGroup.parks), isTrue); + expect(second.groupEnabled(GsiLayerGroup.buildings), isFalse); + }, + ); + + test( + "forceEnabled starts the surface on without overwriting a saved 'off'", + () { + final settings = SettingsStore.inMemory({'map.gsiEnabled': false}); + final controller = GsiOverlayController(settings, forceEnabled: true); + addTearDown(controller.dispose); + + expect(controller.enabled, isTrue); + expect(settings.getBool(SettingKeys.mapGsiEnabled), isFalse); + }, + ); + + test('a stale saved group name is dropped instead of crashing', () { + final settings = SettingsStore.inMemory({ + 'map.gsiEnabledGroups': ['roads', 'no-longer-a-group'], + }); + final controller = GsiOverlayController(settings); + addTearDown(controller.dispose); + + expect(controller.enabledGroupCount, 1); + expect(controller.groupEnabled(GsiLayerGroup.roads), isTrue); + }); + test('enabling OSM synchronously turns mutually-exclusive terrain off', () { final terrain = ValueNotifier(true); - final controller = GsiOverlayController(mutuallyExclusiveTerrain: terrain); + final controller = GsiOverlayController( + SettingsStore.inMemory({}), + mutuallyExclusiveTerrain: terrain, + ); addTearDown(terrain.dispose); addTearDown(controller.dispose); @@ -130,8 +177,9 @@ void main() { test('an OSM-first surface starts enabled with terrain off', () { final terrain = ValueNotifier(true); final controller = GsiOverlayController( + SettingsStore.inMemory({}), mutuallyExclusiveTerrain: terrain, - initiallyEnabled: true, + forceEnabled: true, ); addTearDown(terrain.dispose); addTearDown(controller.dispose); @@ -142,7 +190,7 @@ void main() { test('native add, grouped visibility, and removal stay coherent', () async { final map = RecordingMapController(); - final selection = GsiOverlayController(); + final selection = GsiOverlayController(SettingsStore.inMemory({})); addTearDown(selection.dispose); await addGsiOverlay( diff --git a/test/shared/map/map_town_labels_test.dart b/test/shared/map/map_town_labels_test.dart index 932551834..511fc3cd9 100644 --- a/test/shared/map/map_town_labels_test.dart +++ b/test/shared/map/map_town_labels_test.dart @@ -1,3 +1,4 @@ +import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_gsi_overlay.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; @@ -18,7 +19,7 @@ Widget _wrap( supportedLocales: AppLocalizations.supportedLocales, locale: const Locale('en'), home: GsiOverlayScope( - controller: gsi ?? GsiOverlayController(), + controller: gsi ?? GsiOverlayController(SettingsStore.inMemory({})), child: Scaffold( body: Align( alignment: Alignment.topRight, @@ -150,7 +151,7 @@ void main() { testWidgets('the detailed map exposes all documented layer groups', ( tester, ) async { - final gsi = GsiOverlayController(); + final gsi = GsiOverlayController(SettingsStore.inMemory({})); await tester.pumpWidget(_wrap(ValueNotifier(true), gsi: gsi)); final l10n = await _l10n();