diff --git a/ios/DPIPWidgets/DPIPWidgetEntry.swift b/ios/DPIPWidgets/DPIPWidgetEntry.swift new file mode 100644 index 000000000..c938954fd --- /dev/null +++ b/ios/DPIPWidgets/DPIPWidgetEntry.swift @@ -0,0 +1,44 @@ +import WidgetKit + +/// Keeps navigation identity independent of the availability of weather data. +struct DPIPWidgetEntry: TimelineEntry { + let date: Date + let snapshot: CurrentWeatherWidgetSnapshot? + let isStale: Bool + let isNight: Bool + let forecast: ForecastWidgetSnapshot? + let locationTarget: WidgetLocationTarget + + var widgetURL: URL { locationTarget.widgetURL } + + init(date: Date, snapshot: CurrentWeatherWidgetSnapshot?, + isStale: Bool, isNight: Bool, + forecast: ForecastWidgetSnapshot? = nil, + locationTarget: WidgetLocationTarget = .currentLocation) { + self.date = date + self.snapshot = snapshot + self.isStale = isStale + self.isNight = isNight + self.forecast = forecast + self.locationTarget = locationTarget + } + + /// Fail closed at the display boundary as well as the cache boundary. + /// Current weather remains usable independently of forecast availability. + func upcomingForecastPoints(for family: WidgetFamily) -> [ForecastWidgetPoint] { + guard let snapshot, let forecast, + forecast.sourceIdentifier == snapshot.sourceIdentifier, + forecast.regionCode == snapshot.regionCode, + ForecastWidgetExpiry.isUsable( + forecast, at: date, + calibratedTimeOffsetMilliseconds: + snapshot.calibratedTimeOffsetMilliseconds + ) else { return [] } + let calibratedDate = CurrentWeatherWidgetTimeCalibration( + snapshot: snapshot + ).calibratedDate(fromDeviceDate: date) + return ForecastWidgetFamilyPolicy.visiblePoints( + in: forecast, at: calibratedDate, family: family + ) + } +} diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index ea7eec9be..9b190e9b2 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -60,7 +60,8 @@ struct DPIPWidgetProvider: IntentTimelineProvider { snapshot: snapshot, isStale: state.isStale, isNight: state.isNight, - forecast: content.forecast + forecast: content.forecast, + locationTarget: target ) completion(entry) @@ -106,7 +107,8 @@ struct DPIPWidgetProvider: IntentTimelineProvider { snapshot: plan.snapshot, isStale: state.isStale, isNight: state.isNight, - forecast: plan.forecast(at: state.date) + forecast: plan.forecast(at: state.date), + locationTarget: target ) } @@ -120,43 +122,6 @@ struct DPIPWidgetProvider: IntentTimelineProvider { } } -struct DPIPWidgetEntry: TimelineEntry { - let date: Date - let snapshot: CurrentWeatherWidgetSnapshot? - let isStale: Bool - let isNight: Bool - let forecast: ForecastWidgetSnapshot? - - init(date: Date, snapshot: CurrentWeatherWidgetSnapshot?, - isStale: Bool, isNight: Bool, - forecast: ForecastWidgetSnapshot? = nil) { - self.date = date - self.snapshot = snapshot - self.isStale = isStale - self.isNight = isNight - self.forecast = forecast - } - - /// Fail closed at the display boundary as well as the cache boundary. - /// Current weather remains usable independently of forecast availability. - func upcomingForecastPoints(for family: WidgetFamily) -> [ForecastWidgetPoint] { - guard let snapshot, let forecast, - forecast.sourceIdentifier == snapshot.sourceIdentifier, - forecast.regionCode == snapshot.regionCode, - ForecastWidgetExpiry.isUsable( - forecast, at: date, - calibratedTimeOffsetMilliseconds: - snapshot.calibratedTimeOffsetMilliseconds - ) else { return [] } - let calibratedDate = CurrentWeatherWidgetTimeCalibration( - snapshot: snapshot - ).calibratedDate(fromDeviceDate: date) - return ForecastWidgetFamilyPolicy.visiblePoints( - in: forecast, at: calibratedDate, family: family - ) - } -} - /// - Parameter calibratedDate: a calibrated instant, never a WidgetKit entry /// date. `nextTransitionTime` is published in the calibrated domain, so /// subtracting a device-clock date here would yield the calibration offset @@ -985,11 +950,11 @@ struct DPIPWidgets: Widget { ) { entry in if #available(iOS 17.0, *) { DPIPWidgetsEntryView(entry: entry) - .widgetURL(URL(string: "dpip:///home")) + .widgetURL(entry.widgetURL) .containerBackground(.fill.tertiary, for: .widget) } else { DPIPWidgetsEntryView(entry: entry) - .widgetURL(URL(string: "dpip:///home")) + .widgetURL(entry.widgetURL) .padding() .background() } diff --git a/ios/DPIPWidgets/WidgetLocationTarget.swift b/ios/DPIPWidgets/WidgetLocationTarget.swift index 23704e8c8..9f41a9ec7 100644 --- a/ios/DPIPWidgets/WidgetLocationTarget.swift +++ b/ios/DPIPWidgets/WidgetLocationTarget.swift @@ -29,6 +29,33 @@ enum WidgetLocationTarget: Equatable, Sendable { } extension WidgetLocationTarget { + // This literal is independent of configuration and always a valid URL. + private static let homeURL = URL(string: "dpip:///home")! + + /// The configured target survives missing/expired weather. Never derive a + /// navigation destination from a cached observation's region. + var widgetURL: URL { + var components = URLComponents() + components.scheme = "dpip" + components.host = "" + components.path = "/home" + if let identifier = sourceIdentifier, + WidgetLocationTarget(identifier: identifier) == self { + var allowed = CharacterSet.urlQueryAllowed + allowed.remove(charactersIn: ":&=+?#%") + if let encoded = identifier.addingPercentEncoding( + withAllowedCharacters: allowed + ) { + components.percentEncodedQuery = "widgetLocation=" + encoded + } + } + return Self.widgetURL(from: components) + } + + static func widgetURL(from components: URLComponents) -> URL { + components.url ?? homeURL + } + var sourceIdentifier: String? { switch self { case .currentLocation: diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ba1ba14dc..8c29027b1 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -272,6 +272,7 @@ CurrentWeatherWidgetSnapshotFactory.swift, CurrentWeatherWidgetRefreshPipeline.swift, CurrentWeatherWidgetSnapshotWriter.swift, + DPIPWidgetEntry.swift, DPIPWidgetProviderSupport.swift, DPIPWidgets.swift, Localizable.xcstrings, diff --git a/ios/RunnerTests/WidgetLocationIntentTests.swift b/ios/RunnerTests/WidgetLocationIntentTests.swift index cba75caae..5ec586351 100644 --- a/ios/RunnerTests/WidgetLocationIntentTests.swift +++ b/ios/RunnerTests/WidgetLocationIntentTests.swift @@ -1,8 +1,123 @@ import Foundation +import WidgetKit import XCTest @testable import Runner final class WidgetLocationIntentTests: XCTestCase { + func testWidgetURLsEncodeConfiguredTargets() throws { + let cases: [(WidgetLocationTarget, String)] = [ + (.currentLocation, "dpip:///home?widgetLocation=current-location"), + (.saved(regionCode: "407"), "dpip:///home?widgetLocation=region%3A407"), + (.saved(regionCode: "242"), "dpip:///home?widgetLocation=region%3A242"), + (.invalid(identifier: "region:bad&other=407"), "dpip:///home"), + (.saved(regionCode: "407"), "dpip:///home") + ] + for (target, expected) in cases { + XCTAssertEqual(target.widgetURL.absoluteString, expected) + let components = try XCTUnwrap(URLComponents( + url: target.widgetURL, resolvingAgainstBaseURL: false + )) + if let identifier = components.queryItems?.first?.value { + XCTAssertEqual(WidgetLocationTarget(identifier: identifier), target) + } + } + } + + func testEntryURLUsesConfigurationWithoutWeather() { + for target: WidgetLocationTarget in [.currentLocation, .saved(regionCode: "407")] { + for stale in [false, true] { + let entry = DPIPWidgetEntry( + date: Date(timeIntervalSince1970: 0), snapshot: nil, + isStale: stale, isNight: false, locationTarget: target + ) + XCTAssertEqual(entry.widgetURL, target.widgetURL) + } + } + } + + func testWidgetURLFallsBackWhenComponentsCannotFormURL() { + var components = URLComponents() + components.scheme = "dpip" + components.host = "" + // An authority requires an absolute path; this cannot form a URL. + components.path = "home" + components.queryItems = [URLQueryItem(name: "widgetLocation", value: "region:407")] + XCTAssertNil(components.url) + XCTAssertEqual(WidgetLocationTarget.widgetURL(from: components).absoluteString, + "dpip:///home") + } + + func testWidgetURLRejectsMalformedCodesAndQueryInjection() throws { + for code in ["", "10", "1000", "407", "٤٠٧", "407\n", + "407&other=242", "407+242", "407?#%", "../407", "台北"] { + for target in [WidgetLocationTarget.saved(regionCode: code), + WidgetLocationTarget(identifier: "region:" + code)] { + let url = target.widgetURL + XCTAssertEqual(url.absoluteString, "dpip:///home", code) + let components = try XCTUnwrap(URLComponents( + url: url, resolvingAgainstBaseURL: false + )) + XCTAssertNil(components.queryItems, code) + XCTAssertNil(components.fragment, code) + } + } + } + + func testEntryURLIgnoresAnotherLocationsCachedWeather() { + let entry = DPIPWidgetEntry( + date: Date(timeIntervalSince1970: 0), + snapshot: makeSnapshot(sourceIdentifier: "region:242", regionCode: "242"), + isStale: true, isNight: false, + locationTarget: .saved(regionCode: "407") + ) + XCTAssertEqual(entry.widgetURL.absoluteString, + "dpip:///home?widgetLocation=region%3A407") + } + + func testForecastExpiryDoesNotChangeConfiguredNavigationTarget() throws { + let date = Date(timeIntervalSince1970: 1_789_567_200) + let sample = DPIPWidgetGalleryPreview.sample(at: date) + let forecast = try XCTUnwrap(sample.forecast) + let expiry = ForecastWidgetExpiry.date(for: forecast) + for target: WidgetLocationTarget in [.currentLocation, .saved(regionCode: "242")] { + for family: WidgetFamily in [.systemMedium, .systemLarge] { + let available = DPIPWidgetEntry( + date: date, snapshot: sample.snapshot, + isStale: false, isNight: false, forecast: sample.forecast, + locationTarget: target + ) + let expired = DPIPWidgetEntry( + date: expiry, snapshot: sample.snapshot, + isStale: true, isNight: false, forecast: sample.forecast, + locationTarget: target + ) + XCTAssertFalse(available.upcomingForecastPoints(for: family).isEmpty) + XCTAssertTrue(expired.upcomingForecastPoints(for: family).isEmpty) + XCTAssertEqual(available.widgetURL, target.widgetURL) + XCTAssertEqual(expired.widgetURL, target.widgetURL) + } + } + } + + func testGallerySampleDoesNotDetermineConfiguredNavigationTarget() { + let date = Date(timeIntervalSince1970: 1_789_567_200) + let sample = DPIPWidgetGalleryPreview.sample(at: date) + for target: WidgetLocationTarget in [.currentLocation, .saved(regionCode: "242")] { + for family: WidgetFamily in [.systemSmall, .systemMedium, .systemLarge] { + let entry = DPIPWidgetEntry( + date: date, snapshot: sample.snapshot, + isStale: false, isNight: false, + forecast: ForecastWidgetFamilyPolicy.supportsForecast(family) + ? sample.forecast : nil, + locationTarget: target + ) + XCTAssertEqual(entry.snapshot?.sourceIdentifier, "region:407") + XCTAssertEqual(entry.widgetURL, target.widgetURL) + XCTAssertNotEqual(entry.widgetURL, WidgetLocationTarget.saved(regionCode: "407").widgetURL) + } + } + } + private func makeSnapshot( sourceIdentifier: String?, regionCode: String, diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index c6c23331c..7ddc969d1 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -1,4 +1,5 @@ import 'package:dpip/app/shell/main_shell.dart'; +import 'package:dpip/app/router/widget_location_routes.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/features/data/presentation/pages/data_page.dart'; import 'package:dpip/features/earthquake/presentation/pages/earthquake_page.dart'; @@ -53,6 +54,7 @@ import 'package:provider/provider.dart'; /// user held on the welcome page by a failed launch would stay there all /// session even after the completion flag came back. final OnboardingRefreshSignal onboardingRefresh = OnboardingRefreshSignal(); +final WidgetLocationRoutes _widgetLocationRoutes = WidgetLocationRoutes(); final class OnboardingRefreshSignal extends ChangeNotifier { void fire() => notifyListeners(); @@ -75,10 +77,10 @@ final GoRouter appRouter = GoRouter( // finishing onboarding (which flips the flag) lets a goNamed(home) through. redirect: (context, state) { final complete = context.read().isComplete; - final atOnboarding = state.matchedLocation == AppRoutes.onboardingPath; - if (!complete && !atOnboarding) return AppRoutes.onboardingPath; - if (complete && atOnboarding) return AppRoutes.homePath; - return null; + return _widgetLocationRoutes.redirect( + onboardingComplete: complete, + uri: state.uri, + ); }, routes: [ GoRoute( @@ -90,7 +92,12 @@ final GoRouter appRouter = GoRouter( builder: (context, state, navigationShell) => MainShell(navigationShell: navigationShell), branches: [ - _branch(AppRoutes.homePath, AppRoutes.home, (_, _) => const HomePage()), + _branch( + AppRoutes.homePath, + AppRoutes.home, + (_, _) => + HomePage(widgetLocationRequests: _widgetLocationRoutes.requests), + ), _branch( AppRoutes.eventsPath, AppRoutes.events, diff --git a/lib/app/router/widget_location_routes.dart b/lib/app/router/widget_location_routes.dart new file mode 100644 index 000000000..e8387518a --- /dev/null +++ b/lib/app/router/widget_location_routes.dart @@ -0,0 +1,23 @@ +import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:dpip/shared/navigation/widget_location_link.dart'; + +/// Retains the latest Widget tap through onboarding. Stripping the parameter +/// before building Home lets go_router deliver a subsequent identical URL. +final class WidgetLocationRoutes { + final requests = WidgetLocationRequests(); + + String? redirect({required bool onboardingComplete, required Uri uri}) { + final atHome = uri.path == AppRoutes.homePath; + final atOnboarding = uri.path == AppRoutes.onboardingPath; + if (atHome && + uri.queryParametersAll.containsKey(WidgetLocationLink.queryKey)) { + requests.request(WidgetLocationLink.parse(uri)); + return onboardingComplete ? AppRoutes.homePath : AppRoutes.onboardingPath; + } + if (!onboardingComplete && !atOnboarding) return AppRoutes.onboardingPath; + if (onboardingComplete && atOnboarding) return AppRoutes.homePath; + return null; + } + + WidgetLocationLink? takePending() => requests.takePending(); +} diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 369c2c3ef..ae542e021 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -1,8 +1,11 @@ import 'dart:ui' show ImageFilter; +import 'dart:async'; import 'package:dpip/app/theme/app_gold.dart'; import 'package:dpip/app/theme/app_glass.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/settings/weather_mode.dart'; @@ -21,6 +24,7 @@ import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:dpip/shared/navigation/widget_location_link.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; import 'package:dpip/shared/widgets/frosted_surface.dart' show mapChromeBlursBackdrop; @@ -39,7 +43,9 @@ import 'package:provider/provider.dart'; /// swipe anywhere pages the area (`RegionSwipeArea`); a tap on the exposed map /// opens the full map tab. class HomePage extends StatefulWidget { - const HomePage({super.key}); + const HomePage({super.key, this.widgetLocationRequests}); + + final WidgetLocationRequests? widgetLocationRequests; /// This page's branch index in the shell. static const int tabIndex = 0; @@ -49,6 +55,53 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { + bool _widgetRequestScheduled = false; + + @override + void initState() { + super.initState(); + widget.widgetLocationRequests?.addListener(_queueWidgetRequest); + _queueWidgetRequest(); + } + + @override + void didUpdateWidget(HomePage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.widgetLocationRequests != widget.widgetLocationRequests) { + oldWidget.widgetLocationRequests?.removeListener(_queueWidgetRequest); + widget.widgetLocationRequests?.addListener(_queueWidgetRequest); + } + _queueWidgetRequest(); + } + + void _queueWidgetRequest() { + if (_widgetRequestScheduled || widget.widgetLocationRequests == null) { + return; + } + _widgetRequestScheduled = true; + // RegionStore and weather notify listeners. Consume after the route's + // build, and drop an older callback if another Widget tap overtook it. + WidgetsBinding.instance.addPostFrameCallback((_) { + _widgetRequestScheduled = false; + if (!mounted) return; + final request = widget.widgetLocationRequests?.takePending(); + if (request == null) return; + final regions = context.read(); + final weather = context.read(); + final previousCode = regions.selectedCode; + final selected = request.select(regions, context.read()); + // A changed code already loads through HomeWeatherController's listener. + // Same-code taps (including current ↔ saved) need an explicit refresh. + if (selected && previousCode == regions.selectedCode) { + unawaited(weather.refresh()); + } + Log.debug( + 'WidgetLocation target=${request.identifier} applied=$selected ' + 'selectedIndex=${regions.selectedIndex} selectedCode=${regions.selectedCode}', + ); + }); + } + final DraggableScrollableController _sheet = DraggableScrollableController(); HomeSheetExtent? _extent; HomeResetSignal? _resetSignal; @@ -135,6 +188,7 @@ class _HomePageState extends State { @override void dispose() { + widget.widgetLocationRequests?.removeListener(_queueWidgetRequest); _visibleTab?.removeListener(_syncTabVisibility); _resetSignal?.removeListener(_resetSheet); _sheet.dispose(); diff --git a/lib/shared/navigation/widget_location_link.dart b/lib/shared/navigation/widget_location_link.dart new file mode 100644 index 000000000..e4b389fe7 --- /dev/null +++ b/lib/shared/navigation/widget_location_link.dart @@ -0,0 +1,57 @@ +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/settings/region_store.dart'; +import 'package:flutter/foundation.dart'; + +/// A hand-off to the retained Home branch. Listeners only schedule consumption +/// after the frame; redirect itself must never mutate the selected region. +final class WidgetLocationRequests extends ChangeNotifier { + WidgetLocationLink? _pending; + + void request(WidgetLocationLink? link) { + _pending = link; + notifyListeners(); + } + + WidgetLocationLink? takePending() { + final request = _pending; + _pending = null; + return request; + } +} + +/// One Widget tap. Instances intentionally retain identity, so two taps on the +/// same target are two requests. Only the configured identifier is accepted. +final class WidgetLocationLink { + WidgetLocationLink._(this.identifier); + + final String identifier; + + static const queryKey = 'widgetLocation'; + + static WidgetLocationLink? parse(Uri uri) { + final values = uri.queryParametersAll[queryKey]; + if (values == null || values.length != 1) return null; + final identifier = values.single; + // Match WidgetLocationCatalogLocation: exactly three ASCII digits. + if (identifier != 'current-location' && + !RegExp(r'^region:[0-9]{3}$').hasMatch(identifier)) { + return null; + } + return WidgetLocationLink._(identifier); + } + + /// Resolve at consumption time: saved locations may have been removed while + /// onboarding was visible. Do not add a region or pass -1 into select(), + /// whose clamping would silently select a different area. + bool select(RegionStore regions, TownDirectory directory) { + if (identifier == 'current-location') { + regions.select(1); + return true; + } + final code = identifier.substring('region:'.length); + final position = regions.savedCodes.indexOf(code); + if (position < 0 || directory.byCode(code) == null) return false; + regions.select(position + 2); + return true; + } +} diff --git a/test/app/router/app_router_test.dart b/test/app/router/app_router_test.dart index f50ffa395..d9312f567 100644 --- a/test/app/router/app_router_test.dart +++ b/test/app/router/app_router_test.dart @@ -4,6 +4,7 @@ library; import 'package:dio/dio.dart'; import 'package:dpip/app/router/app_router.dart'; +import 'package:dpip/app/router/notification_routes.dart'; import 'package:dpip/app/shell/main_shell.dart'; import 'package:dpip/core/di/core_providers.dart'; import 'package:dpip/core/di/shared_deps.dart'; @@ -17,6 +18,7 @@ import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/network/region_selection.dart'; import 'package:dpip/core/notifications/notification_service.dart'; +import 'package:dpip/core/notifications/notification_tap.dart'; import 'package:dpip/core/permissions/permission_health.dart'; import 'package:dpip/core/platform/background_location.dart'; import 'package:dpip/core/platform/widget_location_catalog_coordinator.dart'; @@ -63,6 +65,12 @@ 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/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'; +import 'package:dpip/features/weather/domain/rain_hour_trend.dart'; +import 'package:dpip/features/weather/domain/weather_forecast.dart'; +import 'package:dpip/features/weather/domain/weather_realtime.dart'; import 'package:dpip/features/onboarding/presentation/pages/onboarding_page.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; import 'package:dpip/features/weather/weather_providers.dart'; @@ -172,6 +180,199 @@ void main() { await tester.pumpWidget(const SizedBox()); await tester.pump(); }); + + for (final complete in [true, false]) { + testWidgets('Widget cold, warm and repeated taps (onboarded=$complete)', ( + tester, + ) async { + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final previous = MapLibrePlatform.createInstance; + MapLibrePlatform.createInstance = () => _MapPlatform(); + addTearDown(() => MapLibrePlatform.createInstance = previous); + final directory = TownDirectory.fromJson({ + for (final code in ['407', '242']) + code: { + 'city': 'Test', + 'town': code, + 'lat': 24.0, + 'lng': 121.0, + 'cityLevel': '市', + 'townLevel': '區', + }, + }); + final deps = _deps(complete: complete, directory: directory); + final regions = deps.regionStore + ..addSaved('407') + ..addSaved('242'); + final weather = _WidgetWeather(); + appRouter.go('dpip:///home?widgetLocation=region%3A407'); + await tester.pumpWidget(_widgetRouterApp(deps, weather)); + await tester.pump(); + await tester.pump(); + if (!complete) { + expect(find.byType(OnboardingPage), findsOneWidget); + expect(regions.selectedIndex, 0); + await deps.onboarding.complete(); + appRouter.goNamed('home'); + await tester.pump(); + await tester.pump(); + } + expect(find.byType(HomePage).hitTestable(), findsOneWidget); + expect(regions.selectedCode, '407'); + expect(weather.codes.last, '407'); + expect(appRouter.state.uri.toString(), '/home'); + + Future tap(String identifier) async { + await appRouter.routeInformationProvider.didPushRouteInformation( + RouteInformation( + uri: Uri.parse('dpip:///home?widgetLocation=$identifier'), + ), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + } + + appRouter.go('/events'); + await tester.pump(); + await tester.pump(); + await tap('region%3A242'); + expect(find.byType(HomePage).hitTestable(), findsOneWidget); + expect(regions.selectedCode, '242'); + expect(weather.codes.last, '242'); + final beforeChangedCode = weather.codes.length; + await tap('region%3A407'); + expect(regions.selectedCode, '407'); + expect(weather.codes.skip(beforeChangedCode), ['407']); + final before = weather.codes.length; + await tap('region%3A407'); + expect(weather.codes.skip(before), ['407']); + expect(weather.codes.last, '407'); + + // All three platform requests arrive before Home's post-frame consumer. + // Only the last target may update selection and start a weather request. + await tap('region%3A242'); + final beforeBurst = weather.codes.length; + for (final identifier in [ + 'region%3A407', + 'region%3A242', + 'region%3A407', + ]) { + await appRouter.routeInformationProvider.didPushRouteInformation( + RouteInformation( + uri: Uri.parse('dpip:///home?widgetLocation=$identifier'), + ), + ); + } + expect(regions.selectedCode, '242'); + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect(regions.selectedCode, '407'); + expect(weather.codes.skip(beforeBurst), ['407']); + + // An ordinary notification navigation arriving before consumption must + // neither erase the configured target nor cause a second consumption. + await appRouter.routeInformationProvider.didPushRouteInformation( + RouteInformation( + uri: Uri.parse('dpip:///home?widgetLocation=region%3A242'), + ), + ); + routeNotificationTap(const NotificationTap(channelKey: 'unknown')); + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect(appRouter.state.uri.path, '/home'); + expect(regions.selectedCode, '242'); + final afterNotification = weather.codes.length; + await tester.pump(); + expect(weather.codes.length, afterNotification); + + regions.setCurrentCode('242'); + final beforeSameCode = weather.codes.length; + await tap('current-location'); + expect(regions.selectedIndex, 1); + expect(regions.selectedCode, '242'); + expect(weather.codes.last, '242'); + expect(weather.codes.skip(beforeSameCode), ['242']); + await tap('region%3A242'); + expect(regions.selectedIndex, 3); + expect(weather.codes.skip(beforeSameCode), ['242', '242']); + await tap('current-location'); + expect(regions.selectedIndex, 1); + expect(weather.codes.skip(beforeSameCode), ['242', '242', '242']); + regions.removeSaved('407'); + final beforeInvalid = weather.codes.length; + await tap('region%3A407'); + expect(regions.selectedIndex, 1); + expect(regions.savedCodes, ['242']); + await tap('bad'); + expect(regions.selectedIndex, 1); + expect(weather.codes.length, beforeInvalid); + expect(tester.takeException(), isNull); + appRouter.go('/nowhere'); + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); + } +} + +Widget _widgetRouterApp(SharedDeps deps, _WidgetWeather weather) => + MultiProvider( + providers: [ + ...coreProviders(deps), + ...changelogProviders(deps), + Provider.value(value: _Notes()), + ...eventsProviders(deps), + Provider.value(value: _Events()), + ...weatherProviders(deps), + Provider.value(value: _Radar()), + ...homeProviders(), + ChangeNotifierProvider( + create: (_) => HomeWeatherController( + weather, + _WidgetRain(), + deps.regionStore, + deps.townDirectory, + ), + ), + _eew(), + Provider>.value( + value: Future.value(const SeismicTravelTimeTable({})), + ), + ], + child: MaterialApp.router( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: appRouter, + ), + ); + +class _WidgetWeather implements MeteorWeatherRepository { + final codes = []; + @override + Future> realtime( + double latitude, + double longitude, + ) async => const Ok(null); + @override + Future> forecast(String code) async { + codes.add(code); + return Ok(WeatherForecast(updateTime: 0, forecast: const [])); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _WidgetRain implements RainHourTrendRepository { + @override + Future> hourTrend(String code) async => + Ok(RainHourTrend(startSecond: 0, mm: List.filled(60, 0))); } void _walk(List routes, BuildContext context, List built) { @@ -218,14 +419,14 @@ ChangeNotifierProvider>> _eew() => ), ); -SharedDeps _deps({required bool complete}) { +SharedDeps _deps({required bool complete, TownDirectory? directory}) { final settings = SettingsStore.inMemory(); if (complete) { settings.setBool(SettingKeys.onboardingComplete, true); } final regions = RegionSelection(settings); final mesh = FakeMeshService(); - const townDirectory = TownDirectory({}); + final townDirectory = directory ?? const TownDirectory({}); final regionStore = RegionStore(settings)..select(0); final location = LocationService( townDirectory, diff --git a/test/app/router/widget_location_routes_test.dart b/test/app/router/widget_location_routes_test.dart new file mode 100644 index 000000000..145b633eb --- /dev/null +++ b/test/app/router/widget_location_routes_test.dart @@ -0,0 +1,168 @@ +import 'package:dpip/app/router/widget_location_routes.dart'; +import 'package:dpip/app/router/notification_routes.dart'; +import 'package:dpip/core/notifications/notification_tap.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/settings/home_area.dart'; +import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:dpip/shared/navigation/widget_location_link.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late RegionStore regions; + final directory = TownDirectory.fromJson({ + for (final code in ['407', '242']) + code: { + 'city': 'Test', + 'town': code, + 'lat': 24.0, + 'lng': 121.0, + 'cityLevel': '市', + 'townLevel': '區', + }, + }); + + setUp(() { + regions = RegionStore(SettingsStore.inMemory()) + ..addSaved('407') + ..addSaved('242'); + }); + tearDown(() => regions.dispose()); + + WidgetLocationLink? parse(String value) => + WidgetLocationLink.parse(Uri.parse('dpip:///home?widgetLocation=$value')); + + test('saved identifiers accept exactly three ASCII digits', () { + for (final code in ['000', '100', '242', '407', '999']) { + expect(parse('region%3A$code')?.identifier, 'region:$code'); + } + for (final code in ['1', '10', '1000', '10000', '٤٠٧', '४०७', '407']) { + expect(parse('region%3A$code'), isNull, reason: code); + } + }); + + test('percent decoding selects the saved region by code after reorder', () { + final link = parse('region%3A407')!; + regions.reorderSaved(0, 1); + expect(link.select(regions, directory), isTrue); + expect(regions.selected, isA()); + expect(regions.selectedCode, '407'); + expect(regions.selectedIndex, 3); + }); + + test('current location selects its area even without a GPS fix', () { + regions.select(2); + expect(parse('current-location')!.select(regions, directory), isTrue); + expect(regions.selected, isA()); + expect(regions.selectedCode, isNull); + }); + + test('unknown, deleted and invalid directory regions keep the selection', () { + regions.select(3); + final original = regions.savedCodes; + expect(parse('region%3A302')!.select(regions, directory), isFalse); + expect(regions.savedCodes, original); + final removed = parse('region%3A407')!; + regions.removeSaved('407'); + expect(removed.select(regions, directory), isFalse); + expect(regions.selectedCode, '242'); + regions.addSaved('999'); + expect(parse('region%3A999')!.select(regions, directory), isFalse); + expect(regions.selectedCode, '242'); + }); + + test('invalid, ambiguous and double encoded targets are rejected', () { + for (final value in [ + '', + 'region%3A', + 'region%3A40', + 'region%3A4070', + 'region%3A407', + 'region%3A407%0A', + 'region%253A407', + 'region%3A407%26other%3D242', + 'nationwide', + 'region%3A407&widgetLocation=current-location', + ]) { + expect(parse(value), isNull, reason: value); + } + expect(WidgetLocationLink.parse(Uri.parse('/home')), isNull); + }); + + test('onboarding retains the latest request and consumes it only once', () { + final routes = WidgetLocationRoutes(); + expect( + routes.redirect( + onboardingComplete: false, + uri: Uri.parse('dpip:///home?widgetLocation=region%3A407'), + ), + '/onboarding', + ); + routes.redirect( + onboardingComplete: false, + uri: Uri.parse('dpip:///home?widgetLocation=region%3A242'), + ); + expect( + routes.redirect(onboardingComplete: true, uri: Uri.parse('/onboarding')), + '/home', + ); + expect(routes.takePending()!.identifier, 'region:242'); + expect(routes.takePending(), isNull); + }); + + test( + 'identical taps are separate requests; plain navigation is unchanged', + () { + final routes = WidgetLocationRoutes(); + final uri = Uri.parse('dpip:///home?widgetLocation=region%3A407'); + routes.redirect(onboardingComplete: true, uri: uri); + final first = routes.takePending(); + routes.redirect(onboardingComplete: true, uri: uri); + expect(identical(first, routes.takePending()), isFalse); + for (final path in ['/home', '/map', '/events']) { + expect( + routes.redirect(onboardingComplete: true, uri: Uri.parse(path)), + isNull, + ); + } + }, + ); + + test( + 'EEW navigation keeps its map destination while a Widget is pending', + () { + final routes = WidgetLocationRoutes(); + routes.redirect( + onboardingComplete: true, + uri: Uri.parse('dpip:///home?widgetLocation=region%3A407'), + ); + final order = []; + routeNotificationTap( + const NotificationTap(channelKey: 'eew_alert-important-v2'), + focusMapLayer: (layer) => order.add(layer), + navigate: + ( + name, { + Map pathParameters = const {}, + Map queryParameters = const {}, + String? fragment, + Object? extra, + }) { + order.add(name); + expect( + routes.redirect( + onboardingComplete: true, + uri: Uri.parse('/map'), + ), + isNull, + ); + }, + ); + expect(order, ['monitor', 'map']); + expect(routes.takePending()!.select(regions, directory), isTrue); + expect(regions.selectedCode, '407'); + expect(routes.takePending(), isNull); + expect(order.last, 'map'); + }, + ); +} diff --git a/test/features/home/presentation/pages/home_page_test.dart b/test/features/home/presentation/pages/home_page_test.dart index fd1ea3a44..23ba63bed 100644 --- a/test/features/home/presentation/pages/home_page_test.dart +++ b/test/features/home/presentation/pages/home_page_test.dart @@ -16,6 +16,7 @@ import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_source.dart'; import 'package:dpip/core/realtime/ticker.dart'; import 'package:dpip/core/settings/experimental_settings.dart'; +import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/core/settings/sky_time_mode.dart'; @@ -41,6 +42,7 @@ import 'package:dpip/shared/map/map_station_handoff.dart'; import 'package:dpip/shared/map/raster_frame_source.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; +import 'package:dpip/shared/navigation/widget_location_link.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -53,6 +55,159 @@ import 'package:provider/single_child_widget.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + group('Widget location requests', () { + testWidgets('changed codes and repeated taps each fetch exactly once', ( + tester, + ) async { + final home = _WidgetHomeHarness(tester); + final requests = WidgetLocationRequests(); + addTearDown(requests.dispose); + // A cold request is consumed after Home builds, never during build. + requests.request(_link('region:407')); + expect(home.store.selectedIndex, 0); + await tester.pumpWidget(home.app(requests)); + await _flush(tester); + home.expectRequests(['407']); + + requests.request(_link('region:242')); + expect(home.store.selectedCode, '407'); + home.expectRequests(['407']); + await _flush(tester); + expect(home.store.selectedCode, '242'); + home.expectRequests(['407', '242']); + + for (var tap = 0; tap < 2; tap++) { + requests.request(_link('region:242')); + await _flush(tester); + home.expectRequests(['407', '242', ...List.filled(tap + 1, '242')]); + } + expect(requests.takePending(), isNull); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('current and saved areas with the same code refresh once', ( + tester, + ) async { + final home = _WidgetHomeHarness(tester); + final requests = WidgetLocationRequests(); + addTearDown(requests.dispose); + await tester.pumpWidget(home.app(requests)); + for (final identifier in [ + 'region:407', + 'current-location', + 'region:407', + 'current-location', + ]) { + final before = home.weather.codes.length; + requests.request(_link(identifier)); + await _flush(tester); + expect(home.store.selectedCode, '407'); + expect( + home.store.selected, + identifier == 'current-location' + ? isA() + : isA(), + ); + home.expectRequests(List.filled(before + 1, '407')); + } + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets( + 'invalid and deleted targets leave selection and weather alone', + (tester) async { + final home = _WidgetHomeHarness(tester); + final requests = WidgetLocationRequests(); + addTearDown(requests.dispose); + await tester.pumpWidget(home.app(requests)); + requests.request(_link('region:242')); + await _flush(tester); + final deleted = _link('region:407'); + home.store.removeSaved('407'); + for (final link in [ + deleted, + _link('region:999'), // Saved, but missing from the directory. + _link('region:302'), // Not saved. + WidgetLocationLink.parse(Uri.parse('/home?widgetLocation=bad')), + ]) { + requests.request(link); + await _flush(tester); + expect(home.store.selectedCode, '242'); + home.expectRequests(['242']); + expect(requests.takePending(), isNull); + } + expect(home.store.savedCodes, ['242', '999']); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + + testWidgets( + 'updates detach old listeners and consume only the latest source', + (tester) async { + final home = _WidgetHomeHarness(tester); + final first = WidgetLocationRequests(); + final second = WidgetLocationRequests(); + addTearDown(first.dispose); + addTearDown(second.dispose); + await tester.pumpWidget(home.app(first)); + final state = tester.state(find.byType(HomePage)); + // A callback from the old source is already queued when it is replaced. + final oldLink = _link('region:407'); + first.request(oldLink); + second.request(_link('region:242')); + await tester.pumpWidget(home.app(second)); + await _flush(tester); + expect(tester.state(find.byType(HomePage)), same(state)); + expect(first.takePending(), same(oldLink)); + home.expectRequests(['242']); + + // Updating with the same source must not subscribe a second time. + await tester.pumpWidget(home.app(second)); + second.request(_link('region:242')); + await _flush(tester); + home.expectRequests(['242', '242']); + + await tester.pumpWidget(home.app(null)); + final detached = _link('region:407'); + second.request(detached); + await _flush(tester); + expect(second.takePending(), same(detached)); + home.expectRequests(['242', '242']); + + first.request(_link('region:407')); + await tester.pumpWidget(home.app(first)); + await _flush(tester); + home.expectRequests(['242', '242', '407']); + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('disposal drops a queued callback and removes the listener', ( + tester, + ) async { + final home = _WidgetHomeHarness(tester); + final requests = WidgetLocationRequests(); + addTearDown(requests.dispose); + await tester.pumpWidget(home.app(requests)); + final queued = _link('region:407'); + requests.request(queued); + await tester.pumpWidget(home.app(null, showHome: false)); + expect(requests.takePending(), same(queued)); + home.expectRequests([]); + + final afterDispose = _link('region:242'); + requests.request(afterDispose); + await tester.pump(); + expect(requests.takePending(), same(afterDispose)); + home.expectRequests([]); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + }); + test('resolveBackdrop keeps a forced look off the live channels', () { const wind = WeatherWind(); final rain = WeatherRealtimeData( @@ -232,8 +387,10 @@ List _providers({ required HomeSheetExtent extent, required HomeResetSignal reset, required MapCameraHandoff handoff, + TownDirectory directory = const TownDirectory({}), + MeteorWeatherRepository weather = const _Weather(), + RainHourTrendRepository? hours, }) { - const directory = TownDirectory({}); final events = _Events(); final clock = _Clock(); return [ @@ -260,8 +417,8 @@ List _providers({ Provider.value(value: events), ChangeNotifierProvider( create: (_) => HomeWeatherController( - const _Weather(), - _Hours(), + weather, + hours ?? _Hours(), store, directory, gpsFix: () async => null, @@ -290,6 +447,111 @@ List _providers({ ]; } +WidgetLocationLink _link(String identifier) => WidgetLocationLink.parse( + Uri(path: '/home', queryParameters: {'widgetLocation': identifier}), +)!; + +class _WidgetHomeHarness { + _WidgetHomeHarness(WidgetTester tester) { + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final previous = MapLibrePlatform.createInstance; + MapLibrePlatform.createInstance = () => _MapPlatform(); + addTearDown(() => MapLibrePlatform.createInstance = previous); + final settings = SettingsStore.inMemory(); + store = RegionStore(settings) + ..addSaved('407') + ..addSaved('242') + ..addSaved('999') + ..setCurrentCode('407') + ..select(0); + final experimental = ExperimentalSettings(settings); + final extent = HomeSheetExtent(); + final reset = HomeResetSignal(); + final handoff = MapCameraHandoff(); + for (final notifier in [store, experimental, extent, reset, handoff]) { + addTearDown(notifier.dispose); + } + providers = _providers( + store: store, + experimental: experimental, + extent: extent, + reset: reset, + handoff: handoff, + directory: TownDirectory.fromJson({ + for (final (code, lat, lng) in [ + ('407', 24.0, 121.0), + ('242', 25.0, 122.0), + ]) + code: { + 'city': 'Test', + 'town': code, + 'lat': lat, + 'lng': lng, + 'cityLevel': '市', + 'townLevel': '區', + }, + }), + weather: weather, + hours: hours, + ); + } + + late final RegionStore store; + late final List providers; + final weather = _RecordingWeather(); + final hours = _RecordingHours(); + + Widget app(WidgetLocationRequests? requests, {bool showHome = true}) => + MultiProvider( + providers: providers, + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: showHome + ? HomePage(widgetLocationRequests: requests) + : const SizedBox(), + ), + ); + + void expectRequests(List codes) { + expect(weather.codes, codes, reason: 'forecast requests'); + expect(hours.codes, codes, reason: 'rain trend requests'); + expect(weather.coordinates, [ + for (final code in codes) code == '407' ? (24.0, 121.0) : (25.0, 122.0), + ], reason: 'realtime requests use the selected township coordinates'); + } +} + +class _RecordingWeather extends _Weather { + final codes = []; + final coordinates = <(double, double)>[]; + + @override + Future> realtime(double lat, double lng) { + coordinates.add((lat, lng)); + return super.realtime(lat, lng); + } + + @override + Future> forecast(String code) { + codes.add(code); + return super.forecast(code); + } +} + +class _RecordingHours extends _Hours { + final codes = []; + + @override + Future> hourTrend(String code) { + codes.add(code); + return super.hourTrend(code); + } +} + class _Clock implements Clock { @override DateTime now() => DateTime.utc(2026, 1, 15, 4);