From 6a793666bf0f72bc75495cce0ad9219816680241 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 2 Nov 2026 12:55:44 +0800 Subject: [PATCH 1/6] feat(map): add an immersive full-screen map mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all New(zh-Hant): 地圖右上角新增沉浸式按鈕,隱藏控制項與導覽列讓地圖佔滿螢幕 New(en-US): an immersive button on the map hides the controls and the navigation bar for a full-screen map --- lib/app/shell/main_shell.dart | 18 +- lib/bootstrap.dart | 2 + lib/features/map/map_providers.dart | 16 ++ lib/l10n/app_en.arb | 8 + lib/l10n/app_fil.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_th.arb | 2 + lib/l10n/app_vi.arb | 2 + lib/l10n/app_yue.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/l10n/app_zh_Hans.arb | 2 + lib/l10n/app_zh_Hant_HK.arb | 2 + lib/l10n/app_zh_TW.arb | 2 + lib/l10n/gen/app_localizations.dart | 12 + lib/l10n/gen/app_localizations_en.dart | 6 + lib/l10n/gen/app_localizations_fil.dart | 6 + lib/l10n/gen/app_localizations_id.dart | 6 + lib/l10n/gen/app_localizations_ja.dart | 6 + lib/l10n/gen/app_localizations_ko.dart | 6 + lib/l10n/gen/app_localizations_th.dart | 6 + lib/l10n/gen/app_localizations_vi.dart | 6 + lib/l10n/gen/app_localizations_yue.dart | 6 + lib/l10n/gen/app_localizations_zh.dart | 24 ++ lib/shared/map/map_immersive.dart | 25 ++ lib/shared/map/map_scaffold.dart | 239 ++++++++++++------ test/app/app_test.dart | 2 + test/app/router/app_router_test.dart | 3 + test/app/shell/main_shell_test.dart | 23 ++ .../map/presentation/map_scaffold_test.dart | 130 +++++++++- .../map/presentation/pages/map_page_test.dart | 4 + 32 files changed, 492 insertions(+), 84 deletions(-) create mode 100644 lib/features/map/map_providers.dart create mode 100644 lib/shared/map/map_immersive.dart diff --git a/lib/app/shell/main_shell.dart b/lib/app/shell/main_shell.dart index 2837faa67..05d39d740 100644 --- a/lib/app/shell/main_shell.dart +++ b/lib/app/shell/main_shell.dart @@ -14,6 +14,7 @@ import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/default_map_layer_ui.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; +import 'package:dpip/shared/map/map_immersive.dart'; import 'package:dpip/shared/map/map_trace.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; @@ -76,6 +77,12 @@ class _MainShellState extends State with RouteAware { final ValueNotifier _navDismiss = ValueNotifier(0); late final HomeSheetExtent _sheetExtent; + /// The map tab's immersive toggle. It dismisses the bar for the same reason + /// a full-screen Home sheet does: the map is meant to own the whole screen, + /// and a navigation strip over it is the one piece of shell chrome the map + /// cannot hide itself. + late final MapImmersiveController _mapImmersive; + /// The launch summary's frame window, prerelease builds only. Started here /// rather than at the app's first frame because the shell is the first /// screen with a map on it — onboarding has none — and it owns the one @@ -88,6 +95,8 @@ class _MainShellState extends State with RouteAware { _trace(() => 'init current=${widget.navigationShell.currentIndex}'); _sheetExtent = context.read() ..addListener(_syncNavDismiss); + _mapImmersive = context.read() + ..addListener(_syncNavDismiss); if (isPrereleaseBuild()) { // Home (its live map backdrop and the monitor banner) and the map tab // are the screens a cheap phone struggles with; a frame drawn over the @@ -105,10 +114,14 @@ class _MainShellState extends State with RouteAware { } } - /// Only Home dismisses the bar; every other tab keeps it (dismiss 0). + /// Home's sheet or an immersive map dismisses the bar; every other tab and + /// every other map state keeps it (dismiss 0). void _syncNavDismiss() { - final dismiss = widget.navigationShell.currentIndex == 0 + final index = widget.navigationShell.currentIndex; + final dismiss = index == 0 ? HomeChrome.navDismiss(_sheetExtent.value) + : index == _mapBranchIndex && _mapImmersive.value + ? 1.0 : 0.0; if (dismiss == _navDismiss.value) return; _navDismiss.value = dismiss; @@ -155,6 +168,7 @@ class _MainShellState extends State with RouteAware { _startupFrames?.stop(); if (_shellRoute != null) shellRouteObserver.unsubscribe(this); _sheetExtent.removeListener(_syncNavDismiss); + _mapImmersive.removeListener(_syncNavDismiss); _navDismiss.dispose(); _visibleTab.dispose(); super.dispose(); diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 7d6a89062..3a2d82b15 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -78,6 +78,7 @@ import 'package:dpip/features/disaster_map/disaster_map_providers.dart'; import 'package:dpip/features/earthquake/earthquake_providers.dart'; import 'package:dpip/features/events/events_providers.dart'; import 'package:dpip/features/home/home_providers.dart'; +import 'package:dpip/features/map/map_providers.dart'; import 'package:dpip/features/meshtastic/meshtastic_providers.dart'; import 'package:dpip/features/notification/notification_providers.dart'; import 'package:dpip/features/sponsor/sponsor_providers.dart'; @@ -516,6 +517,7 @@ Future bootstrap() async { ...earthquakeProviders(deps), ...weatherProviders(deps), ...disasterMapProviders(deps), + ...mapProviders(), ...typhoonProviders(deps), ...tsunamiProviders(deps), ...eventsProviders(deps), diff --git a/lib/features/map/map_providers.dart b/lib/features/map/map_providers.dart new file mode 100644 index 000000000..bcd16739a --- /dev/null +++ b/lib/features/map/map_providers.dart @@ -0,0 +1,16 @@ +/// Provider wiring for the map tab. +library; + +import 'package:dpip/shared/map/map_immersive.dart'; +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; + +/// Map-tab view state shared with the app shell. +/// +/// Only one map surface is on screen at a time, so its immersive toggle is one +/// app-wide value rather than state inside the scaffold: `MainShell` has to +/// read the same value to hide the bottom navigation, and it sits above every +/// branch in the tree. +List mapProviders() => [ + ChangeNotifierProvider(create: (_) => MapImmersiveController()), +]; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d25d04983..44d194eb0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1416,6 +1416,14 @@ }, "homeForecastUnavailable": "Select a township to see the forecast", "mapLayers": "Layers", + "mapImmersive": "Immersive map", + "mapImmersiveExit": "Exit immersive mode", + "@mapImmersive": { + "description": "Layer sheet entry that hides the map controls and the navigation bar" + }, + "@mapImmersiveExit": { + "description": "Tooltip for the floating button that leaves immersive map mode" + }, "@homeRainTrendHeavyStopping": { "description": "Home rain trend subtitle: heavy rain forecast to stop partway through the hour", "placeholders": { diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 1992d4ab9..fcf260506 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -494,6 +494,8 @@ "moreGithub": "ExpTech GitHub", "homeForecastUnavailable": "Pumili ng bayan para makita ang forecast", "mapLayers": "Mga Layer", + "mapImmersive": "Immersive na mapa", + "mapImmersiveExit": "Lumabas sa immersive mode", "meshtasticHardware": "Hardware", "languageSettings": "Wika", "@moonNextFullMoon": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 363d4e242..3270291f3 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -494,6 +494,8 @@ "moreGithub": "ExpTech GitHub", "homeForecastUnavailable": "Pilih wilayah untuk melihat prakiraan", "mapLayers": "Lapisan", + "mapImmersive": "Peta imersif", + "mapImmersiveExit": "Keluar dari mode imersif", "meshtasticHardware": "Perangkat keras", "languageSettings": "Bahasa", "@moonNextFullMoon": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index ccf68e033..4e9f6acae 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -494,6 +494,8 @@ "moreGithub": "ExpTech GitHub", "homeForecastUnavailable": "地域を選ぶと予報を表示します", "mapLayers": "レイヤー", + "mapImmersive": "没入型マップ", + "mapImmersiveExit": "全画面表示を終了", "meshtasticHardware": "ハードウェア", "languageSettings": "言語設定", "@moonNextFullMoon": { diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 3dc77a78d..26451fe29 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -494,6 +494,8 @@ "moreGithub": "ExpTech GitHub", "homeForecastUnavailable": "지역을 선택하면 예보를 볼 수 있습니다", "mapLayers": "레이어", + "mapImmersive": "몰입형 지도", + "mapImmersiveExit": "몰입 모드 종료", "meshtasticHardware": "하드웨어", "languageSettings": "언어", "@moonNextFullMoon": { diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 3e9b8d828..480b90d87 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -494,6 +494,8 @@ "moreGithub": "ExpTech GitHub", "homeForecastUnavailable": "เลือกพื้นที่เพื่อดูพยากรณ์", "mapLayers": "ชั้นข้อมูล", + "mapImmersive": "แผนที่เต็มจอ", + "mapImmersiveExit": "ออกจากโหมดเต็มจอ", "meshtasticHardware": "ฮาร์ดแวร์", "languageSettings": "ภาษา", "@moonNextFullMoon": { diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index ff4215d0f..1517bd744 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -494,6 +494,8 @@ "moreGithub": "ExpTech GitHub", "homeForecastUnavailable": "Chọn khu vực để xem dự báo", "mapLayers": "Lớp bản đồ", + "mapImmersive": "Bản đồ toàn màn hình", + "mapImmersiveExit": "Thoát chế độ toàn màn hình", "meshtasticHardware": "Phần cứng", "languageSettings": "Ngôn ngữ", "@moonNextFullMoon": { diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index 6e56e73e8..3f5987f57 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -496,6 +496,8 @@ "moreGithub": "ExpTech GitHub", "homeForecastUnavailable": "選擇地區後可查看預報", "mapLayers": "圖層", + "mapImmersive": "沉浸式地圖", + "mapImmersiveExit": "結束沉浸式", "meshtasticHardware": "硬體", "languageSettings": "語言設定", "@moonNextFullMoon": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 4cdc24d8e..24ac7023f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -494,6 +494,8 @@ "moreGithub": "ExpTech GitHub", "homeForecastUnavailable": "選擇鄉鎮後可查看預報", "mapLayers": "圖層", + "mapImmersive": "沉浸式地圖", + "mapImmersiveExit": "結束沉浸式", "meshtasticHardware": "硬體", "languageSettings": "語言設定", "@moonNextFullMoon": { diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index d374dbd61..862c11855 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -494,6 +494,8 @@ "moreGithub": "ExpTech GitHub", "homeForecastUnavailable": "选择乡镇后可查看预报", "mapLayers": "图层", + "mapImmersive": "沉浸式地图", + "mapImmersiveExit": "退出沉浸模式", "meshtasticHardware": "硬件", "languageSettings": "语言设置", "@moonNextFullMoon": { diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 4d1e807e8..62c3faffc 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -494,6 +494,8 @@ "moreGithub": "ExpTech GitHub", "homeForecastUnavailable": "選擇地區後可查看預報", "mapLayers": "圖層", + "mapImmersive": "沉浸式地圖", + "mapImmersiveExit": "結束沉浸式", "meshtasticHardware": "硬體", "languageSettings": "語言設定", "@moonNextFullMoon": { diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 3d2463435..6a7fafd07 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -494,6 +494,8 @@ "moreGithub": "ExpTech GitHub", "homeForecastUnavailable": "選擇鄉鎮後可查看預報", "mapLayers": "圖層", + "mapImmersive": "沉浸式地圖", + "mapImmersiveExit": "結束沉浸式", "meshtasticHardware": "硬體", "languageSettings": "語言設定", "@moonNextFullMoon": { diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index cd838b19e..87e387808 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2064,6 +2064,18 @@ abstract class AppLocalizations { /// **'Layers'** String get mapLayers; + /// Layer sheet entry that hides the map controls and the navigation bar + /// + /// In en, this message translates to: + /// **'Immersive map'** + String get mapImmersive; + + /// Tooltip for the floating button that leaves immersive map mode + /// + /// In en, this message translates to: + /// **'Exit immersive mode'** + String get mapImmersiveExit; + /// Board model /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 0b0e36a9f..bdfea644d 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1084,6 +1084,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get mapLayers => 'Layers'; + @override + String get mapImmersive => 'Immersive map'; + + @override + String get mapImmersiveExit => 'Exit immersive mode'; + @override String get meshtasticHardware => 'Hardware'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 5a9248eb9..e265dc490 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -1093,6 +1093,12 @@ class AppLocalizationsFil extends AppLocalizations { @override String get mapLayers => 'Mga Layer'; + @override + String get mapImmersive => 'Immersive na mapa'; + + @override + String get mapImmersiveExit => 'Lumabas sa immersive mode'; + @override String get meshtasticHardware => 'Hardware'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 500d4e64a..191d48917 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1085,6 +1085,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get mapLayers => 'Lapisan'; + @override + String get mapImmersive => 'Peta imersif'; + + @override + String get mapImmersiveExit => 'Keluar dari mode imersif'; + @override String get meshtasticHardware => 'Perangkat keras'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 578f2ffe8..63d9e4f2f 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1063,6 +1063,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get mapLayers => 'レイヤー'; + @override + String get mapImmersive => '没入型マップ'; + + @override + String get mapImmersiveExit => '全画面表示を終了'; + @override String get meshtasticHardware => 'ハードウェア'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index c52d95a72..d56325f37 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -1063,6 +1063,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get mapLayers => '레이어'; + @override + String get mapImmersive => '몰입형 지도'; + + @override + String get mapImmersiveExit => '몰입 모드 종료'; + @override String get meshtasticHardware => '하드웨어'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index dcb9d2621..bc5e89390 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -1079,6 +1079,12 @@ class AppLocalizationsTh extends AppLocalizations { @override String get mapLayers => 'ชั้นข้อมูล'; + @override + String get mapImmersive => 'แผนที่เต็มจอ'; + + @override + String get mapImmersiveExit => 'ออกจากโหมดเต็มจอ'; + @override String get meshtasticHardware => 'ฮาร์ดแวร์'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index ef8494130..7e088df19 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -1082,6 +1082,12 @@ class AppLocalizationsVi extends AppLocalizations { @override String get mapLayers => 'Lớp bản đồ'; + @override + String get mapImmersive => 'Bản đồ toàn màn hình'; + + @override + String get mapImmersiveExit => 'Thoát chế độ toàn màn hình'; + @override String get meshtasticHardware => 'Phần cứng'; diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 5b65778af..e921f2c79 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -1056,6 +1056,12 @@ class AppLocalizationsYue extends AppLocalizations { @override String get mapLayers => '圖層'; + @override + String get mapImmersive => '沉浸式地圖'; + + @override + String get mapImmersiveExit => '結束沉浸式'; + @override String get meshtasticHardware => '硬體'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index a8abe4261..936c285da 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1056,6 +1056,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get mapLayers => '圖層'; + @override + String get mapImmersive => '沉浸式地圖'; + + @override + String get mapImmersiveExit => '結束沉浸式'; + @override String get meshtasticHardware => '硬體'; @@ -4599,6 +4605,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get mapLayers => '图层'; + @override + String get mapImmersive => '沉浸式地图'; + + @override + String get mapImmersiveExit => '退出沉浸模式'; + @override String get meshtasticHardware => '硬件'; @@ -8141,6 +8153,12 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get mapLayers => '圖層'; + @override + String get mapImmersive => '沉浸式地圖'; + + @override + String get mapImmersiveExit => '結束沉浸式'; + @override String get meshtasticHardware => '硬體'; @@ -11684,6 +11702,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get mapLayers => '圖層'; + @override + String get mapImmersive => '沉浸式地圖'; + + @override + String get mapImmersiveExit => '結束沉浸式'; + @override String get meshtasticHardware => '硬體'; diff --git a/lib/shared/map/map_immersive.dart b/lib/shared/map/map_immersive.dart new file mode 100644 index 000000000..d3eb34500 --- /dev/null +++ b/lib/shared/map/map_immersive.dart @@ -0,0 +1,25 @@ +/// The map surface's immersive view mode — chrome hidden, map edge to edge. +library; + +import 'package:flutter/foundation.dart'; + +/// Whether the map tab is in immersive mode. +/// +/// App-wide rather than per-[MapScaffold] state because two widgets on opposite +/// sides of the tree have to agree: the scaffold hides the map chrome, and +/// `MainShell` slides the bottom navigation away so the map really does fill +/// the screen. A [ValueNotifier] so only those two listeners rebuild when it +/// flips, not the platform-view map underneath them. +/// +/// Deliberately not persisted. Immersive is a way of looking at the map right +/// now, not a preference: a launch that restored it would greet the user with +/// a chrome-less map and no visible way out of it. +class MapImmersiveController extends ValueNotifier { + MapImmersiveController() : super(false); + + /// Hides the map chrome and the shell's navigation bar. + void enter() => value = true; + + /// Brings the controls back. + void exit() => value = false; +} diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index 0f67de954..e02574b2c 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -22,6 +22,7 @@ import 'package:dpip/shared/map/map_layer_switcher.dart'; import 'package:dpip/shared/map/map_compass.dart'; import 'package:dpip/shared/map/basemap_overlay_sync.dart'; import 'package:dpip/shared/map/map_gsi_overlay.dart'; +import 'package:dpip/shared/map/map_immersive.dart'; import 'package:dpip/shared/map/map_style.dart'; import 'package:dpip/shared/map/map_timeline.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; @@ -31,6 +32,7 @@ import 'package:dpip/shared/navigation/refresh_on_appear.dart' show VisibleTab, VisibleTabScope; import 'package:dpip/shared/widgets/collapsible_map_legend.dart'; import 'package:dpip/shared/widgets/frosted_surface.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; @@ -123,6 +125,11 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { /// itself was just tapped from. MapLayerVisibilityController? _visibility; + /// The map tab's immersive toggle — chrome hidden, shell nav dismissed. Read + /// through a listener rather than `context.watch` so entering the mode can + /// also drop the measured timeline height (see [_onImmersiveChanged]). + MapImmersiveController? _immersive; + late MapLayer _active = _resolveInitial(widget); static MapLayer _resolveInitial(MapScaffold widget) { @@ -259,6 +266,11 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { _visibility?.removeListener(_onVisibilityChanged); _visibility = visibility..addListener(_onVisibilityChanged); } + final immersive = context.read(); + if (immersive != _immersive) { + _immersive?.removeListener(_onImmersiveChanged); + _immersive = immersive..addListener(_onImmersiveChanged); + } final visibleTab = VisibleTabScope.of(context); if (identical(visibleTab, _visibleTab)) return; _visibleTab?.removeListener(_onTabChanged); @@ -352,6 +364,7 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { _handoff?.removeListener(_onHandoff); _stationHandoff?.removeListener(_onStationHandoff); _visibility?.removeListener(_onVisibilityChanged); + _immersive?.removeListener(_onImmersiveChanged); super.dispose(); } @@ -1262,26 +1275,52 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { if (candidate.id != _active.id) _onLayerSelected(candidate); } + /// Enters/leaves immersive mode. + /// + /// The timeline is not built while immersive, so its last measured height + /// would otherwise keep padding [_bottomInset] and re-frame the next + /// deliberate camera move (a station hand-off, say) as if a scrubber were + /// still there. Drop it on entry; the next build outside immersive measures + /// the real panel again. + void _onImmersiveChanged() { + if (!mounted) return; + setState(() { + if (_immersive?.value ?? false) _timelineHeight = 0; + }); + } + @override Widget build(BuildContext context) { - return Scaffold( - body: GsiOverlayScope( - controller: _gsi, - child: LayoutBuilder( - builder: (context, constraints) { - final measured = constraints.biggest; - if (measured.isFinite) _mapViewSize = measured; - return _body(context); - }, + final immersive = _immersive?.value ?? false; + return PopScope( + // While immersive the first back gesture belongs to the mode, not to the + // app: a user who hid every control must not have to find the exit chip + // to avoid leaving DPIP. A modal sheet over the map still gets the back + // press first, because it is its own route. + canPop: !immersive, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _immersive?.exit(); + }, + child: Scaffold( + body: GsiOverlayScope( + controller: _gsi, + child: LayoutBuilder( + builder: (context, constraints) { + final measured = constraints.biggest; + if (measured.isFinite) _mapViewSize = measured; + return _body(context); + }, + ), ), ), ); } Widget _body(BuildContext context) { + final immersive = _immersive?.value ?? false; // The timeline's height depends on its content, so measure it after every // build; a change re-frames (see [_measureTimeline]). - if (_active.usesTimeline) _measureTimeline(); + if (_active.usesTimeline && !immersive) _measureTimeline(); return Stack( children: [ Positioned.fill( @@ -1350,79 +1389,94 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { ), // Colour / key legend — top-left. Under the sheet so a dragged-up // sheet covers it instead of sitting behind a floating chip. - Positioned( - top: 0, - left: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - CollapsibleMapLegend( - key: ValueKey(_active.id), - legend: _active.buildLegend(context), - ), - ?_active.buildLegendAccessory(context), - ], + if (!immersive) + Positioned( + top: 0, + left: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + CollapsibleMapLegend( + key: ValueKey(_active.id), + legend: _active.buildLegend(context), + ), + ?_active.buildLegendAccessory(context), + ], + ), ), ), ), - ), - // Layer switcher (+ optional layer chrome) — top-right. - Positioned( - top: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Builder( - builder: (context) { - // Layers with a settings menu (radar / QPESUMS / DPM / typhoon - // / rain) receive the shared township-label toggle to carry; - // the rest return [SizedBox.shrink], so show the standalone - // township-label menu instead — every layer can set it. - final chrome = _active.buildTopTrailingChrome( - context, - showTownLabels: _showTownLabels, - onShowTownLabelsChanged: _setShowTownLabels, - showTerrain: _showTerrain, - onShowTerrainChanged: _setShowTerrain, - onReloadActive: _reloadActive, - ); - final hasChrome = chrome is! SizedBox; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (hasChrome) ...[ - chrome, - const SizedBox(width: AppSpacing.sm), - ] else ...[ - MapBasemapMenu( - showTownLabels: _showTownLabels, - onShowTownLabelsChanged: _setShowTownLabels, - showTerrain: _showTerrain, - onShowTerrainChanged: _setShowTerrain, + // Layer switcher (+ optional layer chrome) — top-right, only while the + // controls are shown. Immersive mode's own exit chip paints above the + // sheet at the end of this stack — see there for why. + if (!immersive) + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Builder( + builder: (context) { + // Layers with a settings menu (radar / QPESUMS / DPM / typhoon + // / rain) receive the shared township-label toggle to carry; + // the rest return [SizedBox.shrink], so show the standalone + // township-label menu instead — every layer can set it. + final chrome = _active.buildTopTrailingChrome( + context, + showTownLabels: _showTownLabels, + onShowTownLabelsChanged: _setShowTownLabels, + showTerrain: _showTerrain, + onShowTerrainChanged: _setShowTerrain, + onReloadActive: _reloadActive, + ); + final hasChrome = chrome is! SizedBox; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Immersive toggle, left of the layer's own options and + // the layer selector: it presents the whole map rather + // than filtering what is drawn on it, so it leads the + // row instead of living inside the layer picker. + MapChipButton( + icon: Icons.fullscreen, + tooltip: AppLocalizations.of(context).mapImmersive, + active: false, + onTap: () => _immersive?.enter(), ), const SizedBox(width: AppSpacing.sm), + if (hasChrome) ...[ + chrome, + const SizedBox(width: AppSpacing.sm), + ] else ...[ + MapBasemapMenu( + showTownLabels: _showTownLabels, + onShowTownLabelsChanged: _setShowTownLabels, + showTerrain: _showTerrain, + onShowTerrainChanged: _setShowTerrain, + ), + const SizedBox(width: AppSpacing.sm), + ], + MapLayerSwitcher( + layers: widget.layers, + active: _active, + onSelected: _onLayerSelected, + ), ], - MapLayerSwitcher( - layers: widget.layers, - active: _active, - onSelected: _onLayerSelected, - ), - ], - ); - }, + ); + }, + ), ), ), ), - ), // Sheet / timeline last so they paint above the chrome when expanded. if (!_active.usesTimeline) Positioned.fill(child: _active.buildSheet(context)), - if (_active.usesTimeline) + if (_active.usesTimeline && !immersive) Positioned( left: 0, right: 0, @@ -1462,19 +1516,42 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { // Compass on top of *everything* — a screen-space callout (typhoon // forecast tips) or an expanded sheet must never hide north. Parked // just under the layer switcher chip, with a touch of breathing room. - Positioned( - top: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: EdgeInsets.only( - top: layerChipBand + AppSpacing.sm, - right: AppSpacing.lg, + // Hidden while immersive, where it would be the only chrome left. + if (!immersive) + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: EdgeInsets.only( + top: layerChipBand + AppSpacing.sm, + right: AppSpacing.lg, + ), + child: MapCompass(bearing: _bearing, onPressed: _resetNorth), + ), + ), + ), + // The way out of immersive mode, above *everything* — including the + // layer's own sheet. Those sheets can be dragged to full height (DPM, + // typhoon, tsunami), and one covering the top-right corner would bury + // the only control left: Android's back gesture also leaves the mode, + // but iOS has no equivalent, so a buried chip is a dead end. + if (immersive) + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: MapChipButton( + icon: Icons.fullscreen_exit, + tooltip: AppLocalizations.of(context).mapImmersiveExit, + active: false, + onTap: () => _immersive?.exit(), + ), ), - child: MapCompass(bearing: _bearing, onPressed: _resetNorth), ), ), - ), ], ); } diff --git a/test/app/app_test.dart b/test/app/app_test.dart index 648ff7e7e..d4b1fceb9 100644 --- a/test/app/app_test.dart +++ b/test/app/app_test.dart @@ -54,6 +54,7 @@ import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/core/settings/theme_controller.dart'; import 'package:dpip/core/storage/app_database.dart'; import 'package:dpip/features/onboarding/presentation/pages/onboarding_page.dart'; +import 'package:dpip/features/map/map_providers.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -109,6 +110,7 @@ void main() { deps: deps, providers: [ ...coreProviders(deps), + ...mapProviders(), ChangeNotifierProvider.value(value: handoff), ], ), diff --git a/test/app/router/app_router_test.dart b/test/app/router/app_router_test.dart index 014f7ef2e..7f62c93c6 100644 --- a/test/app/router/app_router_test.dart +++ b/test/app/router/app_router_test.dart @@ -65,6 +65,7 @@ import 'package:dpip/features/events/domain/event_repository.dart'; import 'package:dpip/features/events/events_providers.dart'; import 'package:dpip/features/home/home_providers.dart'; import 'package:dpip/features/home/presentation/pages/home_page.dart'; +import 'package:dpip/features/map/map_providers.dart'; import 'package:dpip/features/home/presentation/home_weather_controller.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; import 'package:dpip/features/weather/domain/rain_hour_trend_repository.dart'; @@ -224,6 +225,7 @@ void main() { ...weatherProviders(deps), Provider.value(value: _Radar()), ...homeProviders(), + ...mapProviders(), _eew(), Provider>.value( value: Future.value( @@ -400,6 +402,7 @@ Widget _widgetRouterApp(SharedDeps deps, _WidgetWeather weather) => ...weatherProviders(deps), Provider.value(value: _Radar()), ...homeProviders(), + ...mapProviders(), ChangeNotifierProvider( create: (_) => HomeWeatherController( weather, diff --git a/test/app/shell/main_shell_test.dart b/test/app/shell/main_shell_test.dart index ea3394e51..141807dc6 100644 --- a/test/app/shell/main_shell_test.dart +++ b/test/app/shell/main_shell_test.dart @@ -25,6 +25,7 @@ import 'package:dpip/features/home/presentation/home_sheet_extent.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/default_map_layer_ui.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; +import 'package:dpip/shared/map/map_immersive.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; import 'package:flutter/material.dart'; @@ -109,6 +110,9 @@ void main() { ), ChangeNotifierProvider.value(value: endpoints), ChangeNotifierProvider.value(value: unread), + ChangeNotifierProvider( + create: (_) => MapImmersiveController(), + ), ], child: MaterialApp.router( locale: const Locale('en'), @@ -144,6 +148,25 @@ void main() { expect(find.text('map-body'), findsOneWidget); expect(handoff.takePending()?.layerId, layers.layer.id); + // An immersive map owns the whole screen, so the shell's bar slides away + // with it and comes back on exit — the map cannot reach the bar itself. + final immersive = Provider.of( + tester.element(find.byType(MainShell)), + listen: false, + ); + immersive.enter(); + await tester.pump(); + expect( + tester.getTopLeft(find.text(layers.layer.label(l10n))).dy, + greaterThan(600), + ); + immersive.exit(); + await tester.pump(); + expect( + tester.getTopLeft(find.text(layers.layer.label(l10n))).dy, + lessThan(600), + ); + unread.recordIncoming(1, 1); endpoints.failure( ApiTier.lbApi, diff --git a/test/features/map/presentation/map_scaffold_test.dart b/test/features/map/presentation/map_scaffold_test.dart index ea4696878..1c04821ff 100644 --- a/test/features/map/presentation/map_scaffold_test.dart +++ b/test/features/map/presentation/map_scaffold_test.dart @@ -15,6 +15,7 @@ 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/map_camera_handoff.dart'; +import 'package:dpip/shared/map/map_immersive.dart'; import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_scaffold.dart'; import 'package:dpip/shared/map/map_station_handoff.dart'; @@ -112,6 +113,7 @@ class _Layer with MapLayerDefaults implements MapLayer { required this.name, this.timeline = true, this.withChrome = false, + this.sheetFills = false, }); @override @@ -120,6 +122,10 @@ class _Layer with MapLayerDefaults implements MapLayer { final bool timeline; final bool withChrome; + /// Whether [buildSheet] is a full-screen, tap-absorbing panel — the state a + /// draggable detail sheet reaches when the user drags it to the top. + final bool sheetFills; + Result> framesResult = const Ok([]); Completer>>? framesGate; int frameCalls = 0; @@ -235,7 +241,13 @@ class _Layer with MapLayerDefaults implements MapLayer { Widget? buildLegendAccessory(BuildContext context) => const Text('Rank'); @override - Widget buildSheet(BuildContext context) => const Text('Sheet body'); + Widget buildSheet(BuildContext context) => sheetFills + ? GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: const SizedBox.expand(), + ) + : const Text('Sheet body'); @override Widget buildMapOverlay(BuildContext context) => const Text('Callout'); @@ -295,6 +307,7 @@ void main() { bool initialOsmEnabled = false, VisibleTab? visibleTab, int? tabIndex, + MapImmersiveController? immersive, }) async { final child = MapScaffold( layers: layers, @@ -311,6 +324,9 @@ void main() { ChangeNotifierProvider.value( value: visibility, ), + ChangeNotifierProvider.value( + value: immersive ?? MapImmersiveController(), + ), ChangeNotifierProvider.value( value: MapLayerOrderController(settings), ), @@ -373,6 +389,118 @@ void main() { expect(echo.shown.length, greaterThan(1)); }); + testWidgets('immersive hides the chrome, and its chip brings it back', ( + tester, + ) async { + final l10n = await english(); + final immersive = MapImmersiveController(); + final echo = _Layer(id: 'echo', name: 'Echo') + ..framesResult = Ok(twoFrames()); + await show( + tester, + settings: SettingsStore.inMemory(), + camera: MapCameraHandoff(), + stations: MapStationHandoff(), + visibility: MapLayerVisibilityController(SettingsStore.inMemory()), + layers: [echo], + immersive: immersive, + ); + await loadStyle(tester); + + expect(find.byTooltip(l10n.mapImmersive), findsOneWidget); + expect(find.byTooltip(l10n.mapLayers), findsOneWidget); + expect(find.text(l10n.mapTimelineObserved), findsOneWidget); + expect(find.byTooltip(l10n.mapLegendExpand), findsOneWidget); + // It is map chrome on the filter/selector row — not inside the layers + // sheet — and it sits to the left of the layer selector. + expect( + tester.getTopLeft(find.byTooltip(l10n.mapImmersive)).dx, + lessThan(tester.getTopLeft(find.byTooltip(l10n.mapLayers)).dx), + ); + + await tester.tap(find.byTooltip(l10n.mapImmersive)); + await tester.pump(); + + expect(find.byTooltip(l10n.mapLayers), findsNothing); + expect(find.text(l10n.mapTimelineObserved), findsNothing); + expect(find.byTooltip(l10n.mapLegendExpand), findsNothing); + expect(find.byTooltip(l10n.mapImmersiveExit), findsOneWidget); + + await tester.tap(find.byTooltip(l10n.mapImmersiveExit)); + await tester.pump(); + + expect(find.byTooltip(l10n.mapImmersive), findsOneWidget); + expect(find.byTooltip(l10n.mapLayers), findsOneWidget); + expect(find.text(l10n.mapTimelineObserved), findsOneWidget); + expect(find.byTooltip(l10n.mapLegendExpand), findsOneWidget); + expect(immersive.value, isFalse); + }); + + testWidgets('the immersive exit stays tappable above a full-height sheet', ( + tester, + ) async { + final l10n = await english(); + final immersive = MapImmersiveController(); + final stations = _Layer( + id: 'stations', + name: 'Stations', + timeline: false, + sheetFills: true, + ); + await show( + tester, + settings: SettingsStore.inMemory(), + camera: MapCameraHandoff(), + stations: MapStationHandoff(), + visibility: MapLayerVisibilityController(SettingsStore.inMemory()), + layers: [stations], + immersive: immersive, + ); + await loadStyle(tester); + + immersive.enter(); + await tester.pump(); + expect(immersive.value, isTrue); + + // The sheet covers the whole map, including the chip's corner. Tapping + // must still reach the chip: it is the only part of the mode that has to + // stay above the layer's own content. + await tester.tap(find.byTooltip(l10n.mapImmersiveExit)); + await tester.pump(); + + expect(immersive.value, isFalse); + }); + + testWidgets('system back leaves immersive before it leaves the app', ( + tester, + ) async { + final l10n = await english(); + final immersive = MapImmersiveController(); + final echo = _Layer(id: 'echo', name: 'Echo') + ..framesResult = Ok(twoFrames()); + await show( + tester, + settings: SettingsStore.inMemory(), + camera: MapCameraHandoff(), + stations: MapStationHandoff(), + visibility: MapLayerVisibilityController(SettingsStore.inMemory()), + layers: [echo], + immersive: immersive, + ); + await loadStyle(tester); + + immersive.enter(); + await tester.pump(); + + await tester.binding.handlePopRoute(); + await tester.pump(); + + // The map route is the app's root: a back press there would otherwise + // leave DPIP. While immersive it exits the mode instead. + expect(immersive.value, isFalse); + expect(find.byTooltip(l10n.mapImmersiveExit), findsNothing); + }); + testWidgets('a failed frame list shows the message and retry reloads', ( tester, ) async { diff --git a/test/features/map/presentation/pages/map_page_test.dart b/test/features/map/presentation/pages/map_page_test.dart index d88285f93..f8bcd2ab0 100644 --- a/test/features/map/presentation/pages/map_page_test.dart +++ b/test/features/map/presentation/pages/map_page_test.dart @@ -46,6 +46,7 @@ import 'package:dpip/features/weather/domain/wind_forecast_model.dart'; import 'package:dpip/features/weather/domain/wind_forecast_repository.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; +import 'package:dpip/shared/map/map_immersive.dart'; import 'package:dpip/shared/map/map_station_handoff.dart'; import 'package:dpip/shared/map/map_tile_cache.dart'; import 'package:flutter/foundation.dart'; @@ -219,6 +220,9 @@ void main() { ChangeNotifierProvider.value( value: visibility, ), + ChangeNotifierProvider( + create: (_) => MapImmersiveController(), + ), ChangeNotifierProvider.value( value: MapLayerOrderController(settings), ), From 592f556f98423cc4564478ef942748ed53153099 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Tue, 3 Nov 2026 01:17:02 +0800 Subject: [PATCH 2/6] fix(map): hide MapLibre's attribution button in the corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Fix(zh-Hant): 地圖右下角的 MapLibre 資訊按鈕已移除 Fix(en-US): the MapLibre info button is gone from the map's bottom-right corner --- lib/shared/map/base_map.dart | 12 ++++++++++ .../map/presentation/map_scaffold_test.dart | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/lib/shared/map/base_map.dart b/lib/shared/map/base_map.dart index 79f9048d6..6f5ee18cd 100644 --- a/lib/shared/map/base_map.dart +++ b/lib/shared/map/base_map.dart @@ -545,6 +545,18 @@ class _BaseMapState extends State with WidgetsBindingObserver { compassEnabled: widget.interactive && widget.compassEnabled, compassViewPosition: CompassViewPosition.topRight, compassViewMargins: Point(AppSpacing.lg, compassTop), + // MapLibre draws its own attribution (i) ornament in the bottom-right + // corner. The plugin exposes no visibility flag, so push it past the + // trailing/bottom edges with negative margins — the platform view clips, + // so it is neither painted nor tappable. iOS also tints it clear as a + // second line of defence; Android treats a clear tint as "keep the + // default", so the margin is the fix there. The attribution text itself + // stays in More → data sources ("© OpenStreetMap contributors"). + // Replace this with an `attributionButtonEnabled: false` option if the + // maplibre_gl fork ever grows one. + attributionButtonPosition: AttributionButtonPosition.bottomRight, + attributionButtonMargins: const Point(-4000, -4000), + attributionButtonColor: Colors.transparent, scrollGesturesEnabled: widget.interactive, zoomGesturesEnabled: widget.interactive, rotateGesturesEnabled: widget.interactive, diff --git a/test/features/map/presentation/map_scaffold_test.dart b/test/features/map/presentation/map_scaffold_test.dart index 1c04821ff..4332ef00b 100644 --- a/test/features/map/presentation/map_scaffold_test.dart +++ b/test/features/map/presentation/map_scaffold_test.dart @@ -501,6 +501,29 @@ void main() { expect(find.byTooltip(l10n.mapImmersiveExit), findsNothing); }); + testWidgets('the MapLibre corner ornament is pushed out of the map', ( + tester, + ) async { + final echo = _Layer(id: 'echo', name: 'Echo') + ..framesResult = Ok(twoFrames()); + await show( + tester, + settings: SettingsStore.inMemory(), + camera: MapCameraHandoff(), + stations: MapStationHandoff(), + visibility: MapLayerVisibilityController(SettingsStore.inMemory()), + layers: [echo], + ); + await loadStyle(tester); + + // The plugin has no hide flag for the (i) ornament; the view is moved off + // the platform view and tinted clear. If this regresses, the button is + // back in the bottom-right corner with no test failing. + final map = tester.widget(find.byType(MapLibreMap)); + expect(map.attributionButtonColor, Colors.transparent); + expect(map.attributionButtonMargins, const Point(-4000, -4000)); + }); + testWidgets('a failed frame list shows the message and retry reloads', ( tester, ) async { From 469a58e2d2d2f2a93a02b2716362c60a9f22cd49 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Tue, 3 Nov 2026 02:07:07 +0800 Subject: [PATCH 3/6] feat(map): animate the immersive transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Optimization(zh-Hant): 進出沉浸式地圖時有轉場動畫,不再瞬間切換 Optimization(en-US): entering and leaving immersive map mode animates instead of snapping --- lib/app/shell/main_shell.dart | 38 +++- lib/shared/map/map_immersive.dart | 10 + lib/shared/map/map_scaffold.dart | 191 ++++++++++++------ test/app/shell/main_shell_test.dart | 26 ++- .../map/presentation/map_scaffold_test.dart | 33 ++- 5 files changed, 215 insertions(+), 83 deletions(-) diff --git a/lib/app/shell/main_shell.dart b/lib/app/shell/main_shell.dart index 05d39d740..7f62c31af 100644 --- a/lib/app/shell/main_shell.dart +++ b/lib/app/shell/main_shell.dart @@ -41,7 +41,8 @@ class MainShell extends StatefulWidget { State createState() => _MainShellState(); } -class _MainShellState extends State with RouteAware { +class _MainShellState extends State + with RouteAware, SingleTickerProviderStateMixin { final int _traceId = nextMapTraceId(); String get _traceScope => 'shell#$_traceId'; @@ -77,6 +78,15 @@ class _MainShellState extends State with RouteAware { final ValueNotifier _navDismiss = ValueNotifier(0); late final HomeSheetExtent _sheetExtent; + /// Drives [_navDismiss] on the immersive edge. Home's sheet writes the same + /// notifier directly on every drag tick — that is a drag, not a transition — + /// so this controller only runs for the map mode's slide. + late final AnimationController _navSlide; + + /// Where the bar is heading, kept apart from the animation's current value so + /// a rebuild mid-transition does not restart it. + double _navTarget = 0; + /// The map tab's immersive toggle. It dismisses the bar for the same reason /// a full-screen Home sheet does: the map is meant to own the whole screen, /// and a navigation strip over it is the one piece of shell chrome the map @@ -93,6 +103,10 @@ class _MainShellState extends State with RouteAware { void initState() { super.initState(); _trace(() => 'init current=${widget.navigationShell.currentIndex}'); + _navSlide = AnimationController( + vsync: this, + duration: mapImmersiveTransition, + )..addListener(() => _navDismiss.value = _navSlide.value); _sheetExtent = context.read() ..addListener(_syncNavDismiss); _mapImmersive = context.read() @@ -115,16 +129,29 @@ class _MainShellState extends State with RouteAware { } /// Home's sheet or an immersive map dismisses the bar; every other tab and - /// every other map state keeps it (dismiss 0). + /// every other map state keeps it (dismiss 0). The immersive edge is + /// animated; Home's sheet is a live drag and is followed exactly. void _syncNavDismiss() { final index = widget.navigationShell.currentIndex; - final dismiss = index == 0 + final target = index == 0 ? HomeChrome.navDismiss(_sheetExtent.value) : index == _mapBranchIndex && _mapImmersive.value ? 1.0 : 0.0; - if (dismiss == _navDismiss.value) return; - _navDismiss.value = dismiss; + if (target == _navTarget) return; + _navTarget = target; + if (index == 0) { + _navSlide.stop(); + _navSlide.value = target; + return; + } + unawaited( + _navSlide.animateTo( + target, + duration: mapImmersiveTransition, + curve: mapImmersiveCurve, + ), + ); } @override @@ -169,6 +196,7 @@ class _MainShellState extends State with RouteAware { if (_shellRoute != null) shellRouteObserver.unsubscribe(this); _sheetExtent.removeListener(_syncNavDismiss); _mapImmersive.removeListener(_syncNavDismiss); + _navSlide.dispose(); _navDismiss.dispose(); _visibleTab.dispose(); super.dispose(); diff --git a/lib/shared/map/map_immersive.dart b/lib/shared/map/map_immersive.dart index d3eb34500..516f05ce1 100644 --- a/lib/shared/map/map_immersive.dart +++ b/lib/shared/map/map_immersive.dart @@ -1,8 +1,18 @@ /// The map surface's immersive view mode — chrome hidden, map edge to edge. library; +import 'package:dpip/app/theme/app_motion.dart'; +import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; +/// How long the map chrome and the shell's navigation bar take to clear or +/// return. One choreography for both surfaces: the bar and the chrome have to +/// move together or the mode reads as two separate changes. +const Duration mapImmersiveTransition = AppMotion.medium; + +/// The easing both surfaces use for that transition. +const Curve mapImmersiveCurve = Curves.easeOutCubic; + /// Whether the map tab is in immersive mode. /// /// App-wide rather than per-[MapScaffold] state because two widgets on opposite diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index e02574b2c..e5a09570a 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -591,8 +591,13 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { /// How much of the map's bottom the active layer's resting chrome hides: the /// layer's own declared share (a collapsed sheet, a status strip) plus the /// timeline, whose real height the scaffold measures because it owns it. + /// + /// The timeline keeps its place in the tree while immersive — it fades out + /// rather than unmounting — so its measured height is still real; it just is + /// not in the way any more. double _bottomInset(Size size) => - size.height * _active.bottomChromeFraction + _timelineHeight; + size.height * _active.bottomChromeFraction + + ((_immersive?.value ?? false) ? 0 : _timelineHeight); /// Measures the timeline panel after layout. /// @@ -1275,18 +1280,46 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { if (candidate.id != _active.id) _onLayerSelected(candidate); } + /// Fades and nudges a block of map chrome as immersive mode toggles. + /// + /// Blocks stay mounted: the timeline carries a [GlobalKey] for measurement, + /// and swapping it out and back mid-transition could duplicate that key on a + /// fast double-tap. Hidden therefore means opacity 0 plus no hit testing, + /// semantics or focus — not absence. + Widget _immersiveChrome({ + required bool hidden, + required Widget child, + Offset slide = const Offset(0, -0.08), + }) { + return IgnorePointer( + ignoring: hidden, + child: ExcludeSemantics( + excluding: hidden, + child: ExcludeFocus( + excluding: hidden, + child: AnimatedSlide( + duration: mapImmersiveTransition, + curve: mapImmersiveCurve, + offset: hidden ? slide : Offset.zero, + child: AnimatedOpacity( + duration: mapImmersiveTransition, + curve: mapImmersiveCurve, + opacity: hidden ? 0 : 1, + child: child, + ), + ), + ), + ), + ); + } + /// Enters/leaves immersive mode. /// - /// The timeline is not built while immersive, so its last measured height - /// would otherwise keep padding [_bottomInset] and re-frame the next - /// deliberate camera move (a station hand-off, say) as if a scrubber were - /// still there. Drop it on entry; the next build outside immersive measures - /// the real panel again. + /// The chrome stays mounted and cross-fades (see [_immersiveChrome]), so the + /// only state this has to rebuild is the target of those animations. void _onImmersiveChanged() { if (!mounted) return; - setState(() { - if (_immersive?.value ?? false) _timelineHeight = 0; - }); + setState(() {}); } @override @@ -1320,7 +1353,7 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { final immersive = _immersive?.value ?? false; // The timeline's height depends on its content, so measure it after every // build; a change re-frames (see [_measureTimeline]). - if (_active.usesTimeline && !immersive) _measureTimeline(); + if (_active.usesTimeline) _measureTimeline(); return Stack( children: [ Positioned.fill( @@ -1389,10 +1422,11 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { ), // Colour / key legend — top-left. Under the sheet so a dragged-up // sheet covers it instead of sitting behind a floating chip. - if (!immersive) - Positioned( - top: 0, - left: 0, + Positioned( + top: 0, + left: 0, + child: _immersiveChrome( + hidden: immersive, child: SafeArea( child: Padding( padding: const EdgeInsets.all(AppSpacing.lg), @@ -1410,13 +1444,15 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { ), ), ), - // Layer switcher (+ optional layer chrome) — top-right, only while the - // controls are shown. Immersive mode's own exit chip paints above the - // sheet at the end of this stack — see there for why. - if (!immersive) - Positioned( - top: 0, - right: 0, + ), + // Layer switcher (+ optional layer chrome) — top-right. Immersive + // mode's own exit chip paints above the sheet at the end of this stack + // — see there for why. + Positioned( + top: 0, + right: 0, + child: _immersiveChrome( + hidden: immersive, child: SafeArea( child: Padding( padding: const EdgeInsets.all(AppSpacing.lg), @@ -1473,54 +1509,61 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { ), ), ), + ), // Sheet / timeline last so they paint above the chrome when expanded. if (!_active.usesTimeline) Positioned.fill(child: _active.buildSheet(context)), - if (_active.usesTimeline && !immersive) + if (_active.usesTimeline) Positioned( left: 0, right: 0, bottom: 0, - child: SafeArea( - key: _timelineKey, - top: false, - child: Stack( - clipBehavior: Clip.none, - children: [ - _frames.isNotEmpty - ? _timelinePanel(context) - : _error != null - ? _errorPanel(context) - : const SizedBox.shrink(), - if (_frames.isNotEmpty) - Positioned( - top: 0, - left: AppSpacing.lg, - right: AppSpacing.lg, - child: IgnorePointer( - child: Transform.translate( - offset: const Offset(0, -AppSpacing.sm), - child: FractionalTranslation( - translation: const Offset(0, -1), - child: MapTimelineScrubPauseNotice( - paused: _scrubBackpressure, + child: _immersiveChrome( + hidden: immersive, + // Slides back down as it clears, then rises on the way back. + slide: const Offset(0, 0.25), + child: SafeArea( + key: _timelineKey, + top: false, + child: Stack( + clipBehavior: Clip.none, + children: [ + _frames.isNotEmpty + ? _timelinePanel(context) + : _error != null + ? _errorPanel(context) + : const SizedBox.shrink(), + if (_frames.isNotEmpty) + Positioned( + top: 0, + left: AppSpacing.lg, + right: AppSpacing.lg, + child: IgnorePointer( + child: Transform.translate( + offset: const Offset(0, -AppSpacing.sm), + child: FractionalTranslation( + translation: const Offset(0, -1), + child: MapTimelineScrubPauseNotice( + paused: _scrubBackpressure, + ), ), ), ), ), - ), - ], + ], + ), ), ), ), // Compass on top of *everything* — a screen-space callout (typhoon // forecast tips) or an expanded sheet must never hide north. Parked // just under the layer switcher chip, with a touch of breathing room. - // Hidden while immersive, where it would be the only chrome left. - if (!immersive) - Positioned( - top: 0, - right: 0, + // It fades out with the rest of the chrome while immersive. + Positioned( + top: 0, + right: 0, + child: _immersiveChrome( + hidden: immersive, child: SafeArea( child: Padding( padding: EdgeInsets.only( @@ -1531,27 +1574,47 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { ), ), ), + ), // The way out of immersive mode, above *everything* — including the // layer's own sheet. Those sheets can be dragged to full height (DPM, // typhoon, tsunami), and one covering the top-right corner would bury // the only control left: Android's back gesture also leaves the mode, // but iOS has no equivalent, so a buried chip is a dead end. - if (immersive) - Positioned( - top: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: MapChipButton( - icon: Icons.fullscreen_exit, - tooltip: AppLocalizations.of(context).mapImmersiveExit, - active: false, - onTap: () => _immersive?.exit(), + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: AnimatedSwitcher( + duration: mapImmersiveTransition, + switchInCurve: mapImmersiveCurve, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween( + begin: 0.88, + end: 1, + ).animate(animation), + child: child, + ), ), + child: immersive + ? MapChipButton( + key: const ValueKey('map-immersive-exit'), + icon: Icons.fullscreen_exit, + tooltip: AppLocalizations.of(context).mapImmersiveExit, + active: false, + onTap: () => _immersive?.exit(), + ) + : const SizedBox.shrink( + key: ValueKey('map-immersive-exit-hidden'), + ), ), ), ), + ), ], ); } diff --git a/test/app/shell/main_shell_test.dart b/test/app/shell/main_shell_test.dart index 141807dc6..f75074630 100644 --- a/test/app/shell/main_shell_test.dart +++ b/test/app/shell/main_shell_test.dart @@ -154,18 +154,28 @@ void main() { tester.element(find.byType(MainShell)), listen: false, ); + final label = find.text(layers.layer.label(l10n)); + final restingY = tester.getTopLeft(label).dy; + immersive.enter(); await tester.pump(); - expect( - tester.getTopLeft(find.text(layers.layer.label(l10n))).dy, - greaterThan(600), - ); + // The first frame starts the transition without moving the bar yet; a + // snap would already be at the bottom here. + expect(tester.getTopLeft(label).dy, closeTo(restingY, 0.01)); + + await tester.pump(const Duration(milliseconds: 110)); + final midway = tester.getTopLeft(label).dy; + expect(midway, greaterThan(restingY)); + + await tester.pump(const Duration(milliseconds: 200)); + final hiddenY = tester.getTopLeft(label).dy; + expect(hiddenY, greaterThan(midway)); + expect(hiddenY, greaterThan(600)); + immersive.exit(); await tester.pump(); - expect( - tester.getTopLeft(find.text(layers.layer.label(l10n))).dy, - lessThan(600), - ); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.getTopLeft(label).dy, lessThan(600)); unread.recordIncoming(1, 1); endpoints.failure( diff --git a/test/features/map/presentation/map_scaffold_test.dart b/test/features/map/presentation/map_scaffold_test.dart index 4332ef00b..8facfeac7 100644 --- a/test/features/map/presentation/map_scaffold_test.dart +++ b/test/features/map/presentation/map_scaffold_test.dart @@ -418,21 +418,41 @@ void main() { lessThan(tester.getTopLeft(find.byTooltip(l10n.mapLayers)).dx), ); + final timelineAtRest = tester + .getTopLeft(find.text(l10n.mapTimelineObserved)) + .dy; await tester.tap(find.byTooltip(l10n.mapImmersive)); await tester.pump(); - expect(find.byTooltip(l10n.mapLayers), findsNothing); - expect(find.text(l10n.mapTimelineObserved), findsNothing); - expect(find.byTooltip(l10n.mapLegendExpand), findsNothing); + // The chrome is still painted on the first frame (it is fading), but it is + // already non-interactive. That is the transition, not a swap. + expect(find.byTooltip(l10n.mapLayers).hitTestable(), findsNothing); + expect(find.text(l10n.mapTimelineObserved).hitTestable(), findsNothing); + expect(find.byTooltip(l10n.mapLegendExpand).hitTestable(), findsNothing); expect(find.byTooltip(l10n.mapImmersiveExit), findsOneWidget); + expect( + tester.getTopLeft(find.text(l10n.mapTimelineObserved)).dy, + closeTo(timelineAtRest, 0.01), + ); + + await tester.pump(const Duration(milliseconds: 110)); + final midway = tester.getTopLeft(find.text(l10n.mapTimelineObserved)).dy; + expect(midway, greaterThan(timelineAtRest)); + await tester.pump(const Duration(milliseconds: 200)); + expect( + tester.getTopLeft(find.text(l10n.mapTimelineObserved)).dy, + greaterThan(midway), + ); await tester.tap(find.byTooltip(l10n.mapImmersiveExit)); await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byTooltip(l10n.mapImmersiveExit), findsNothing); expect(find.byTooltip(l10n.mapImmersive), findsOneWidget); - expect(find.byTooltip(l10n.mapLayers), findsOneWidget); - expect(find.text(l10n.mapTimelineObserved), findsOneWidget); - expect(find.byTooltip(l10n.mapLegendExpand), findsOneWidget); + expect(find.byTooltip(l10n.mapLayers).hitTestable(), findsOneWidget); + expect(find.text(l10n.mapTimelineObserved).hitTestable(), findsOneWidget); + expect(find.byTooltip(l10n.mapLegendExpand).hitTestable(), findsOneWidget); expect(immersive.value, isFalse); }); @@ -494,6 +514,7 @@ void main() { await tester.binding.handlePopRoute(); await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); // The map route is the app's root: a back press there would otherwise // leave DPIP. While immersive it exits the mode instead. From 27814b774e9853d624f73570aa43558e3a1a9d7a Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Tue, 3 Nov 2026 02:21:44 +0800 Subject: [PATCH 4/6] feat(eew): give the earthquake replay an immersive mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all New(zh-Hant): 地震重播也能進入沉浸式模式,隱藏介面並以同樣的轉場顯示地圖 New(en-US): the earthquake replay can enter immersive mode, hiding its chrome with the same transition --- .../pages/report_replay_page.dart | 377 +++++++++++------- lib/shared/map/map_immersive.dart | 15 +- lib/shared/map/map_immersive_chrome.dart | 96 +++++ lib/shared/map/map_scaffold.dart | 70 +--- .../pages/report_replay_page_test.dart | 30 ++ 5 files changed, 372 insertions(+), 216 deletions(-) create mode 100644 lib/shared/map/map_immersive_chrome.dart diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 4e53ad25a..05da16306 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -60,6 +60,8 @@ import 'package:dpip/shared/map/camera_fit.dart'; import 'package:dpip/shared/map/geo_circle.dart'; import 'package:dpip/shared/map/wavefront_cadence.dart'; import 'package:dpip/shared/map/map_compass.dart'; +import 'package:dpip/shared/map/map_immersive.dart'; +import 'package:dpip/shared/map/map_immersive_chrome.dart'; import 'package:dpip/shared/map/map_gsi_overlay.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/map/map_style.dart' @@ -118,6 +120,16 @@ class _ReportReplayPageState extends State { /// advances it through the alert set (modulo the count in the builder). int _eewIndex = 0; + /// Immersive view state, local to this page. The map tab's controller is + /// app-wide because the shell draws its nav bar; a replay is a pushed route + /// with no nav bar to move, and sharing that controller would let a replay + /// pop back into an unexpectedly chrome-less map tab (and vice versa). + final MapImmersiveController _immersive = MapImmersiveController(); + + void _onImmersiveChanged() { + if (mounted) setState(() {}); + } + /// Speaks each new report's estimated intensity while the replay runs, the /// same way the live monitor does — a replay that stayed silent would not be /// a replay of what the user would have heard. @@ -153,6 +165,7 @@ class _ReportReplayPageState extends State { cwaOnly: () => cwaOnly.enabled, )..start(); _session.eew.addListener(_syncAnnouncement); + _immersive.addListener(_onImmersiveChanged); _startTicker(); } @@ -281,158 +294,200 @@ class _ReportReplayPageState extends State { _speechSettings?.removeListener(_syncAnnouncement); _announcement?.dispose(); _clockSecond.dispose(); + _immersive + ..removeListener(_onImmersiveChanged) + ..dispose(); _session.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + final immersive = _immersive.value; return ActiveWhileVisible( tabIndex: ReportListPage.tabIndex, onActiveChanged: _applyActivity, - child: Scaffold( - body: Stack( - children: [ - Positioned.fill( - child: _ReplayMap( - stationRepository: context.read(), - travelTimeTable: context.read>(), - boxGrid: context.read>(), - rts: _session.rts, - alerts: _alerts, - eew: _session.eew, - tick: _tick, - clock: _session.clock, - eewIndex: _eewIndex, + child: PopScope( + // While immersive the first back gesture belongs to the mode, not to + // the route: a user who hid the back chip must not have to find the + // exit chip to avoid leaving the replay. + canPop: !immersive, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _immersive.exit(); + }, + child: Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: _ReplayMap( + stationRepository: context.read(), + travelTimeTable: context + .read>(), + boxGrid: context.read>(), + rts: _session.rts, + alerts: _alerts, + eew: _session.eew, + tick: _tick, + clock: _session.clock, + eewIndex: _eewIndex, + immersive: immersive, + onImmersive: _immersive.enter, + ), ), - ), - Positioned( - top: 0, - left: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Back and legend share one row, and both are the - // shared [MapChipButton] so the whole top edge — back, - // legend, and the base-map chip on the right — is one - // height. A bare [IconButton] back was the tallest thing - // on screen (its padded tap target is 48px) and read as - // a different app's control sitting next to ours. - Row( - mainAxisSize: MainAxisSize.min, + Positioned( + top: 0, + left: 0, + child: MapImmersiveChrome( + hidden: immersive, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MapChipButton( - icon: Icons.arrow_back, - tooltip: MaterialLocalizations.of(context) - .backButtonTooltip, - active: false, - onTap: () => context.pop(), + // Back and legend share one row, and both are the + // shared [MapChipButton] so the whole top edge — back, + // legend, and the base-map chip on the right — is one + // height. A bare [IconButton] back was the tallest thing + // on screen (its padded tap target is 48px) and read as + // a different app's control sitting next to ours. + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MapChipButton( + icon: Icons.arrow_back, + tooltip: MaterialLocalizations.of(context) + .backButtonTooltip, + active: false, + onTap: () => context.pop(), + ), + const SizedBox(width: AppSpacing.sm), + // The replay surface is the 強震監視器 frozen in time — + // the same intensity legend the live monitor carries, + // switching to the EEW felt-scale while an alert is up + // (the legacy monitor did exactly this on active EEW). + // + // Collapsible, and collapsed to a chip to start with, + // exactly as [MapScaffold] mounts every layer's legend: + // this page is a full-screen map too, and an + // eleven-row scale pinned open covers the north-west + // corner of the island for the whole replay. The wrap + // sits *outside* the mode swap on purpose — an alert + // arriving mid-replay changes the scale being shown, + // not whether the user asked to see it. + CollapsibleMapLegend( + legend: ListenableBuilder( + listenable: _session.eew, + builder: (context, _) { + final hasEew = + _session.eew.alerts.hasUncancelled; + return MapLegendCard( + child: IntensityLegend( + mode: hasEew + ? IntensityLegendMode.eew + : IntensityLegendMode.rts, + ), + ); + }, + ), + ), + ], ), - const SizedBox(width: AppSpacing.sm), - // The replay surface is the 強震監視器 frozen in time — - // the same intensity legend the live monitor carries, - // switching to the EEW felt-scale while an alert is up - // (the legacy monitor did exactly this on active EEW). - // - // Collapsible, and collapsed to a chip to start with, - // exactly as [MapScaffold] mounts every layer's legend: - // this page is a full-screen map too, and an - // eleven-row scale pinned open covers the north-west - // corner of the island for the whole replay. The wrap - // sits *outside* the mode swap on purpose — an alert - // arriving mid-replay changes the scale being shown, - // not whether the user asked to see it. - CollapsibleMapLegend( - legend: ListenableBuilder( - listenable: _session.eew, - builder: (context, _) { - final hasEew = - _session.eew.alerts.hasUncancelled; - return MapLegendCard( - child: IntensityLegend( - mode: hasEew - ? IntensityLegendMode.eew - : IntensityLegendMode.rts, - ), - ); - }, - ), + const SizedBox(height: AppSpacing.md), + ListenableBuilder( + listenable: _session.rts, + builder: (context, _) { + final towns = context.read(); + return IntensityRankingCard( + entries: [ + for (final row in _alerts.ranking( + towns.byCode, + )) + (name: row.name, level: row.level), + ], + ); + }, ), ], ), - const SizedBox(height: AppSpacing.md), - ListenableBuilder( - listenable: _session.rts, - builder: (context, _) { - final towns = context.read(); - return IntensityRankingCard( - entries: [ - for (final row in _alerts.ranking(towns.byCode)) - (name: row.name, level: row.level), - ], - ); - }, - ), - ], + ), ), ), ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ListenableBuilder( - listenable: _session.eew, - builder: (context, _) { - final alerts = _session.eew.alerts; - if (alerts.isEmpty) return const SizedBox.shrink(); - // One card at a time — tapping cycles through the - // active alerts (parallel earthquakes, overlapping - // reports) instead of stacking every one on screen. - // The index is clamped by modulo, so a report leaving - // the active set mid-replay can't point past the list. - final index = _eewIndex % alerts.length; - final eew = alerts[index]; - return _EewAlertCard( - eew: eew, - clock: () => _session.clock.now(), - position: index + 1, - count: alerts.length, - onTap: alerts.length > 1 - ? () => setState( - () => _eewIndex = - (_eewIndex + 1) % alerts.length, - ) - : null, - ); - }, - ), - const SizedBox(height: AppSpacing.sm), - _ReplayStatusBar( - clock: _session.clock, - second: _clockSecond, - rts: _session.rts, - eew: _session.eew, + Positioned( + bottom: 0, + left: 0, + right: 0, + child: MapImmersiveChrome( + hidden: immersive, + slide: const Offset(0, 0.25), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListenableBuilder( + listenable: _session.eew, + builder: (context, _) { + final alerts = _session.eew.alerts; + if (alerts.isEmpty) { + return const SizedBox.shrink(); + } + // One card at a time — tapping cycles through the + // active alerts (parallel earthquakes, overlapping + // reports) instead of stacking every one on screen. + // The index is clamped by modulo, so a report leaving + // the active set mid-replay can't point past the list. + final index = _eewIndex % alerts.length; + final eew = alerts[index]; + return _EewAlertCard( + eew: eew, + clock: () => _session.clock.now(), + position: index + 1, + count: alerts.length, + onTap: alerts.length > 1 + ? () => setState( + () => _eewIndex = + (_eewIndex + 1) % alerts.length, + ) + : null, + ); + }, + ), + const SizedBox(height: AppSpacing.sm), + _ReplayStatusBar( + clock: _session.clock, + second: _clockSecond, + rts: _session.rts, + eew: _session.eew, + ), + ], ), - ], + ), ), ), ), - ), - ], + // The one control left in immersive mode, above every card the + // replay can draw — and in the same corner the map tab puts it. + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: MapImmersiveExitChip( + immersive: immersive, + onExit: _immersive.exit, + ), + ), + ), + ), + ], + ), ), ), ); @@ -456,6 +511,8 @@ class _ReplayMap extends StatefulWidget { required this.tick, required this.clock, required this.eewIndex, + required this.immersive, + required this.onImmersive, }); final TremStationRepository stationRepository; @@ -482,6 +539,13 @@ class _ReplayMap extends StatefulWidget { /// showing, not always the newest. final int eewIndex; + /// Whether the replay is in immersive mode. The map hides its own top-right + /// chrome with the rest of the page's. + final bool immersive; + + /// Enters the mode from the map's own chip. + final VoidCallback onImmersive; + @override State<_ReplayMap> createState() => _ReplayMapState(); } @@ -1378,28 +1442,45 @@ class _ReplayMapState extends State<_ReplayMap> { ), ), // Base-map options (OSM detailed map / terrain relief / township - // names) above the compass — the same chrome the 強震監視器 carries in - // the map tab, persisted to the shared settings store. No sheet - // here, so the controls stay up for the whole replay. + // names) and the immersive toggle, above the compass — the same + // chrome the 強震監視器 carries in the map tab, persisted to the + // shared settings store. No sheet here, so the controls stay up + // for the whole replay. Positioned( top: 0, right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - MapBasemapMenu( - showTownLabels: _showTownLabels, - onShowTownLabelsChanged: _setShowTownLabels, - showTerrain: _showTerrain, - onShowTerrainChanged: _setShowTerrain, - ), - const SizedBox(height: AppSpacing.sm), - MapCompass(bearing: _bearing, onPressed: _resetNorth), - ], + child: MapImmersiveChrome( + hidden: widget.immersive, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + MapChipButton( + icon: Icons.fullscreen, + tooltip: AppLocalizations.of(context) + .mapImmersive, + active: false, + onTap: widget.onImmersive, + ), + const SizedBox(width: AppSpacing.sm), + MapBasemapMenu( + showTownLabels: _showTownLabels, + onShowTownLabelsChanged: _setShowTownLabels, + showTerrain: _showTerrain, + onShowTerrainChanged: _setShowTerrain, + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + MapCompass(bearing: _bearing, onPressed: _resetNorth), + ], + ), ), ), ), diff --git a/lib/shared/map/map_immersive.dart b/lib/shared/map/map_immersive.dart index 516f05ce1..9fbf859dc 100644 --- a/lib/shared/map/map_immersive.dart +++ b/lib/shared/map/map_immersive.dart @@ -13,13 +13,16 @@ const Duration mapImmersiveTransition = AppMotion.medium; /// The easing both surfaces use for that transition. const Curve mapImmersiveCurve = Curves.easeOutCubic; -/// Whether the map tab is in immersive mode. +/// Whether a map surface is in immersive mode. /// -/// App-wide rather than per-[MapScaffold] state because two widgets on opposite -/// sides of the tree have to agree: the scaffold hides the map chrome, and -/// `MainShell` slides the bottom navigation away so the map really does fill -/// the screen. A [ValueNotifier] so only those two listeners rebuild when it -/// flips, not the platform-view map underneath them. +/// The map tab's instance is installed app-wide rather than held by the +/// scaffold because two widgets on opposite sides of the tree have to agree: +/// the scaffold hides the map chrome, and `MainShell` slides the bottom +/// navigation away so the map really does fill the screen. A [ValueNotifier] so +/// only those two listeners rebuild when it flips, not the platform-view map +/// underneath them. A full-screen map that owns its own chrome (the earthquake +/// replay) holds a page-scoped instance instead — it has no shell nav bar to +/// move, and must not leave the map tab's mode behind when it pops. /// /// Deliberately not persisted. Immersive is a way of looking at the map right /// now, not a preference: a launch that restored it would greet the user with diff --git a/lib/shared/map/map_immersive_chrome.dart b/lib/shared/map/map_immersive_chrome.dart new file mode 100644 index 000000000..de1b75b2d --- /dev/null +++ b/lib/shared/map/map_immersive_chrome.dart @@ -0,0 +1,96 @@ +/// Shared immersive-mode chrome for the app's full-screen map surfaces. +library; + +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_immersive.dart'; +import 'package:dpip/shared/widgets/map_chip_button.dart'; +import 'package:flutter/material.dart'; + +/// Fades and nudges a block of map chrome as immersive mode toggles. +/// +/// Blocks stay mounted: the map's timeline carries a [GlobalKey] for +/// measurement, and swapping it out and back mid-transition could duplicate +/// that key on a fast double-tap. Hidden therefore means opacity 0 plus no hit +/// testing, semantics or focus — not absence. +class MapImmersiveChrome extends StatelessWidget { + const MapImmersiveChrome({ + super.key, + required this.hidden, + required this.child, + this.slide = const Offset(0, -0.08), + }); + + /// Whether the chrome is on its way out (or already gone). + final bool hidden; + + final Widget child; + + /// Where the block drifts as it fades, as a fraction of its own size. The + /// timeline passes a downward offset; top-edge chrome takes the default lift. + final Offset slide; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + ignoring: hidden, + child: ExcludeSemantics( + excluding: hidden, + child: ExcludeFocus( + excluding: hidden, + child: AnimatedSlide( + duration: mapImmersiveTransition, + curve: mapImmersiveCurve, + offset: hidden ? slide : Offset.zero, + child: AnimatedOpacity( + duration: mapImmersiveTransition, + curve: mapImmersiveCurve, + opacity: hidden ? 0 : 1, + child: child, + ), + ), + ), + ), + ); + } +} + +/// The one control immersive mode leaves on screen: the way back out. +/// +/// Callers paint it above the layer's own sheet — a detail sheet dragged to +/// full height must not bury the only control left, because iOS has no system +/// back gesture to fall back on. +class MapImmersiveExitChip extends StatelessWidget { + const MapImmersiveExitChip({ + super.key, + required this.immersive, + required this.onExit, + }); + + final bool immersive; + final VoidCallback onExit; + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: mapImmersiveTransition, + switchInCurve: mapImmersiveCurve, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: 0.88, end: 1).animate(animation), + child: child, + ), + ), + child: immersive + ? MapChipButton( + key: const ValueKey('map-immersive-exit'), + icon: Icons.fullscreen_exit, + tooltip: AppLocalizations.of(context).mapImmersiveExit, + active: false, + onTap: onExit, + ) + : const SizedBox.shrink(key: ValueKey('map-immersive-exit-hidden')), + ); + } +} diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index e5a09570a..4bf130d77 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -23,6 +23,7 @@ import 'package:dpip/shared/map/map_compass.dart'; import 'package:dpip/shared/map/basemap_overlay_sync.dart'; import 'package:dpip/shared/map/map_gsi_overlay.dart'; import 'package:dpip/shared/map/map_immersive.dart'; +import 'package:dpip/shared/map/map_immersive_chrome.dart'; import 'package:dpip/shared/map/map_style.dart'; import 'package:dpip/shared/map/map_timeline.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; @@ -1280,39 +1281,6 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { if (candidate.id != _active.id) _onLayerSelected(candidate); } - /// Fades and nudges a block of map chrome as immersive mode toggles. - /// - /// Blocks stay mounted: the timeline carries a [GlobalKey] for measurement, - /// and swapping it out and back mid-transition could duplicate that key on a - /// fast double-tap. Hidden therefore means opacity 0 plus no hit testing, - /// semantics or focus — not absence. - Widget _immersiveChrome({ - required bool hidden, - required Widget child, - Offset slide = const Offset(0, -0.08), - }) { - return IgnorePointer( - ignoring: hidden, - child: ExcludeSemantics( - excluding: hidden, - child: ExcludeFocus( - excluding: hidden, - child: AnimatedSlide( - duration: mapImmersiveTransition, - curve: mapImmersiveCurve, - offset: hidden ? slide : Offset.zero, - child: AnimatedOpacity( - duration: mapImmersiveTransition, - curve: mapImmersiveCurve, - opacity: hidden ? 0 : 1, - child: child, - ), - ), - ), - ), - ); - } - /// Enters/leaves immersive mode. /// /// The chrome stays mounted and cross-fades (see [_immersiveChrome]), so the @@ -1425,7 +1393,7 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { Positioned( top: 0, left: 0, - child: _immersiveChrome( + child: MapImmersiveChrome( hidden: immersive, child: SafeArea( child: Padding( @@ -1451,7 +1419,7 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { Positioned( top: 0, right: 0, - child: _immersiveChrome( + child: MapImmersiveChrome( hidden: immersive, child: SafeArea( child: Padding( @@ -1518,7 +1486,7 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { left: 0, right: 0, bottom: 0, - child: _immersiveChrome( + child: MapImmersiveChrome( hidden: immersive, // Slides back down as it clears, then rises on the way back. slide: const Offset(0, 0.25), @@ -1562,7 +1530,7 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { Positioned( top: 0, right: 0, - child: _immersiveChrome( + child: MapImmersiveChrome( hidden: immersive, child: SafeArea( child: Padding( @@ -1586,31 +1554,9 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { child: SafeArea( child: Padding( padding: const EdgeInsets.all(AppSpacing.lg), - child: AnimatedSwitcher( - duration: mapImmersiveTransition, - switchInCurve: mapImmersiveCurve, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: ScaleTransition( - scale: Tween( - begin: 0.88, - end: 1, - ).animate(animation), - child: child, - ), - ), - child: immersive - ? MapChipButton( - key: const ValueKey('map-immersive-exit'), - icon: Icons.fullscreen_exit, - tooltip: AppLocalizations.of(context).mapImmersiveExit, - active: false, - onTap: () => _immersive?.exit(), - ) - : const SizedBox.shrink( - key: ValueKey('map-immersive-exit-hidden'), - ), + child: MapImmersiveExitChip( + immersive: immersive, + onExit: () => _immersive?.exit(), ), ), ), diff --git a/test/features/earthquake/presentation/pages/report_replay_page_test.dart b/test/features/earthquake/presentation/pages/report_replay_page_test.dart index 243a8429f..817a281e3 100644 --- a/test/features/earthquake/presentation/pages/report_replay_page_test.dart +++ b/test/features/earthquake/presentation/pages/report_replay_page_test.dart @@ -630,6 +630,36 @@ void main() { tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pump(); + // The replay carries the same immersive chip as the map tab, and hides + // its own back/legend/ranking and base-map chrome while it is on. + expect(find.byTooltip(l10n.mapImmersive), findsOneWidget); + await tester.tap(find.byTooltip(l10n.mapImmersive)); + await tester.pump(); + expect(find.byIcon(Icons.arrow_back).hitTestable(), findsNothing); + expect(find.byTooltip(l10n.mapLegendExpand).hitTestable(), findsNothing); + expect(find.byTooltip('Township names').hitTestable(), findsNothing); + expect(find.byTooltip(l10n.mapImmersiveExit), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byTooltip(l10n.mapImmersiveExit)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byTooltip(l10n.mapImmersiveExit), findsNothing); + expect(find.byIcon(Icons.arrow_back).hitTestable(), findsOneWidget); + expect( + find.byTooltip(l10n.mapLegendExpand).hitTestable(), + findsOneWidget, + ); + expect(find.byTooltip('Township names').hitTestable(), findsOneWidget); + + // A back press belongs to the mode first: it must not pop the replay. + await tester.tap(find.byTooltip(l10n.mapImmersive)); + await tester.pump(); + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byType(ReportReplayPage), findsOneWidget); + expect(find.byIcon(Icons.arrow_back).hitTestable(), findsOneWidget); + await tester.tap(find.byIcon(Icons.arrow_back)); await tester.pump(); await tester.pump(); From 519f6a5c8cb6b295488e3f2f2e094a163ebf596a Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Tue, 3 Nov 2026 02:53:03 +0800 Subject: [PATCH 5/6] fix(map): drop sheet chrome into the hidden nav bar's space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Fix(zh-Hant): 沉浸式地圖時,強震監視器面板會跟著底部導覽列往下收 Fix(en-US): the monitor panel follows the hidden navigation bar down in immersive mode --- lib/shared/map/map_scaffold.dart | 49 +++++++++- .../map/presentation/map_scaffold_test.dart | 89 ++++++++++++++++--- 2 files changed, 125 insertions(+), 13 deletions(-) diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index 4bf130d77..0137570d1 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -1281,10 +1281,48 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { if (candidate.id != _active.id) _onLayerSelected(candidate); } + /// Drops a sheet layer's bottom chrome toward the space the shell's nav bar + /// leaves behind in immersive mode. + /// + /// `MainShell` extends its body behind the nav bar, so Scaffold adds the + /// bar's height to this subtree's MediaQuery padding. Sliding the bar away + /// does not shrink that padding, which left the 強震監視器 status strip — and + /// every other sheet layer's peek — floating a bar's height above the + /// bottom. Animate the inset away on the same curve as the bar, but stop one + /// spacing token above the system inset: flush against it read as too low + /// next to the home indicator, while the bar's full height read as a gap. + Widget _immersiveSheetInset({ + required bool immersive, + required Widget child, + }) { + return TweenAnimationBuilder( + tween: Tween(end: immersive ? 1 : 0), + duration: mapImmersiveTransition, + curve: mapImmersiveCurve, + builder: (context, progress, child) { + final media = MediaQuery.of(context); + final extra = media.padding.bottom - media.viewPadding.bottom; + if (extra <= 0) return child!; + final settled = media.viewPadding.bottom + AppSpacing.lg; + return MediaQuery( + data: media.copyWith( + padding: media.padding.copyWith( + bottom: + media.padding.bottom + + (settled - media.padding.bottom) * progress, + ), + ), + child: child!, + ); + }, + child: child, + ); + } + /// Enters/leaves immersive mode. /// - /// The chrome stays mounted and cross-fades (see [_immersiveChrome]), so the - /// only state this has to rebuild is the target of those animations. + /// The chrome stays mounted and cross-fades (see [MapImmersiveChrome]), so + /// the only state this has to rebuild is the target of those animations. void _onImmersiveChanged() { if (!mounted) return; setState(() {}); @@ -1480,7 +1518,12 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { ), // Sheet / timeline last so they paint above the chrome when expanded. if (!_active.usesTimeline) - Positioned.fill(child: _active.buildSheet(context)), + Positioned.fill( + child: _immersiveSheetInset( + immersive: immersive, + child: _active.buildSheet(context), + ), + ), if (_active.usesTimeline) Positioned( left: 0, diff --git a/test/features/map/presentation/map_scaffold_test.dart b/test/features/map/presentation/map_scaffold_test.dart index 8facfeac7..9aee4b68e 100644 --- a/test/features/map/presentation/map_scaffold_test.dart +++ b/test/features/map/presentation/map_scaffold_test.dart @@ -114,6 +114,7 @@ class _Layer with MapLayerDefaults implements MapLayer { this.timeline = true, this.withChrome = false, this.sheetFills = false, + this.sheetBottom = false, }); @override @@ -126,6 +127,10 @@ class _Layer with MapLayerDefaults implements MapLayer { /// draggable detail sheet reaches when the user drags it to the top. final bool sheetFills; + /// Whether [buildSheet] is a bottom strip inside a [SafeArea] — the shape the + /// RTS monitor's status bar and every sheet layer's peek use. + final bool sheetBottom; + Result> framesResult = const Ok([]); Completer>>? framesGate; int frameCalls = 0; @@ -241,13 +246,22 @@ class _Layer with MapLayerDefaults implements MapLayer { Widget? buildLegendAccessory(BuildContext context) => const Text('Rank'); @override - Widget buildSheet(BuildContext context) => sheetFills - ? GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () {}, - child: const SizedBox.expand(), - ) - : const Text('Sheet body'); + Widget buildSheet(BuildContext context) { + if (sheetBottom) { + return Align( + alignment: Alignment.bottomCenter, + child: SafeArea(top: false, child: const Text('Sheet bottom')), + ); + } + if (sheetFills) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: const SizedBox.expand(), + ); + } + return const Text('Sheet body'); + } @override Widget buildMapOverlay(BuildContext context) => const Text('Callout'); @@ -296,6 +310,24 @@ void main() { Future english() => AppLocalizations.delegate.load(const Locale('en')); + /// Reproduces the shell's `extendBody` contract: the nav bar's height is part + /// of this subtree's bottom padding, above the system inset. + Widget homeWithInset(Widget home, {required bool navInset}) { + if (!navInset) return home; + return Builder( + builder: (context) { + final media = MediaQuery.of(context); + return MediaQuery( + data: media.copyWith( + padding: media.padding.copyWith(bottom: 80), + viewPadding: media.viewPadding.copyWith(bottom: 24), + ), + child: home, + ); + }, + ); + } + Future show( WidgetTester tester, { required SettingsStore settings, @@ -308,6 +340,7 @@ void main() { VisibleTab? visibleTab, int? tabIndex, MapImmersiveController? immersive, + bool navInset = false, }) async { final child = MapScaffold( layers: layers, @@ -339,9 +372,12 @@ void main() { locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: visibleTab == null - ? child - : VisibleTabScope(visibleTab: visibleTab, child: child), + home: homeWithInset( + visibleTab == null + ? child + : VisibleTabScope(visibleTab: visibleTab, child: child), + navInset: navInset, + ), ), ), ); @@ -545,6 +581,39 @@ void main() { expect(map.attributionButtonMargins, const Point(-4000, -4000)); }); + testWidgets('a sheet layer drops into the hidden nav bar space', ( + tester, + ) async { + final immersive = MapImmersiveController(); + final stations = _Layer( + id: 'stations', + name: 'Stations', + timeline: false, + sheetBottom: true, + ); + await show( + tester, + settings: SettingsStore.inMemory(), + camera: MapCameraHandoff(), + stations: MapStationHandoff(), + visibility: MapLayerVisibilityController(SettingsStore.inMemory()), + layers: [stations], + immersive: immersive, + navInset: true, + ); + await loadStyle(tester); + + final before = tester.getBottomLeft(find.text('Sheet bottom')).dy; + immersive.enter(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + final after = tester.getBottomLeft(find.text('Sheet bottom')).dy; + + // The bar reserved 80 logical px above the 24 px system inset; immersive + // settles one spacing token above that inset, so the strip drops by 40. + expect(after - before, closeTo(40, 0.5)); + }); + testWidgets('a failed frame list shows the message and retry reloads', ( tester, ) async { From ec999e67195963923c1d9ea38fb394ff508ec9f8 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Tue, 3 Nov 2026 17:29:47 +0800 Subject: [PATCH 6/6] fix(eew): hide the shell nav bar for replay immersive mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all Fix(zh-Hant): 地震重播進入沉浸式時,底部導覽列也會收起 Fix(en-US): the bottom navigation bar now retracts for the earthquake replay's immersive mode --- lib/app/shell/main_shell.dart | 8 ++++++- .../pages/report_replay_page.dart | 22 +++++++++++-------- lib/shared/map/map_scaffold.dart | 10 +++++++++ test/app/shell/main_shell_test.dart | 11 ++++++++++ .../pages/report_replay_page_test.dart | 13 +++++++++++ 5 files changed, 54 insertions(+), 10 deletions(-) diff --git a/lib/app/shell/main_shell.dart b/lib/app/shell/main_shell.dart index 7f62c31af..7fa1fa4ce 100644 --- a/lib/app/shell/main_shell.dart +++ b/lib/app/shell/main_shell.dart @@ -133,9 +133,15 @@ class _MainShellState extends State /// animated; Home's sheet is a live drag and is followed exactly. void _syncNavDismiss() { final index = widget.navigationShell.currentIndex; + // Both the map tab and the data branch can show a surface that owns the + // whole screen (the map, or the earthquake replay nested under data). The + // controller is only true while one of them is in the mode; other branches + // never hide the bar. + final immersiveBranch = + index == _mapBranchIndex || index == _dataBranchIndex; final target = index == 0 ? HomeChrome.navDismiss(_sheetExtent.value) - : index == _mapBranchIndex && _mapImmersive.value + : immersiveBranch && _mapImmersive.value ? 1.0 : 0.0; if (target == _navTarget) return; diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 05da16306..0402aa8b7 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -120,11 +120,13 @@ class _ReportReplayPageState extends State { /// advances it through the alert set (modulo the count in the builder). int _eewIndex = 0; - /// Immersive view state, local to this page. The map tab's controller is - /// app-wide because the shell draws its nav bar; a replay is a pushed route - /// with no nav bar to move, and sharing that controller would let a replay - /// pop back into an unexpectedly chrome-less map tab (and vice versa). - final MapImmersiveController _immersive = MapImmersiveController(); + /// The app-wide immersive toggle, borrowed from the map tab for the length + /// of this route. The shell draws the bottom nav bar for every branch, + /// including this nested one, so a page-local flag could hide the replay's + /// own chrome while leaving the bar on screen. The controller is reset on + /// entry and exit so the map tab and the replay never inherit each other's + /// mode. + late final MapImmersiveController _immersive; void _onImmersiveChanged() { if (mounted) setState(() {}); @@ -165,7 +167,10 @@ class _ReportReplayPageState extends State { cwaOnly: () => cwaOnly.enabled, )..start(); _session.eew.addListener(_syncAnnouncement); - _immersive.addListener(_onImmersiveChanged); + _immersive = context.read() + ..addListener(_onImmersiveChanged); + // A replay opens with its controls, whatever the map tab was left in. + if (_immersive.value) _immersive.exit(); _startTicker(); } @@ -294,9 +299,8 @@ class _ReportReplayPageState extends State { _speechSettings?.removeListener(_syncAnnouncement); _announcement?.dispose(); _clockSecond.dispose(); - _immersive - ..removeListener(_onImmersiveChanged) - ..dispose(); + _immersive.removeListener(_onImmersiveChanged); + if (_immersive.value) _immersive.exit(); _session.dispose(); super.dispose(); } diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index 0137570d1..c78c06e54 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -389,6 +389,16 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { 'style=$_styleLoaded controller=${mapTraceObject(_controller)}', ); _wasVisible = visible; + // The controller is shared with the shell, which hides its bar for any + // immersive-capable branch. Leaving this tab must clear it, or a stale + // true would follow the user to another branch (or the replay's route) + // and hide a bar with no immersive surface behind it. + final tabIndex = widget.tabIndex; + if ((_immersive?.value ?? false) && + tabIndex != null && + _visibleTab?.value != tabIndex) { + _immersive?.exit(); + } // Both edges, before the timeline reload: a realtime layer skips its // platform uploads while hidden and flushes once on return. if (changed) _active.onSurfaceVisibility(visible); diff --git a/test/app/shell/main_shell_test.dart b/test/app/shell/main_shell_test.dart index f75074630..60ae423f8 100644 --- a/test/app/shell/main_shell_test.dart +++ b/test/app/shell/main_shell_test.dart @@ -199,6 +199,17 @@ void main() { await tester.pump(); expect(find.text('replay-body'), findsOneWidget); + // The replay is nested under the data branch; its immersive mode must + // retract the shell's bar even though the map branch is not selected. + immersive.enter(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.getTopLeft(label).dy, greaterThan(600)); + immersive.exit(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.getTopLeft(label).dy, lessThan(600)); + await tester.tap(find.text(l10n.navHome)); await tester.pump(); await tester.pump(); diff --git a/test/features/earthquake/presentation/pages/report_replay_page_test.dart b/test/features/earthquake/presentation/pages/report_replay_page_test.dart index 817a281e3..0f404c897 100644 --- a/test/features/earthquake/presentation/pages/report_replay_page_test.dart +++ b/test/features/earthquake/presentation/pages/report_replay_page_test.dart @@ -41,6 +41,7 @@ import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; import 'package:dpip/features/earthquake/domain/trem_station_repository.dart'; import 'package:dpip/features/earthquake/presentation/pages/report_replay_page.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/map_immersive.dart'; import 'package:dpip/shared/seismic/intensity_circle_renderer.dart'; import 'package:dpip/shared/seismic/intensity_icon_renderer.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; @@ -491,6 +492,9 @@ void main() { MultiProvider( providers: [ Provider.value(value: archive), + ChangeNotifierProvider( + create: (_) => MapImmersiveController(), + ), Provider.value(value: realtime), ChangeNotifierProvider.value(value: cwaOnly), ChangeNotifierProvider.value( @@ -700,6 +704,9 @@ void main() { MultiProvider( providers: [ Provider.value(value: archive), + ChangeNotifierProvider( + create: (_) => MapImmersiveController(), + ), Provider.value(value: realtime), ChangeNotifierProvider.value(value: cwaOnly), ChangeNotifierProvider.value( @@ -777,6 +784,9 @@ void main() { MultiProvider( providers: [ Provider.value(value: archive), + ChangeNotifierProvider( + create: (_) => MapImmersiveController(), + ), Provider.value(value: realtime), ChangeNotifierProvider.value( value: cwaOnly, @@ -877,6 +887,9 @@ void main() { child: MultiProvider( providers: [ Provider.value(value: archive), + ChangeNotifierProvider( + create: (_) => MapImmersiveController(), + ), Provider.value(value: realtime), ChangeNotifierProvider.value(value: cwaOnly), ChangeNotifierProvider.value(