diff --git a/assets/map/town_boundaries.bin.gz b/assets/map/town_boundaries.bin.gz index 39025fe10..93a0e6f66 100644 Binary files a/assets/map/town_boundaries.bin.gz and b/assets/map/town_boundaries.bin.gz differ diff --git a/assets/map/town_boundaries.json.gz b/assets/map/town_boundaries.json.gz index 17947def4..2227b7f0d 100644 Binary files a/assets/map/town_boundaries.json.gz and b/assets/map/town_boundaries.json.gz differ diff --git a/lib/app/app.dart b/lib/app/app.dart index a88bb6ba5..7fa33624d 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -21,6 +21,8 @@ import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/settings/color_vision_controller.dart'; import 'package:dpip/core/settings/display_settings.dart'; import 'package:dpip/core/settings/theme_controller.dart'; +import 'package:dpip/shared/map/base_map.dart'; +import 'package:dpip/shared/map/map_camera_handoff.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:provider/single_child_widget.dart'; @@ -172,7 +174,15 @@ class _AppServicesHostState extends State<_AppServicesHost> super.initState(); _observer = RealtimeLifecycleObserver(widget.realtimeService); WidgetsBinding.instance.addObserver(this); - NotificationTaps.onTap = routeNotificationTap; + // The map tab keeps whichever overlay the session left it on, so an EEW + // tap has to name 強震監視器 as well as the route. Same hand-off the home + // monitor banner and the nav bar use, so the map has one way in. + final mapCamera = context.read(); + NotificationTaps.onTap = (tap) => routeNotificationTap( + tap, + focusMapLayer: (layerId) => + mapCamera.request(BaseMap.taiwanBounds, layerId: layerId), + ); widget.onboarding.addListener(_onOnboardingChanged); WidgetsBinding.instance.addPostFrameCallback((_) { // INFO, not DEBUG: this is the number anyone asking "why is launch slow" diff --git a/lib/app/router/notification_routes.dart b/lib/app/router/notification_routes.dart index e983d7111..3edad806f 100644 --- a/lib/app/router/notification_routes.dart +++ b/lib/app/router/notification_routes.dart @@ -9,6 +9,13 @@ import 'package:url_launcher/url_launcher.dart'; /// Opens a URL outside the app. Injectable so tests never reach the browser. typedef NotificationUrlLauncher = Future Function(Uri url); +/// Asks the map tab to open on a given `MapLayer.id` when it next appears. +/// +/// Injected rather than reached for: this function has no `BuildContext`, and +/// the hand-off it drives lives in the widget tree. `app.dart` supplies the +/// real one; a test supplies a recorder. +typedef NotificationMapFocus = void Function(String layerId); + /// The slice of the router a notification tap needs — [GoRouter.goNamed]. typedef NotificationRouteNavigator = void Function( String name, { @@ -29,6 +36,7 @@ void routeNotificationTap( NotificationTap tap, { NotificationRouteNavigator? navigate, NotificationUrlLauncher? launch, + NotificationMapFocus? focusMapLayer, }) { final go = navigate ?? appRouter.goNamed; Log.info( @@ -55,6 +63,14 @@ void routeNotificationTap( final reason = notificationChannelDetailRoutes.containsKey(tap.channelKey) ? 'no $notificationTargetKey in payload' : 'channel has no detail route'; + // Before the navigation, not after: the map consumes the pending overlay on + // the first frame it is ready, and a request queued after that frame waits + // for the *next* time the tab opens. + final layerId = notificationChannelMapLayers[tap.channelKey]; + if (layerId != null) { + Log.info('Notification tap: channel=${tap.channelKey} -> layer=$layerId'); + focusMapLayer?.call(layerId); + } Log.info( 'Notification tap: channel=${tap.channelKey} -> route=$route ($reason)', ); @@ -77,6 +93,28 @@ const Map notificationChannelDetailRoutes = { 'report-silence-v2': AppRoutes.earthquakeReport, }; +/// Channels whose destination is a specific map overlay, and its `MapLayer.id`. +/// +/// The map tab is one route for fourteen overlays, and it keeps whichever one +/// the session was last on. So a route is not a destination here — an EEW tap +/// that arrives while the user was reading radar would open radar. The overlay +/// is handed over the same way every other in-app "open this on the map" does +/// (`MapCameraHandoff`), as a one-shot the map consumes when it is ready. +const Map notificationChannelMapLayers = { + 'eew_alert-important-v2': monitorMapLayerId, + 'eew_alert-general-v2': monitorMapLayerId, + 'eew_alert-silent-v2': monitorMapLayerId, + 'eew-important-v2': monitorMapLayerId, + 'eew-general-v2': monitorMapLayerId, + 'eew-silence-v2': monitorMapLayerId, + 'eq-v2': monitorMapLayerId, +}; + +/// `RtsMapLayer.id` — 強震監視器. A literal because `app/router` must not import +/// a feature's presentation layer; `notification_routes_test` pins the two +/// together. +const String monitorMapLayerId = 'monitor'; + /// The payload key naming the item a tap should open. const String notificationTargetKey = 'reportId'; @@ -155,16 +193,18 @@ const Map notificationChannelUrls = { /// Grouped by subject for reading only — the lookup is exact, so order and /// grouping carry no meaning and no prefix can shadow another. const Map notificationChannelRoutes = { - // 地震速報 — the live monitor, where the countdown and the shaking are. - 'eew_alert-important-v2': AppRoutes.eew, - 'eew_alert-general-v2': AppRoutes.eew, - 'eew_alert-silent-v2': AppRoutes.eew, - 'eew-important-v2': AppRoutes.eew, - 'eew-general-v2': AppRoutes.eew, - 'eew-silence-v2': AppRoutes.eew, - 'eq-v2': AppRoutes.eew, - 'int_report-general-v2': AppRoutes.eew, // 需要 ID - 'int_report-silence-v2': AppRoutes.eew, // 需要 ID + // 地震速報 — 強震監視器 on the map, where the countdown, the wavefront and + // the shaking all are. The overlay comes from [notificationChannelMapLayers]; + // the route alone would land on whichever layer the session last used. + 'eew_alert-important-v2': AppRoutes.map, + 'eew_alert-general-v2': AppRoutes.map, + 'eew_alert-silent-v2': AppRoutes.map, + 'eew-important-v2': AppRoutes.map, + 'eew-general-v2': AppRoutes.map, + 'eew-silence-v2': AppRoutes.map, + 'eq-v2': AppRoutes.map, + 'int_report-general-v2': AppRoutes.home, // 需要 ID + 'int_report-silence-v2': AppRoutes.home, // 需要 ID // 地震 — the report list. Detail-by-id comes later; the tap already carries // the id, so that is a change to `routeNotificationTap`, not to this table. 'report-general-v2': AppRoutes.earthquake, // 需要 ID diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index 3565c3d59..247a199a0 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -278,8 +278,15 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { 'features': [], }; + /// This layer's `MapLayer.id` — 強震監視器. + /// + /// A constant as well as the getter because a caller outside the map needs + /// it without an instance: an EEW notification tap names this overlay when + /// it opens the map tab (`notificationChannelMapLayers`). + static const String layerId = 'monitor'; + @override - String get id => 'monitor'; + String get id => layerId; @override IconData get icon => Icons.sensors_outlined; diff --git a/lib/shared/map/town_label_points.g.dart b/lib/shared/map/town_label_points.g.dart index dc49875a6..7d132ac18 100644 --- a/lib/shared/map/town_label_points.g.dart +++ b/lib/shared/map/town_label_points.g.dart @@ -80,6 +80,7 @@ const Map townLabelPoints = { '306': (24.79081, 121.17018), '307': (24.74870, 121.12303), '308': (24.74315, 120.99660), + '309': (24.76917, 120.92875), '310': (24.77074, 121.04901), '311': (24.56461, 121.13490), '312': (24.69800, 121.13456), diff --git a/test/app/router/notification_routes_test.dart b/test/app/router/notification_routes_test.dart index 026516605..947aa716f 100644 --- a/test/app/router/notification_routes_test.dart +++ b/test/app/router/notification_routes_test.dart @@ -1,6 +1,7 @@ import 'package:dpip/app/router/notification_routes.dart'; import 'package:dpip/core/notifications/notification_channels.dart'; import 'package:dpip/core/notifications/notification_tap.dart'; +import 'package:dpip/features/map/presentation/layers/rts_layer.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -65,14 +66,15 @@ void main() { }); test('routes each family to the expected screen', () { + // 地震速報 opens 強震監視器, which is an overlay on the map tab rather than a + // screen of its own — see the map-layer test below for the other half. expect( routeForNotificationChannel('eew_alert-important-v2'), - AppRoutes.eew, + AppRoutes.map, ); // The intra-family split: 震度速報 is a live broadcast and opens the // monitor, while a 地震報告 is a finished record and opens the list. - expect(routeForNotificationChannel('eq-v2'), AppRoutes.eew); - expect(routeForNotificationChannel('int_report-general-v2'), AppRoutes.eew); + expect(routeForNotificationChannel('eq-v2'), AppRoutes.map); expect( routeForNotificationChannel('report-general-v2'), AppRoutes.earthquake, @@ -94,7 +96,7 @@ void main() { channelKey: 'eew_alert-important-v2', ); expect(tap.channelKey, 'eew_alert-important-v2'); - expect(routeForNotificationChannel(tap.channelKey), AppRoutes.eew); + expect(routeForNotificationChannel(tap.channelKey), AppRoutes.map); }); test('a locally displayed tap still routes off its payload', () { @@ -128,7 +130,7 @@ void main() { }, channelKey: 'int_report-general-v2'); expect(tap.channelKey, 'int_report-general-v2'); expect(tap.id, '1897213924'); - expect(routeForNotificationChannel(tap.channelKey), AppRoutes.eew); + expect(routeForNotificationChannel(tap.channelKey), AppRoutes.home); }); test('a malformed content string still routes', () { @@ -362,7 +364,7 @@ void main() { named.add(name); } - // EEW channel → the live monitor tab. + // EEW channel → the map tab, where 強震監視器 lives. routeNotificationTap( const NotificationTap(channelKey: 'eew_alert-important-v2'), navigate: record, @@ -378,6 +380,55 @@ void main() { navigate: record, ); - expect(named, [AppRoutes.eew, AppRoutes.earthquake, AppRoutes.home]); + expect(named, [AppRoutes.map, AppRoutes.earthquake, AppRoutes.home]); + }); + + // Half of an EEW tap's destination is the route; the other half is which of + // the map's fourteen overlays it lands on. Routing alone would drop the user + // on whatever the session was last looking at. + test('an EEW tap asks the map for 強震監視器, before it navigates', () { + final order = []; + void record( + String name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + String? fragment, + Object? extra, + }) => order.add('go:$name'); + + routeNotificationTap( + const NotificationTap(channelKey: 'eew_alert-important-v2'), + navigate: record, + focusMapLayer: (layerId) => order.add('layer:$layerId'), + ); + + // The map consumes one pending overlay per open, so the request has to be + // waiting before the tab is shown, not queued after it. + expect(order, ['layer:$monitorMapLayerId', 'go:${AppRoutes.map}']); + }); + + test('a channel with no overlay of its own leaves the map alone', () { + final focused = []; + routeNotificationTap( + const NotificationTap(channelKey: 'report-general-v2'), + navigate: ( + name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + String? fragment, + Object? extra, + }) {}, + focusMapLayer: focused.add, + ); + expect(focused, isEmpty); + }); + + // The id is a literal in `app/router` because that layer must not import a + // feature's presentation code. This is what keeps the literal honest. + test('every mapped overlay id is one the map actually offers', () { + expect(RtsMapLayer.layerId, monitorMapLayerId); + for (final id in notificationChannelMapLayers.values) { + expect(id, monitorMapLayerId); + } }); } diff --git a/test/shared/map/town_label_points_test.dart b/test/shared/map/town_label_points_test.dart index 059a32974..78c62c208 100644 --- a/test/shared/map/town_label_points_test.dart +++ b/test/shared/map/town_label_points_test.dart @@ -41,12 +41,15 @@ void main() { /// data does not carry — and by nothing else. A wider gap means someone /// regenerated against stale boundaries. /// - /// Today that is one township, and the gap is not a label problem: - /// 新竹市香山區 has **no polygon at all** in `town_boundaries.json.gz`, and - /// 新竹市北區's polygon covers its ground, so `TownBoundaries.codeAt` answers - /// 北區 for a GPS fix anywhere in 香山. That misroutes township-level alert - /// targeting for everyone there, not just the label, and it can only be fixed - /// in the source data. + /// Today the boundary data carries all 368, so the gap is empty. + /// + /// It was not always: 新竹市香山區 used to have no polygon at all, because the + /// upstream township source gave it 北區's code (300) instead of its own (309) + /// and the by-code keying merged the two. `TownBoundaries.codeAt` therefore + /// answered 北區 for a GPS fix anywhere in 香山, misrouting township-level + /// alert targeting for everyone there. The boundaries were split back apart + /// against the high-resolution source; if this gap ever reopens, suspect the + /// same class of collision rather than the label table. test( 'the table covers every township that has a boundary to place in', () async { @@ -65,7 +68,7 @@ void main() { expect(missing, equals(withoutBoundary)); expect( withoutBoundary, - ['新竹市香山區 (309)'], + isEmpty, reason: 'a new gap here is a boundary-data regression, not a label one', ); },