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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions lib/core/build/demo_flags.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,28 @@ 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
/// outside [kDebugMode] so a release build can never ship the synthetic feeds.
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
Expand Down
43 changes: 32 additions & 11 deletions lib/features/changelog/presentation/widgets/update_prompt.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -102,18 +103,38 @@ class _UpdatePromptState extends State<UpdatePrompt> {
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)),
),
],
),
],
),
Expand Down
46 changes: 46 additions & 0 deletions lib/features/earthquake/data/monitor_demo.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<Eew>> {
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<Eew> _alerts;

@override
Future<Result<List<Eew>>> fetch() async => Ok(_alerts);

@override
DateTime? timestampOf(List<Eew> value) => null;

@override
bool sameData(List<Eew>? a, List<Eew>? 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.
Expand Down
2 changes: 2 additions & 0 deletions lib/features/earthquake/earthquake_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ List<SingleChildWidget> earthquakeProviders(SharedDeps deps) {
// without waiting for a live event.
final eewSource = kMonitorDemoEnabled
? DemoEewSource(reports) as RealtimeSource<List<Eew>>
: kStartupEewDemoEnabled
? StartupEewDemoSource() as RealtimeSource<List<Eew>>
: EewRealtimeSource(api.openEewSse, cwaOnly: () => eewCwaOnly.enabled)
as RealtimeSource<List<Eew>>;
final eewChannel = RealtimeChannel<List<Eew>>(
Expand Down
16 changes: 14 additions & 2 deletions lib/features/home/presentation/widgets/home_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions lib/shared/map/map_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
10 changes: 10 additions & 0 deletions lib/shared/map/map_scaffold.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,16 @@ class _MapScaffoldState extends State<MapScaffold> 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(() {
Expand Down
20 changes: 20 additions & 0 deletions test/features/changelog/update_prompt_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions test/features/earthquake/startup_eew_demo_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
}
73 changes: 71 additions & 2 deletions test/features/home/presentation/widgets/home_content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<WeatherRealtime?>> realtime(double lat, double lng) async =>
const Ok(null);

@override
Future<Result<WeatherForecast>> forecast(String code) async =>
Ok(WeatherForecast(updateTime: 0, forecast: const []));
Ok(forecastValue ?? WeatherForecast(updateTime: 0, forecast: const []));

@override
dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError();
Expand Down Expand Up @@ -115,7 +119,9 @@ class _StaticEewSource extends RealtimeSource<List<Eew>> {
Widget _wrap(
RegionStore store, {
bool expanded = false,
double topInset = 0,
RainHourTrendRepository? hourTrend,
WeatherForecast? forecast,
TownDirectory directory = const TownDirectory(<String, Town>{}),
}) {
final events = _FakeEventRepository();
Expand All @@ -130,7 +136,7 @@ Widget _wrap(
Provider<EventRepository>.value(value: events),
ChangeNotifierProvider<HomeWeatherController>(
create: (_) => HomeWeatherController(
_FakeWeatherRepository(),
_FakeWeatherRepository(forecastValue: forecast),
hourTrend ?? _FakeHourTrendRepository(),
store,
directory,
Expand All @@ -156,6 +162,7 @@ Widget _wrap(
body: HomeContent(
scrollController: ScrollController(),
expanded: expanded,
topInset: topInset,
),
),
),
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading