Skip to content
Open
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
17 changes: 13 additions & 4 deletions ios/Runner/WidgetSnapshotPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,27 @@ import WidgetKit
enum WidgetSnapshotKind: String {
case weatherForecast
case currentWeather
case locationCatalog

var filename: String {
switch self {
case .weatherForecast:
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
}
}
}
Expand Down Expand Up @@ -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)) }
}
Expand Down Expand Up @@ -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)) }
Expand Down
7 changes: 7 additions & 0 deletions ios/RunnerTests/RunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
7 changes: 7 additions & 0 deletions lib/app/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions lib/bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -287,6 +289,12 @@ Future<void> 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,
Expand Down Expand Up @@ -379,6 +387,7 @@ Future<void> bootstrap() async {
townDirectory: townDirectory,
townBoundaries: townBoundaries,
regionStore: regionStore,
widgetLocationCatalogCoordinator: widgetLocationCatalogCoordinator,
locationService: locationService,
deviceLocationReporter: deviceLocationReporter,
backgroundLocation: backgroundLocation,
Expand Down
5 changes: 5 additions & 0 deletions lib/core/di/shared_deps.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down
69 changes: 69 additions & 0 deletions lib/core/platform/widget_location_catalog.dart
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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<WidgetLocationCatalogLocation> locations;

Map<String, Object> toJson() {
return {
'schemaVersion': schemaVersion,
'locations': [for (final location in locations) location.toJson()],
};
}
}

WidgetLocationCatalog createWidgetLocationCatalog({
required List<String> savedCodes,
required TownDirectory townDirectory,
}) {
final locations = <WidgetLocationCatalogLocation>[];

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);
}
79 changes: 79 additions & 0 deletions lib/core/platform/widget_location_catalog_coordinator.dart
Original file line number Diff line number Diff line change
@@ -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<String>? _lastSavedCodes;
Future<void> _pendingWrite = Future<void>.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<void> waitForPendingWrites() => _pendingWrite;

void _handleRegionChanged() {
_schedulePublish();
}

void _schedulePublish({bool force = false}) {
final savedCodes = _regions.savedCodes;

if (!force && _sameCodes(_lastSavedCodes, savedCodes)) {
return;
}

_lastSavedCodes = List<String>.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<String>? previous, List<String> 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;
}
}
4 changes: 2 additions & 2 deletions lib/core/platform/widget_snapshot_writer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading