diff --git a/ios/Runner/WidgetSnapshotPlugin.swift b/ios/Runner/WidgetSnapshotPlugin.swift index 104327ab6..0bf45b86d 100644 --- a/ios/Runner/WidgetSnapshotPlugin.swift +++ b/ios/Runner/WidgetSnapshotPlugin.swift @@ -6,6 +6,7 @@ import WidgetKit enum WidgetSnapshotKind: String { case weatherForecast case currentWeather + case locationCatalog var filename: String { switch self { @@ -13,15 +14,19 @@ enum WidgetSnapshotKind: String { return "weather-forecast.json" case .currentWeather: return "current-weather.json" + case .locationCatalog: + return "location-catalog.json" } } - var widgetKind: String { + var widgetKind: String? { switch self { case .weatherForecast: return "DPIPWidgets" case .currentWeather: return "DPIPWidgets" + case .locationCatalog: + return nil } } } @@ -144,8 +149,10 @@ public final class WidgetSnapshotPlugin: NSObject, FlutterPlugin { do { try WidgetSnapshotFile.replace(data, kind: kind, in: container) - WidgetCenter.shared.reloadTimelines(ofKind: kind.widgetKind) - DispatchQueue.main.async { result(nil) } + if let widgetKind = kind.widgetKind { + WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) + }; + DispatchQueue.main.async { result(nil) } } catch { DispatchQueue.main.async { result(self.flutterError(.writeFailed)) } } @@ -182,7 +189,9 @@ public final class WidgetSnapshotPlugin: NSObject, FlutterPlugin { do { try WidgetSnapshotFile.clear(kind, in: container) - WidgetCenter.shared.reloadTimelines(ofKind: kind.widgetKind) + if let widgetKind = kind.widgetKind { + WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) + } DispatchQueue.main.async { result(nil) } } catch { DispatchQueue.main.async { result(self.flutterError(.writeFailed)) } diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 6226e98dc..f1385a739 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -7,9 +7,16 @@ final class RunnerTests: XCTestCase { let kind = try WidgetSnapshotFile.kind("weatherForecast") XCTAssertEqual(kind.filename, "weather-forecast.json") XCTAssertEqual(kind.rawValue, "weatherForecast") + let currentWeather = try WidgetSnapshotFile.kind("currentWeather") XCTAssertEqual(currentWeather.filename, "current-weather.json") XCTAssertEqual(currentWeather.rawValue, "currentWeather") + + let locationCatalog = try WidgetSnapshotFile.kind("locationCatalog") + XCTAssertEqual(locationCatalog.filename, "location-catalog.json") + XCTAssertEqual(locationCatalog.rawValue, "locationCatalog") + XCTAssertNil(locationCatalog.widgetKind) + XCTAssertThrowsError(try WidgetSnapshotFile.kind("../other.json")) { error in XCTAssertEqual(error as? WidgetSnapshotError, .invalidKind) } diff --git a/lib/app/app.dart b/lib/app/app.dart index 7fa33624d..de654337b 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -12,6 +12,7 @@ import 'package:dpip/core/notifications/notification_service.dart'; import 'package:dpip/core/notifications/notification_taps.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'; import 'package:dpip/core/realtime/realtime_lifecycle.dart'; import 'package:dpip/core/realtime/realtime_service.dart'; import 'package:dpip/core/settings/locale_config.dart'; @@ -51,6 +52,7 @@ class DpipApp extends StatelessWidget { notificationService: deps.notificationService, locationService: deps.locationService, regionStore: deps.regionStore, + widgetLocationCatalogCoordinator: deps.widgetLocationCatalogCoordinator, deviceLocationReporter: deps.deviceLocationReporter, backgroundLocation: deps.backgroundLocation, locationMonitor: deps.locationMonitor, @@ -141,6 +143,7 @@ class _AppServicesHost extends StatefulWidget { required this.notificationService, required this.locationService, required this.regionStore, + required this.widgetLocationCatalogCoordinator, required this.deviceLocationReporter, required this.backgroundLocation, required this.locationMonitor, @@ -153,6 +156,7 @@ class _AppServicesHost extends StatefulWidget { final NotificationService notificationService; final LocationService locationService; final RegionStore regionStore; + final WidgetLocationCatalogCoordinator widgetLocationCatalogCoordinator; final DeviceLocationReporter deviceLocationReporter; final BackgroundLocationService backgroundLocation; final LocationMonitor locationMonitor; @@ -174,6 +178,8 @@ class _AppServicesHostState extends State<_AppServicesHost> super.initState(); _observer = RealtimeLifecycleObserver(widget.realtimeService); WidgetsBinding.instance.addObserver(this); + + widget.widgetLocationCatalogCoordinator.start(); // The map tab keeps whichever overlay the session left it on, so an EEW // tap has to name 強震監視器 as well as the route. Same hand-off the home // monitor banner and the nav bar use, so the map has one way in. @@ -276,6 +282,7 @@ class _AppServicesHostState extends State<_AppServicesHost> NotificationTaps.onTap = null; widget.onboarding.removeListener(_onOnboardingChanged); WidgetsBinding.instance.removeObserver(this); + widget.widgetLocationCatalogCoordinator.dispose(); _observer.dispose(); widget.locationMonitor.dispose(); widget.permissionHealth.dispose(); diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index c523aa7d2..7e96e48d3 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -15,6 +15,8 @@ import 'package:dpip/core/logging/log_store.dart'; import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/platform/background_location.dart'; import 'package:dpip/core/platform/install_source.dart'; +import 'package:dpip/core/platform/widget_location_catalog_coordinator.dart'; +import 'package:dpip/core/platform/widget_snapshot_writer.dart'; import 'package:dpip/core/network/dio_client.dart'; import 'package:dpip/core/network/endpoint_health.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; @@ -287,6 +289,12 @@ Future bootstrap() async { final townDirectory = await townDirectoryFuture; final townMs = Log.sinceStartMs - townStart; final regionStore = RegionStore(settings); + final widgetSnapshotWriter = IosWidgetSnapshotWriter(); + final widgetLocationCatalogCoordinator = WidgetLocationCatalogCoordinator( + regionStore, + townDirectory, + widgetSnapshotWriter, + ); final locationService = LocationService( townDirectory, boundaries: townBoundaries, @@ -379,6 +387,7 @@ Future bootstrap() async { townDirectory: townDirectory, townBoundaries: townBoundaries, regionStore: regionStore, + widgetLocationCatalogCoordinator: widgetLocationCatalogCoordinator, locationService: locationService, deviceLocationReporter: deviceLocationReporter, backgroundLocation: backgroundLocation, diff --git a/lib/core/di/shared_deps.dart b/lib/core/di/shared_deps.dart index ac38b8a52..05a1b682b 100644 --- a/lib/core/di/shared_deps.dart +++ b/lib/core/di/shared_deps.dart @@ -20,6 +20,7 @@ import 'package:dpip/core/network/region_selection.dart'; import 'package:dpip/core/notifications/notification_service.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'; import 'package:dpip/core/realtime/realtime_service.dart'; import 'package:dpip/core/realtime/server_clock.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; @@ -58,6 +59,7 @@ class SharedDeps { required this.townDirectory, required this.townBoundaries, required this.regionStore, + required this.widgetLocationCatalogCoordinator, required this.locationService, required this.deviceLocationReporter, required this.backgroundLocation, @@ -117,6 +119,9 @@ class SharedDeps { /// App-wide Home region selection (also provided). final RegionStore regionStore; + /// Synchronizes DPIP's saved locations into the Widget App Group catalog. + final WidgetLocationCatalogCoordinator widgetLocationCatalogCoordinator; + /// GPS → current township resolver (geolocator). final LocationService locationService; diff --git a/lib/core/platform/widget_location_catalog.dart b/lib/core/platform/widget_location_catalog.dart new file mode 100644 index 000000000..bf173b1f3 --- /dev/null +++ b/lib/core/platform/widget_location_catalog.dart @@ -0,0 +1,69 @@ +import 'package:dpip/core/geo/town_directory.dart'; + +final class WidgetLocationCatalogLocation { + const WidgetLocationCatalogLocation({ + required this.regionCode, + required this.displayName, + required this.administrativeAreaName, + required this.latitude, + required this.longitude, + }); + + final String regionCode; + final String displayName; + final String administrativeAreaName; + final double latitude; + final double longitude; + + Map toJson() { + return { + 'regionCode': regionCode, + 'displayName': displayName, + 'administrativeAreaName': administrativeAreaName, + 'latitude': latitude, + 'longitude': longitude, + }; + } +} + +final class WidgetLocationCatalog { + const WidgetLocationCatalog({ + this.schemaVersion = 1, + required this.locations, + }); + + final int schemaVersion; + final List locations; + + Map toJson() { + return { + 'schemaVersion': schemaVersion, + 'locations': [for (final location in locations) location.toJson()], + }; + } +} + +WidgetLocationCatalog createWidgetLocationCatalog({ + required List savedCodes, + required TownDirectory townDirectory, +}) { + final locations = []; + + for (final code in savedCodes) { + final town = townDirectory.byCode(code); + + if (town == null) continue; + + locations.add( + WidgetLocationCatalogLocation( + regionCode: town.code, + displayName: town.townName, + administrativeAreaName: town.cityName, + latitude: town.lat, + longitude: town.lng, + ), + ); + } + + return WidgetLocationCatalog(locations: locations); +} diff --git a/lib/core/platform/widget_location_catalog_coordinator.dart b/lib/core/platform/widget_location_catalog_coordinator.dart new file mode 100644 index 000000000..e303551a7 --- /dev/null +++ b/lib/core/platform/widget_location_catalog_coordinator.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; + +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/platform/widget_location_catalog.dart'; +import 'package:dpip/core/platform/widget_snapshot_writer.dart'; +import 'package:dpip/core/settings/region_store.dart'; + +final class WidgetLocationCatalogCoordinator { + WidgetLocationCatalogCoordinator( + this._regions, + this._directory, + this._writer, + ); + + final RegionStore _regions; + final TownDirectory _directory; + final WidgetSnapshotWriter _writer; + + List? _lastSavedCodes; + Future _pendingWrite = Future.value(); + bool _started = false; + + void start() { + if (_started) return; + + _started = true; + _regions.addListener(_handleRegionChanged); + + _schedulePublish(force: true); + } + + void dispose() { + if (!_started) return; + + _regions.removeListener(_handleRegionChanged); + _started = false; + } + + Future waitForPendingWrites() => _pendingWrite; + + void _handleRegionChanged() { + _schedulePublish(); + } + + void _schedulePublish({bool force = false}) { + final savedCodes = _regions.savedCodes; + + if (!force && _sameCodes(_lastSavedCodes, savedCodes)) { + return; + } + + _lastSavedCodes = List.unmodifiable(savedCodes); + + final catalog = createWidgetLocationCatalog( + savedCodes: savedCodes, + townDirectory: _directory, + ); + + final json = jsonEncode(catalog.toJson()); + + _pendingWrite = _pendingWrite.then((_) async { + await _writer.write(kind: WidgetSnapshotKind.locationCatalog, json: json); + }); + } + + bool _sameCodes(List? previous, List current) { + if (previous == null || previous.length != current.length) { + return false; + } + + for (var index = 0; index < current.length; index++) { + if (previous[index] != current[index]) { + return false; + } + } + + return true; + } +} diff --git a/lib/core/platform/widget_snapshot_writer.dart b/lib/core/platform/widget_snapshot_writer.dart index 7be128ca3..62d01b809 100644 --- a/lib/core/platform/widget_snapshot_writer.dart +++ b/lib/core/platform/widget_snapshot_writer.dart @@ -5,8 +5,8 @@ import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:flutter/services.dart'; -/// Native allowlist key. This reserves a file only; no forecast producer exists. -enum WidgetSnapshotKind { weatherForecast, currentWeather } +/// Native allowlist key for versioned Widget shared data. +enum WidgetSnapshotKind { weatherForecast, currentWeather, locationCatalog } /// Writes an already encoded, versioned JSON snapshot for a future widget. /// The caller owns its schema; this boundary owns delivery and persistence. diff --git a/test/core/platform/widget_location_catalog_coordinator_test.dart b/test/core/platform/widget_location_catalog_coordinator_test.dart new file mode 100644 index 000000000..6bebc75cb --- /dev/null +++ b/test/core/platform/widget_location_catalog_coordinator_test.dart @@ -0,0 +1,416 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/platform/widget_location_catalog_coordinator.dart'; +import 'package:dpip/core/platform/widget_snapshot_writer.dart'; +import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final townDirectory = TownDirectory.fromJson({ + '407': { + 'city': '臺中', + 'town': '西屯', + 'lat': 24.1658213, + 'lng': 120.6336717, + 'cityLevel': '市', + 'townLevel': '區', + }, + '700': { + 'city': '臺南', + 'town': '中西', + 'lat': 22.994821, + 'lng': 120.196452, + 'cityLevel': '市', + 'townLevel': '區', + }, + }); + + test('publishes the saved-location catalog when started', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['407'], + }), + ); + + final writer = _RecordingWidgetSnapshotWriter(); + + final coordinator = WidgetLocationCatalogCoordinator( + regions, + townDirectory, + writer, + ); + + addTearDown(coordinator.dispose); + + coordinator.start(); + await coordinator.waitForPendingWrites(); + + expect(writer.writes, hasLength(1)); + expect(writer.writes.single.kind, WidgetSnapshotKind.locationCatalog); + + final json = jsonDecode(writer.writes.single.json) as Map; + + expect(json['schemaVersion'], 1); + + final locations = json['locations'] as List; + + expect(locations, hasLength(1)); + expect((locations.single as Map)['regionCode'], '407'); + }); + + test('does not republish when only the selected Home area changes', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['407'], + }), + ); + + final writer = _RecordingWidgetSnapshotWriter(); + + final coordinator = WidgetLocationCatalogCoordinator( + regions, + townDirectory, + writer, + ); + + addTearDown(coordinator.dispose); + + coordinator.start(); + await coordinator.waitForPendingWrites(); + + expect(writer.writes, hasLength(1)); + + regions.select(0); + + await coordinator.waitForPendingWrites(); + + expect(writer.writes, hasLength(1)); + }); + + test('does not republish when only the current GPS code changes', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['407'], + }), + ); + + final writer = _RecordingWidgetSnapshotWriter(); + + final coordinator = WidgetLocationCatalogCoordinator( + regions, + townDirectory, + writer, + ); + + addTearDown(coordinator.dispose); + + coordinator.start(); + await coordinator.waitForPendingWrites(); + + expect(writer.writes, hasLength(1)); + + regions.setCurrentCode('700'); + + await coordinator.waitForPendingWrites(); + + expect(writer.writes, hasLength(1)); + }); + + test('republishes when the saved locations change', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['407'], + }), + ); + + final writer = _RecordingWidgetSnapshotWriter(); + + final coordinator = WidgetLocationCatalogCoordinator( + regions, + townDirectory, + writer, + ); + + addTearDown(coordinator.dispose); + + coordinator.start(); + await coordinator.waitForPendingWrites(); + + regions.addSaved('700'); + + await coordinator.waitForPendingWrites(); + + expect(writer.writes, hasLength(2)); + + final json = jsonDecode(writer.writes.last.json) as Map; + final locations = json['locations'] as List; + + expect( + locations + .cast>() + .map((location) => location['regionCode']) + .toList(), + ['407', '700'], + ); + }); + + test('republishes when a saved location is removed', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['407', '700'], + }), + ); + + final writer = _RecordingWidgetSnapshotWriter(); + + final coordinator = WidgetLocationCatalogCoordinator( + regions, + townDirectory, + writer, + ); + + addTearDown(coordinator.dispose); + + coordinator.start(); + await coordinator.waitForPendingWrites(); + + regions.removeSaved('407'); + + await coordinator.waitForPendingWrites(); + + expect(writer.writes, hasLength(2)); + + final json = jsonDecode(writer.writes.last.json) as Map; + final locations = json['locations'] as List; + + expect( + locations + .cast>() + .map((location) => location['regionCode']) + .toList(), + ['700'], + ); + }); + + test('republishes when a saved location is replaced', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['407'], + }), + ); + + final writer = _RecordingWidgetSnapshotWriter(); + + final coordinator = WidgetLocationCatalogCoordinator( + regions, + townDirectory, + writer, + ); + + addTearDown(coordinator.dispose); + + coordinator.start(); + await coordinator.waitForPendingWrites(); + + final replaced = regions.replaceSaved('407', '700'); + + expect(replaced, isTrue); + + await coordinator.waitForPendingWrites(); + + expect(writer.writes, hasLength(2)); + + final json = jsonDecode(writer.writes.last.json) as Map; + final locations = json['locations'] as List; + + expect( + locations + .cast>() + .map((location) => location['regionCode']) + .toList(), + ['700'], + ); + }); + + test('republishes when saved locations are reordered', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['407', '700'], + }), + ); + + final writer = _RecordingWidgetSnapshotWriter(); + + final coordinator = WidgetLocationCatalogCoordinator( + regions, + townDirectory, + writer, + ); + + addTearDown(coordinator.dispose); + + coordinator.start(); + await coordinator.waitForPendingWrites(); + + regions.reorderSaved(1, 0); + + await coordinator.waitForPendingWrites(); + + expect(writer.writes, hasLength(2)); + + final json = jsonDecode(writer.writes.last.json) as Map; + final locations = json['locations'] as List; + + expect( + locations + .cast>() + .map((location) => location['regionCode']) + .toList(), + ['700', '407'], + ); + }); + + test('does not publish after disposal', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['407'], + }), + ); + + final writer = _RecordingWidgetSnapshotWriter(); + + final coordinator = WidgetLocationCatalogCoordinator( + regions, + townDirectory, + writer, + ); + + coordinator.start(); + await coordinator.waitForPendingWrites(); + + expect(writer.writes, hasLength(1)); + + coordinator.dispose(); + + regions.addSaved('700'); + + await coordinator.waitForPendingWrites(); + + expect(writer.writes, hasLength(1)); + }); + + test('serializes writes so the newest catalog is written last', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['407'], + }), + ); + + final writer = _BlockingFirstWriteWidgetSnapshotWriter(); + + final coordinator = WidgetLocationCatalogCoordinator( + regions, + townDirectory, + writer, + ); + + addTearDown(coordinator.dispose); + + coordinator.start(); + + // Wait until the initial catalog write has actually started. + await writer.firstWriteStarted.future; + + expect(writer.writes, hasLength(1)); + + // Change the saved locations while the first write is still blocked. + regions.addSaved('700'); + + // Give any queued async work a chance to run. + await Future.delayed(Duration.zero); + + // The second write must NOT start before the first one finishes. + expect(writer.writes, hasLength(1)); + + writer.releaseFirstWrite.complete(); + + await coordinator.waitForPendingWrites(); + + expect(writer.writes, hasLength(2)); + + final firstJson = + jsonDecode(writer.writes.first.json) as Map; + final firstLocations = firstJson['locations'] as List; + + expect( + firstLocations + .cast>() + .map((location) => location['regionCode']) + .toList(), + ['407'], + ); + + final lastJson = + jsonDecode(writer.writes.last.json) as Map; + final lastLocations = lastJson['locations'] as List; + + expect( + lastLocations + .cast>() + .map((location) => location['regionCode']) + .toList(), + ['407', '700'], + ); + }); +} + +final class _RecordingWidgetSnapshotWriter implements WidgetSnapshotWriter { + final List<({WidgetSnapshotKind kind, String json})> writes = []; + + @override + Future> write({ + required WidgetSnapshotKind kind, + required String json, + }) async { + writes.add((kind: kind, json: json)); + return const Ok(null); + } + + @override + Future> clear({required WidgetSnapshotKind kind}) async { + return const Ok(null); + } +} + +final class _BlockingFirstWriteWidgetSnapshotWriter + implements WidgetSnapshotWriter { + final List<({WidgetSnapshotKind kind, String json})> writes = []; + + final Completer firstWriteStarted = Completer(); + final Completer releaseFirstWrite = Completer(); + + @override + Future> write({ + required WidgetSnapshotKind kind, + required String json, + }) async { + writes.add((kind: kind, json: json)); + + if (writes.length == 1) { + firstWriteStarted.complete(); + + await releaseFirstWrite.future; + } + + return const Ok(null); + } + + @override + Future> clear({required WidgetSnapshotKind kind}) async { + return const Ok(null); + } +} diff --git a/test/core/platform/widget_location_catalog_test.dart b/test/core/platform/widget_location_catalog_test.dart new file mode 100644 index 000000000..7ac925367 --- /dev/null +++ b/test/core/platform/widget_location_catalog_test.dart @@ -0,0 +1,87 @@ +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/platform/widget_location_catalog.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final townDirectory = TownDirectory.fromJson({ + '407': { + 'city': '臺中', + 'town': '西屯', + 'lat': 24.1658213, + 'lng': 120.6336717, + 'cityLevel': '市', + 'townLevel': '區', + }, + '700': { + 'city': '臺南', + 'town': '中西', + 'lat': 22.994821, + 'lng': 120.196452, + 'cityLevel': '市', + 'townLevel': '區', + }, + }); + + group('createWidgetLocationCatalog', () { + test('projects saved region codes in order', () { + final catalog = createWidgetLocationCatalog( + savedCodes: ['407', '700'], + townDirectory: townDirectory, + ); + + expect(catalog.schemaVersion, 1); + expect(catalog.locations, hasLength(2)); + + expect(catalog.locations[0].regionCode, '407'); + expect(catalog.locations[0].displayName, '西屯區'); + expect(catalog.locations[0].administrativeAreaName, '臺中市'); + expect(catalog.locations[0].latitude, 24.1658213); + expect(catalog.locations[0].longitude, 120.6336717); + + expect(catalog.locations[1].regionCode, '700'); + expect(catalog.locations[1].displayName, '中西區'); + expect(catalog.locations[1].administrativeAreaName, '臺南市'); + }); + + test('omits unresolved codes while preserving resolved order', () { + final catalog = createWidgetLocationCatalog( + savedCodes: ['407', '999', '700'], + townDirectory: townDirectory, + ); + + expect( + catalog.locations.map((location) => location.regionCode).toList(), + ['407', '700'], + ); + }); + + test('serializes the exact widget location catalog contract', () { + final catalog = createWidgetLocationCatalog( + savedCodes: ['407'], + townDirectory: townDirectory, + ); + + expect(catalog.toJson(), { + 'schemaVersion': 1, + 'locations': [ + { + 'regionCode': '407', + 'displayName': '西屯區', + 'administrativeAreaName': '臺中市', + 'latitude': 24.1658213, + 'longitude': 120.6336717, + }, + ], + }); + }); + + test('serializes an empty saved-location catalog', () { + final catalog = createWidgetLocationCatalog( + savedCodes: const [], + townDirectory: townDirectory, + ); + + expect(catalog.toJson(), {'schemaVersion': 1, 'locations': []}); + }); + }); +} diff --git a/test/core/platform/widget_snapshot_writer_test.dart b/test/core/platform/widget_snapshot_writer_test.dart index 687a45f35..c96c2f0a8 100644 --- a/test/core/platform/widget_snapshot_writer_test.dart +++ b/test/core/platform/widget_snapshot_writer_test.dart @@ -200,6 +200,30 @@ void main() { entry.value, ); }); + + test( + 'writes the location catalog kind without exposing a file path', + () async { + final writer = IosWidgetSnapshotWriter( + channel: channel, + isSupportedPlatform: true, + ); + + const json = '{"schemaVersion":1,"locations":[]}'; + + final result = await writer.write( + kind: WidgetSnapshotKind.locationCatalog, + json: json, + ); + + expect(result.isOk, isTrue); + expect(calls.single.method, 'write'); + expect(calls.single.arguments, { + 'kind': 'locationCatalog', + 'json': json, + }); + }, + ); } }