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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified assets/map/town_boundaries.bin.gz
Binary file not shown.
Binary file modified assets/map/town_boundaries.json.gz
Binary file not shown.
12 changes: 11 additions & 1 deletion lib/app/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<MapCameraHandoff>();
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"
Expand Down
60 changes: 50 additions & 10 deletions lib/app/router/notification_routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> 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, {
Expand All @@ -29,6 +36,7 @@ void routeNotificationTap(
NotificationTap tap, {
NotificationRouteNavigator? navigate,
NotificationUrlLauncher? launch,
NotificationMapFocus? focusMapLayer,
}) {
final go = navigate ?? appRouter.goNamed;
Log.info(
Expand All @@ -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)',
);
Expand All @@ -77,6 +93,28 @@ const Map<String, String> 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<String, String> 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';

Expand Down Expand Up @@ -155,16 +193,18 @@ const Map<String, String> 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<String, String> 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
Expand Down
9 changes: 8 additions & 1 deletion lib/features/map/presentation/layers/rts_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,15 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer {
'features': <dynamic>[],
};

/// 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;
Expand Down
1 change: 1 addition & 0 deletions lib/shared/map/town_label_points.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const Map<String, (double, double)> 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),
Expand Down
65 changes: 58 additions & 7 deletions test/app/router/notification_routes_test.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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,
Expand All @@ -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', () {
Expand Down Expand Up @@ -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', () {
Expand Down Expand Up @@ -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,
Expand All @@ -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 = <String>[];
void record(
String name, {
Map<String, String> pathParameters = const {},
Map<String, dynamic> 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 = <String>[];
routeNotificationTap(
const NotificationTap(channelKey: 'report-general-v2'),
navigate: (
name, {
Map<String, String> pathParameters = const {},
Map<String, dynamic> 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);
}
});
}
17 changes: 10 additions & 7 deletions test/shared/map/town_label_points_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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',
);
},
Expand Down
Loading