From accb4eba89fcc800842fd0404cf1bef726493ea6 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Fri, 28 Aug 2026 11:03:08 +0800 Subject: [PATCH 1/4] fix(map): restore live data when a hidden layer is re-selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 切回強震監視器時會立刻補上最新的地震速報 Fix(en-US): switching back to the monitor immediately replays the latest EEW --- lib/shared/map/map_layer.dart | 16 ++++++ lib/shared/map/map_scaffold.dart | 10 ++++ .../map_layer_visibility_handoff_test.dart | 53 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 test/shared/map/map_layer_visibility_handoff_test.dart diff --git a/lib/shared/map/map_layer.dart b/lib/shared/map/map_layer.dart index 817a7177a..c58659d9b 100644 --- a/lib/shared/map/map_layer.dart +++ b/lib/shared/map/map_layer.dart @@ -254,6 +254,22 @@ abstract interface class MapLayer { void onStyleReset(); } +/// Transfers the hosting surface's visibility when its active layer changes. +/// +/// Inactive realtime layers may retain `visible = false` from an earlier tab +/// switch. Merely calling [MapLayer.render] when selecting one again does not +/// repair that state, so its guarded map pushes remain disabled even though the +/// user is looking at it. Every selection therefore hides the outgoing layer +/// and explicitly gives the incoming one the surface's current visibility. +void handoffMapLayerVisibility({ + required MapLayer previous, + required MapLayer next, + required bool surfaceVisible, +}) { + previous.onSurfaceVisibility(false); + next.onSurfaceVisibility(surfaceVisible); +} + /// No-op bodies for the [MapLayer] members a given layer type doesn't use. /// /// Timeline layers (radar, satellite, QPESUMS) draw nothing in [MapLayer.render] diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart index 2a4dd9bc9..ef8ad8432 100644 --- a/lib/shared/map/map_scaffold.dart +++ b/lib/shared/map/map_scaffold.dart @@ -1142,6 +1142,16 @@ class _MapScaffoldState extends State with WidgetsBindingObserver { if (layer.id == _active.id) return; final controller = _controller; final previous = _active; + // A layer can have retained `visible = false` since it was active when the + // map tab was hidden. Rendering it again is not enough: realtime layers + // gate their source updates on that flag, leaving their freshly-created + // sources empty. Transfer visibility on every selection so switching back + // to the monitor immediately replays the current EEW snapshot. + handoffMapLayerVisibility( + previous: previous, + next: layer, + surfaceVisible: _isVisible, + ); // Invalidate in-flight loads/renders of the previous layer. _generation++; setState(() { diff --git a/test/shared/map/map_layer_visibility_handoff_test.dart b/test/shared/map/map_layer_visibility_handoff_test.dart new file mode 100644 index 000000000..273804810 --- /dev/null +++ b/test/shared/map/map_layer_visibility_handoff_test.dart @@ -0,0 +1,53 @@ +import 'package:dpip/shared/map/map_layer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _VisibilityLayer implements MapLayer { + _VisibilityLayer(this.id); + + @override + final String id; + + final visibility = []; + + @override + void onSurfaceVisibility(bool visible) => visibility.add(visible); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test('selecting a layer restores the current surface visibility', () { + final radar = _VisibilityLayer('radar'); + final monitor = _VisibilityLayer('monitor'); + + // The monitor last owned the map when the user left its shell tab. + monitor.onSurfaceVisibility(false); + handoffMapLayerVisibility( + previous: radar, + next: monitor, + surfaceVisible: true, + ); + + expect(radar.visibility, [false]); + expect( + monitor.visibility, + [false, true], + reason: 'the selected monitor must be allowed to replay its current EEW', + ); + }); + + test('selecting while the map is hidden keeps the new layer hidden', () { + final radar = _VisibilityLayer('radar'); + final monitor = _VisibilityLayer('monitor'); + + handoffMapLayerVisibility( + previous: radar, + next: monitor, + surfaceVisible: false, + ); + + expect(radar.visibility, [false]); + expect(monitor.visibility, [false]); + }); +} From d0b638f4ad0123c1e563fdd7aea83ff958fef719 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Fri, 28 Aug 2026 11:03:40 +0800 Subject: [PATCH 2/4] fix(changelog): line up the update prompt buttons in two tidy rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 更新提示的按鈕在手機上改為兩列整齊排列 Fix(en-US): the update prompt's buttons line up in two tidy rows on a phone --- .../presentation/widgets/update_prompt.dart | 43 ++++++++++++++----- .../changelog/update_prompt_test.dart | 20 +++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/lib/features/changelog/presentation/widgets/update_prompt.dart b/lib/features/changelog/presentation/widgets/update_prompt.dart index 8e027be12..a27a61bf1 100644 --- a/lib/features/changelog/presentation/widgets/update_prompt.dart +++ b/lib/features/changelog/presentation/widgets/update_prompt.dart @@ -16,6 +16,7 @@ import 'package:dpip/core/version/app_build.dart'; import 'package:dpip/core/platform/install_source.dart'; import 'package:dpip/core/settings/setting_keys.dart'; import 'package:dpip/core/settings/settings_store.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/features/changelog/domain/changelog_repository.dart'; import 'package:dpip/features/changelog/domain/release_note.dart'; import 'package:dpip/features/changelog/domain/update_check.dart'; @@ -102,18 +103,38 @@ class _UpdatePromptState extends State { icon: const Icon(Icons.system_update_outlined), title: Text(l10n.updateAvailableTitle), content: Text(l10n.updateAvailableBody(version)), + // A regular AlertDialog OverflowBar stacks these three buttons at the + // trailing edge on a phone, making them look detached from each other. + // Keep the two alternatives together and give the primary destination + // a stable, full-width row at every supported phone width. actions: [ - TextButton( - onPressed: () => Navigator.pop(context, _UpdateAction.skip), - child: Text(l10n.updateSkip), - ), - TextButton( - onPressed: () => Navigator.pop(context, _UpdateAction.changelog), - child: Text(l10n.updateViewChangelog), - ), - FilledButton( - onPressed: () => Navigator.pop(context, _UpdateAction.update), - child: Text(_updateLabel(l10n, source)), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => + Navigator.pop(context, _UpdateAction.skip), + child: Text(l10n.updateSkip), + ), + ), + Expanded( + child: TextButton( + onPressed: () => + Navigator.pop(context, _UpdateAction.changelog), + child: Text(l10n.updateViewChangelog), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + FilledButton( + onPressed: () => Navigator.pop(context, _UpdateAction.update), + child: Text(_updateLabel(l10n, source)), + ), + ], ), ], ), diff --git a/test/features/changelog/update_prompt_test.dart b/test/features/changelog/update_prompt_test.dart index de412db03..f33a8471c 100644 --- a/test/features/changelog/update_prompt_test.dart +++ b/test/features/changelog/update_prompt_test.dart @@ -137,6 +137,26 @@ void main() { expect(find.text('App Store'), findsOneWidget); }); + testWidgets('keeps phone-width actions in two intentional rows', ( + tester, + ) async { + tester.view.physicalSize = const Size(371, 667); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pump(tester, version: '3.2.0'); + + final skip = tester.getRect(find.text('Skip this one')); + final changes = tester.getRect(find.text('View changes')); + final store = tester.getRect( + find.widgetWithText(FilledButton, 'App Store'), + ); + expect(skip.center.dy, changes.center.dy); + expect(store.top, greaterThan(skip.bottom)); + expect(store.width, greaterThan(skip.width + changes.width)); + }); + testWidgets('a TestFlight build is sent to TestFlight', (tester) async { await _pump( tester, From 0773e369bd4c83abdea3a86da607f6b975d514c9 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Fri, 28 Aug 2026 11:03:41 +0800 Subject: [PATCH 3/4] fix(home): let short phones scroll to the full forecast detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 較短的螢幕也能捲動看到完整的逐時天氣預報 Fix(en-US): shorter phones can scroll to the complete hourly forecast detail --- .../presentation/widgets/home_content.dart | 16 +++- .../widgets/home_content_test.dart | 73 ++++++++++++++++++- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 931ef4126..aa7d499d3 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -164,6 +164,12 @@ class HomeContent extends StatelessWidget { /// distance. static const double _forecastExpandExtent = 200; + /// Minimum room for the header + the hero's bottom weather card. A short + /// phone can provide less than this after the region-bar inset; making that + /// extra height part of the outer list keeps both the loading/rain card and + /// the fully expanded dry forecast scrollable instead of clipping them. + static const double _minimumWeatherHeroHeight = 760; + /// Current growth of the hero forecast card for [offset]. static double _forecastExpansion(double offset) => (offset / _forecastExpandExtent).clamp(0.0, 1.0); @@ -260,6 +266,12 @@ class HomeContent extends StatelessWidget { // sky-tuned ink is exactly what makes scrolled content hard // to read, no matter how dimmed the backdrop behind it is. final reveal = this.reveal * (1 - _focus(offset)); + final forecastExpansion = _forecastExpansion(offset); + final heroLayoutHeight = heroHeight == null + ? null + : heroHeight < _minimumWeatherHeroHeight + ? _minimumWeatherHeroHeight + : heroHeight; return Column( key: ValueKey(areaIndex), crossAxisAlignment: CrossAxisAlignment.stretch, @@ -274,7 +286,7 @@ class HomeContent extends StatelessWidget { // changes shape, so it never needs to be torn down and // rebuilt when the sheet opens or closes. SizedBox( - height: heroHeight, + height: heroLayoutHeight, // Only the trailing gap depends on scroll offset, so // that is all this block's Padding re-reads per tick. child: Padding( @@ -340,7 +352,7 @@ class HomeContent extends StatelessWidget { opacity: reveal, child: HomeForecastSection( key: ValueKey('forecast-hero-$areaIndex'), - expansion: _forecastExpansion(offset), + expansion: forecastExpansion, reveal: reveal, sky: sky, weatherMode: weatherMode, diff --git a/test/features/home/presentation/widgets/home_content_test.dart b/test/features/home/presentation/widgets/home_content_test.dart index 1972002d5..1544b4e64 100644 --- a/test/features/home/presentation/widgets/home_content_test.dart +++ b/test/features/home/presentation/widgets/home_content_test.dart @@ -36,13 +36,17 @@ import 'package:provider/provider.dart'; /// Stub weather: home content resolves no towns from an empty directory, so /// neither endpoint is invoked in the switch tests. class _FakeWeatherRepository implements MeteorWeatherRepository { + const _FakeWeatherRepository({this.forecastValue}); + + final WeatherForecast? forecastValue; + @override Future> realtime(double lat, double lng) async => const Ok(null); @override Future> forecast(String code) async => - Ok(WeatherForecast(updateTime: 0, forecast: const [])); + Ok(forecastValue ?? WeatherForecast(updateTime: 0, forecast: const [])); @override dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); @@ -115,7 +119,9 @@ class _StaticEewSource extends RealtimeSource> { Widget _wrap( RegionStore store, { bool expanded = false, + double topInset = 0, RainHourTrendRepository? hourTrend, + WeatherForecast? forecast, TownDirectory directory = const TownDirectory({}), }) { final events = _FakeEventRepository(); @@ -130,7 +136,7 @@ Widget _wrap( Provider.value(value: events), ChangeNotifierProvider( create: (_) => HomeWeatherController( - _FakeWeatherRepository(), + _FakeWeatherRepository(forecastValue: forecast), hourTrend ?? _FakeHourTrendRepository(), store, directory, @@ -156,6 +162,7 @@ Widget _wrap( body: HomeContent( scrollController: ScrollController(), expanded: expanded, + topInset: topInset, ), ), ), @@ -397,6 +404,68 @@ void main() { }, ); + testWidgets('a short phone can scroll to the full forecast detail band', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final store = await _store(); + store + ..select(1) + ..setCurrentCode('100'); + const directory = TownDirectory({ + '100': Town( + code: '100', + city: 'Test', + town: 'North', + lat: 25.0, + lng: 121.5, + cityLevel: 'City', + townLevel: 'District', + ), + }); + const point = WeatherForecastPoint( + time: '14:00', + temperature: 30, + apparentTemp: 33, + humidity: 70, + weather: 'Clear', + weatherCode: 100, + pop: 0, + wind: ForecastWind(direction: 'NE', speed: 2, beaufort: 2), + ); + await tester.pumpWidget( + _wrap( + store, + expanded: true, + topInset: 88, + hourTrend: _FakeHourTrendRepository(dry: true), + forecast: const WeatherForecast( + updateTime: 0, + forecast: [point, point, point], + ), + directory: directory, + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull, reason: 'initial short layout'); + + final details = find.text('Feels like 33°'); + await tester.scrollUntilVisible( + details, + 120, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect(details, findsOneWidget); + expect(details.hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('全國 keeps its events card (it is not a missing location)', ( tester, ) async { From a4223353e0b1c4fc19e746e82973e1d97802180b Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Fri, 28 Aug 2026 11:03:41 +0800 Subject: [PATCH 4/4] chore(eew): add a startup-only demo EEW for visual testing --- lib/core/build/demo_flags.dart | 15 ++++++ .../earthquake/data/monitor_demo.dart | 46 +++++++++++++++++++ .../earthquake/earthquake_providers.dart | 2 + .../earthquake/startup_eew_demo_test.dart | 19 ++++++++ 4 files changed, 82 insertions(+) create mode 100644 test/features/earthquake/startup_eew_demo_test.dart diff --git a/lib/core/build/demo_flags.dart b/lib/core/build/demo_flags.dart index 70395389c..b1dd11fb0 100644 --- a/lib/core/build/demo_flags.dart +++ b/lib/core/build/demo_flags.dart @@ -15,6 +15,9 @@ const String _monitorDemoRaw = String.fromEnvironment('DPIP_DEMO_MONITOR'); const String _monitorDemoSevereRaw = String.fromEnvironment( 'DPIP_DEMO_MONITOR_SEVERE', ); +const String _startupEewDemoRaw = String.fromEnvironment( + 'DPIP_DEMO_STARTUP_EEW', +); /// Whether the 強震監視器 demo feeds are on: debug builds launched with /// `--dart-define=DPIP_DEMO_MONITOR=true` (or `=1`). The flag is forced off @@ -22,6 +25,18 @@ const String _monitorDemoSevereRaw = String.fromEnvironment( const bool kMonitorDemoEnabled = (_monitorDemoRaw == 'true' || _monitorDemoRaw == '1') && kDebugMode; +/// Temporary startup-only EEW used for visual testing with +/// `--dart-define=DPIP_DEMO_STARTUP_EEW=true` (or `=1`). It is deliberately +/// impossible in release builds and yields to the explicit full monitor demo +/// above. +/// +/// Remove this flag and the corresponding provider branch when the startup +/// alert test is finished. +const bool kStartupEewDemoEnabled = + (_startupEewDemoRaw == 'true' || _startupEewDemoRaw == '1') && + kDebugMode && + !kMonitorDemoEnabled; + /// Whether the demo event uses a fixed, severe preset (large magnitude, /// shallow depth) instead of the newest real report — /// `--dart-define=DPIP_DEMO_MONITOR_SEVERE=true` (or `=1`), alongside diff --git a/lib/features/earthquake/data/monitor_demo.dart b/lib/features/earthquake/data/monitor_demo.dart index 0d53c5053..ae4569088 100644 --- a/lib/features/earthquake/data/monitor_demo.dart +++ b/lib/features/earthquake/data/monitor_demo.dart @@ -114,6 +114,52 @@ abstract final class MonitorDemo { static DateTime? _origin; } +/// A single, stable EEW inserted on debug startup for visual testing. +/// +/// [RealtimeChannel] continues polling at its normal safety-critical cadence, +/// but every fetch returns this same immutable alert. Its collection equality +/// therefore suppresses every later poll and consumers observe exactly one +/// alert rather than a new serial every second. +class StartupEewDemoSource extends RealtimeSource> { + StartupEewDemoSource({DateTime Function()? clock}) + : _alerts = [ + Eew( + agency: 'DEMO', + id: 'startup-demo', + serial: 1, + status: 0, + isFinal: false, + info: EewInfo( + time: (clock ?? DateTime.now)().toUtc().millisecondsSinceEpoch, + longitude: 121.7, + latitude: 23.9, + depth: 10, + magnitude: 6.5, + location: '花蓮縣近海', + max: 7, + ), + ), + ]; + + final List _alerts; + + @override + Future>> fetch() async => Ok(_alerts); + + @override + DateTime? timestampOf(List value) => null; + + @override + bool sameData(List? a, List? b) { + if (a == null || b == null) return a == b; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} + /// Polls as an always-live EEW alert for [MonitorDemo]'s event, bumping the /// serial every couple of seconds so the feed visibly updates and the monitor /// cards re-render while the wavefront keeps expanding. diff --git a/lib/features/earthquake/earthquake_providers.dart b/lib/features/earthquake/earthquake_providers.dart index 16dc4df6a..73fb2caf0 100644 --- a/lib/features/earthquake/earthquake_providers.dart +++ b/lib/features/earthquake/earthquake_providers.dart @@ -56,6 +56,8 @@ List earthquakeProviders(SharedDeps deps) { // without waiting for a live event. final eewSource = kMonitorDemoEnabled ? DemoEewSource(reports) as RealtimeSource> + : kStartupEewDemoEnabled + ? StartupEewDemoSource() as RealtimeSource> : EewRealtimeSource(api.openEewSse, cwaOnly: () => eewCwaOnly.enabled) as RealtimeSource>; final eewChannel = RealtimeChannel>( diff --git a/test/features/earthquake/startup_eew_demo_test.dart b/test/features/earthquake/startup_eew_demo_test.dart new file mode 100644 index 000000000..043e263da --- /dev/null +++ b/test/features/earthquake/startup_eew_demo_test.dart @@ -0,0 +1,19 @@ +import 'package:dpip/features/earthquake/data/monitor_demo.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('startup demo always exposes the same single EEW', () async { + final source = StartupEewDemoSource( + clock: () => DateTime.utc(2026, 8, 28, 10), + ); + + final first = (await source.fetch()).valueOrNull!; + final second = (await source.fetch()).valueOrNull!; + + expect(first, hasLength(1)); + expect(first.single.id, 'startup-demo'); + expect(first.single.serial, 1); + expect(second, hasLength(1)); + expect(source.sameData(first, second), isTrue); + }); +}