From d54ace625a4b219660233b8b7c1ba0b57714412c Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Sun, 13 Sep 2026 23:49:32 +0800 Subject: [PATCH 01/14] chore: add the iOS widget snapshot storage bridge Platform: ios --- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 1 + ios/Runner/Info.plist | 2 + ios/Runner/Runner.entitlements | 4 + ios/Runner/RunnerRelease.entitlements | 4 + ios/Runner/WidgetSnapshotPlugin.swift | 127 ++++++++++++++++++ ios/RunnerTests/RunnerTests.swift | 42 +++++- lib/core/error/failure.dart | 15 +++ lib/core/platform/widget_snapshot_writer.dart | 88 ++++++++++++ .../platform/widget_snapshot_writer_test.dart | 106 +++++++++++++++ 10 files changed, 387 insertions(+), 6 deletions(-) create mode 100644 ios/Runner/WidgetSnapshotPlugin.swift create mode 100644 lib/core/platform/widget_snapshot_writer.dart create mode 100644 test/core/platform/widget_snapshot_writer_test.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b80d3b508..3e0309a7f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ CAC4EF00000000000000B001 /* MapCachePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000B002 /* MapCachePlugin.swift */; }; CAC4EF00000000000000C001 /* BackgroundLocationPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000C002 /* BackgroundLocationPlugin.swift */; }; CAC4EF00000000000000C101 /* BackgroundExecutionPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000C102 /* BackgroundExecutionPlugin.swift */; }; + CAC4EF00000000000000E101 /* WidgetSnapshotPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000E102 /* WidgetSnapshotPlugin.swift */; }; CAC4EF00000000000000D001 /* StorageScanPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000D002 /* StorageScanPlugin.swift */; }; DB8E84D957A6C2B12EEB0AB7 /* Sounds/report.aiff in Resources */ = {isa = PBXBuildFile; fileRef = FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */; }; DP1PF1REBASE0001PL1ST010 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */; }; @@ -102,6 +103,7 @@ CAC4EF00000000000000B002 /* MapCachePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MapCachePlugin.swift; sourceTree = ""; }; CAC4EF00000000000000C002 /* BackgroundLocationPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundLocationPlugin.swift; sourceTree = ""; }; CAC4EF00000000000000C102 /* BackgroundExecutionPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundExecutionPlugin.swift; sourceTree = ""; }; + CAC4EF00000000000000E102 /* WidgetSnapshotPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshotPlugin.swift; sourceTree = ""; }; CAC4EF00000000000000D002 /* StorageScanPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageScanPlugin.swift; sourceTree = ""; }; CCF0E2286072A9283A916C69 /* Sounds/tsunami.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/tsunami.aiff; sourceTree = ""; }; DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; @@ -190,6 +192,7 @@ CAC4EF00000000000000B002 /* MapCachePlugin.swift */, CAC4EF00000000000000C002 /* BackgroundLocationPlugin.swift */, CAC4EF00000000000000C102 /* BackgroundExecutionPlugin.swift */, + CAC4EF00000000000000E102 /* WidgetSnapshotPlugin.swift */, CAC4EF00000000000000D002 /* StorageScanPlugin.swift */, 7A6E88CB92902C0CACB07792 /* Sounds/eew.aiff */, 4D00D2962CF4598B61D0C722 /* Sounds/eew_alert.aiff */, @@ -387,6 +390,7 @@ CAC4EF00000000000000B001 /* MapCachePlugin.swift in Sources */, CAC4EF00000000000000C001 /* BackgroundLocationPlugin.swift in Sources */, CAC4EF00000000000000C101 /* BackgroundExecutionPlugin.swift in Sources */, + CAC4EF00000000000000E101 /* WidgetSnapshotPlugin.swift in Sources */, CAC4EF00000000000000D001 /* StorageScanPlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c315634bb..c8851dfe0 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -38,6 +38,7 @@ import UserNotifications with: registry.registrar(forPlugin: "BackgroundLocationPlugin")!) BackgroundExecutionPlugin.register( with: registry.registrar(forPlugin: "BackgroundExecutionPlugin")!) + WidgetSnapshotPlugin.register(with: registry.registrar(forPlugin: "WidgetSnapshotPlugin")!) // Re-post the launch notification the plugins just missed. // diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 9f8c6160c..b6ed58d7a 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + DPIPWidgetAppGroupIdentifier + group.com.exptech.dpip.dpip.widgets NSAppTransportSecurity NSAllowsLocalNetworking diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index be4adcbe1..16e36bcf7 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -6,5 +6,9 @@ development com.apple.developer.usernotifications.critical-alerts + com.apple.security.application-groups + + group.com.exptech.dpip.dpip.widgets + diff --git a/ios/Runner/RunnerRelease.entitlements b/ios/Runner/RunnerRelease.entitlements index 10834719c..b104f9b81 100644 --- a/ios/Runner/RunnerRelease.entitlements +++ b/ios/Runner/RunnerRelease.entitlements @@ -16,5 +16,9 @@ and provisioning profile must both carry it or signing fails outright. --> com.apple.developer.usernotifications.critical-alerts + com.apple.security.application-groups + + group.com.exptech.dpip.dpip.widgets + diff --git a/ios/Runner/WidgetSnapshotPlugin.swift b/ios/Runner/WidgetSnapshotPlugin.swift new file mode 100644 index 000000000..10fc46912 --- /dev/null +++ b/ios/Runner/WidgetSnapshotPlugin.swift @@ -0,0 +1,127 @@ +import Flutter +import Foundation + +/// The native allowlist also provides the WidgetKit kind for a future reload. +enum WidgetSnapshotKind: String { + case weatherForecast + + var filename: String { + switch self { + case .weatherForecast: return "weather-forecast.json" + } + } +} + +enum WidgetSnapshotError: Error, Equatable { + case invalidKind + case invalidPayload + case appGroupUnavailable + case writeFailed + + var flutterCode: String { + switch self { + case .invalidKind: return "invalid_kind" + case .invalidPayload: return "invalid_payload" + case .appGroupUnavailable: return "app_group_unavailable" + case .writeFailed: return "write_failed" + } + } +} + +/// File operations are separate so RunnerTests can exercise them in a sandbox. +enum WidgetSnapshotFile { + static let maximumPayloadBytes = 128 * 1024 + + static func kind(_ rawValue: String) throws -> WidgetSnapshotKind { + guard let kind = WidgetSnapshotKind(rawValue: rawValue) else { + throw WidgetSnapshotError.invalidKind + } + return kind + } + + static func payload(_ json: String) throws -> Data { + let data = Data(json.utf8) + guard !data.isEmpty, data.count <= maximumPayloadBytes, + let object = try? JSONSerialization.jsonObject(with: data), + object is [String: Any] + else { + throw WidgetSnapshotError.invalidPayload + } + return data + } + + static func replace(_ data: Data, kind: WidgetSnapshotKind, in container: URL) throws { + let directory = container.appendingPathComponent("WidgetSnapshots", isDirectory: true) + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + // Foundation stages the complete bytes in this directory and renames the + // temporary file over the destination. Readers see an old or new inode. + try data.write(to: directory.appendingPathComponent(kind.filename), options: .atomic) + } catch { + throw WidgetSnapshotError.writeFailed + } + } +} + +/// Infrastructure-only Flutter bridge. It never interprets domain JSON. +public final class WidgetSnapshotPlugin: NSObject, FlutterPlugin { + private let writeQueue = DispatchQueue(label: "com.exptech.dpip.widget-snapshot.write") + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "com.exptech.dpip/widget_snapshot", + binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(WidgetSnapshotPlugin(), channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard call.method == "write" else { + result(FlutterMethodNotImplemented) + return + } + + guard let arguments = call.arguments as? [String: Any], + let rawKind = arguments["kind"] as? String, + let json = arguments["json"] as? String + else { + result(flutterError(.invalidPayload)) + return + } + + let kind: WidgetSnapshotKind + let data: Data + do { + kind = try WidgetSnapshotFile.kind(rawKind) + data = try WidgetSnapshotFile.payload(json) + } catch let error as WidgetSnapshotError { + result(flutterError(error)) + return + } catch { + result(flutterError(.invalidPayload)) + return + } + + writeQueue.async { + // An absent key or a profile without this entitlement must fail closed. + guard let group = Bundle.main.object(forInfoDictionaryKey: "DPIPWidgetAppGroupIdentifier") as? String, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: group) + else { + DispatchQueue.main.async { result(self.flutterError(.appGroupUnavailable)) } + return + } + + do { + try WidgetSnapshotFile.replace(data, kind: kind, in: container) + // Future WidgetCenter.reloadTimelines(ofKind: kind.rawValue) belongs here. + DispatchQueue.main.async { result(nil) } + } catch { + DispatchQueue.main.async { result(self.flutterError(.writeFailed)) } + } + } + } + + private func flutterError(_ error: WidgetSnapshotError) -> FlutterError { + FlutterError(code: error.flutterCode, message: error.flutterCode, details: nil) + } +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 86a7c3b1b..a18560a6c 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -1,12 +1,42 @@ -import Flutter -import UIKit +import Foundation import XCTest +@testable import Runner -class RunnerTests: XCTestCase { +final class RunnerTests: XCTestCase { + func testSnapshotKindAllowlist() throws { + let kind = try WidgetSnapshotFile.kind("weatherForecast") + XCTAssertEqual(kind.filename, "weather-forecast.json") + XCTAssertEqual(kind.rawValue, "weatherForecast") + XCTAssertThrowsError(try WidgetSnapshotFile.kind("../other.json")) { error in + XCTAssertEqual(error as? WidgetSnapshotError, .invalidKind) + } + } - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + func testPayloadValidationAndSizeLimit() throws { + XCTAssertEqual(try WidgetSnapshotFile.payload("{\"schemaVersion\":1}"), Data("{\"schemaVersion\":1}".utf8)) + XCTAssertThrowsError(try WidgetSnapshotFile.payload("{")) { error in + XCTAssertEqual(error as? WidgetSnapshotError, .invalidPayload) + } + XCTAssertThrowsError(try WidgetSnapshotFile.payload("[]")) { error in + XCTAssertEqual(error as? WidgetSnapshotError, .invalidPayload) + } + let oversized = "{\"data\":\"\(String(repeating: "a", count: WidgetSnapshotFile.maximumPayloadBytes))\"}" + XCTAssertThrowsError(try WidgetSnapshotFile.payload(oversized)) { error in + XCTAssertEqual(error as? WidgetSnapshotError, .invalidPayload) + } } + func testAtomicSnapshotReplacement() throws { + let container = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: container) } + let kind = try WidgetSnapshotFile.kind("weatherForecast") + let first = try WidgetSnapshotFile.payload("{\"schemaVersion\":1,\"value\":\"old\"}") + let second = try WidgetSnapshotFile.payload("{\"schemaVersion\":1,\"value\":\"new\"}") + let target = container.appendingPathComponent("WidgetSnapshots/weather-forecast.json") + + try WidgetSnapshotFile.replace(first, kind: kind, in: container) + XCTAssertEqual(try Data(contentsOf: target), first) + try WidgetSnapshotFile.replace(second, kind: kind, in: container) + XCTAssertEqual(try Data(contentsOf: target), second) + } } diff --git a/lib/core/error/failure.dart b/lib/core/error/failure.dart index a81a4a2b2..b1c89ef90 100644 --- a/lib/core/error/failure.dart +++ b/lib/core/error/failure.dart @@ -73,3 +73,18 @@ final class MeshChannelConflictFailure extends Failure { final class PermissionDeniedFailure extends Failure { const PermissionDeniedFailure(super.message); } + +/// Recoverable failures at the native Widget snapshot writing boundary. +enum WidgetSnapshotFailureReason { + unavailable, + invalidKind, + invalidPayload, + appGroupUnavailable, + writeFailed, +} + +final class WidgetSnapshotFailure extends Failure { + const WidgetSnapshotFailure(this.reason, super.message); + + final WidgetSnapshotFailureReason reason; +} diff --git a/lib/core/platform/widget_snapshot_writer.dart b/lib/core/platform/widget_snapshot_writer.dart new file mode 100644 index 000000000..c7453912b --- /dev/null +++ b/lib/core/platform/widget_snapshot_writer.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:dpip/core/error/failure.dart'; +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 } + +/// Writes an already encoded, versioned JSON snapshot for a future widget. +/// The caller owns its schema; this boundary owns delivery and persistence. +abstract interface class WidgetSnapshotWriter { + Future> write({ + required WidgetSnapshotKind kind, + required String json, + }); +} + +/// iOS implementation. No App Group path or filename crosses this boundary. +final class IosWidgetSnapshotWriter implements WidgetSnapshotWriter { + IosWidgetSnapshotWriter({MethodChannel? channel, bool? isSupportedPlatform}) + : _channel = + channel ?? const MethodChannel('com.exptech.dpip/widget_snapshot'), + _isSupportedPlatform = isSupportedPlatform ?? Platform.isIOS; + + final MethodChannel _channel; + final bool _isSupportedPlatform; + + @override + Future> write({ + required WidgetSnapshotKind kind, + required String json, + }) async { + if (!_isSupportedPlatform) { + return const Err( + WidgetSnapshotFailure( + WidgetSnapshotFailureReason.unavailable, + 'Widget snapshot writer is unavailable on this platform.', + ), + ); + } + + try { + await _channel.invokeMethod('write', { + 'kind': kind.name, + 'json': json, + }); + return const Ok(null); + } on MissingPluginException { + return const Err( + WidgetSnapshotFailure( + WidgetSnapshotFailureReason.unavailable, + 'Widget snapshot writer is unavailable.', + ), + ); + } on PlatformException catch (error) { + final failure = switch (error.code) { + 'invalid_kind' => const WidgetSnapshotFailure( + WidgetSnapshotFailureReason.invalidKind, + 'Widget snapshot kind is unsupported.', + ), + 'invalid_payload' => const WidgetSnapshotFailure( + WidgetSnapshotFailureReason.invalidPayload, + 'Widget snapshot JSON is invalid or too large.', + ), + 'app_group_unavailable' => const WidgetSnapshotFailure( + WidgetSnapshotFailureReason.appGroupUnavailable, + 'Widget shared storage is unavailable.', + ), + _ => const WidgetSnapshotFailure( + WidgetSnapshotFailureReason.writeFailed, + 'Widget snapshot could not be written.', + ), + }; + Log.warning('Widget snapshot write failed: ${failure.reason.name}'); + return Err(failure); + } on Object { + Log.warning('Widget snapshot write failed unexpectedly'); + return const Err( + WidgetSnapshotFailure( + WidgetSnapshotFailureReason.writeFailed, + 'Widget snapshot could not be written.', + ), + ); + } + } +} diff --git a/test/core/platform/widget_snapshot_writer_test.dart b/test/core/platform/widget_snapshot_writer_test.dart new file mode 100644 index 000000000..7b44cbb0e --- /dev/null +++ b/test/core/platform/widget_snapshot_writer_test.dart @@ -0,0 +1,106 @@ +import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/platform/widget_snapshot_writer.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('test/widget_snapshot'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final calls = []; + + setUp(() { + calls.clear(); + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + }); + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + test( + 'writes a typed kind and encoded JSON without exposing a file path', + () async { + final writer = IosWidgetSnapshotWriter( + channel: channel, + isSupportedPlatform: true, + ); + const json = '{"schemaVersion":1}'; + + final result = await writer.write( + kind: WidgetSnapshotKind.weatherForecast, + json: json, + ); + + expect(result.isOk, isTrue); + expect(calls.single.method, 'write'); + expect(calls.single.arguments, {'kind': 'weatherForecast', 'json': json}); + }, + ); + + test('unsupported platform does not call the native channel', () async { + final writer = IosWidgetSnapshotWriter( + channel: channel, + isSupportedPlatform: false, + ); + + final result = await writer.write( + kind: WidgetSnapshotKind.weatherForecast, + json: '{}', + ); + + expect( + (result.failureOrNull as WidgetSnapshotFailure).reason, + WidgetSnapshotFailureReason.unavailable, + ); + expect(calls, isEmpty); + }); + + test('missing plugin is a typed unavailable failure', () async { + messenger.setMockMethodCallHandler(channel, null); + final writer = IosWidgetSnapshotWriter( + channel: channel, + isSupportedPlatform: true, + ); + + final result = await writer.write( + kind: WidgetSnapshotKind.weatherForecast, + json: '{}', + ); + + expect( + (result.failureOrNull as WidgetSnapshotFailure).reason, + WidgetSnapshotFailureReason.unavailable, + ); + }); + + for (final entry in { + 'invalid_kind': WidgetSnapshotFailureReason.invalidKind, + 'invalid_payload': WidgetSnapshotFailureReason.invalidPayload, + 'app_group_unavailable': WidgetSnapshotFailureReason.appGroupUnavailable, + 'write_failed': WidgetSnapshotFailureReason.writeFailed, + }.entries) { + test('${entry.key} maps to a typed failure', () async { + messenger.setMockMethodCallHandler( + channel, + (_) async => throw PlatformException(code: entry.key), + ); + final writer = IosWidgetSnapshotWriter( + channel: channel, + isSupportedPlatform: true, + ); + + final result = await writer.write( + kind: WidgetSnapshotKind.weatherForecast, + json: '{}', + ); + + expect( + (result.failureOrNull as WidgetSnapshotFailure).reason, + entry.value, + ); + }); + } +} From a9bf1af8e7cfc3fc57b0633c5a89e253138d64da Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 14 Sep 2026 17:04:16 +0800 Subject: [PATCH 02/14] chore: add iOS widget extension infrastructure Platform: ios --- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 +++ ios/DPIPWidgets/Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + ios/DPIPWidgets/DPIPWidgets.swift | 68 +++++ ios/DPIPWidgets/DPIPWidgetsBundle.swift | 9 + ios/DPIPWidgets/Info.plist | 11 + ios/DPIPWidgets/WidgetSnapshotStore.swift | 42 +++ ios/DPIPWidgetsExtension.entitlements | 10 + ios/Runner.xcodeproj/project.pbxproj | 282 ++++++++++++++++-- 10 files changed, 468 insertions(+), 17 deletions(-) create mode 100644 ios/DPIPWidgets/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/DPIPWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/DPIPWidgets/Assets.xcassets/Contents.json create mode 100644 ios/DPIPWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 ios/DPIPWidgets/DPIPWidgets.swift create mode 100644 ios/DPIPWidgets/DPIPWidgetsBundle.swift create mode 100644 ios/DPIPWidgets/Info.plist create mode 100644 ios/DPIPWidgets/WidgetSnapshotStore.swift create mode 100644 ios/DPIPWidgetsExtension.entitlements diff --git a/ios/DPIPWidgets/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/DPIPWidgets/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/ios/DPIPWidgets/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/DPIPWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/DPIPWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/ios/DPIPWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/DPIPWidgets/Assets.xcassets/Contents.json b/ios/DPIPWidgets/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/ios/DPIPWidgets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/DPIPWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/DPIPWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/ios/DPIPWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift new file mode 100644 index 000000000..0ab08ea1f --- /dev/null +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -0,0 +1,68 @@ +import WidgetKit +import SwiftUI + +struct DPIPWidgetProvider: TimelineProvider { + func placeholder(in context: Context) -> DPIPWidgetEntry { + DPIPWidgetEntry(date: .now) + } + + func getSnapshot(in context: Context, completion: @escaping (DPIPWidgetEntry) -> ()) { + let entry = DPIPWidgetEntry(date: .now) + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + let entry = DPIPWidgetEntry(date: .now) + let timeline = Timeline( + entries: [entry], + policy: .never + ) + + completion(timeline) + } +} + +struct DPIPWidgetEntry: TimelineEntry { + let date: Date +} + +struct DPIPWidgetsEntryView : View { + let entry: DPIPWidgetEntry + + var body: some View { + VStack { + Text("DPIP") + Text("Widget Connected") + } + } +} + +struct DPIPWidgets: Widget { + let kind: String = "DPIPWidgets" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: DPIPWidgetProvider()) { entry in + if #available(iOS 17.0, *) { + DPIPWidgetsEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } else { + DPIPWidgetsEntryView(entry: entry) + .padding() + .background() + } + } + .configurationDisplayName("My Widget") + .description("This is an example widget.") + } +} + +struct DPIPWidgets_Previews: PreviewProvider { + static var previews: some View { + DPIPWidgetsEntryView( + entry: DPIPWidgetEntry(date: .now) + ) + .previewContext( + WidgetPreviewContext(family: .systemSmall) + ) + } +} diff --git a/ios/DPIPWidgets/DPIPWidgetsBundle.swift b/ios/DPIPWidgets/DPIPWidgetsBundle.swift new file mode 100644 index 000000000..a4c51ee4d --- /dev/null +++ b/ios/DPIPWidgets/DPIPWidgetsBundle.swift @@ -0,0 +1,9 @@ +import WidgetKit +import SwiftUI + +@main +struct DPIPWidgetsBundle: WidgetBundle { + var body: some Widget { + DPIPWidgets() + } +} diff --git a/ios/DPIPWidgets/Info.plist b/ios/DPIPWidgets/Info.plist new file mode 100644 index 000000000..0f118fb75 --- /dev/null +++ b/ios/DPIPWidgets/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/DPIPWidgets/WidgetSnapshotStore.swift b/ios/DPIPWidgets/WidgetSnapshotStore.swift new file mode 100644 index 000000000..d4777a3bf --- /dev/null +++ b/ios/DPIPWidgets/WidgetSnapshotStore.swift @@ -0,0 +1,42 @@ +import Foundation + +enum WidgetSnapshotKind { + case weatherForecast + + var filename: String { + switch self { + case .weatherForecast: + return "weather-forecast.json" + } + } +} + +struct WidgetSnapshotStore { + private let appGroupIdentifier = + "group.com.exptech.dpip.dpip.widgets" + + func snapshotURL(for kind: WidgetSnapshotKind) -> URL? { + guard let appGroupContainerURL = + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + else { + return nil + } + + let widgetSnapshotsURL = + appGroupContainerURL.appendingPathComponent("WidgetSnapshots") + + return widgetSnapshotsURL.appendingPathComponent( + kind.filename + ) + } + + func loadData(for kind: WidgetSnapshotKind) -> Data? { + guard let url = snapshotURL(for: kind) else { + return nil + } + + return try? Data(contentsOf: url) + } +} diff --git a/ios/DPIPWidgetsExtension.entitlements b/ios/DPIPWidgetsExtension.entitlements new file mode 100644 index 000000000..ace03de41 --- /dev/null +++ b/ios/DPIPWidgetsExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.exptech.dpip.dpip.widgets + + + diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3e0309a7f..91ccd28aa 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -12,6 +12,9 @@ 10D4BCAF9E098BDA400160FB /* Sounds/tsunami.aiff in Resources */ = {isa = PBXBuildFile; fileRef = CCF0E2286072A9283A916C69 /* Sounds/tsunami.aiff */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 17BD5D769AA990E7EE681203 /* Sounds/eew_alert.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 4D00D2962CF4598B61D0C722 /* Sounds/eew_alert.aiff */; }; + 2715FCD830570C1C0014DC8A /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2715FCD730570C1C0014DC8A /* WidgetKit.framework */; }; + 2715FCDA30570C1C0014DC8A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2715FCD930570C1C0014DC8A /* SwiftUI.framework */; }; + 2715FCE530570C1D0014DC8A /* DPIPWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2715FCD530570C1C0014DC8A /* DPIPWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 522508B9301F863A006148C2 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 522508B7301F863A006148C2 /* InfoPlist.strings */; }; @@ -33,8 +36,8 @@ CAC4EF00000000000000B001 /* MapCachePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000B002 /* MapCachePlugin.swift */; }; CAC4EF00000000000000C001 /* BackgroundLocationPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000C002 /* BackgroundLocationPlugin.swift */; }; CAC4EF00000000000000C101 /* BackgroundExecutionPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000C102 /* BackgroundExecutionPlugin.swift */; }; - CAC4EF00000000000000E101 /* WidgetSnapshotPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000E102 /* WidgetSnapshotPlugin.swift */; }; CAC4EF00000000000000D001 /* StorageScanPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000D002 /* StorageScanPlugin.swift */; }; + CAC4EF00000000000000E101 /* WidgetSnapshotPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000E102 /* WidgetSnapshotPlugin.swift */; }; DB8E84D957A6C2B12EEB0AB7 /* Sounds/report.aiff in Resources */ = {isa = PBXBuildFile; fileRef = FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */; }; DP1PF1REBASE0001PL1ST010 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */; }; E1EBD3F0B2991F7D60DF56C5 /* Sounds/weather.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 762B2690D1D999F97D84162C /* Sounds/weather.aiff */; }; @@ -42,6 +45,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 2715FCE330570C1D0014DC8A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2715FCD430570C1C0014DC8A; + remoteInfo = DPIPWidgetsExtension; + }; 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -52,6 +62,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 2715FCE630570C1D0014DC8A /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 2715FCE530570C1D0014DC8A /* DPIPWidgetsExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -67,24 +88,28 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2715FCD530570C1C0014DC8A /* DPIPWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DPIPWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 2715FCD730570C1C0014DC8A /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 2715FCD930570C1C0014DC8A /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 272EFB373057247600B78F5D /* DPIPWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DPIPWidgetsExtension.entitlements; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3AE87ED82FDB896B2B5C5F1B /* Sounds/warn.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/warn.aiff; sourceTree = ""; }; + 3AE87ED82FDB896B2B5C5F1B /* Sounds/warn.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/warn.aiff; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 4D00D2962CF4598B61D0C722 /* Sounds/eew_alert.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/eew_alert.aiff; sourceTree = ""; }; + 4D00D2962CF4598B61D0C722 /* Sounds/eew_alert.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/eew_alert.aiff; sourceTree = ""; }; 522508B8301F863A006148C2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; 522508BA301F8687006148C2 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; 522508BB301F868B006148C2 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; 522508BC301F868E006148C2 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = ""; }; 52632D1A304BDB7500955176 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; - 682D0165E2FF3895C5B252C5 /* Sounds/eq.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/eq.aiff; sourceTree = ""; }; + 682D0165E2FF3895C5B252C5 /* Sounds/eq.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/eq.aiff; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 762B2690D1D999F97D84162C /* Sounds/weather.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/weather.aiff; sourceTree = ""; }; + 762B2690D1D999F97D84162C /* Sounds/weather.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/weather.aiff; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 797A71348A49484748BB3224 /* MapSnapshotPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MapSnapshotPlugin.swift; sourceTree = ""; }; - 7A6E88CB92902C0CACB07792 /* Sounds/eew.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/eew.aiff; sourceTree = ""; }; + 7A6E88CB92902C0CACB07792 /* Sounds/eew.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/eew.aiff; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -93,25 +118,48 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - A382CD9DEA741E45DBF741D7 /* Sounds/rain.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/rain.aiff; sourceTree = ""; }; + A382CD9DEA741E45DBF741D7 /* Sounds/rain.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/rain.aiff; sourceTree = ""; }; AA0000000000000000000C01 /* CompassPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassPlugin.swift; sourceTree = ""; }; AA0000000000000000000D01 /* DeviceInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlugin.swift; sourceTree = ""; }; AA0000000000000000000E01 /* ScreenWakePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenWakePlugin.swift; sourceTree = ""; }; AA0000000000000000000F01 /* ApnsTokenPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApnsTokenPlugin.swift; sourceTree = ""; }; AA0000000000000000000G01 /* LocationTrackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTrackStore.swift; sourceTree = ""; }; - B916667D1B2356583B174E80 /* Sounds/normal.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/normal.aiff; sourceTree = ""; }; + B916667D1B2356583B174E80 /* Sounds/normal.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/normal.aiff; sourceTree = ""; }; CAC4EF00000000000000B002 /* MapCachePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MapCachePlugin.swift; sourceTree = ""; }; CAC4EF00000000000000C002 /* BackgroundLocationPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundLocationPlugin.swift; sourceTree = ""; }; CAC4EF00000000000000C102 /* BackgroundExecutionPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundExecutionPlugin.swift; sourceTree = ""; }; - CAC4EF00000000000000E102 /* WidgetSnapshotPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshotPlugin.swift; sourceTree = ""; }; CAC4EF00000000000000D002 /* StorageScanPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageScanPlugin.swift; sourceTree = ""; }; - CCF0E2286072A9283A916C69 /* Sounds/tsunami.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/tsunami.aiff; sourceTree = ""; }; + CAC4EF00000000000000E102 /* WidgetSnapshotPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshotPlugin.swift; sourceTree = ""; }; + CCF0E2286072A9283A916C69 /* Sounds/tsunami.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/tsunami.aiff; sourceTree = ""; }; DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; - F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/info.aiff; sourceTree = ""; }; - FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/report.aiff; sourceTree = ""; }; + F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/info.aiff; sourceTree = ""; }; + FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/report.aiff; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 2715FCEA30570C1D0014DC8A /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 2715FCD430570C1C0014DC8A /* DPIPWidgetsExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 2715FCDB30570C1C0014DC8A /* DPIPWidgets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2715FCEA30570C1D0014DC8A /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = DPIPWidgets; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ + 2715FCD230570C1C0014DC8A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2715FCDA30570C1C0014DC8A /* SwiftUI.framework in Frameworks */, + 2715FCD830570C1C0014DC8A /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -130,6 +178,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 2715FCD630570C1C0014DC8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + 2715FCD730570C1C0014DC8A /* WidgetKit.framework */, + 2715FCD930570C1C0014DC8A /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -153,8 +210,11 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + 272EFB373057247600B78F5D /* DPIPWidgetsExtension.entitlements */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + 2715FCDB30570C1C0014DC8A /* DPIPWidgets */, + 2715FCD630570C1C0014DC8A /* Frameworks */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, ); @@ -165,6 +225,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + 2715FCD530570C1C0014DC8A /* DPIPWidgetsExtension.appex */, ); name = Products; sourceTree = ""; @@ -211,6 +272,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 2715FCD430570C1C0014DC8A /* DPIPWidgetsExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2715FCEB30570C1D0014DC8A /* Build configuration list for PBXNativeTarget "DPIPWidgetsExtension" */; + buildPhases = ( + 2715FCD130570C1C0014DC8A /* Sources */, + 2715FCD230570C1C0014DC8A /* Frameworks */, + 2715FCD330570C1C0014DC8A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 2715FCDB30570C1C0014DC8A /* DPIPWidgets */, + ); + name = DPIPWidgetsExtension; + packageProductDependencies = ( + ); + productName = DPIPWidgetsExtension; + productReference = 2715FCD530570C1C0014DC8A /* DPIPWidgetsExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 331C8080294A63A400263BE5 /* RunnerTests */ = { isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; @@ -233,6 +316,7 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 2715FCE630570C1D0014DC8A /* Embed Foundation Extensions */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -243,6 +327,7 @@ buildRules = ( ); dependencies = ( + 2715FCE430570C1D0014DC8A /* PBXTargetDependency */, ); name = Runner; packageProductDependencies = ( @@ -259,9 +344,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 2660; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { + 2715FCD430570C1C0014DC8A = { + CreatedOnToolsVersion = 26.6; + }; 331C8080294A63A400263BE5 = { CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; @@ -286,12 +375,13 @@ ); mainGroup = 97C146E51CF9000F007C117D; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( + 2715FCD430570C1C0014DC8A /* DPIPWidgetsExtension */, 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, ); @@ -299,6 +389,13 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 2715FCD330570C1C0014DC8A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807F294A63A400263BE5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -346,7 +443,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; @@ -361,11 +458,18 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 2715FCD130570C1C0014DC8A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807D294A63A400263BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -398,6 +502,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 2715FCE430570C1D0014DC8A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2715FCD430570C1C0014DC8A /* DPIPWidgetsExtension */; + targetProxy = 2715FCE330570C1D0014DC8A /* PBXContainerItemProxy */; + }; 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 97C146ED1CF9000F007C117D /* Runner */; @@ -514,6 +623,135 @@ }; name = Profile; }; + 2715FCE730570C1D0014DC8A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = DPIPWidgetsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 98Q7JARYZF; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DPIPWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DPIPWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.DPIPWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2715FCE830570C1D0014DC8A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = DPIPWidgetsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 98Q7JARYZF; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DPIPWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DPIPWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.DPIPWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 2715FCE930570C1D0014DC8A /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = DPIPWidgetsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 98Q7JARYZF; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DPIPWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DPIPWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.DPIPWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -726,6 +964,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 2715FCEB30570C1D0014DC8A /* Build configuration list for PBXNativeTarget "DPIPWidgetsExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2715FCE730570C1D0014DC8A /* Debug */, + 2715FCE830570C1D0014DC8A /* Release */, + 2715FCE930570C1D0014DC8A /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -759,7 +1007,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; From f72df056fd66234276d60bcfd2eba0ec9b6a04d0 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 14 Sep 2026 21:21:41 +0800 Subject: [PATCH 03/14] chore: reload iOS widgets after snapshot writes Platform: ios --- ios/Runner/WidgetSnapshotPlugin.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ios/Runner/WidgetSnapshotPlugin.swift b/ios/Runner/WidgetSnapshotPlugin.swift index 10fc46912..c42e16bea 100644 --- a/ios/Runner/WidgetSnapshotPlugin.swift +++ b/ios/Runner/WidgetSnapshotPlugin.swift @@ -1,13 +1,22 @@ import Flutter import Foundation +import WidgetKit -/// The native allowlist also provides the WidgetKit kind for a future reload. +/// The native allowlist provides storage and WidgetKit identities. enum WidgetSnapshotKind: String { case weatherForecast var filename: String { switch self { - case .weatherForecast: return "weather-forecast.json" + case .weatherForecast: + return "weather-forecast.json" + } + } + + var widgetKind: String { + switch self { + case .weatherForecast: + return "DPIPWidgets" } } } @@ -113,7 +122,7 @@ public final class WidgetSnapshotPlugin: NSObject, FlutterPlugin { do { try WidgetSnapshotFile.replace(data, kind: kind, in: container) - // Future WidgetCenter.reloadTimelines(ofKind: kind.rawValue) belongs here. + WidgetCenter.shared.reloadTimelines(ofKind: kind.widgetKind) DispatchQueue.main.async { result(nil) } } catch { DispatchQueue.main.async { result(self.flutterError(.writeFailed)) } From b64a8e583a83f9d57a8db5b64604f1b70debec9d Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Tue, 15 Sep 2026 20:37:42 +0800 Subject: [PATCH 04/14] feat(weather): show current weather on iOS widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios New(zh-Hant): iOS 小工具可顯示 DPIP 所選地區的目前天氣 New(en-US): iOS widgets can show current weather for the area selected in DPIP --- .../CurrentWeatherWidgetSnapshot.swift | 19 + ios/DPIPWidgets/DPIPWidgets.swift | 68 +++- ios/DPIPWidgets/WidgetSnapshotStore.swift | 14 + ios/Runner.xcodeproj/project.pbxproj | 15 +- ios/Runner/WidgetSnapshotPlugin.swift | 60 +++ ios/RunnerTests/RunnerTests.swift | 21 + lib/core/platform/widget_snapshot_writer.dart | 55 ++- lib/features/home/home_providers.dart | 61 ++- .../presentation/home_weather_controller.dart | 37 +- .../current_weather_widget_coordinator.dart | 48 +++ .../current_weather_widget_publisher.dart | 21 + .../current_weather_widget_snapshot.dart | 65 ++++ .../domain/current_weather_widget_sync.dart | 16 + lib/features/weather/weather_providers.dart | 11 + .../platform/widget_snapshot_writer_test.dart | 69 ++++ test/features/home/home_providers_test.dart | 109 ++++++ .../home_weather_controller_test.dart | 368 ++++++++++++++++++ ...rrent_weather_widget_coordinator_test.dart | 163 ++++++++ ...current_weather_widget_publisher_test.dart | 98 +++++ .../current_weather_widget_snapshot_test.dart | 95 +++++ 20 files changed, 1383 insertions(+), 30 deletions(-) create mode 100644 ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift create mode 100644 lib/features/weather/current_weather_widget_coordinator.dart create mode 100644 lib/features/weather/data/current_weather_widget_publisher.dart create mode 100644 lib/features/weather/domain/current_weather_widget_snapshot.dart create mode 100644 lib/features/weather/domain/current_weather_widget_sync.dart create mode 100644 test/features/home/home_providers_test.dart create mode 100644 test/features/home/presentation/home_weather_controller_test.dart create mode 100644 test/features/weather/current_weather_widget_coordinator_test.dart create mode 100644 test/features/weather/current_weather_widget_publisher_test.dart create mode 100644 test/features/weather/current_weather_widget_snapshot_test.dart diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift new file mode 100644 index 000000000..0acc306ee --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -0,0 +1,19 @@ +import Foundation + +struct CurrentWeatherWidgetSnapshot: Decodable { + let schemaVersion: Int + + let regionCode: String + let regionName: String + + let observationTime: Int + + let stationName: String + + let weather: String + let weatherCode: Int + + let temperature: Double? + let humidity: Int? + let rain: Double? +} diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 0ab08ea1f..8a5ccdfb8 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -3,36 +3,53 @@ import SwiftUI struct DPIPWidgetProvider: TimelineProvider { func placeholder(in context: Context) -> DPIPWidgetEntry { - DPIPWidgetEntry(date: .now) + DPIPWidgetEntry(date: .now, snapshot: nil) } - func getSnapshot(in context: Context, completion: @escaping (DPIPWidgetEntry) -> ()) { - let entry = DPIPWidgetEntry(date: .now) + func getSnapshot(in context: Context, completion: @escaping (DPIPWidgetEntry) -> Void) { + let snapshot = WidgetSnapshotStore() + .loadCurrentWeatherSnapshot() + + let entry = DPIPWidgetEntry(date: .now, snapshot: snapshot) completion(entry) } - func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { - let entry = DPIPWidgetEntry(date: .now) + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let snapshot = WidgetSnapshotStore() + .loadCurrentWeatherSnapshot() + + let entry = DPIPWidgetEntry(date: .now, snapshot: snapshot) + let timeline = Timeline( entries: [entry], policy: .never ) - + completion(timeline) } } struct DPIPWidgetEntry: TimelineEntry { let date: Date + let snapshot: CurrentWeatherWidgetSnapshot? } struct DPIPWidgetsEntryView : View { let entry: DPIPWidgetEntry var body: some View { - VStack { - Text("DPIP") - Text("Widget Connected") + if let snapshot = entry.snapshot { + VStack(alignment: .leading) { + Text(snapshot.regionName) + + if let temperature = snapshot.temperature { + Text("\(temperature, specifier: "%.1f")°") + } else { + Text("--°") + } + } + } else { + Text("尚無天氣資料") } } } @@ -51,16 +68,39 @@ struct DPIPWidgets: Widget { .background() } } - .configurationDisplayName("My Widget") - .description("This is an example widget.") + .configurationDisplayName("目前天氣") + .description("顯示 DPIP 所選地區的目前天氣。") } } struct DPIPWidgets_Previews: PreviewProvider { - static var previews: some View { - DPIPWidgetsEntryView( - entry: DPIPWidgetEntry(date: .now) + private static let previewEntry = DPIPWidgetEntry( + date: .now, + snapshot: CurrentWeatherWidgetSnapshot( + schemaVersion: 1, + regionCode: "660", + regionName: "西屯區", + observationTime: 0, + stationName: "西屯", + weather: "多雲", + weatherCode: 200, + temperature: 28.4, + humidity: 76, + rain: 0 ) + ) + + static var previews: some View { + Group { + if #available(iOSApplicationExtension 17.0, *) { + DPIPWidgetsEntryView(entry: previewEntry) + .containerBackground(.fill.tertiary, for: .widget) + } else { + DPIPWidgetsEntryView(entry: previewEntry) + .padding() + .background() + } + } .previewContext( WidgetPreviewContext(family: .systemSmall) ) diff --git a/ios/DPIPWidgets/WidgetSnapshotStore.swift b/ios/DPIPWidgets/WidgetSnapshotStore.swift index d4777a3bf..00e45e0b7 100644 --- a/ios/DPIPWidgets/WidgetSnapshotStore.swift +++ b/ios/DPIPWidgets/WidgetSnapshotStore.swift @@ -2,11 +2,14 @@ import Foundation enum WidgetSnapshotKind { case weatherForecast + case currentWeather var filename: String { switch self { case .weatherForecast: return "weather-forecast.json" + case .currentWeather: + return "current-weather.json" } } } @@ -39,4 +42,15 @@ struct WidgetSnapshotStore { return try? Data(contentsOf: url) } + + func loadCurrentWeatherSnapshot() -> CurrentWeatherWidgetSnapshot? { + guard let data = loadData(for: .currentWeather) else { + return nil + } + + return try? JSONDecoder().decode( + CurrentWeatherWidgetSnapshot.self, + from: data + ) + } } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 91ccd28aa..91732d0bf 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -625,6 +625,7 @@ }; 2715FCE730570C1D0014DC8A /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; @@ -636,7 +637,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = DPIPWidgetsExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 98Q7JARYZF; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -651,7 +652,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.DPIPWidgets; @@ -670,6 +671,7 @@ }; 2715FCE830570C1D0014DC8A /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; @@ -681,7 +683,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = DPIPWidgetsExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 98Q7JARYZF; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -696,7 +698,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.DPIPWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -712,6 +714,7 @@ }; 2715FCE930570C1D0014DC8A /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; @@ -723,7 +726,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = DPIPWidgetsExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 98Q7JARYZF; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -738,7 +741,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.DPIPWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/ios/Runner/WidgetSnapshotPlugin.swift b/ios/Runner/WidgetSnapshotPlugin.swift index c42e16bea..104327ab6 100644 --- a/ios/Runner/WidgetSnapshotPlugin.swift +++ b/ios/Runner/WidgetSnapshotPlugin.swift @@ -5,11 +5,14 @@ import WidgetKit /// The native allowlist provides storage and WidgetKit identities. enum WidgetSnapshotKind: String { case weatherForecast + case currentWeather var filename: String { switch self { case .weatherForecast: return "weather-forecast.json" + case .currentWeather: + return "current-weather.json" } } @@ -17,6 +20,8 @@ enum WidgetSnapshotKind: String { switch self { case .weatherForecast: return "DPIPWidgets" + case .currentWeather: + return "DPIPWidgets" } } } @@ -70,6 +75,18 @@ enum WidgetSnapshotFile { throw WidgetSnapshotError.writeFailed } } + + static func clear(_ kind: WidgetSnapshotKind, in container: URL) throws { + let directory = container.appendingPathComponent("WidgetSnapshots", isDirectory: true) + let snapshot = directory.appendingPathComponent(kind.filename) + do { + try FileManager.default.removeItem(at: snapshot) + } catch let error as CocoaError where error.code == .fileNoSuchFile { + // Clearing an absent snapshot is intentionally idempotent. + } catch { + throw WidgetSnapshotError.writeFailed + } + } } /// Infrastructure-only Flutter bridge. It never interprets domain JSON. @@ -84,6 +101,11 @@ public final class WidgetSnapshotPlugin: NSObject, FlutterPlugin { } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if call.method == "clear" { + handleClear(call, result: result) + return + } + guard call.method == "write" else { result(FlutterMethodNotImplemented) return @@ -130,6 +152,44 @@ public final class WidgetSnapshotPlugin: NSObject, FlutterPlugin { } } + private func handleClear(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let arguments = call.arguments as? [String: Any], + let rawKind = arguments["kind"] as? String + else { + result(flutterError(.invalidKind)) + return + } + + let kind: WidgetSnapshotKind + do { + kind = try WidgetSnapshotFile.kind(rawKind) + } catch let error as WidgetSnapshotError { + result(flutterError(error)) + return + } catch { + result(flutterError(.invalidKind)) + return + } + + writeQueue.async { + guard let group = Bundle.main.object(forInfoDictionaryKey: "DPIPWidgetAppGroupIdentifier") as? String, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: group) + else { + DispatchQueue.main.async { result(self.flutterError(.appGroupUnavailable)) } + return + } + + do { + try WidgetSnapshotFile.clear(kind, in: container) + WidgetCenter.shared.reloadTimelines(ofKind: kind.widgetKind) + DispatchQueue.main.async { result(nil) } + } catch { + DispatchQueue.main.async { result(self.flutterError(.writeFailed)) } + } + } + } + private func flutterError(_ error: WidgetSnapshotError) -> FlutterError { FlutterError(code: error.flutterCode, message: error.flutterCode, details: nil) } diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index a18560a6c..6226e98dc 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -7,6 +7,9 @@ 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") XCTAssertThrowsError(try WidgetSnapshotFile.kind("../other.json")) { error in XCTAssertEqual(error as? WidgetSnapshotError, .invalidKind) } @@ -39,4 +42,22 @@ final class RunnerTests: XCTestCase { try WidgetSnapshotFile.replace(second, kind: kind, in: container) XCTAssertEqual(try Data(contentsOf: target), second) } + + func testSnapshotClearIsIdempotent() throws { + let container = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: container) } + let kind = try WidgetSnapshotFile.kind("currentWeather") + let data = try WidgetSnapshotFile.payload("{\"schemaVersion\":1}") + let target = container.appendingPathComponent("WidgetSnapshots/current-weather.json") + + XCTAssertNoThrow(try WidgetSnapshotFile.clear(kind, in: container)) + + try WidgetSnapshotFile.replace(data, kind: kind, in: container) + XCTAssertTrue(FileManager.default.fileExists(atPath: target.path)) + + try WidgetSnapshotFile.clear(kind, in: container) + XCTAssertFalse(FileManager.default.fileExists(atPath: target.path)) + + XCTAssertNoThrow(try WidgetSnapshotFile.clear(kind, in: container)) + } } diff --git a/lib/core/platform/widget_snapshot_writer.dart b/lib/core/platform/widget_snapshot_writer.dart index c7453912b..7be128ca3 100644 --- a/lib/core/platform/widget_snapshot_writer.dart +++ b/lib/core/platform/widget_snapshot_writer.dart @@ -6,7 +6,7 @@ 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 } +enum WidgetSnapshotKind { weatherForecast, currentWeather } /// Writes an already encoded, versioned JSON snapshot for a future widget. /// The caller owns its schema; this boundary owns delivery and persistence. @@ -15,6 +15,8 @@ abstract interface class WidgetSnapshotWriter { required WidgetSnapshotKind kind, required String json, }); + + Future> clear({required WidgetSnapshotKind kind}); } /// iOS implementation. No App Group path or filename crosses this boundary. @@ -85,4 +87,55 @@ final class IosWidgetSnapshotWriter implements WidgetSnapshotWriter { ); } } + + @override + Future> clear({required WidgetSnapshotKind kind}) async { + if (!_isSupportedPlatform) { + return const Err( + WidgetSnapshotFailure( + WidgetSnapshotFailureReason.unavailable, + 'Widget snapshot writer is unavailable on this platform.', + ), + ); + } + + try { + await _channel.invokeMethod('clear', {'kind': kind.name}); + + return const Ok(null); + } on MissingPluginException { + return const Err( + WidgetSnapshotFailure( + WidgetSnapshotFailureReason.unavailable, + 'Widget snapshot writer is unavailable.', + ), + ); + } on PlatformException catch (error) { + final failure = switch (error.code) { + 'invalid_kind' => const WidgetSnapshotFailure( + WidgetSnapshotFailureReason.invalidKind, + 'Widget snapshot kind is unsupported.', + ), + 'app_group_unavailable' => const WidgetSnapshotFailure( + WidgetSnapshotFailureReason.appGroupUnavailable, + 'Widget shared storage is unavailable.', + ), + _ => const WidgetSnapshotFailure( + WidgetSnapshotFailureReason.writeFailed, + 'Widget snapshot could not be cleared.', + ), + }; + + Log.warning('Widget snapshot clear failed: ${failure.reason.name}'); + return Err(failure); + } on Object { + Log.warning('Widget snapshot clear failed unexpectedly'); + return const Err( + WidgetSnapshotFailure( + WidgetSnapshotFailureReason.writeFailed, + 'Widget snapshot could not be cleared.', + ), + ); + } + } } diff --git a/lib/features/home/home_providers.dart b/lib/features/home/home_providers.dart index 35668c4a6..82c5bf599 100644 --- a/lib/features/home/home_providers.dart +++ b/lib/features/home/home_providers.dart @@ -6,13 +6,46 @@ import 'package:dpip/features/home/presentation/home_active_events_controller.da import 'package:dpip/features/home/presentation/home_reset_signal.dart'; import 'package:dpip/features/home/presentation/home_sheet_extent.dart'; import 'package:dpip/features/home/presentation/home_weather_controller.dart'; +import 'package:dpip/features/weather/domain/current_weather_widget_sync.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/weather_realtime.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; import 'package:dpip/shared/map/map_station_handoff.dart'; +import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; import 'package:provider/single_child_widget.dart'; +typedef CurrentWeatherWidgetPublish = Future Function({ + required String regionCode, + required WeatherRealtime weather, +}); + +typedef CurrentWeatherWidgetClear = Future Function(); + +/// Adapts the home controller callback to the iOS Widget coordinator. +@visibleForTesting +RealtimeWeatherLoadedCallback? createCurrentWeatherWidgetCallback({ + required TargetPlatform platform, + required CurrentWeatherWidgetPublish publish, +}) { + if (platform != TargetPlatform.iOS) return null; + + return (regionCode, weather) => + publish(regionCode: regionCode, weather: weather); +} + +@visibleForTesting +RealtimeWeatherInvalidatedCallback? +createCurrentWeatherWidgetInvalidatedCallback({ + required TargetPlatform platform, + required CurrentWeatherWidgetClear clear, +}) { + if (platform != TargetPlatform.iOS) return null; + + return clear; +} + /// Home providers: sheet-extent chrome, tab-reset, map hand-offs, weather /// (header + forecast), and active-events (collapsed sheet) — the latter reads /// [EventRepository] from `eventsProviders`. @@ -22,13 +55,27 @@ List homeProviders() => [ ChangeNotifierProvider(create: (_) => MapCameraHandoff()), ChangeNotifierProvider(create: (_) => MapStationHandoff()), ChangeNotifierProvider( - create: (context) => HomeWeatherController( - context.read(), - context.read(), - context.read(), - context.read(), - gpsFix: context.read().currentFix, - ), + create: (context) { + final regions = context.read(); + final directory = context.read(); + final widgetSync = context.read(); + + return HomeWeatherController( + context.read(), + context.read(), + regions, + directory, + gpsFix: context.read().currentFix, + onRealtimeLoaded: createCurrentWeatherWidgetCallback( + platform: defaultTargetPlatform, + publish: widgetSync.publish, + ), + onRealtimeInvalidated: createCurrentWeatherWidgetInvalidatedCallback( + platform: defaultTargetPlatform, + clear: widgetSync.clear, + ), + ); + }, ), ChangeNotifierProvider( create: (context) => HomeActiveEventsController( diff --git a/lib/features/home/presentation/home_weather_controller.dart b/lib/features/home/presentation/home_weather_controller.dart index c51a2bde1..b9a51ccc9 100644 --- a/lib/features/home/presentation/home_weather_controller.dart +++ b/lib/features/home/presentation/home_weather_controller.dart @@ -16,6 +16,13 @@ import 'package:dpip/features/weather/domain/weather_forecast.dart'; import 'package:dpip/features/weather/domain/weather_realtime.dart'; import 'package:flutter/foundation.dart'; +typedef RealtimeWeatherLoadedCallback = Future Function( + String regionCode, + WeatherRealtime weather, +); + +typedef RealtimeWeatherInvalidatedCallback = Future Function(); + /// Fetches nearest-station realtime weather, the township hourly forecast, and /// the next-hour rain trend for the home sheet, following the selected /// [RegionStore] township. 全國 has no point weather — [areaCode] is null and @@ -32,6 +39,8 @@ class HomeWeatherController extends ChangeNotifier { this._regions, this._directory, { this.gpsFix, + this.onRealtimeLoaded, + this.onRealtimeInvalidated, }) { _regions.addListener(_sync); _sync(); @@ -42,8 +51,12 @@ class HomeWeatherController extends ChangeNotifier { final RegionStore _regions; final TownDirectory _directory; + bool _hasSyncedRegion = false; + /// Live GPS fix for the debug log; null when unavailable. final Future Function()? gpsFix; + final RealtimeWeatherLoadedCallback? onRealtimeLoaded; + final RealtimeWeatherInvalidatedCallback? onRealtimeInvalidated; WeatherRealtime? _weather; String? _weatherCode; @@ -98,8 +111,21 @@ class HomeWeatherController extends ChangeNotifier { void _sync() { final code = areaCode; - if (code == _loadedCode) return; + + if (_hasSyncedRegion && code == _loadedCode) { + return; + } + + if (_hasSyncedRegion) { + final callback = onRealtimeInvalidated; + if (callback != null) { + unawaited(callback()); + } + } + + _hasSyncedRegion = true; _loadedCode = code; + final town = code == null ? null : _directory.byCode(code); if (town == null || code == null) { _weather = null; @@ -141,7 +167,14 @@ class HomeWeatherController extends ChangeNotifier { ok: (value) { _weather = value; _weatherCode = value == null ? null : code; - if (value != null) unawaited(_logRealtime(code, gpsFuture, value)); + if (value != null) { + unawaited(_logRealtime(code, gpsFuture, value)); + + final callback = onRealtimeLoaded; + if (callback != null) { + unawaited(callback(code, value)); + } + } }, err: (failure) { _failure = failure; diff --git a/lib/features/weather/current_weather_widget_coordinator.dart b/lib/features/weather/current_weather_widget_coordinator.dart new file mode 100644 index 000000000..c54703836 --- /dev/null +++ b/lib/features/weather/current_weather_widget_coordinator.dart @@ -0,0 +1,48 @@ +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/features/weather/data/current_weather_widget_publisher.dart'; +import 'package:dpip/features/weather/domain/current_weather_widget_snapshot.dart'; +import 'package:dpip/features/weather/domain/current_weather_widget_sync.dart'; +import 'package:dpip/features/weather/domain/weather_realtime.dart'; + +final class CurrentWeatherWidgetCoordinator + implements CurrentWeatherWidgetSync { + CurrentWeatherWidgetCoordinator( + this._regions, + this._directory, + this._publisher, + ); + + final RegionStore _regions; + final TownDirectory _directory; + final CurrentWeatherWidgetPublisher _publisher; + + @override + Future publish({ + required String regionCode, + required WeatherRealtime weather, + }) async { + if (_regions.selectedCode != regionCode) { + return; + } + + final town = _directory.byCode(regionCode); + + if (town == null) { + return; + } + + final snapshot = createCurrentWeatherWidgetSnapshot( + regionCode: regionCode, + regionName: town.townName, + weather: weather, + ); + + await _publisher.publish(snapshot); + } + + @override + Future clear() async { + await _publisher.clear(); + } +} diff --git a/lib/features/weather/data/current_weather_widget_publisher.dart b/lib/features/weather/data/current_weather_widget_publisher.dart new file mode 100644 index 000000000..a3efda40c --- /dev/null +++ b/lib/features/weather/data/current_weather_widget_publisher.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/platform/widget_snapshot_writer.dart'; +import 'package:dpip/features/weather/domain/current_weather_widget_snapshot.dart'; + +final class CurrentWeatherWidgetPublisher { + const CurrentWeatherWidgetPublisher(this._writer); + + final WidgetSnapshotWriter _writer; + + Future> publish(CurrentWeatherWidgetSnapshot snapshot) { + final json = jsonEncode(snapshot.toJson()); + + return _writer.write(kind: WidgetSnapshotKind.currentWeather, json: json); + } + + Future> clear() { + return _writer.clear(kind: WidgetSnapshotKind.currentWeather); + } +} diff --git a/lib/features/weather/domain/current_weather_widget_snapshot.dart b/lib/features/weather/domain/current_weather_widget_snapshot.dart new file mode 100644 index 000000000..f1e1575e8 --- /dev/null +++ b/lib/features/weather/domain/current_weather_widget_snapshot.dart @@ -0,0 +1,65 @@ +import 'package:dpip/features/weather/domain/weather_realtime.dart'; + +final class CurrentWeatherWidgetSnapshot { + const CurrentWeatherWidgetSnapshot({ + this.schemaVersion = 1, + required this.regionCode, + required this.regionName, + required this.observationTime, + required this.stationName, + required this.weather, + required this.weatherCode, + this.temperature, + this.humidity, + this.rain, + }); + + final int schemaVersion; + + final String regionCode; + final String regionName; + + final int observationTime; + + final String stationName; + + final String weather; + final int weatherCode; + + final double? temperature; + final int? humidity; + final double? rain; + + Map toJson() { + return { + 'schemaVersion': schemaVersion, + 'regionCode': regionCode, + 'regionName': regionName, + 'observationTime': observationTime, + 'stationName': stationName, + 'weather': weather, + 'weatherCode': weatherCode, + 'temperature': temperature, + 'humidity': humidity, + 'rain': rain, + }; + } +} + +CurrentWeatherWidgetSnapshot createCurrentWeatherWidgetSnapshot({ + required String regionCode, + required String regionName, + required WeatherRealtime weather, +}) { + return CurrentWeatherWidgetSnapshot( + regionCode: regionCode, + regionName: regionName, + observationTime: weather.time, + stationName: weather.station.name, + weather: weather.data.weather, + weatherCode: weather.data.weatherCode, + temperature: weather.data.temperature, + humidity: weather.data.humidity, + rain: weather.data.rain, + ); +} diff --git a/lib/features/weather/domain/current_weather_widget_sync.dart b/lib/features/weather/domain/current_weather_widget_sync.dart new file mode 100644 index 000000000..9fce9347a --- /dev/null +++ b/lib/features/weather/domain/current_weather_widget_sync.dart @@ -0,0 +1,16 @@ +/// Synchronization contract for the selected region's current-weather Widget. +library; + +import 'package:dpip/features/weather/domain/weather_realtime.dart'; + +/// Publishes or clears the current-weather Widget state. +abstract interface class CurrentWeatherWidgetSync { + /// Publishes [weather] for the selected [regionCode] when it is valid. + Future publish({ + required String regionCode, + required WeatherRealtime weather, + }); + + /// Clears the current-weather Widget state. + Future clear(); +} diff --git a/lib/features/weather/weather_providers.dart b/lib/features/weather/weather_providers.dart index b3a3f34f0..7632da04e 100644 --- a/lib/features/weather/weather_providers.dart +++ b/lib/features/weather/weather_providers.dart @@ -1,4 +1,7 @@ import 'package:dpip/core/di/shared_deps.dart'; +import 'package:dpip/core/platform/widget_snapshot_writer.dart'; +import 'package:dpip/features/weather/current_weather_widget_coordinator.dart'; +import 'package:dpip/features/weather/data/current_weather_widget_publisher.dart'; import 'package:dpip/features/weather/data/frame_tile_api.dart'; import 'package:dpip/features/weather/data/frame_tile_repository.dart'; import 'package:dpip/features/weather/data/meteor_lightning_repository_impl.dart'; @@ -8,6 +11,7 @@ import 'package:dpip/features/weather/data/meteor_weather_api.dart'; import 'package:dpip/features/weather/data/meteor_weather_repository_impl.dart'; import 'package:dpip/features/weather/data/rain_hour_trend_api.dart'; import 'package:dpip/features/weather/data/rain_hour_trend_repository_impl.dart'; +import 'package:dpip/features/weather/domain/current_weather_widget_sync.dart'; import 'package:dpip/features/weather/domain/meteor_lightning_repository.dart'; import 'package:dpip/features/weather/domain/meteor_rain_repository.dart'; import 'package:dpip/features/weather/domain/meteor_weather_repository.dart'; @@ -29,6 +33,13 @@ import 'package:provider/single_child_widget.dart'; /// DB wouldn't open) degrades to a no-op warmer, never a failed launch. List weatherProviders(SharedDeps deps) { return [ + Provider.value( + value: CurrentWeatherWidgetCoordinator( + deps.regionStore, + deps.townDirectory, + CurrentWeatherWidgetPublisher(IosWidgetSnapshotWriter()), + ), + ), Provider.value( value: FrameTileRepositoryImpl( FrameTileApi(deps.apiClient, 'radar'), diff --git a/test/core/platform/widget_snapshot_writer_test.dart b/test/core/platform/widget_snapshot_writer_test.dart index 7b44cbb0e..3ec284c7f 100644 --- a/test/core/platform/widget_snapshot_writer_test.dart +++ b/test/core/platform/widget_snapshot_writer_test.dart @@ -76,6 +76,75 @@ void main() { ); }); + test('clears a typed kind without exposing a file path', () async { + final writer = IosWidgetSnapshotWriter( + channel: channel, + isSupportedPlatform: true, + ); + + final result = await writer.clear(kind: WidgetSnapshotKind.currentWeather); + + expect(result.isOk, isTrue); + expect(calls.single.method, 'clear'); + expect(calls.single.arguments, {'kind': 'currentWeather'}); + }); + + test('clear on an unsupported platform does not call the channel', () async { + final writer = IosWidgetSnapshotWriter( + channel: channel, + isSupportedPlatform: false, + ); + + final result = await writer.clear(kind: WidgetSnapshotKind.currentWeather); + + expect( + (result.failureOrNull as WidgetSnapshotFailure).reason, + WidgetSnapshotFailureReason.unavailable, + ); + expect(calls, isEmpty); + }); + + test('clear maps a missing plugin to unavailable', () async { + messenger.setMockMethodCallHandler(channel, null); + final writer = IosWidgetSnapshotWriter( + channel: channel, + isSupportedPlatform: true, + ); + + final result = await writer.clear(kind: WidgetSnapshotKind.currentWeather); + + expect( + (result.failureOrNull as WidgetSnapshotFailure).reason, + WidgetSnapshotFailureReason.unavailable, + ); + }); + + for (final entry in { + 'invalid_kind': WidgetSnapshotFailureReason.invalidKind, + 'app_group_unavailable': WidgetSnapshotFailureReason.appGroupUnavailable, + 'write_failed': WidgetSnapshotFailureReason.writeFailed, + }.entries) { + test('clear ${entry.key} maps to a typed failure', () async { + messenger.setMockMethodCallHandler( + channel, + (_) async => throw PlatformException(code: entry.key), + ); + final writer = IosWidgetSnapshotWriter( + channel: channel, + isSupportedPlatform: true, + ); + + final result = await writer.clear( + kind: WidgetSnapshotKind.currentWeather, + ); + + expect( + (result.failureOrNull as WidgetSnapshotFailure).reason, + entry.value, + ); + }); + } + for (final entry in { 'invalid_kind': WidgetSnapshotFailureReason.invalidKind, 'invalid_payload': WidgetSnapshotFailureReason.invalidPayload, diff --git a/test/features/home/home_providers_test.dart b/test/features/home/home_providers_test.dart new file mode 100644 index 000000000..471b994b4 --- /dev/null +++ b/test/features/home/home_providers_test.dart @@ -0,0 +1,109 @@ +import 'package:dpip/features/home/home_providers.dart'; +import 'package:dpip/features/weather/domain/weather_realtime.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('iOS callback forwards region and weather to Widget publish', () async { + final weather = _weather(); + String? publishedRegionCode; + WeatherRealtime? publishedWeather; + final callback = createCurrentWeatherWidgetCallback( + platform: TargetPlatform.iOS, + publish: ({required regionCode, required weather}) async { + publishedRegionCode = regionCode; + publishedWeather = weather; + }, + ); + + expect(callback, isNotNull); + + await callback!('660', weather); + + expect(publishedRegionCode, '660'); + expect(publishedWeather, same(weather)); + }); + + test('Widget callback is enabled for iOS', () { + final callback = createCurrentWeatherWidgetCallback( + platform: TargetPlatform.iOS, + publish: _unusedPublish, + ); + + expect(callback, isNotNull); + }); + + test('Widget callback is disabled for every non-iOS platform', () { + for (final platform in TargetPlatform.values.where( + (platform) => platform != TargetPlatform.iOS, + )) { + final callback = createCurrentWeatherWidgetCallback( + platform: platform, + publish: _unusedPublish, + ); + + expect( + callback, + isNull, + reason: '$platform must not publish iOS Widgets', + ); + } + }); + + test('iOS invalidation callback invokes Widget clear once', () async { + var clearCallCount = 0; + final callback = createCurrentWeatherWidgetInvalidatedCallback( + platform: TargetPlatform.iOS, + clear: () async { + clearCallCount += 1; + }, + ); + + expect(callback, isNotNull); + + await callback!(); + + expect(clearCallCount, 1); + }); + + test('invalidation callback is disabled for every non-iOS platform', () { + for (final platform in TargetPlatform.values.where( + (platform) => platform != TargetPlatform.iOS, + )) { + final callback = createCurrentWeatherWidgetInvalidatedCallback( + platform: platform, + clear: _unusedClear, + ); + + expect(callback, isNull, reason: '$platform must not clear iOS Widgets'); + } + }); +} + +Future _unusedPublish({ + required String regionCode, + required WeatherRealtime weather, +}) async {} + +Future _unusedClear() async {} + +WeatherRealtime _weather() => WeatherRealtime( + id: 'C0X160', + station: const WeatherRealtimeStation( + name: '西屯', + latitude: 24.18, + longitude: 120.64, + altitude: 85, + distance: 1.2, + ), + time: 1789398000, + data: const WeatherRealtimeData( + weather: '多雲', + weatherCode: 200, + temperature: 28.4, + humidity: 76, + rain: 0, + wind: WeatherWind(direction: '北', speed: 1.5, beaufort: 1), + gust: WeatherWind(speed: 3, beaufort: 2), + ), +); diff --git a/test/features/home/presentation/home_weather_controller_test.dart b/test/features/home/presentation/home_weather_controller_test.dart new file mode 100644 index 000000000..fe1f17d06 --- /dev/null +++ b/test/features/home/presentation/home_weather_controller_test.dart @@ -0,0 +1,368 @@ +import 'dart:async'; + +import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/core/settings/settings_store.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.dart'; +import 'package:dpip/features/weather/domain/rain_hour_trend_repository.dart'; +import 'package:dpip/features/weather/domain/weather_forecast.dart'; +import 'package:dpip/features/weather/domain/weather_realtime.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('initial valid-region sync does not invalidate realtime', () async { + final regions = _savedRegions(['660'])..select(2); + addTearDown(regions.dispose); + var invalidationCount = 0; + final controller = HomeWeatherController( + _FakeWeatherRepository(onRealtime: (_, _) async => const Ok(null)), + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeInvalidated: () async { + invalidationCount += 1; + }, + ); + addTearDown(controller.dispose); + + await _waitUntilSettled(controller); + + expect(regions.selectedCode, '660'); + expect(invalidationCount, 0); + }); + + test('initial null-region sync does not invalidate realtime', () { + final regions = _savedRegions(['660']); + addTearDown(regions.dispose); + var invalidationCount = 0; + final controller = HomeWeatherController( + _FakeWeatherRepository(onRealtime: (_, _) async => const Ok(null)), + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeInvalidated: () async { + invalidationCount += 1; + }, + ); + addTearDown(controller.dispose); + + expect(regions.selectedCode, isNull); + expect(invalidationCount, 0); + }); + + test('valid region change invalidates realtime exactly once', () async { + final regions = _savedRegions(['660', '100'])..select(2); + addTearDown(regions.dispose); + var invalidationCount = 0; + final controller = HomeWeatherController( + _FakeWeatherRepository(onRealtime: (_, _) async => const Ok(null)), + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeInvalidated: () async { + invalidationCount += 1; + }, + ); + addTearDown(controller.dispose); + await _waitUntilSettled(controller); + + regions.select(3); + await _waitUntilSettled(controller); + + expect(regions.selectedCode, '100'); + expect(invalidationCount, 1); + }); + + test('successful changed-region response loads after invalidation', () async { + final weather = _weather(); + final regions = _savedRegions(['660', '100'])..select(2); + addTearDown(regions.dispose); + final events = []; + final controller = HomeWeatherController( + _FakeWeatherRepository(onRealtime: (_, _) async => Ok(weather)), + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeLoaded: (regionCode, _) async { + events.add('loaded:$regionCode'); + }, + onRealtimeInvalidated: () async { + events.add('invalidated'); + }, + ); + addTearDown(controller.dispose); + await _waitUntilSettled(controller); + events.clear(); + + regions.select(3); + await _waitUntilSettled(controller); + + expect(events, ['invalidated', 'loaded:100']); + }); + + test('valid region to Nationwide invalidates realtime once', () async { + final regions = _savedRegions(['660'])..select(2); + addTearDown(regions.dispose); + var invalidationCount = 0; + final controller = HomeWeatherController( + _FakeWeatherRepository(onRealtime: (_, _) async => const Ok(null)), + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeInvalidated: () async { + invalidationCount += 1; + }, + ); + addTearDown(controller.dispose); + await _waitUntilSettled(controller); + + regions.select(0); + + expect(regions.selectedCode, isNull); + expect(invalidationCount, 1); + }); + + test('null region to valid region invalidates realtime once', () async { + final regions = _savedRegions(['660']); + addTearDown(regions.dispose); + var invalidationCount = 0; + final controller = HomeWeatherController( + _FakeWeatherRepository(onRealtime: (_, _) async => const Ok(null)), + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeInvalidated: () async { + invalidationCount += 1; + }, + ); + addTearDown(controller.dispose); + + regions.select(2); + await _waitUntilSettled(controller); + + expect(regions.selectedCode, '660'); + expect(invalidationCount, 1); + }); + + test( + 'notification with unchanged selected region does not invalidate', + () async { + final regions = _savedRegions(['660'])..select(2); + addTearDown(regions.dispose); + var invalidationCount = 0; + final controller = HomeWeatherController( + _FakeWeatherRepository(onRealtime: (_, _) async => const Ok(null)), + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeInvalidated: () async { + invalidationCount += 1; + }, + ); + addTearDown(controller.dispose); + await _waitUntilSettled(controller); + + expect(regions.addSaved('100'), isTrue); + + expect(regions.selectedCode, '660'); + expect(invalidationCount, 0); + }, + ); + + test('successful realtime weather invokes the callback once', () async { + final weather = _weather(); + final repository = _FakeWeatherRepository( + onRealtime: (_, _) async => Ok(weather), + ); + final regions = _savedRegions(['660'])..select(2); + addTearDown(regions.dispose); + + final callbackRelease = Completer(); + final callbackCalls = <(String, WeatherRealtime)>[]; + final controller = HomeWeatherController( + repository, + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeLoaded: (regionCode, result) { + callbackCalls.add((regionCode, result)); + return callbackRelease.future; + }, + ); + addTearDown(controller.dispose); + + await _waitUntilSettled(controller); + + expect(callbackCalls, hasLength(1)); + expect(callbackCalls.single.$1, '660'); + expect(callbackCalls.single.$2, same(weather)); + callbackRelease.complete(); + }); + + test('Ok(null) realtime weather does not invoke the callback', () async { + final repository = _FakeWeatherRepository( + onRealtime: (_, _) async => const Ok(null), + ); + final regions = _savedRegions(['660'])..select(2); + addTearDown(regions.dispose); + var callbackCount = 0; + final controller = HomeWeatherController( + repository, + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeLoaded: (_, _) async { + callbackCount += 1; + }, + ); + addTearDown(controller.dispose); + + await _waitUntilSettled(controller); + + expect(callbackCount, 0); + }); + + test('realtime failure does not invoke the callback', () async { + final repository = _FakeWeatherRepository( + onRealtime: (_, _) async => const Err(NetworkFailure('offline')), + ); + final regions = _savedRegions(['660'])..select(2); + addTearDown(regions.dispose); + var callbackCount = 0; + final controller = HomeWeatherController( + repository, + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeLoaded: (_, _) async { + callbackCount += 1; + }, + ); + addTearDown(controller.dispose); + + await _waitUntilSettled(controller); + + expect(callbackCount, 0); + }); + + test('superseded realtime response does not invoke the callback', () async { + final regionAResult = Completer>(); + final regionBResult = Completer>(); + final repository = _FakeWeatherRepository( + onRealtime: (latitude, _) => switch (latitude) { + 24.18 => regionAResult.future, + 25.04 => regionBResult.future, + _ => throw StateError('Unexpected latitude: $latitude'), + }, + ); + final regions = _savedRegions(['660', '100'])..select(2); + addTearDown(regions.dispose); + final callbackRegions = []; + final controller = HomeWeatherController( + repository, + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeLoaded: (regionCode, _) async { + callbackRegions.add(regionCode); + }, + ); + addTearDown(controller.dispose); + + regions.select(3); + final settled = _waitUntilSettled(controller); + regionAResult.complete(Ok(_weather())); + regionBResult.complete(const Ok(null)); + await settled; + + expect(regions.selectedCode, '100'); + expect(callbackRegions, isEmpty); + }); +} + +final _directory = TownDirectory.fromJson({ + '660': { + 'city': '臺中', + 'town': '西屯', + 'lat': 24.18, + 'lng': 120.64, + 'cityLevel': '市', + 'townLevel': '區', + }, + '100': { + 'city': '臺北', + 'town': '中正', + 'lat': 25.04, + 'lng': 121.52, + 'cityLevel': '市', + 'townLevel': '區', + }, +}); + +RegionStore _savedRegions(List codes) => + RegionStore(SettingsStore.inMemory({'home.savedRegionCodes': codes})); + +Future _waitUntilSettled(HomeWeatherController controller) async { + if (!controller.loading) return; + + final settled = Completer(); + void listener() { + if (!controller.loading && !settled.isCompleted) settled.complete(); + } + + controller.addListener(listener); + await settled.future; + controller.removeListener(listener); +} + +WeatherRealtime _weather() => WeatherRealtime( + id: 'C0X160', + station: const WeatherRealtimeStation( + name: '西屯', + latitude: 24.18, + longitude: 120.64, + altitude: 85, + distance: 1.2, + ), + time: 1789398000, + data: const WeatherRealtimeData( + weather: '多雲', + weatherCode: 200, + temperature: 28.4, + humidity: 76, + rain: 0, + wind: WeatherWind(direction: '北', speed: 1.5, beaufort: 1), + gust: WeatherWind(speed: 3, beaufort: 2), + ), +); + +final class _FakeWeatherRepository implements MeteorWeatherRepository { + const _FakeWeatherRepository({required this.onRealtime}); + + final Future> Function(double, double) onRealtime; + + @override + Future> realtime( + double latitude, + double longitude, + ) => onRealtime(latitude, longitude); + + @override + Future> forecast(String code) async => + Ok(WeatherForecast(updateTime: 0, forecast: const [])); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _FakeHourTrendRepository implements RainHourTrendRepository { + const _FakeHourTrendRepository(); + + @override + Future> hourTrend(String code) async => + Ok(RainHourTrend(startSecond: 0, mm: List.filled(60, 0))); +} diff --git a/test/features/weather/current_weather_widget_coordinator_test.dart b/test/features/weather/current_weather_widget_coordinator_test.dart new file mode 100644 index 000000000..b428d4d67 --- /dev/null +++ b/test/features/weather/current_weather_widget_coordinator_test.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/geo/town_directory.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:dpip/features/weather/current_weather_widget_coordinator.dart'; +import 'package:dpip/features/weather/data/current_weather_widget_publisher.dart'; +import 'package:dpip/features/weather/domain/weather_realtime.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('publishes a snapshot for the matching selected region', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['660'], + }), + ); + final directory = _directoryWithXitun(); + final writer = _FakeWidgetSnapshotWriter(); + final coordinator = CurrentWeatherWidgetCoordinator( + regions, + directory, + CurrentWeatherWidgetPublisher(writer), + ); + + regions.select(2); + + await coordinator.publish(regionCode: '660', weather: _weather()); + + expect(writer.called, isTrue); + expect(writer.writtenKind, WidgetSnapshotKind.currentWeather); + + final decoded = jsonDecode(writer.writtenJson!) as Map; + + expect(decoded['schemaVersion'], 1); + expect(decoded['regionCode'], '660'); + expect(decoded['regionName'], '西屯區'); + expect(decoded['stationName'], '西屯'); + expect(decoded['weather'], '多雲'); + expect(decoded['temperature'], 28.4); + }); + + test('does not publish when the selected region does not match', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['660', '100'], + }), + ); + final writer = _FakeWidgetSnapshotWriter(); + final coordinator = CurrentWeatherWidgetCoordinator( + regions, + _directoryWithXitun(), + CurrentWeatherWidgetPublisher(writer), + ); + + regions.select(3); + + await coordinator.publish(regionCode: '660', weather: _weather()); + + expect(regions.selectedCode, '100'); + expect(writer.called, isFalse); + }); + + test('does not publish when the selected town is unknown', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['660'], + }), + ); + final writer = _FakeWidgetSnapshotWriter(); + final coordinator = CurrentWeatherWidgetCoordinator( + regions, + TownDirectory.fromJson(const {}), + CurrentWeatherWidgetPublisher(writer), + ); + + regions.select(2); + + await coordinator.publish(regionCode: '660', weather: _weather()); + + expect(writer.called, isFalse); + }); + + test('clear delegates to the current weather writer path', () async { + final regions = RegionStore(SettingsStore.inMemory()); + final writer = _FakeWidgetSnapshotWriter(); + final coordinator = CurrentWeatherWidgetCoordinator( + regions, + _directoryWithXitun(), + CurrentWeatherWidgetPublisher(writer), + ); + + await coordinator.clear(); + + expect(writer.clearCallCount, 1); + expect(writer.clearedKind, WidgetSnapshotKind.currentWeather); + }); +} + +TownDirectory _directoryWithXitun() { + return TownDirectory.fromJson({ + '660': { + 'city': '臺中', + 'town': '西屯', + 'lat': 24.18, + 'lng': 120.64, + 'cityLevel': '市', + 'townLevel': '區', + }, + }); +} + +WeatherRealtime _weather() { + return WeatherRealtime( + id: 'C0X160', + station: const WeatherRealtimeStation( + name: '西屯', + latitude: 24.18, + longitude: 120.64, + altitude: 85, + distance: 1.2, + ), + time: 1789398000, + data: const WeatherRealtimeData( + weather: '多雲', + weatherCode: 200, + temperature: 28.4, + humidity: 76, + rain: 0.0, + wind: WeatherWind(direction: '北', speed: 1.5, beaufort: 1), + gust: WeatherWind(speed: 3.0, beaufort: 2), + ), + ); +} + +final class _FakeWidgetSnapshotWriter implements WidgetSnapshotWriter { + bool called = false; + WidgetSnapshotKind? writtenKind; + String? writtenJson; + int clearCallCount = 0; + WidgetSnapshotKind? clearedKind; + + @override + Future> clear({required WidgetSnapshotKind kind}) async { + clearCallCount += 1; + clearedKind = kind; + return const Ok(null); + } + + @override + Future> write({ + required WidgetSnapshotKind kind, + required String json, + }) async { + called = true; + writtenKind = kind; + writtenJson = json; + + return const Ok(null); + } +} diff --git a/test/features/weather/current_weather_widget_publisher_test.dart b/test/features/weather/current_weather_widget_publisher_test.dart new file mode 100644 index 000000000..607033002 --- /dev/null +++ b/test/features/weather/current_weather_widget_publisher_test.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/platform/widget_snapshot_writer.dart'; +import 'package:dpip/features/weather/data/current_weather_widget_publisher.dart'; +import 'package:dpip/features/weather/domain/current_weather_widget_snapshot.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class FakeWidgetSnapshotWriter implements WidgetSnapshotWriter { + WidgetSnapshotKind? writtenKind; + String? writtenJson; + int clearCallCount = 0; + WidgetSnapshotKind? clearedKind; + + @override + Future> clear({required WidgetSnapshotKind kind}) async { + clearCallCount += 1; + clearedKind = kind; + return const Ok(null); + } + + @override + Future> write({ + required WidgetSnapshotKind kind, + required String json, + }) async { + writtenKind = kind; + writtenJson = json; + + return const Ok(null); + } +} + +void main() { + test('publishes current weather snapshot as JSON', () async { + final writer = FakeWidgetSnapshotWriter(); + final publisher = CurrentWeatherWidgetPublisher(writer); + + final snapshot = CurrentWeatherWidgetSnapshot( + regionCode: '660', + regionName: '西屯區', + observationTime: 1789398000, + stationName: '西屯', + weather: '多雲', + weatherCode: 200, + temperature: 28.4, + humidity: 76, + rain: 0.0, + ); + + final result = await publisher.publish(snapshot); + + expect(result, isA>()); + expect(writer.writtenKind, WidgetSnapshotKind.currentWeather); + + final decoded = jsonDecode(writer.writtenJson!) as Map; + + expect(decoded['regionCode'], '660'); + expect(decoded['regionName'], '西屯區'); + expect(decoded['temperature'], 28.4); + }); + + test('preserves null values when publishing', () async { + final writer = FakeWidgetSnapshotWriter(); + final publisher = CurrentWeatherWidgetPublisher(writer); + + final snapshot = CurrentWeatherWidgetSnapshot( + regionCode: '660', + regionName: '西屯區', + observationTime: 1789398000, + stationName: '西屯', + weather: '多雲', + weatherCode: 200, + temperature: null, + humidity: null, + rain: null, + ); + + await publisher.publish(snapshot); + + final decoded = jsonDecode(writer.writtenJson!) as Map; + + expect(decoded['temperature'], isNull); + expect(decoded['humidity'], isNull); + expect(decoded['rain'], isNull); + }); + + test('clears the current weather snapshot', () async { + final writer = FakeWidgetSnapshotWriter(); + final publisher = CurrentWeatherWidgetPublisher(writer); + + final result = await publisher.clear(); + + expect(result, isA>()); + expect(writer.clearCallCount, 1); + expect(writer.clearedKind, WidgetSnapshotKind.currentWeather); + }); +} diff --git a/test/features/weather/current_weather_widget_snapshot_test.dart b/test/features/weather/current_weather_widget_snapshot_test.dart new file mode 100644 index 000000000..c788b4d47 --- /dev/null +++ b/test/features/weather/current_weather_widget_snapshot_test.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dpip/features/weather/domain/current_weather_widget_snapshot.dart'; +import 'package:dpip/features/weather/domain/weather_realtime.dart'; + +void main() { + test('creates a current weather widget snapshot', () { + final weather = WeatherRealtime( + id: 'C0X160', + station: const WeatherRealtimeStation( + name: '西屯', + latitude: 24.18, + longitude: 120.64, + altitude: 85, + distance: 1.2, + ), + time: 1789398000, + data: const WeatherRealtimeData( + weather: '多雲', + weatherCode: 200, + temperature: 28.4, + humidity: 76, + rain: 0.0, + wind: WeatherWind(direction: '北', speed: 1.5, beaufort: 1), + gust: WeatherWind(speed: 3.0, beaufort: 2), + ), + ); + + final snapshot = createCurrentWeatherWidgetSnapshot( + regionCode: '660', + regionName: '西屯區', + weather: weather, + ); + + expect(snapshot.schemaVersion, 1); + expect(snapshot.regionCode, '660'); + expect(snapshot.regionName, '西屯區'); + expect(snapshot.observationTime, 1789398000); + expect(snapshot.stationName, '西屯'); + expect(snapshot.weather, '多雲'); + expect(snapshot.weatherCode, 200); + expect(snapshot.temperature, 28.4); + expect(snapshot.humidity, 76); + expect(snapshot.rain, 0.0); + + final json = jsonEncode(snapshot.toJson()); + final decoded = jsonDecode(json) as Map; + + expect(decoded['schemaVersion'], 1); + expect(decoded['regionCode'], '660'); + expect(decoded['temperature'], 28.4); + }); + + test('preserves null weather values', () { + final weather = WeatherRealtime( + id: 'C0X160', + station: const WeatherRealtimeStation( + name: '西屯', + latitude: 24.18, + longitude: 120.64, + altitude: 85, + distance: 1.2, + ), + time: 1789398000, + data: const WeatherRealtimeData( + weather: '多雲', + weatherCode: 200, + temperature: null, + humidity: null, + rain: null, + wind: WeatherWind(direction: '北', speed: 1.5, beaufort: 1), + gust: WeatherWind(speed: 3.0, beaufort: 2), + ), + ); + + final snapshot = createCurrentWeatherWidgetSnapshot( + regionCode: '660', + regionName: '西屯區', + weather: weather, + ); + + expect(snapshot.temperature, isNull); + expect(snapshot.humidity, isNull); + expect(snapshot.rain, isNull); + + final json = jsonEncode(snapshot.toJson()); + final decoded = jsonDecode(json) as Map; + + expect(decoded['temperature'], isNull); + expect(decoded['humidity'], isNull); + expect(decoded['rain'], isNull); + }); +} From d4beb7a5804d13fa2de6c5474db072c8eb8ae965 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Wed, 16 Sep 2026 00:32:15 +0800 Subject: [PATCH 05/14] feat(weather): polish iOS current weather widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios New(zh-Hant): iOS 天氣小工具新增天氣圖示、資料狀態與點擊開啟首頁 New(en-US): iOS weather widgets add condition icons, data states, and Home deep linking --- .../CurrentWeatherWidgetSnapshot.swift | 138 +++++++++++++ ios/DPIPWidgets/DPIPWidgets.swift | 190 ++++++++++++++++-- .../xcshareddata/swiftpm/Package.resolved | 16 +- ios/Runner/Info.plist | 11 + .../current_weather_widget_snapshot.dart | 51 ++++- ...rrent_weather_widget_coordinator_test.dart | 2 +- ...current_weather_widget_publisher_test.dart | 2 + .../current_weather_widget_snapshot_test.dart | 133 ++++++++---- 8 files changed, 481 insertions(+), 62 deletions(-) diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index 0acc306ee..4ee37a7be 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -1,5 +1,37 @@ import Foundation +enum CurrentWeatherWidgetCondition: String, Decodable { + case clear + case cloudy + case overcast + case rain + case thunderstorm + case snow + case fog + case unknown + + var systemImageName: String { + switch self { + case .clear: + return "sun.max.fill" + case .cloudy: + return "cloud.sun.fill" + case .overcast: + return "cloud.fill" + case .rain: + return "cloud.rain.fill" + case .thunderstorm: + return "cloud.bolt.rain.fill" + case .snow: + return "cloud.snow.fill" + case .fog: + return "cloud.fog.fill" + case .unknown: + return "cloud.fill" + } + } +} + struct CurrentWeatherWidgetSnapshot: Decodable { let schemaVersion: Int @@ -12,8 +44,114 @@ struct CurrentWeatherWidgetSnapshot: Decodable { let weather: String let weatherCode: Int + let condition: CurrentWeatherWidgetCondition let temperature: Double? let humidity: Int? let rain: Double? + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case regionCode + case regionName + case observationTime + case stationName + case weather + case weatherCode + case condition + case temperature + case humidity + case rain + } + + init( + schemaVersion: Int, + regionCode: String, + regionName: String, + observationTime: Int, + stationName: String, + weather: String, + weatherCode: Int, + condition: CurrentWeatherWidgetCondition, + temperature: Double?, + humidity: Int?, + rain: Double? + ) { + self.schemaVersion = schemaVersion + self.regionCode = regionCode + self.regionName = regionName + self.observationTime = observationTime + self.stationName = stationName + self.weather = weather + self.weatherCode = weatherCode + self.condition = condition + self.temperature = temperature + self.humidity = humidity + self.rain = rain + } + + init(from decoder: Decoder) throws { + let container = try decoder.container( + keyedBy: CodingKeys.self + ) + + schemaVersion = try container.decode( + Int.self, + forKey: .schemaVersion + ) + + regionCode = try container.decode( + String.self, + forKey: .regionCode + ) + + regionName = try container.decode( + String.self, + forKey: .regionName + ) + + observationTime = try container.decode( + Int.self, + forKey: .observationTime + ) + + stationName = try container.decode( + String.self, + forKey: .stationName + ) + + weather = try container.decode( + String.self, + forKey: .weather + ) + + weatherCode = try container.decode( + Int.self, + forKey: .weatherCode + ) + + let conditionRawValue = try container.decodeIfPresent( + String.self, + forKey: .condition + ) + + condition = conditionRawValue + .flatMap(CurrentWeatherWidgetCondition.init(rawValue:)) + ?? .unknown + + temperature = try container.decodeIfPresent( + Double.self, + forKey: .temperature + ) + + humidity = try container.decodeIfPresent( + Int.self, + forKey: .humidity + ) + + rain = try container.decodeIfPresent( + Double.self, + forKey: .rain + ) + } } diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 8a5ccdfb8..7582d6b61 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -2,36 +2,110 @@ import WidgetKit import SwiftUI struct DPIPWidgetProvider: TimelineProvider { + private let staleAfter: TimeInterval = 30 * 60 + func placeholder(in context: Context) -> DPIPWidgetEntry { - DPIPWidgetEntry(date: .now, snapshot: nil) + DPIPWidgetEntry(date: .now, snapshot: nil, isStale: false) } - func getSnapshot(in context: Context, completion: @escaping (DPIPWidgetEntry) -> Void) { + func getSnapshot( + in context: Context, + completion: @escaping (DPIPWidgetEntry) -> Void + ) { let snapshot = WidgetSnapshotStore() .loadCurrentWeatherSnapshot() - let entry = DPIPWidgetEntry(date: .now, snapshot: snapshot) + let now = Date.now + + let isStale: Bool + + if let snapshot { + let observationDate = Date( + timeIntervalSince1970: TimeInterval(snapshot.observationTime) + ) + + isStale = + now >= observationDate.addingTimeInterval(staleAfter) + } else { + isStale = false + } + + let entry = DPIPWidgetEntry( + date: now, + snapshot: snapshot, + isStale: isStale + ) + completion(entry) } - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + func getTimeline( + in context: Context, + completion: @escaping (Timeline) -> Void + ) { let snapshot = WidgetSnapshotStore() .loadCurrentWeatherSnapshot() - let entry = DPIPWidgetEntry(date: .now, snapshot: snapshot) + let now = Date.now - let timeline = Timeline( - entries: [entry], - policy: .never + guard let snapshot else { + let entry = DPIPWidgetEntry( + date: now, + snapshot: nil, + isStale: false + ) + + completion( + Timeline(entries: [entry], policy: .never) + ) + return + } + + let observationDate = Date( + timeIntervalSince1970: TimeInterval(snapshot.observationTime) ) - completion(timeline) + let staleAt = observationDate.addingTimeInterval(staleAfter) + + if now >= staleAt { + let staleEntry = DPIPWidgetEntry( + date: now, + snapshot: snapshot, + isStale: true + ) + + completion( + Timeline( + entries: [staleEntry], + policy: .never + ) + ) + } else { + let freshEntry = DPIPWidgetEntry( + date: now, + snapshot: snapshot, + isStale: false + ) + + let staleEntry = DPIPWidgetEntry( + date: staleAt, + snapshot: snapshot, + isStale: true + ) + completion( + Timeline( + entries: [freshEntry, staleEntry], + policy: .never + ) + ) + } } } struct DPIPWidgetEntry: TimelineEntry { let date: Date let snapshot: CurrentWeatherWidgetSnapshot? + let isStale: Bool } struct DPIPWidgetsEntryView : View { @@ -39,17 +113,101 @@ struct DPIPWidgetsEntryView : View { var body: some View { if let snapshot = entry.snapshot { + let observationDate = Date( + timeIntervalSince1970: TimeInterval(snapshot.observationTime) + ) + VStack(alignment: .leading) { - Text(snapshot.regionName) + HStack { + Text(snapshot.regionName) + .font(.headline) + .layoutPriority(1) + + Spacer() + + HStack(spacing: 4) { + Image(systemName: snapshot.condition.systemImageName) + + Text(snapshot.weather) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() if let temperature = snapshot.temperature { - Text("\(temperature, specifier: "%.1f")°") + Text("\(temperature, specifier: "%.0f")°") + .font(.system(size: 42, weight: .semibold, design: .rounded)) } else { Text("--°") + .font(.system(size: 42, weight: .semibold, design: .rounded)) + } + + HStack { + if let humidity = snapshot.humidity { + HStack(spacing: 4) { + Image(systemName: "humidity") + Text("\(humidity)%") + } + .font(.caption) + } else { + HStack(spacing: 4) { + Image(systemName: "humidity") + Text("--%") + } + .font(.caption) + } + + Spacer() + + if let rain = snapshot.rain { + HStack(spacing: 4) { + Image(systemName: "drop.fill") + Text("\(rain, specifier: "%.1f") mm") + } + .font(.caption) + } else { + HStack(spacing: 4) { + Image(systemName: "drop.fill") + Text("-- mm") + } + .font(.caption) + } } + + HStack(spacing: 4) { + if entry.isStale { + Text("較舊") + .lineLimit(1) + } + + Spacer() + + Image(systemName: "clock") + + Text(observationDate, style: .time) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + .font(.caption2) + .foregroundStyle(.secondary) } } else { - Text("尚無天氣資料") + VStack(spacing: 8) { + Image(systemName: "cloud.fill") + .font(.title) + + Text("尚無天氣資料") + .font(.caption) + } + .foregroundStyle(.secondary) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) } } } @@ -61,13 +219,16 @@ struct DPIPWidgets: Widget { StaticConfiguration(kind: kind, provider: DPIPWidgetProvider()) { entry in if #available(iOS 17.0, *) { DPIPWidgetsEntryView(entry: entry) + .widgetURL(URL(string: "dpip:///home")) .containerBackground(.fill.tertiary, for: .widget) } else { DPIPWidgetsEntryView(entry: entry) + .widgetURL(URL(string: "dpip:///home")) .padding() .background() } } + .supportedFamilies([.systemSmall]) .configurationDisplayName("目前天氣") .description("顯示 DPIP 所選地區的目前天氣。") } @@ -82,12 +243,13 @@ struct DPIPWidgets_Previews: PreviewProvider { regionName: "西屯區", observationTime: 0, stationName: "西屯", - weather: "多雲", + weather: "短暫雷雨", weatherCode: 200, + condition: .thunderstorm, temperature: 28.4, humidity: 76, rain: 0 - ) + ),isStale: true ) static var previews: some View { diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2f2801e7f..0f57be1d8 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", - "version" : "11.3.1" + "revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a", + "version" : "11.3.2" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleDataTransport.git", "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" + "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", + "version" : "10.1.1" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", - "version" : "8.1.2" + "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3", + "version" : "8.1.3" } }, { @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", - "version" : "5.3.0" + "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", + "version" : "5.3.1" } }, { diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index b6ed58d7a..47c5df755 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -37,6 +37,17 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + CFBundleURLTypes + + + CFBundleURLName + com.exptech.dpip.dpip + CFBundleURLSchemes + + dpip + + + ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/lib/features/weather/domain/current_weather_widget_snapshot.dart b/lib/features/weather/domain/current_weather_widget_snapshot.dart index f1e1575e8..af70b87cc 100644 --- a/lib/features/weather/domain/current_weather_widget_snapshot.dart +++ b/lib/features/weather/domain/current_weather_widget_snapshot.dart @@ -1,14 +1,26 @@ import 'package:dpip/features/weather/domain/weather_realtime.dart'; +enum CurrentWeatherWidgetCondition { + clear, + cloudy, + overcast, + rain, + thunderstorm, + snow, + fog, + unknown, +} + final class CurrentWeatherWidgetSnapshot { const CurrentWeatherWidgetSnapshot({ - this.schemaVersion = 1, + this.schemaVersion = 2, required this.regionCode, required this.regionName, required this.observationTime, required this.stationName, required this.weather, required this.weatherCode, + required this.condition, this.temperature, this.humidity, this.rain, @@ -25,6 +37,7 @@ final class CurrentWeatherWidgetSnapshot { final String weather; final int weatherCode; + final CurrentWeatherWidgetCondition condition; final double? temperature; final int? humidity; @@ -39,6 +52,7 @@ final class CurrentWeatherWidgetSnapshot { 'stationName': stationName, 'weather': weather, 'weatherCode': weatherCode, + 'condition': condition.name, 'temperature': temperature, 'humidity': humidity, 'rain': rain, @@ -46,6 +60,40 @@ final class CurrentWeatherWidgetSnapshot { } } +CurrentWeatherWidgetCondition currentWeatherWidgetCondition(int code) { + if (code <= 0) { + return CurrentWeatherWidgetCondition.unknown; + } + + final suffix = code % 100; + + final phenomenon = switch (suffix) { + 1 || 2 || 5 => CurrentWeatherWidgetCondition.fog, + 3 || + 4 || + 14 || + 15 || + 16 || + 17 || + 18 || + 19 => CurrentWeatherWidgetCondition.thunderstorm, + 6 || 7 || 11 || 13 => CurrentWeatherWidgetCondition.rain, + 8 || 9 || 10 || 12 => CurrentWeatherWidgetCondition.snow, + _ => null, + }; + + if (phenomenon != null) { + return phenomenon; + } + + return switch (code ~/ 100) { + 1 => CurrentWeatherWidgetCondition.clear, + 2 => CurrentWeatherWidgetCondition.cloudy, + 3 => CurrentWeatherWidgetCondition.overcast, + _ => CurrentWeatherWidgetCondition.unknown, + }; +} + CurrentWeatherWidgetSnapshot createCurrentWeatherWidgetSnapshot({ required String regionCode, required String regionName, @@ -58,6 +106,7 @@ CurrentWeatherWidgetSnapshot createCurrentWeatherWidgetSnapshot({ stationName: weather.station.name, weather: weather.data.weather, weatherCode: weather.data.weatherCode, + condition: currentWeatherWidgetCondition(weather.data.weatherCode), temperature: weather.data.temperature, humidity: weather.data.humidity, rain: weather.data.rain, diff --git a/test/features/weather/current_weather_widget_coordinator_test.dart b/test/features/weather/current_weather_widget_coordinator_test.dart index b428d4d67..07ba24325 100644 --- a/test/features/weather/current_weather_widget_coordinator_test.dart +++ b/test/features/weather/current_weather_widget_coordinator_test.dart @@ -34,7 +34,7 @@ void main() { final decoded = jsonDecode(writer.writtenJson!) as Map; - expect(decoded['schemaVersion'], 1); + expect(decoded['schemaVersion'], 2); expect(decoded['regionCode'], '660'); expect(decoded['regionName'], '西屯區'); expect(decoded['stationName'], '西屯'); diff --git a/test/features/weather/current_weather_widget_publisher_test.dart b/test/features/weather/current_weather_widget_publisher_test.dart index 607033002..6e669e220 100644 --- a/test/features/weather/current_weather_widget_publisher_test.dart +++ b/test/features/weather/current_weather_widget_publisher_test.dart @@ -43,6 +43,7 @@ void main() { stationName: '西屯', weather: '多雲', weatherCode: 200, + condition: .cloudy, temperature: 28.4, humidity: 76, rain: 0.0, @@ -71,6 +72,7 @@ void main() { stationName: '西屯', weather: '多雲', weatherCode: 200, + condition: .cloudy, temperature: null, humidity: null, rain: null, diff --git a/test/features/weather/current_weather_widget_snapshot_test.dart b/test/features/weather/current_weather_widget_snapshot_test.dart index c788b4d47..137cfbf49 100644 --- a/test/features/weather/current_weather_widget_snapshot_test.dart +++ b/test/features/weather/current_weather_widget_snapshot_test.dart @@ -6,7 +6,70 @@ import 'package:dpip/features/weather/domain/current_weather_widget_snapshot.dar import 'package:dpip/features/weather/domain/weather_realtime.dart'; void main() { - test('creates a current weather widget snapshot', () { + group('currentWeatherWidgetCondition', () { + test('maps weather code families and phenomena', () { + const cases = { + 100: .clear, + 200: .cloudy, + 300: .overcast, + 101: .fog, + 102: .fog, + 105: .fog, + 103: .thunderstorm, + 104: .thunderstorm, + 114: .thunderstorm, + 115: .thunderstorm, + 116: .thunderstorm, + 117: .thunderstorm, + 118: .thunderstorm, + 119: .thunderstorm, + 106: .rain, + 107: .rain, + 111: .rain, + 113: .rain, + 108: .snow, + 109: .snow, + 110: .snow, + 112: .snow, + }; + + for (final MapEntry(key: code, value: condition) in cases.entries) { + expect( + currentWeatherWidgetCondition(code), + condition, + reason: 'weather code $code', + ); + } + }); + + test('returns unknown for invalid or unsupported codes', () { + for (final code in [0, -1, 420]) { + expect( + currentWeatherWidgetCondition(code), + CurrentWeatherWidgetCondition.unknown, + reason: 'weather code $code', + ); + } + }); + + test('phenomenon takes precedence over the weather family', () { + const cases = { + 106: .rain, + 214: .thunderstorm, + 305: .fog, + }; + + for (final MapEntry(key: code, value: condition) in cases.entries) { + expect( + currentWeatherWidgetCondition(code), + condition, + reason: 'weather code $code', + ); + } + }); + }); + + test('creates a current weather widget snapshot from realtime weather', () { final weather = WeatherRealtime( id: 'C0X160', station: const WeatherRealtimeStation( @@ -18,8 +81,8 @@ void main() { ), time: 1789398000, data: const WeatherRealtimeData( - weather: '多雲', - weatherCode: 200, + weather: '多雲時雨', + weatherCode: 214, temperature: 28.4, humidity: 76, rain: 0.0, @@ -34,13 +97,14 @@ void main() { weather: weather, ); - expect(snapshot.schemaVersion, 1); + expect(snapshot.schemaVersion, 2); expect(snapshot.regionCode, '660'); expect(snapshot.regionName, '西屯區'); expect(snapshot.observationTime, 1789398000); expect(snapshot.stationName, '西屯'); - expect(snapshot.weather, '多雲'); - expect(snapshot.weatherCode, 200); + expect(snapshot.weather, '多雲時雨'); + expect(snapshot.weatherCode, 214); + expect(snapshot.condition, CurrentWeatherWidgetCondition.thunderstorm); expect(snapshot.temperature, 28.4); expect(snapshot.humidity, 76); expect(snapshot.rain, 0.0); @@ -48,48 +112,41 @@ void main() { final json = jsonEncode(snapshot.toJson()); final decoded = jsonDecode(json) as Map; - expect(decoded['schemaVersion'], 1); + expect(decoded['schemaVersion'], 2); expect(decoded['regionCode'], '660'); + expect(decoded['condition'], 'thunderstorm'); expect(decoded['temperature'], 28.4); }); - test('preserves null weather values', () { - final weather = WeatherRealtime( - id: 'C0X160', - station: const WeatherRealtimeStation( - name: '西屯', - latitude: 24.18, - longitude: 120.64, - altitude: 85, - distance: 1.2, - ), - time: 1789398000, - data: const WeatherRealtimeData( - weather: '多雲', - weatherCode: 200, - temperature: null, - humidity: null, - rain: null, - wind: WeatherWind(direction: '北', speed: 1.5, beaufort: 1), - gust: WeatherWind(speed: 3.0, beaufort: 2), - ), - ); - - final snapshot = createCurrentWeatherWidgetSnapshot( + test('serializes condition name and preserves nullable weather values', () { + const snapshot = CurrentWeatherWidgetSnapshot( regionCode: '660', regionName: '西屯區', - weather: weather, + observationTime: 1789398000, + stationName: '西屯', + weather: '多雲', + weatherCode: 200, + condition: .cloudy, + temperature: null, + humidity: null, + rain: null, ); - expect(snapshot.temperature, isNull); - expect(snapshot.humidity, isNull); - expect(snapshot.rain, isNull); - final json = jsonEncode(snapshot.toJson()); final decoded = jsonDecode(json) as Map; - expect(decoded['temperature'], isNull); - expect(decoded['humidity'], isNull); - expect(decoded['rain'], isNull); + expect(decoded, { + 'schemaVersion': 2, + 'regionCode': '660', + 'regionName': '西屯區', + 'observationTime': 1789398000, + 'stationName': '西屯', + 'weather': '多雲', + 'weatherCode': 200, + 'condition': 'cloudy', + 'temperature': null, + 'humidity': null, + 'rain': null, + }); }); } From e8fdbce37699bf24315458bc075f7a06ee7836ab Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Wed, 16 Sep 2026 14:55:19 +0800 Subject: [PATCH 06/14] feat(weather): add day-night states to iOS weather widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios New(zh-Hant): iOS 天氣小工具可依日出日落自動切換日夜天氣圖示 New(en-US): iOS weather widgets can switch day and night icons based on sunrise and sunset --- .../CurrentWeatherWidgetSnapshot.swift | 35 ++++- ios/DPIPWidgets/DPIPWidgets.swift | 134 ++++++++++++------ .../weather}/solar_time.dart | 116 ++++++++++++++- .../widgets/home_forecast_section.dart | 2 +- .../widgets/home_sheet_header.dart | 2 +- .../weather_sky/weather_sky_background.dart | 2 +- .../current_weather_widget_coordinator.dart | 14 ++ .../current_weather_widget_snapshot.dart | 12 +- test/core/weather/solar_time_test.dart | 121 ++++++++++++++++ .../home/weather_sky/sky_keyframe_test.dart | 2 +- ...rrent_weather_widget_coordinator_test.dart | 4 +- ...current_weather_widget_publisher_test.dart | 4 + .../current_weather_widget_snapshot_test.dart | 19 ++- 13 files changed, 404 insertions(+), 63 deletions(-) rename lib/{features/home/presentation/widgets/weather_sky => core/weather}/solar_time.dart (71%) create mode 100644 test/core/weather/solar_time_test.dart diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index 4ee37a7be..760304644 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -10,22 +10,33 @@ enum CurrentWeatherWidgetCondition: String, Decodable { case fog case unknown - var systemImageName: String { + func systemImageName(isNight: Bool) -> String { switch self { case .clear: - return "sun.max.fill" + return isNight + ? "moon.stars.fill" + : "sun.max.fill" + case .cloudy: - return "cloud.sun.fill" + return isNight + ? "cloud.moon.fill" + : "cloud.sun.fill" + case .overcast: return "cloud.fill" + case .rain: return "cloud.rain.fill" + case .thunderstorm: return "cloud.bolt.rain.fill" + case .snow: return "cloud.snow.fill" + case .fog: return "cloud.fog.fill" + case .unknown: return "cloud.fill" } @@ -45,6 +56,8 @@ struct CurrentWeatherWidgetSnapshot: Decodable { let weather: String let weatherCode: Int let condition: CurrentWeatherWidgetCondition + let isNight: Bool + let nextDayNightTransitionTime: Int let temperature: Double? let humidity: Int? @@ -59,6 +72,8 @@ struct CurrentWeatherWidgetSnapshot: Decodable { case weather case weatherCode case condition + case isNight + case nextDayNightTransitionTime case temperature case humidity case rain @@ -73,6 +88,8 @@ struct CurrentWeatherWidgetSnapshot: Decodable { weather: String, weatherCode: Int, condition: CurrentWeatherWidgetCondition, + isNight: Bool, + nextDayNightTransitionTime: Int, temperature: Double?, humidity: Int?, rain: Double? @@ -85,6 +102,8 @@ struct CurrentWeatherWidgetSnapshot: Decodable { self.weather = weather self.weatherCode = weatherCode self.condition = condition + self.isNight = isNight + self.nextDayNightTransitionTime = nextDayNightTransitionTime self.temperature = temperature self.humidity = humidity self.rain = rain @@ -139,6 +158,16 @@ struct CurrentWeatherWidgetSnapshot: Decodable { .flatMap(CurrentWeatherWidgetCondition.init(rawValue:)) ?? .unknown + isNight = try container.decodeIfPresent( + Bool.self, + forKey: .isNight + ) ?? false + + nextDayNightTransitionTime = try container.decodeIfPresent( + Int.self, + forKey: .nextDayNightTransitionTime + ) ?? 0 + temperature = try container.decodeIfPresent( Double.self, forKey: .temperature diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 7582d6b61..acaa7cf74 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -3,18 +3,22 @@ import SwiftUI struct DPIPWidgetProvider: TimelineProvider { private let staleAfter: TimeInterval = 30 * 60 + private let snapshotStore = WidgetSnapshotStore() func placeholder(in context: Context) -> DPIPWidgetEntry { - DPIPWidgetEntry(date: .now, snapshot: nil, isStale: false) + DPIPWidgetEntry( + date: .now, + snapshot: nil, + isStale: false, + isNight: false, + ) } func getSnapshot( in context: Context, completion: @escaping (DPIPWidgetEntry) -> Void ) { - let snapshot = WidgetSnapshotStore() - .loadCurrentWeatherSnapshot() - + let snapshot = snapshotStore.loadCurrentWeatherSnapshot() let now = Date.now let isStale: Bool @@ -33,7 +37,10 @@ struct DPIPWidgetProvider: TimelineProvider { let entry = DPIPWidgetEntry( date: now, snapshot: snapshot, - isStale: isStale + isStale: isStale, + isNight: snapshot.map { + isNight(for: $0, at: now) + } ?? false ) completion(entry) @@ -43,62 +50,90 @@ struct DPIPWidgetProvider: TimelineProvider { in context: Context, completion: @escaping (Timeline) -> Void ) { - let snapshot = WidgetSnapshotStore() - .loadCurrentWeatherSnapshot() + let now = Date() - let now = Date.now - - guard let snapshot else { + guard let snapshot = snapshotStore.loadCurrentWeatherSnapshot() else { let entry = DPIPWidgetEntry( date: now, snapshot: nil, - isStale: false + isStale: false, + isNight: false ) completion( - Timeline(entries: [entry], policy: .never) + Timeline( + entries: [entry], + policy: .never + ) ) return } let observationDate = Date( - timeIntervalSince1970: TimeInterval(snapshot.observationTime) + timeIntervalSince1970: + TimeInterval(snapshot.observationTime) ) - let staleAt = observationDate.addingTimeInterval(staleAfter) + let staleAt = observationDate.addingTimeInterval( + staleAfter + ) - if now >= staleAt { - let staleEntry = DPIPWidgetEntry( - date: now, - snapshot: snapshot, - isStale: true - ) + let transitionAt = Date( + timeIntervalSince1970: + TimeInterval(snapshot.nextDayNightTransitionTime) + ) - completion( - Timeline( - entries: [staleEntry], - policy: .never - ) - ) - } else { - let freshEntry = DPIPWidgetEntry( - date: now, - snapshot: snapshot, - isStale: false - ) + var dates = [now] - let staleEntry = DPIPWidgetEntry( - date: staleAt, - snapshot: snapshot, - isStale: true - ) - completion( - Timeline( - entries: [freshEntry, staleEntry], - policy: .never + if staleAt > now { + dates.append(staleAt) + } + + if snapshot.nextDayNightTransitionTime > 0, + transitionAt > now { + dates.append(transitionAt) + } + + let entries = Array(Set(dates)) + .sorted() + .map { date in + DPIPWidgetEntry( + date: date, + snapshot: snapshot, + isStale: date >= staleAt, + isNight: isNight( + for: snapshot, + at: date + ) ) + } + + completion( + Timeline( + entries: entries, + policy: .never ) + ) + } + + private func isNight( + for snapshot: CurrentWeatherWidgetSnapshot, + at date: Date + ) -> Bool { + guard snapshot.nextDayNightTransitionTime > 0 else { + return snapshot.isNight } + + let transitionAt = Date( + timeIntervalSince1970: + TimeInterval(snapshot.nextDayNightTransitionTime) + ) + + if date >= transitionAt { + return !snapshot.isNight + } + + return snapshot.isNight } } @@ -106,6 +141,7 @@ struct DPIPWidgetEntry: TimelineEntry { let date: Date let snapshot: CurrentWeatherWidgetSnapshot? let isStale: Bool + let isNight: Bool } struct DPIPWidgetsEntryView : View { @@ -126,7 +162,9 @@ struct DPIPWidgetsEntryView : View { Spacer() HStack(spacing: 4) { - Image(systemName: snapshot.condition.systemImageName) + Image(systemName: snapshot.condition.systemImageName( + isNight: entry.isNight + )) Text(snapshot.weather) .lineLimit(1) @@ -238,18 +276,20 @@ struct DPIPWidgets_Previews: PreviewProvider { private static let previewEntry = DPIPWidgetEntry( date: .now, snapshot: CurrentWeatherWidgetSnapshot( - schemaVersion: 1, + schemaVersion: 3, regionCode: "660", regionName: "西屯區", observationTime: 0, stationName: "西屯", - weather: "短暫雷雨", - weatherCode: 200, - condition: .thunderstorm, + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: true, + nextDayNightTransitionTime: 1_789_562_700, temperature: 28.4, humidity: 76, rain: 0 - ),isStale: true + ),isStale: true, isNight: true ) static var previews: some View { diff --git a/lib/features/home/presentation/widgets/weather_sky/solar_time.dart b/lib/core/weather/solar_time.dart similarity index 71% rename from lib/features/home/presentation/widgets/weather_sky/solar_time.dart rename to lib/core/weather/solar_time.dart index 9c5f14e7b..64fb1a4e2 100644 --- a/lib/features/home/presentation/widgets/weather_sky/solar_time.dart +++ b/lib/core/weather/solar_time.dart @@ -206,18 +206,126 @@ bool isNightHour( } /// Whether the sun is below the horizon at the instant [utc]. +/// +/// Sunrise and sunset are fixed for the local calendar day and rounded to the +/// same whole-second precision used by the Widget snapshot contract. The +/// rounded sunrise second is daytime; the rounded sunset second is nighttime. bool isNightAt( DateTime utc, { double latitude = kTaiwanLatitude, double longitude = kTaiwanLongitude, double utcOffsetHours = 8, }) { - final local = utc.add(Duration(minutes: (utcOffsetHours * 60).round())); - return isNightHour( - local.hour + local.minute / 60.0 + local.second / 3600.0, - utcDay: utc, + final instant = utc.toUtc(); + + final offset = Duration(minutes: (utcOffsetHours * 60).round()); + + final local = instant.add(offset); + final times = _sunSecondsForLocalDay( + local, + offset, + latitude: latitude, + longitude: longitude, + utcOffsetHours: utcOffsetHours, + ); + + return _isNightAtLocalSecond(local, times); +} + +bool _isNightAtLocalSecond(DateTime local, ({int sunrise, int sunset}) times) { + final localSeconds = local.hour * 3600 + local.minute * 60 + local.second; + + return localSeconds < times.sunrise || localSeconds >= times.sunset; +} + +DateTime _localSecondToUtc(DateTime localDay, int second, Duration offset) { + final localMidnight = DateTime.utc( + localDay.year, + localDay.month, + localDay.day, + ); + + return localMidnight.add(Duration(seconds: second)).subtract(offset); +} + +DateTime _localDayAnchorUtc(DateTime localDay, Duration offset) { + final localNoon = DateTime.utc( + localDay.year, + localDay.month, + localDay.day, + 12, + ); + + return localNoon.subtract(offset); +} + +({int sunrise, int sunset}) _sunSecondsForLocalDay( + DateTime localDay, + Duration offset, { + required double latitude, + required double longitude, + required double utcOffsetHours, +}) { + final times = sunTimes( + _localDayAnchorUtc(localDay, offset), + latitude: latitude, + longitude: longitude, + utcOffsetHours: utcOffsetHours, + ); + + return ( + sunrise: (times.sunrise * Duration.secondsPerHour).round(), + sunset: (times.sunset * Duration.secondsPerHour).round(), + ); +} + +/// Returns the next sunrise or sunset after the whole second containing [utc]. +/// +/// This uses the same local-day anchor and second rounding as [isNightAt], so +/// the two exact-instant APIs agree at sunrise and sunset boundaries. +DateTime nextDayNightTransitionAt( + DateTime utc, { + double latitude = kTaiwanLatitude, + double longitude = kTaiwanLongitude, + double utcOffsetHours = 8, +}) { + final instant = utc.toUtc(); + + final offset = Duration(minutes: (utcOffsetHours * 60).round()); + + final localNow = instant.add(offset); + final today = _sunSecondsForLocalDay( + localNow, + offset, + latitude: latitude, + longitude: longitude, + utcOffsetHours: utcOffsetHours, + ); + + final localSeconds = + localNow.hour * 3600 + localNow.minute * 60 + localNow.second; + + if (localSeconds < today.sunrise) { + return _localSecondToUtc(localNow, today.sunrise, offset); + } + + if (localSeconds < today.sunset) { + return _localSecondToUtc(localNow, today.sunset, offset); + } + + final tomorrowLocal = DateTime.utc( + localNow.year, + localNow.month, + localNow.day + 1, + ); + + final tomorrow = _sunSecondsForLocalDay( + tomorrowLocal, + offset, latitude: latitude, longitude: longitude, utcOffsetHours: utcOffsetHours, ); + + return _localSecondToUtc(tomorrowLocal, tomorrow.sunrise, offset); } diff --git a/lib/features/home/presentation/widgets/home_forecast_section.dart b/lib/features/home/presentation/widgets/home_forecast_section.dart index d90631d5f..e79c61017 100644 --- a/lib/features/home/presentation/widgets/home_forecast_section.dart +++ b/lib/features/home/presentation/widgets/home_forecast_section.dart @@ -12,7 +12,7 @@ import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/settings/weather_mode.dart'; import 'package:dpip/features/home/presentation/home_weather_controller.dart'; import 'package:dpip/core/weather/weather_condition.dart'; -import 'package:dpip/features/home/presentation/widgets/weather_sky/solar_time.dart'; +import 'package:dpip/core/weather/solar_time.dart'; import 'package:dpip/features/weather/domain/weather_forecast.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/widgets/loading_view.dart'; diff --git a/lib/features/home/presentation/widgets/home_sheet_header.dart b/lib/features/home/presentation/widgets/home_sheet_header.dart index af932fa39..4e8c4a7bf 100644 --- a/lib/features/home/presentation/widgets/home_sheet_header.dart +++ b/lib/features/home/presentation/widgets/home_sheet_header.dart @@ -10,7 +10,7 @@ import 'package:dpip/core/settings/weather_mode.dart'; import 'package:dpip/core/weather/weather_condition.dart'; import 'package:dpip/core/weather/weather_icons.dart'; import 'package:dpip/features/home/presentation/home_weather_controller.dart'; -import 'package:dpip/features/home/presentation/widgets/weather_sky/solar_time.dart'; +import 'package:dpip/core/weather/solar_time.dart'; import 'package:dpip/features/weather/domain/weather_realtime.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/map/map_station_handoff.dart'; diff --git a/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart b/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart index 2acba1dd5..246b4c6bb 100644 --- a/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart @@ -12,7 +12,7 @@ import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_clouds.d import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe.dart'; import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart'; import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart'; -import 'package:dpip/features/home/presentation/widgets/weather_sky/solar_time.dart'; +import 'package:dpip/core/weather/solar_time.dart'; import 'package:dpip/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; diff --git a/lib/features/weather/current_weather_widget_coordinator.dart b/lib/features/weather/current_weather_widget_coordinator.dart index c54703836..d8920d7f1 100644 --- a/lib/features/weather/current_weather_widget_coordinator.dart +++ b/lib/features/weather/current_weather_widget_coordinator.dart @@ -4,6 +4,8 @@ import 'package:dpip/features/weather/data/current_weather_widget_publisher.dart import 'package:dpip/features/weather/domain/current_weather_widget_snapshot.dart'; import 'package:dpip/features/weather/domain/current_weather_widget_sync.dart'; import 'package:dpip/features/weather/domain/weather_realtime.dart'; +import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/core/weather/solar_time.dart'; final class CurrentWeatherWidgetCoordinator implements CurrentWeatherWidgetSync { @@ -32,10 +34,22 @@ final class CurrentWeatherWidgetCoordinator return; } + final now = AppTime.utc; + + final isNight = isNightAt(now, latitude: town.lat, longitude: town.lng); + + final nextTransition = nextDayNightTransitionAt( + now, + latitude: town.lat, + longitude: town.lng, + ); + final snapshot = createCurrentWeatherWidgetSnapshot( regionCode: regionCode, regionName: town.townName, weather: weather, + isNight: isNight, + nextDayNightTransitionTime: nextTransition.millisecondsSinceEpoch ~/ 1000, ); await _publisher.publish(snapshot); diff --git a/lib/features/weather/domain/current_weather_widget_snapshot.dart b/lib/features/weather/domain/current_weather_widget_snapshot.dart index af70b87cc..0f088a0e0 100644 --- a/lib/features/weather/domain/current_weather_widget_snapshot.dart +++ b/lib/features/weather/domain/current_weather_widget_snapshot.dart @@ -13,7 +13,7 @@ enum CurrentWeatherWidgetCondition { final class CurrentWeatherWidgetSnapshot { const CurrentWeatherWidgetSnapshot({ - this.schemaVersion = 2, + this.schemaVersion = 3, required this.regionCode, required this.regionName, required this.observationTime, @@ -21,6 +21,8 @@ final class CurrentWeatherWidgetSnapshot { required this.weather, required this.weatherCode, required this.condition, + required this.isNight, + required this.nextDayNightTransitionTime, this.temperature, this.humidity, this.rain, @@ -38,6 +40,8 @@ final class CurrentWeatherWidgetSnapshot { final String weather; final int weatherCode; final CurrentWeatherWidgetCondition condition; + final bool isNight; + final int nextDayNightTransitionTime; final double? temperature; final int? humidity; @@ -53,6 +57,8 @@ final class CurrentWeatherWidgetSnapshot { 'weather': weather, 'weatherCode': weatherCode, 'condition': condition.name, + 'isNight': isNight, + 'nextDayNightTransitionTime': nextDayNightTransitionTime, 'temperature': temperature, 'humidity': humidity, 'rain': rain, @@ -98,6 +104,8 @@ CurrentWeatherWidgetSnapshot createCurrentWeatherWidgetSnapshot({ required String regionCode, required String regionName, required WeatherRealtime weather, + required bool isNight, + required int nextDayNightTransitionTime, }) { return CurrentWeatherWidgetSnapshot( regionCode: regionCode, @@ -107,6 +115,8 @@ CurrentWeatherWidgetSnapshot createCurrentWeatherWidgetSnapshot({ weather: weather.data.weather, weatherCode: weather.data.weatherCode, condition: currentWeatherWidgetCondition(weather.data.weatherCode), + isNight: isNight, + nextDayNightTransitionTime: nextDayNightTransitionTime, temperature: weather.data.temperature, humidity: weather.data.humidity, rain: weather.data.rain, diff --git a/test/core/weather/solar_time_test.dart b/test/core/weather/solar_time_test.dart new file mode 100644 index 000000000..1e9a7c475 --- /dev/null +++ b/test/core/weather/solar_time_test.dart @@ -0,0 +1,121 @@ +import 'package:dpip/core/weather/solar_time.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _latitude = 23.75; +const _longitude = 121.0; +const _utcOffsetHours = 8.0; +const _utcOffset = Duration(hours: 8); + +bool _isNight(DateTime instant) { + return isNightAt( + instant, + latitude: _latitude, + longitude: _longitude, + utcOffsetHours: _utcOffsetHours, + ); +} + +DateTime _nextTransition(DateTime instant) { + return nextDayNightTransitionAt( + instant, + latitude: _latitude, + longitude: _longitude, + utcOffsetHours: _utcOffsetHours, + ); +} + +DateTime _localInstant( + int year, + int month, + int day, + int hour, [ + int minute = 0, +]) { + return DateTime.utc(year, month, day, hour, minute).subtract(_utcOffset); +} + +({DateTime sunrise, DateTime sunset}) _canonicalSunTransitions( + int year, + int month, + int day, +) { + final localNoonUtc = _localInstant(year, month, day, 12); + final times = sunTimes( + localNoonUtc, + latitude: _latitude, + longitude: _longitude, + utcOffsetHours: _utcOffsetHours, + ); + final localMidnight = DateTime.utc(year, month, day); + + DateTime transition(double localHour) { + return localMidnight + .add(Duration(seconds: (localHour * Duration.secondsPerHour).round())) + .subtract(_utcOffset); + } + + return (sunrise: transition(times.sunrise), sunset: transition(times.sunset)); +} + +void main() { + final today = _canonicalSunTransitions(2026, 9, 16); + final tomorrow = _canonicalSunTransitions(2026, 9, 17); + + group('exact-instant day/night contract', () { + test('before sunrise is night and transitions to today sunrise', () { + final instant = today.sunrise.subtract(const Duration(seconds: 1)); + + expect(_isNight(instant), isTrue); + expect(_nextTransition(instant), today.sunrise); + }); + + test('during daytime transitions to today sunset', () { + final instant = _localInstant(2026, 9, 16, 12); + + expect(_isNight(instant), isFalse); + expect(_nextTransition(instant), today.sunset); + }); + + test('after sunset is night and transitions to tomorrow sunrise', () { + final instant = today.sunset.add(const Duration(seconds: 1)); + + expect(_isNight(instant), isTrue); + expect(_nextTransition(instant), tomorrow.sunrise); + }); + + test('exact rounded sunrise is daytime and transitions to sunset', () { + expect(_isNight(today.sunrise), isFalse); + expect(_nextTransition(today.sunrise), today.sunset); + }); + + test('exact rounded sunset is nighttime and transitions to sunrise', () { + expect(_isNight(today.sunset), isTrue); + expect(_nextTransition(today.sunset), tomorrow.sunrise); + }); + + test('returns a UTC transition', () { + final transition = _nextTransition(_localInstant(2026, 9, 16, 12)); + + expect(transition, today.sunset); + expect(transition.isUtc, isTrue); + }); + + test('normalizes equivalent instant representations to UTC', () { + final utc = DateTime.utc(2026, 9, 16, 4); + final offset = DateTime.parse('2026-09-16T12:00:00+08:00'); + + expect(offset, utc); + expect(_isNight(offset), _isNight(utc)); + expect(_nextTransition(offset), _nextTransition(utc)); + expect(_nextTransition(offset).isUtc, isTrue); + }); + + test('uses the local calendar date when UTC is still the previous day', () { + final localSeptember16 = _localInstant(2026, 9, 16, 0, 30); + + expect(localSeptember16, DateTime.utc(2026, 9, 15, 16, 30)); + expect(_isNight(localSeptember16), isTrue); + expect(_nextTransition(localSeptember16), today.sunrise); + }); + }); +} diff --git a/test/features/home/weather_sky/sky_keyframe_test.dart b/test/features/home/weather_sky/sky_keyframe_test.dart index 8eb72520e..03f9cf5e8 100644 --- a/test/features/home/weather_sky/sky_keyframe_test.dart +++ b/test/features/home/weather_sky/sky_keyframe_test.dart @@ -2,7 +2,7 @@ import 'dart:math' as math; import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe.dart'; import 'package:dpip/features/home/presentation/widgets/weather_sky/sky_keyframe_data.dart'; -import 'package:dpip/features/home/presentation/widgets/weather_sky/solar_time.dart'; +import 'package:dpip/core/weather/solar_time.dart'; import 'package:flutter_test/flutter_test.dart'; /// The solar ephemeris is what decides whether the backdrop looks like noon, diff --git a/test/features/weather/current_weather_widget_coordinator_test.dart b/test/features/weather/current_weather_widget_coordinator_test.dart index 07ba24325..3f063372d 100644 --- a/test/features/weather/current_weather_widget_coordinator_test.dart +++ b/test/features/weather/current_weather_widget_coordinator_test.dart @@ -34,11 +34,13 @@ void main() { final decoded = jsonDecode(writer.writtenJson!) as Map; - expect(decoded['schemaVersion'], 2); + expect(decoded['schemaVersion'], 3); expect(decoded['regionCode'], '660'); expect(decoded['regionName'], '西屯區'); expect(decoded['stationName'], '西屯'); expect(decoded['weather'], '多雲'); + expect(decoded['isNight'], isA()); + expect(decoded['nextDayNightTransitionTime'], isA()); expect(decoded['temperature'], 28.4); }); diff --git a/test/features/weather/current_weather_widget_publisher_test.dart b/test/features/weather/current_weather_widget_publisher_test.dart index 6e669e220..54a244471 100644 --- a/test/features/weather/current_weather_widget_publisher_test.dart +++ b/test/features/weather/current_weather_widget_publisher_test.dart @@ -44,6 +44,8 @@ void main() { weather: '多雲', weatherCode: 200, condition: .cloudy, + isNight: false, + nextDayNightTransitionTime: 1_789_562_700, temperature: 28.4, humidity: 76, rain: 0.0, @@ -73,6 +75,8 @@ void main() { weather: '多雲', weatherCode: 200, condition: .cloudy, + isNight: false, + nextDayNightTransitionTime: 1_789_562_700, temperature: null, humidity: null, rain: null, diff --git a/test/features/weather/current_weather_widget_snapshot_test.dart b/test/features/weather/current_weather_widget_snapshot_test.dart index 137cfbf49..701a7ad63 100644 --- a/test/features/weather/current_weather_widget_snapshot_test.dart +++ b/test/features/weather/current_weather_widget_snapshot_test.dart @@ -95,9 +95,11 @@ void main() { regionCode: '660', regionName: '西屯區', weather: weather, + isNight: false, + nextDayNightTransitionTime: 1_789_562_700, ); - expect(snapshot.schemaVersion, 2); + expect(snapshot.schemaVersion, 3); expect(snapshot.regionCode, '660'); expect(snapshot.regionName, '西屯區'); expect(snapshot.observationTime, 1789398000); @@ -105,6 +107,8 @@ void main() { expect(snapshot.weather, '多雲時雨'); expect(snapshot.weatherCode, 214); expect(snapshot.condition, CurrentWeatherWidgetCondition.thunderstorm); + expect(snapshot.isNight, isFalse); + expect(snapshot.nextDayNightTransitionTime, 1_789_562_700); expect(snapshot.temperature, 28.4); expect(snapshot.humidity, 76); expect(snapshot.rain, 0.0); @@ -112,9 +116,11 @@ void main() { final json = jsonEncode(snapshot.toJson()); final decoded = jsonDecode(json) as Map; - expect(decoded['schemaVersion'], 2); + expect(decoded['schemaVersion'], 3); expect(decoded['regionCode'], '660'); expect(decoded['condition'], 'thunderstorm'); + expect(decoded['isNight'], isFalse); + expect(decoded['nextDayNightTransitionTime'], 1_789_562_700); expect(decoded['temperature'], 28.4); }); @@ -127,16 +133,21 @@ void main() { weather: '多雲', weatherCode: 200, condition: .cloudy, + isNight: true, + nextDayNightTransitionTime: 1_789_562_700, temperature: null, humidity: null, rain: null, ); + expect(snapshot.isNight, isTrue); + expect(snapshot.nextDayNightTransitionTime, 1_789_562_700); + final json = jsonEncode(snapshot.toJson()); final decoded = jsonDecode(json) as Map; expect(decoded, { - 'schemaVersion': 2, + 'schemaVersion': 3, 'regionCode': '660', 'regionName': '西屯區', 'observationTime': 1789398000, @@ -144,6 +155,8 @@ void main() { 'weather': '多雲', 'weatherCode': 200, 'condition': 'cloudy', + 'isNight': true, + 'nextDayNightTransitionTime': 1_789_562_700, 'temperature': null, 'humidity': null, 'rain': null, From 117faf6bfe297d3d5be1a1c2f2259f1edbda1d0b Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Wed, 16 Sep 2026 15:19:19 +0800 Subject: [PATCH 07/14] test(weather): cover iOS widget timeline states Platform: ios --- .../CurrentWeatherWidgetTimeline.swift | 98 ++++++ ios/DPIPWidgets/DPIPWidgets.swift | 106 +----- ios/Runner.xcodeproj/project.pbxproj | 12 + .../CurrentWeatherWidgetTests.swift | 331 ++++++++++++++++++ 4 files changed, 458 insertions(+), 89 deletions(-) create mode 100644 ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift create mode 100644 ios/RunnerTests/CurrentWeatherWidgetTests.swift diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift b/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift new file mode 100644 index 000000000..e61c22798 --- /dev/null +++ b/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift @@ -0,0 +1,98 @@ +import Foundation + +struct CurrentWeatherWidgetTimelineState: Equatable { + let date: Date + let isStale: Bool + let isNight: Bool +} + +enum CurrentWeatherWidgetTimeline { + static func state( + snapshot: CurrentWeatherWidgetSnapshot?, + at date: Date, + staleAfter: TimeInterval + ) -> CurrentWeatherWidgetTimelineState { + guard let snapshot else { + return CurrentWeatherWidgetTimelineState( + date: date, + isStale: false, + isNight: false + ) + } + + let observationDate = Date( + timeIntervalSince1970: TimeInterval(snapshot.observationTime) + ) + let staleAt = observationDate.addingTimeInterval(staleAfter) + + return CurrentWeatherWidgetTimelineState( + date: date, + isStale: date >= staleAt, + isNight: isNight(for: snapshot, at: date) + ) + } + + static func states( + snapshot: CurrentWeatherWidgetSnapshot?, + now: Date, + staleAfter: TimeInterval + ) -> [CurrentWeatherWidgetTimelineState] { + guard let snapshot else { + return [ + state( + snapshot: nil, + at: now, + staleAfter: staleAfter + ) + ] + } + + let observationDate = Date( + timeIntervalSince1970: TimeInterval(snapshot.observationTime) + ) + let staleAt = observationDate.addingTimeInterval(staleAfter) + let transitionAt = Date( + timeIntervalSince1970: + TimeInterval(snapshot.nextDayNightTransitionTime) + ) + + var dates = [now] + + if staleAt > now { + dates.append(staleAt) + } + + if snapshot.nextDayNightTransitionTime > 0, + transitionAt > now { + dates.append(transitionAt) + } + + return Array(Set(dates)) + .sorted() + .map { + state( + snapshot: snapshot, + at: $0, + staleAfter: staleAfter + ) + } + } + + private static func isNight( + for snapshot: CurrentWeatherWidgetSnapshot, + at date: Date + ) -> Bool { + guard snapshot.nextDayNightTransitionTime > 0 else { + return snapshot.isNight + } + + let transitionAt = Date( + timeIntervalSince1970: + TimeInterval(snapshot.nextDayNightTransitionTime) + ) + + return date >= transitionAt + ? !snapshot.isNight + : snapshot.isNight + } +} diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index acaa7cf74..533fa42ed 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -20,27 +20,17 @@ struct DPIPWidgetProvider: TimelineProvider { ) { let snapshot = snapshotStore.loadCurrentWeatherSnapshot() let now = Date.now - - let isStale: Bool - - if let snapshot { - let observationDate = Date( - timeIntervalSince1970: TimeInterval(snapshot.observationTime) - ) - - isStale = - now >= observationDate.addingTimeInterval(staleAfter) - } else { - isStale = false - } + let state = CurrentWeatherWidgetTimeline.state( + snapshot: snapshot, + at: now, + staleAfter: staleAfter + ) let entry = DPIPWidgetEntry( - date: now, + date: state.date, snapshot: snapshot, - isStale: isStale, - isNight: snapshot.map { - isNight(for: $0, at: now) - } ?? false + isStale: state.isStale, + isNight: state.isNight ) completion(entry) @@ -51,60 +41,18 @@ struct DPIPWidgetProvider: TimelineProvider { completion: @escaping (Timeline) -> Void ) { let now = Date() - - guard let snapshot = snapshotStore.loadCurrentWeatherSnapshot() else { - let entry = DPIPWidgetEntry( - date: now, - snapshot: nil, - isStale: false, - isNight: false - ) - - completion( - Timeline( - entries: [entry], - policy: .never - ) - ) - return - } - - let observationDate = Date( - timeIntervalSince1970: - TimeInterval(snapshot.observationTime) - ) - - let staleAt = observationDate.addingTimeInterval( - staleAfter - ) - - let transitionAt = Date( - timeIntervalSince1970: - TimeInterval(snapshot.nextDayNightTransitionTime) + let snapshot = snapshotStore.loadCurrentWeatherSnapshot() + let entries = CurrentWeatherWidgetTimeline.states( + snapshot: snapshot, + now: now, + staleAfter: staleAfter ) - - var dates = [now] - - if staleAt > now { - dates.append(staleAt) - } - - if snapshot.nextDayNightTransitionTime > 0, - transitionAt > now { - dates.append(transitionAt) - } - - let entries = Array(Set(dates)) - .sorted() - .map { date in + .map { state in DPIPWidgetEntry( - date: date, + date: state.date, snapshot: snapshot, - isStale: date >= staleAt, - isNight: isNight( - for: snapshot, - at: date - ) + isStale: state.isStale, + isNight: state.isNight ) } @@ -115,26 +63,6 @@ struct DPIPWidgetProvider: TimelineProvider { ) ) } - - private func isNight( - for snapshot: CurrentWeatherWidgetSnapshot, - at date: Date - ) -> Bool { - guard snapshot.nextDayNightTransitionTime > 0 else { - return snapshot.isNight - } - - let transitionAt = Date( - timeIntervalSince1970: - TimeInterval(snapshot.nextDayNightTransitionTime) - ) - - if date >= transitionAt { - return !snapshot.isNight - } - - return snapshot.isNight - } } struct DPIPWidgetEntry: TimelineEntry { diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 91732d0bf..b2888dba8 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -16,6 +16,9 @@ 2715FCDA30570C1C0014DC8A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2715FCD930570C1C0014DC8A /* SwiftUI.framework */; }; 2715FCE530570C1D0014DC8A /* DPIPWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2715FCD530570C1C0014DC8A /* DPIPWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + D91A00000000000000000011 /* CurrentWeatherWidgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */; }; + D91A00000000000000000012 /* CurrentWeatherWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */; }; + D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 522508B9301F863A006148C2 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 522508B7301F863A006148C2 /* InfoPlist.strings */; }; 72E4CBC23930C168D057AC64 /* Sounds/warn.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 3AE87ED82FDB896B2B5C5F1B /* Sounds/warn.aiff */; }; @@ -94,6 +97,9 @@ 272EFB373057247600B78F5D /* DPIPWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DPIPWidgetsExtension.entitlements; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWeatherWidgetTests.swift; sourceTree = ""; }; + D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherWidgetSnapshot.swift; path = ../DPIPWidgets/CurrentWeatherWidgetSnapshot.swift; sourceTree = ""; }; + D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CurrentWeatherWidgetTimeline.swift; path = ../DPIPWidgets/CurrentWeatherWidgetTimeline.swift; sourceTree = ""; }; 3AE87ED82FDB896B2B5C5F1B /* Sounds/warn.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/warn.aiff; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 4D00D2962CF4598B61D0C722 /* Sounds/eew_alert.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/eew_alert.aiff; sourceTree = ""; }; @@ -191,6 +197,9 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */, + D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */, + D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */, ); path = RunnerTests; sourceTree = ""; @@ -475,6 +484,9 @@ buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + D91A00000000000000000011 /* CurrentWeatherWidgetTests.swift in Sources */, + D91A00000000000000000012 /* CurrentWeatherWidgetSnapshot.swift in Sources */, + D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift new file mode 100644 index 000000000..02ece233a --- /dev/null +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -0,0 +1,331 @@ +import Foundation +import XCTest + +final class CurrentWeatherWidgetSnapshotTests: XCTestCase { + func testDecodesSchemaVersionThreeSnapshot() throws { + let snapshot = try decode( + """ + { + "schemaVersion": 3, + "regionCode": "660", + "regionName": "西屯區", + "observationTime": 1789567200, + "stationName": "西屯", + "weather": "晴", + "weatherCode": 100, + "condition": "clear", + "isNight": true, + "nextDayNightTransitionTime": 1789562700, + "temperature": 28.5, + "humidity": null, + "rain": 0.0 + } + """ + ) + + XCTAssertEqual(snapshot.schemaVersion, 3) + XCTAssertEqual(snapshot.regionCode, "660") + XCTAssertEqual(snapshot.regionName, "西屯區") + XCTAssertEqual(snapshot.observationTime, 1_789_567_200) + XCTAssertEqual(snapshot.stationName, "西屯") + XCTAssertEqual(snapshot.weather, "晴") + XCTAssertEqual(snapshot.weatherCode, 100) + XCTAssertEqual(snapshot.condition, .clear) + XCTAssertTrue(snapshot.isNight) + XCTAssertEqual(snapshot.nextDayNightTransitionTime, 1_789_562_700) + XCTAssertEqual(snapshot.temperature, 28.5) + XCTAssertNil(snapshot.humidity) + XCTAssertEqual(snapshot.rain, 0) + } + + func testDecodesOlderSnapshotWithDayNightDefaults() throws { + let snapshot = try decode( + """ + { + "schemaVersion": 2, + "regionCode": "660", + "regionName": "西屯區", + "observationTime": 1789567200, + "stationName": "西屯", + "weather": "晴", + "weatherCode": 100, + "condition": "clear", + "temperature": null, + "humidity": null, + "rain": null + } + """ + ) + + XCTAssertFalse(snapshot.isNight) + XCTAssertEqual(snapshot.nextDayNightTransitionTime, 0) + XCTAssertNil(snapshot.temperature) + XCTAssertNil(snapshot.humidity) + XCTAssertNil(snapshot.rain) + } + + func testUnknownConditionDecodesAsUnknown() throws { + let snapshot = try decode( + """ + { + "schemaVersion": 3, + "regionCode": "660", + "regionName": "西屯區", + "observationTime": 1789567200, + "stationName": "西屯", + "weather": "未知", + "weatherCode": 999, + "condition": "future-condition", + "isNight": false, + "nextDayNightTransitionTime": 0, + "temperature": null, + "humidity": null, + "rain": null + } + """ + ) + + XCTAssertEqual(snapshot.condition, .unknown) + } + + private func decode(_ json: String) throws -> CurrentWeatherWidgetSnapshot { + try JSONDecoder().decode( + CurrentWeatherWidgetSnapshot.self, + from: Data(json.utf8) + ) + } +} + +final class CurrentWeatherWidgetConditionTests: XCTestCase { + func testClearAndCloudyUseDayNightSymbols() { + XCTAssertEqual( + CurrentWeatherWidgetCondition.clear.systemImageName(isNight: false), + "sun.max.fill" + ) + XCTAssertEqual( + CurrentWeatherWidgetCondition.clear.systemImageName(isNight: true), + "moon.stars.fill" + ) + XCTAssertEqual( + CurrentWeatherWidgetCondition.cloudy.systemImageName(isNight: false), + "cloud.sun.fill" + ) + XCTAssertEqual( + CurrentWeatherWidgetCondition.cloudy.systemImageName(isNight: true), + "cloud.moon.fill" + ) + } + + func testRainSymbolDoesNotDependOnDayNight() { + XCTAssertEqual( + CurrentWeatherWidgetCondition.rain.systemImageName(isNight: false), + "cloud.rain.fill" + ) + XCTAssertEqual( + CurrentWeatherWidgetCondition.rain.systemImageName(isNight: true), + "cloud.rain.fill" + ) + } +} + +final class CurrentWeatherWidgetTimelineTests: XCTestCase { + private let staleAfter: TimeInterval = 30 * 60 + + func testDayToNightBeforeStale() { + let now = date(10_000) + let states = CurrentWeatherWidgetTimeline.states( + snapshot: snapshot( + observationTime: 10_000, + isNight: false, + transitionTime: 11_200 + ), + now: now, + staleAfter: staleAfter + ) + + XCTAssertEqual( + states, + [ + state(at: 10_000, isStale: false, isNight: false), + state(at: 11_200, isStale: false, isNight: true), + state(at: 11_800, isStale: true, isNight: true), + ] + ) + } + + func testStaleBeforeDayToNightTransition() { + let states = CurrentWeatherWidgetTimeline.states( + snapshot: snapshot( + observationTime: 8_800, + isNight: false, + transitionTime: 11_200 + ), + now: date(10_000), + staleAfter: staleAfter + ) + + XCTAssertEqual( + states, + [ + state(at: 10_000, isStale: false, isNight: false), + state(at: 10_600, isStale: true, isNight: false), + state(at: 11_200, isStale: true, isNight: true), + ] + ) + } + + func testNightToDayTransition() { + let states = CurrentWeatherWidgetTimeline.states( + snapshot: snapshot( + observationTime: 10_000, + isNight: true, + transitionTime: 11_200 + ), + now: date(10_000), + staleAfter: staleAfter + ) + + XCTAssertTrue(states[0].isNight) + XCTAssertFalse(states[1].isNight) + XCTAssertFalse(states[2].isNight) + } + + func testEqualStaleAndTransitionDatesAreDeduplicatedAndCombined() { + let states = CurrentWeatherWidgetTimeline.states( + snapshot: snapshot( + observationTime: 10_000, + isNight: false, + transitionTime: 11_800 + ), + now: date(10_000), + staleAfter: staleAfter + ) + + XCTAssertEqual( + states, + [ + state(at: 10_000, isStale: false, isNight: false), + state(at: 11_800, isStale: true, isNight: true), + ] + ) + } + + func testPastTransitionAffectsNowWithoutSchedulingPastDate() { + let states = CurrentWeatherWidgetTimeline.states( + snapshot: snapshot( + observationTime: 10_000, + isNight: false, + transitionTime: 9_999 + ), + now: date(10_000), + staleAfter: staleAfter + ) + + XCTAssertEqual(states.map(\.date), [date(10_000), date(11_800)]) + XCTAssertTrue(states[0].isNight) + XCTAssertFalse(states[0].isStale) + } + + func testAlreadyStaleSnapshotIsStaleAtNow() { + let states = CurrentWeatherWidgetTimeline.states( + snapshot: snapshot( + observationTime: 8_000, + isNight: false, + transitionTime: 0 + ), + now: date(10_000), + staleAfter: staleAfter + ) + + XCTAssertEqual( + states, + [state(at: 10_000, isStale: true, isNight: false)] + ) + } + + func testZeroTransitionUsesSnapshotStateWithoutSolarEntry() { + let states = CurrentWeatherWidgetTimeline.states( + snapshot: snapshot( + observationTime: 10_000, + isNight: true, + transitionTime: 0 + ), + now: date(10_000), + staleAfter: staleAfter + ) + + XCTAssertEqual( + states, + [ + state(at: 10_000, isStale: false, isNight: true), + state(at: 11_800, isStale: true, isNight: true), + ] + ) + } + + func testNoSnapshotProducesOneEmptyState() { + let states = CurrentWeatherWidgetTimeline.states( + snapshot: nil, + now: date(10_000), + staleAfter: staleAfter + ) + + XCTAssertEqual( + states, + [state(at: 10_000, isStale: false, isNight: false)] + ) + } + + func testSnapshotStateAfterTransitionUsesDateAwareNightValue() { + let state = CurrentWeatherWidgetTimeline.state( + snapshot: snapshot( + observationTime: 10_000, + isNight: false, + transitionTime: 11_200 + ), + at: date(11_201), + staleAfter: staleAfter + ) + + XCTAssertTrue(state.isNight) + XCTAssertFalse(state.isStale) + } + + private func snapshot( + observationTime: Int, + isNight: Bool, + transitionTime: Int + ) -> CurrentWeatherWidgetSnapshot { + CurrentWeatherWidgetSnapshot( + schemaVersion: 3, + regionCode: "660", + regionName: "西屯區", + observationTime: observationTime, + stationName: "西屯", + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: isNight, + nextDayNightTransitionTime: transitionTime, + temperature: 28, + humidity: 76, + rain: 0 + ) + } + + private func state( + at timestamp: TimeInterval, + isStale: Bool, + isNight: Bool + ) -> CurrentWeatherWidgetTimelineState { + CurrentWeatherWidgetTimelineState( + date: date(timestamp), + isStale: isStale, + isNight: isNight + ) + } + + private func date(_ timestamp: TimeInterval) -> Date { + Date(timeIntervalSince1970: timestamp) + } +} From 8357f4cac8f7f6e85d4e9d713deb1847e2432cb1 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Wed, 16 Sep 2026 15:45:49 +0800 Subject: [PATCH 08/14] chore(ios): restore Swift package resolution Platform: ios --- .../xcshareddata/swiftpm/Package.resolved | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0f57be1d8..2f2801e7f 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a", - "version" : "11.3.2" + "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", + "version" : "11.3.1" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleDataTransport.git", "state" : { - "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", - "version" : "10.1.1" + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3", - "version" : "8.1.3" + "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", + "version" : "8.1.2" } }, { @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", - "version" : "5.3.1" + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" } }, { From bedc230f10bd05c9e72532c525f352d50f38786d Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Wed, 16 Sep 2026 15:56:30 +0800 Subject: [PATCH 09/14] feat(weather): localize iOS weather widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios New(zh-Hant): iOS 天氣小工具支援繁體中文與英文介面 New(en-US): iOS weather widgets support Traditional Chinese and English --- ios/DPIPWidgets/DPIPWidgets.swift | 8 +-- ios/DPIPWidgets/Localizable.xcstrings | 74 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 ios/DPIPWidgets/Localizable.xcstrings diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 533fa42ed..500c56b5f 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -146,7 +146,7 @@ struct DPIPWidgetsEntryView : View { HStack(spacing: 4) { if entry.isStale { - Text("較舊") + Text("widget.stale") .lineLimit(1) } @@ -166,7 +166,7 @@ struct DPIPWidgetsEntryView : View { Image(systemName: "cloud.fill") .font(.title) - Text("尚無天氣資料") + Text("widget.no_weather_data") .font(.caption) } .foregroundStyle(.secondary) @@ -195,8 +195,8 @@ struct DPIPWidgets: Widget { } } .supportedFamilies([.systemSmall]) - .configurationDisplayName("目前天氣") - .description("顯示 DPIP 所選地區的目前天氣。") + .configurationDisplayName("widget.current_weather") + .description("widget.current_weather_description") } } diff --git a/ios/DPIPWidgets/Localizable.xcstrings b/ios/DPIPWidgets/Localizable.xcstrings new file mode 100644 index 000000000..71103afac --- /dev/null +++ b/ios/DPIPWidgets/Localizable.xcstrings @@ -0,0 +1,74 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "widget.current_weather" : { + "comment" : "Display name for the current weather Widget in the Widget Gallery.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current Weather" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "目前天氣" + } + } + } + }, + "widget.current_weather_description" : { + "comment" : "Description for the current weather Widget in the Widget Gallery.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shows current weather for the area selected in DPIP." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "顯示 DPIP 所選地區的目前天氣。" + } + } + } + }, + "widget.no_weather_data" : { + "comment" : "Message shown when the Widget has no current weather snapshot.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No weather data" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚無天氣資料" + } + } + } + }, + "widget.stale" : { + "comment" : "Label shown when the current weather snapshot is outdated.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Outdated" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "較舊" + } + } + } + } + }, + "version" : "1.0" +} From 20be2b81d6dc5c17b629bb660538abbe774e17a1 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Thu, 17 Sep 2026 01:49:10 +0800 Subject: [PATCH 10/14] refactor(weather): share weather code classification --- lib/core/weather/weather_code.dart | 55 +++++++++++++++++ lib/core/weather/weather_condition.dart | 61 ++++++------------- .../current_weather_widget_snapshot.dart | 40 +++--------- test/core/weather/weather_condition_test.dart | 19 ++++++ 4 files changed, 101 insertions(+), 74 deletions(-) create mode 100644 lib/core/weather/weather_code.dart diff --git a/lib/core/weather/weather_code.dart b/lib/core/weather/weather_code.dart new file mode 100644 index 000000000..951882c8a --- /dev/null +++ b/lib/core/weather/weather_code.dart @@ -0,0 +1,55 @@ +/// Pure semantic classification for CWB weather-condition codes. +library; + +/// The weather meaning shared by Flutter visuals and platform transports. +enum WeatherCondition { + clear, + cloudy, + overcast, + rain, + thunderstorm, + snow, + fog, + unknown, +} + +/// Weather-code suffix → weather phenomenon. +/// +/// The phenomenon wins over the family sky: `106` is rain even though it is +/// in the clear-sky family. This is the authoritative suffix classification. +const Map _phenomenonCondition = { + 1: WeatherCondition.fog, // 有霾 + 2: WeatherCondition.fog, // 有靄 + 3: WeatherCondition.thunderstorm, // 有閃電 + 4: WeatherCondition.thunderstorm, // 有雷聲 + 5: WeatherCondition.fog, // 有霧 + 6: WeatherCondition.rain, // 有雨 + 7: WeatherCondition.rain, // 有雨雪 — rain is the dominant hazard + 8: WeatherCondition.snow, // 有大雪 + 9: WeatherCondition.snow, // 有雪珠 + 10: WeatherCondition.snow, // 有冰珠 + 11: WeatherCondition.rain, // 有陣雨 + 12: WeatherCondition.snow, // 陣雨雪 + 13: WeatherCondition.rain, // 有雹 + 14: WeatherCondition.thunderstorm, // 有雷雨 + 15: WeatherCondition.thunderstorm, // 有雷雪 + 16: WeatherCondition.thunderstorm, // 有雷雹 + 17: WeatherCondition.thunderstorm, // 大雷雨 + 18: WeatherCondition.thunderstorm, // 大雷雹 + 19: WeatherCondition.thunderstorm, // 有雷 +}; + +/// Classifies a CWB [code] without depending on Flutter presentation types. +WeatherCondition weatherConditionForCode(int code) { + if (code <= 0) return WeatherCondition.unknown; + + final phenomenon = _phenomenonCondition[code % 100]; + if (phenomenon != null) return phenomenon; + + return switch (code ~/ 100) { + 1 => WeatherCondition.clear, + 2 => WeatherCondition.cloudy, + 3 => WeatherCondition.overcast, + _ => WeatherCondition.unknown, + }; +} diff --git a/lib/core/weather/weather_condition.dart b/lib/core/weather/weather_condition.dart index aaa44a739..ce6763808 100644 --- a/lib/core/weather/weather_condition.dart +++ b/lib/core/weather/weather_condition.dart @@ -5,43 +5,18 @@ library; import 'package:dpip/core/settings/weather_mode.dart'; +import 'package:dpip/core/weather/weather_code.dart'; import 'package:dpip/core/weather/weather_icons.dart'; import 'package:flutter/material.dart'; -/// The single mapping for CWB's weather-code table: families 100 (晴) / 200 -/// (多雲) / 300 (陰), each carrying the same 20 phenomenon suffixes (ones -/// digits 1–19, `0` = the plain family sky). `0` alone means 缺值/未知. +/// CWB families 100 (晴) / 200 (多雲) / 300 (陰) each carry the same 20 +/// phenomenon suffixes (ones digits 1–19, `0` = the plain family sky). `0` +/// alone means 缺值/未知. /// -/// Consumers never read the raw table — they call [weatherVisual] for the -/// icon/accent and [weatherModeFor] for the backdrop, so the code→look -/// decisions live in exactly one place. +/// The pure code→semantic mapping lives in [weatherConditionForCode]. This +/// file only translates that semantic result into Flutter visuals and modes. -/// Ones digit → backdrop mode. The suffix describes a phenomenon the family -/// base already classifies as sky cover; where the two conflict the phenomenon -/// wins — a clear-code 106 (有雨) is still rain. -const Map _phenomenonMode = { - 1: WeatherMode.fog, // 有霾 - 2: WeatherMode.fog, // 有靄 - 3: WeatherMode.thunderstorm, // 有閃電 - 4: WeatherMode.thunderstorm, // 有雷聲 - 5: WeatherMode.fog, // 有霧 - 6: WeatherMode.rain, // 有雨 - 7: WeatherMode.rain, // 有雨雪 — a rain-snow mix; rain is the dominant hazard - 8: WeatherMode.snow, // 有大雪 - 9: WeatherMode.snow, // 有雪珠 - 10: WeatherMode.snow, // 有冰珠 - 11: WeatherMode.rain, // 有陣雨 - 12: WeatherMode.snow, // 陣雨雪 - 13: WeatherMode.rain, // 有雹 - 14: WeatherMode.thunderstorm, // 有雷雨 - 15: WeatherMode.thunderstorm, // 有雷雪 - 16: WeatherMode.thunderstorm, // 有雷雹 - 17: WeatherMode.thunderstorm, // 大雷雨 - 18: WeatherMode.thunderstorm, // 大雷雹 - 19: WeatherMode.thunderstorm, // 有雷 -}; - -/// Ones digit → a distinct glyph, finer than the eight backdrop modes. +/// Weather-code suffix → a distinct glyph, finer than the eight backdrop modes. /// /// Drawn from the bundled weather font, which exists precisely because /// Flutter's icon set has no rain glyph: the three rain intensities, the @@ -79,16 +54,6 @@ const Map _phenomenonIcon = { 19: bolt, // 有雷 }; -/// The plain sky of a code's family (its hundreds digit), used when the ones -/// digit is `0` or unknown. `0` (缺值) and any unrecognised family fall back -/// to [WeatherMode.auto]. -WeatherMode _familyMode(int code) => switch (code ~/ 100) { - 1 => WeatherMode.clear, - 2 => WeatherMode.cloudy, - 3 => WeatherMode.overcast, - _ => WeatherMode.auto, -}; - /// The plain-sky glyph for a family, by daylight. /// /// This is the one place day and night differ: a clear midnight drawn as a sun @@ -104,8 +69,16 @@ IconData _familySky(WeatherMode mode, {required bool isNight}) => /// The backdrop mode for a CWB [code]: the phenomenon (ones digit) wins over /// the family sky, and `0`/unknown codes fall back to [WeatherMode.auto]. WeatherMode weatherModeFor(int code) { - if (code <= 0) return WeatherMode.auto; - return _phenomenonMode[code % 100] ?? _familyMode(code); + return switch (weatherConditionForCode(code)) { + WeatherCondition.clear => WeatherMode.clear, + WeatherCondition.cloudy => WeatherMode.cloudy, + WeatherCondition.overcast => WeatherMode.overcast, + WeatherCondition.rain => WeatherMode.rain, + WeatherCondition.thunderstorm => WeatherMode.thunderstorm, + WeatherCondition.snow => WeatherMode.snow, + WeatherCondition.fog => WeatherMode.fog, + WeatherCondition.unknown => WeatherMode.auto, + }; } /// Rain intensity for a CWB [code], on the painter's continuous ladder where diff --git a/lib/features/weather/domain/current_weather_widget_snapshot.dart b/lib/features/weather/domain/current_weather_widget_snapshot.dart index 0f088a0e0..707ba5561 100644 --- a/lib/features/weather/domain/current_weather_widget_snapshot.dart +++ b/lib/features/weather/domain/current_weather_widget_snapshot.dart @@ -1,3 +1,4 @@ +import 'package:dpip/core/weather/weather_code.dart'; import 'package:dpip/features/weather/domain/weather_realtime.dart'; enum CurrentWeatherWidgetCondition { @@ -67,36 +68,15 @@ final class CurrentWeatherWidgetSnapshot { } CurrentWeatherWidgetCondition currentWeatherWidgetCondition(int code) { - if (code <= 0) { - return CurrentWeatherWidgetCondition.unknown; - } - - final suffix = code % 100; - - final phenomenon = switch (suffix) { - 1 || 2 || 5 => CurrentWeatherWidgetCondition.fog, - 3 || - 4 || - 14 || - 15 || - 16 || - 17 || - 18 || - 19 => CurrentWeatherWidgetCondition.thunderstorm, - 6 || 7 || 11 || 13 => CurrentWeatherWidgetCondition.rain, - 8 || 9 || 10 || 12 => CurrentWeatherWidgetCondition.snow, - _ => null, - }; - - if (phenomenon != null) { - return phenomenon; - } - - return switch (code ~/ 100) { - 1 => CurrentWeatherWidgetCondition.clear, - 2 => CurrentWeatherWidgetCondition.cloudy, - 3 => CurrentWeatherWidgetCondition.overcast, - _ => CurrentWeatherWidgetCondition.unknown, + return switch (weatherConditionForCode(code)) { + WeatherCondition.clear => CurrentWeatherWidgetCondition.clear, + WeatherCondition.cloudy => CurrentWeatherWidgetCondition.cloudy, + WeatherCondition.overcast => CurrentWeatherWidgetCondition.overcast, + WeatherCondition.rain => CurrentWeatherWidgetCondition.rain, + WeatherCondition.thunderstorm => CurrentWeatherWidgetCondition.thunderstorm, + WeatherCondition.snow => CurrentWeatherWidgetCondition.snow, + WeatherCondition.fog => CurrentWeatherWidgetCondition.fog, + WeatherCondition.unknown => CurrentWeatherWidgetCondition.unknown, }; } diff --git a/test/core/weather/weather_condition_test.dart b/test/core/weather/weather_condition_test.dart index 0bb803bf9..8f161c322 100644 --- a/test/core/weather/weather_condition_test.dart +++ b/test/core/weather/weather_condition_test.dart @@ -1,4 +1,5 @@ import 'package:dpip/core/settings/weather_mode.dart'; +import 'package:dpip/core/weather/weather_code.dart'; import 'package:dpip/core/weather/weather_condition.dart'; import 'package:dpip/core/weather/weather_icons.dart'; import 'package:flutter/material.dart'; @@ -7,6 +8,24 @@ import 'package:flutter_test/flutter_test.dart'; void main() { const colors = ColorScheme.light(); + group('weatherConditionForCode', () { + test('classifies families and lets phenomena take precedence', () { + expect(weatherConditionForCode(100), WeatherCondition.clear); + expect(weatherConditionForCode(200), WeatherCondition.cloudy); + expect(weatherConditionForCode(300), WeatherCondition.overcast); + expect(weatherConditionForCode(106), WeatherCondition.rain); + expect(weatherConditionForCode(214), WeatherCondition.thunderstorm); + expect(weatherConditionForCode(305), WeatherCondition.fog); + expect(weatherConditionForCode(308), WeatherCondition.snow); + }); + + test('invalid and unsupported codes are unknown', () { + expect(weatherConditionForCode(0), WeatherCondition.unknown); + expect(weatherConditionForCode(-1), WeatherCondition.unknown); + expect(weatherConditionForCode(420), WeatherCondition.unknown); + }); + }); + group('weatherModeFor', () { test('plain families map clear / cloudy / overcast', () { expect(weatherModeFor(100), WeatherMode.clear); From 363ff79ded40b4ef2636eb911c6cc5e39ad3d93e Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Thu, 17 Sep 2026 02:05:17 +0800 Subject: [PATCH 11/14] fix(weather): enforce latest weather request ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正天氣請求競態造成舊資料覆蓋新資料 Fix(en-US): Prevent stale weather requests from overwriting newer data --- .../presentation/home_weather_controller.dart | 12 +- .../home_weather_controller_test.dart | 284 ++++++++++++++++-- 2 files changed, 268 insertions(+), 28 deletions(-) diff --git a/lib/features/home/presentation/home_weather_controller.dart b/lib/features/home/presentation/home_weather_controller.dart index b9a51ccc9..a6935cea3 100644 --- a/lib/features/home/presentation/home_weather_controller.dart +++ b/lib/features/home/presentation/home_weather_controller.dart @@ -67,6 +67,7 @@ class HomeWeatherController extends ChangeNotifier { Failure? _forecastFailure; Failure? _hourTrendFailure; String? _loadedCode; + int _requestGeneration = 0; /// The latest realtime observation, or null before the first load / at sea. WeatherRealtime? get weather => _weather; @@ -128,10 +129,12 @@ class HomeWeatherController extends ChangeNotifier { final town = code == null ? null : _directory.byCode(code); if (town == null || code == null) { + _requestGeneration += 1; _weather = null; _weatherCode = null; _forecast = null; _hourTrend = null; + _loading = false; _failure = null; _forecastFailure = null; _hourTrendFailure = null; @@ -142,6 +145,8 @@ class HomeWeatherController extends ChangeNotifier { } Future _load(String code, double lat, double lng) async { + final requestGeneration = ++_requestGeneration; + _loading = true; _failure = null; _forecastFailure = null; @@ -159,8 +164,10 @@ class HomeWeatherController extends ChangeNotifier { final realtime = await realtimeFuture; final forecast = await forecastFuture; final hourTrend = await hourTrendFuture; - // Drop a superseded response if the user switched area mid-flight. - if (_loadedCode != code) return; + // Region equality alone cannot distinguish two requests for the same area, + // or A1 from a later A2 after an A → B → A sequence. Only the most recent + // generation may mutate any state or publish a Widget snapshot. + if (requestGeneration != _requestGeneration) return; _loading = false; realtime.when( @@ -226,6 +233,7 @@ class HomeWeatherController extends ChangeNotifier { @override void dispose() { + _requestGeneration += 1; _regions.removeListener(_sync); super.dispose(); } diff --git a/test/features/home/presentation/home_weather_controller_test.dart b/test/features/home/presentation/home_weather_controller_test.dart index fe1f17d06..ff757ca58 100644 --- a/test/features/home/presentation/home_weather_controller_test.dart +++ b/test/features/home/presentation/home_weather_controller_test.dart @@ -282,6 +282,226 @@ void main() { expect(regions.selectedCode, '100'); expect(callbackRegions, isEmpty); }); + + test('latest same-region request wins across all weather state', () async { + final firstRealtime = Completer>(); + final secondRealtime = Completer>(); + var realtimeCall = 0; + var forecastCall = 0; + var hourTrendCall = 0; + final oldWeather = _weather(id: 'old', temperature: 20); + final newWeather = _weather(id: 'new', temperature: 30); + final newForecast = WeatherForecast(updateTime: 2, forecast: const []); + final newHourTrend = RainHourTrend(startSecond: 2, mm: List.filled(60, 2)); + final repository = _FakeWeatherRepository( + onRealtime: (_, _) { + final call = realtimeCall++; + return call == 0 ? firstRealtime.future : secondRealtime.future; + }, + onForecast: (_) async { + final call = forecastCall++; + return call == 0 + ? const Err(NetworkFailure('old forecast failure')) + : Ok(newForecast); + }, + ); + final hourTrendRepository = _FakeHourTrendRepository( + onHourTrend: (_) async { + final call = hourTrendCall++; + return call == 0 + ? const Err(NetworkFailure('old trend failure')) + : Ok(newHourTrend); + }, + ); + final regions = _savedRegions(['660'])..select(2); + addTearDown(regions.dispose); + final callbackWeather = []; + final controller = HomeWeatherController( + repository, + hourTrendRepository, + regions, + _directory, + onRealtimeLoaded: (_, weather) async { + callbackWeather.add(weather); + }, + ); + addTearDown(controller.dispose); + + final refresh = controller.refresh(); + secondRealtime.complete(Ok(newWeather)); + await refresh; + + expect(controller.weather, same(newWeather)); + expect(controller.forecast, same(newForecast)); + expect(controller.hourTrend, same(newHourTrend)); + expect(controller.failure, isNull); + expect(controller.forecastFailure, isNull); + expect(controller.hourTrendFailure, isNull); + expect(controller.loading, isFalse); + expect(callbackWeather, hasLength(1)); + expect(callbackWeather.single, same(newWeather)); + + firstRealtime.complete(Ok(oldWeather)); + await pumpEventQueue(); + + expect(controller.weather, same(newWeather)); + expect(controller.forecast, same(newForecast)); + expect(controller.hourTrend, same(newHourTrend)); + expect(controller.failure, isNull); + expect(controller.forecastFailure, isNull); + expect(controller.hourTrendFailure, isNull); + expect(controller.loading, isFalse); + expect(callbackWeather, hasLength(1)); + expect(callbackWeather.single, same(newWeather)); + }); + + test('A1 cannot overwrite or publish after B then A2', () async { + final firstA = Completer>(); + final secondA = Completer>(); + var aCall = 0; + final repository = _FakeWeatherRepository( + onRealtime: (latitude, _) { + if (latitude == 25.04) return Future.value(const Ok(null)); + if (latitude != 24.18) { + throw StateError('Unexpected latitude: $latitude'); + } + + final call = aCall++; + return call == 0 ? firstA.future : secondA.future; + }, + ); + final regions = _savedRegions(['660', '100'])..select(2); + addTearDown(regions.dispose); + final callbackWeather = []; + final controller = HomeWeatherController( + repository, + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeLoaded: (_, weather) async { + callbackWeather.add(weather); + }, + ); + addTearDown(controller.dispose); + + regions.select(3); + regions.select(2); + final newWeather = _weather(id: 'A2', temperature: 30); + secondA.complete(Ok(newWeather)); + await _waitUntilSettled(controller); + + expect(controller.weather, same(newWeather)); + expect(callbackWeather, hasLength(1)); + expect(callbackWeather.single, same(newWeather)); + + firstA.complete(Ok(_weather(id: 'A1', temperature: 20))); + await pumpEventQueue(); + + expect(controller.weather, same(newWeather)); + expect(controller.loading, isFalse); + expect(callbackWeather, hasLength(1)); + expect(callbackWeather.single, same(newWeather)); + }); + + test( + 'late valid-region response cannot mutate or publish after Nationwide', + () async { + final realtime = Completer>(); + final oldForecast = WeatherForecast(updateTime: 1, forecast: const []); + final oldHourTrend = RainHourTrend( + startSecond: 1, + mm: List.filled(60, 1), + ); + final repository = _FakeWeatherRepository( + onRealtime: (_, _) => realtime.future, + onForecast: (_) async => Ok(oldForecast), + ); + final regions = _savedRegions(['660'])..select(2); + addTearDown(regions.dispose); + final callbackWeather = []; + final controller = HomeWeatherController( + repository, + _FakeHourTrendRepository(onHourTrend: (_) async => Ok(oldHourTrend)), + regions, + _directory, + onRealtimeLoaded: (_, weather) async { + callbackWeather.add(weather); + }, + ); + addTearDown(controller.dispose); + + expect(controller.loading, isTrue); + regions.select(0); + + expect(controller.areaCode, isNull); + expect(controller.weather, isNull); + expect(controller.weatherCode, isNull); + expect(controller.forecast, isNull); + expect(controller.hourTrend, isNull); + expect(controller.failure, isNull); + expect(controller.forecastFailure, isNull); + expect(controller.hourTrendFailure, isNull); + expect(controller.loading, isFalse); + + var lateNotificationCount = 0; + controller.addListener(() { + lateNotificationCount += 1; + }); + realtime.complete(Ok(_weather(id: 'late'))); + await pumpEventQueue(); + + expect(controller.weather, isNull); + expect(controller.weatherCode, isNull); + expect(controller.forecast, isNull); + expect(controller.hourTrend, isNull); + expect(controller.failure, isNull); + expect(controller.forecastFailure, isNull); + expect(controller.hourTrendFailure, isNull); + expect(controller.loading, isFalse); + expect(callbackWeather, isEmpty); + expect(lateNotificationCount, 0); + }, + ); + + test('late response cannot mutate or publish after dispose', () async { + final realtime = Completer>(); + final repository = _FakeWeatherRepository( + onRealtime: (_, _) => realtime.future, + onForecast: (_) async => + Ok(WeatherForecast(updateTime: 1, forecast: const [])), + ); + final regions = _savedRegions(['660'])..select(2); + addTearDown(regions.dispose); + final callbackWeather = []; + final controller = HomeWeatherController( + repository, + _FakeHourTrendRepository( + onHourTrend: (_) async => + Ok(RainHourTrend(startSecond: 1, mm: List.filled(60, 1))), + ), + regions, + _directory, + onRealtimeLoaded: (_, weather) async { + callbackWeather.add(weather); + }, + ); + + expect(controller.loading, isTrue); + controller.dispose(); + + realtime.complete(Ok(_weather(id: 'late'))); + await pumpEventQueue(); + + expect(controller.weather, isNull); + expect(controller.weatherCode, isNull); + expect(controller.forecast, isNull); + expect(controller.hourTrend, isNull); + expect(controller.failure, isNull); + expect(controller.forecastFailure, isNull); + expect(controller.hourTrendFailure, isNull); + expect(controller.loading, isTrue); + expect(callbackWeather, isEmpty); + }); } final _directory = TownDirectory.fromJson({ @@ -319,31 +539,33 @@ Future _waitUntilSettled(HomeWeatherController controller) async { controller.removeListener(listener); } -WeatherRealtime _weather() => WeatherRealtime( - id: 'C0X160', - station: const WeatherRealtimeStation( - name: '西屯', - latitude: 24.18, - longitude: 120.64, - altitude: 85, - distance: 1.2, - ), - time: 1789398000, - data: const WeatherRealtimeData( - weather: '多雲', - weatherCode: 200, - temperature: 28.4, - humidity: 76, - rain: 0, - wind: WeatherWind(direction: '北', speed: 1.5, beaufort: 1), - gust: WeatherWind(speed: 3, beaufort: 2), - ), -); +WeatherRealtime _weather({String id = 'C0X160', double temperature = 28.4}) => + WeatherRealtime( + id: id, + station: const WeatherRealtimeStation( + name: '西屯', + latitude: 24.18, + longitude: 120.64, + altitude: 85, + distance: 1.2, + ), + time: 1789398000, + data: WeatherRealtimeData( + weather: '多雲', + weatherCode: 200, + temperature: temperature, + humidity: 76, + rain: 0, + wind: WeatherWind(direction: '北', speed: 1.5, beaufort: 1), + gust: WeatherWind(speed: 3, beaufort: 2), + ), + ); final class _FakeWeatherRepository implements MeteorWeatherRepository { - const _FakeWeatherRepository({required this.onRealtime}); + const _FakeWeatherRepository({required this.onRealtime, this.onForecast}); final Future> Function(double, double) onRealtime; + final Future> Function(String)? onForecast; @override Future> realtime( @@ -352,17 +574,27 @@ final class _FakeWeatherRepository implements MeteorWeatherRepository { ) => onRealtime(latitude, longitude); @override - Future> forecast(String code) async => - Ok(WeatherForecast(updateTime: 0, forecast: const [])); + Future> forecast(String code) async { + final callback = onForecast; + return callback == null + ? Ok(WeatherForecast(updateTime: 0, forecast: const [])) + : callback(code); + } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } final class _FakeHourTrendRepository implements RainHourTrendRepository { - const _FakeHourTrendRepository(); + const _FakeHourTrendRepository({this.onHourTrend}); + + final Future> Function(String)? onHourTrend; @override - Future> hourTrend(String code) async => - Ok(RainHourTrend(startSecond: 0, mm: List.filled(60, 0))); + Future> hourTrend(String code) async { + final callback = onHourTrend; + return callback == null + ? Ok(RainHourTrend(startSecond: 0, mm: List.filled(60, 0))) + : callback(code); + } } From f8bee56a7cd7a59ab748d0161633c69de6986d0c Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Thu, 17 Sep 2026 02:09:37 +0800 Subject: [PATCH 12/14] fix(weather): clear stale widget snapshot on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 啟動時清除尚未驗證地區的舊天氣小工具資料 Fix(en-US): Clear unverified weather widget snapshots on app startup --- .../presentation/home_weather_controller.dart | 8 ++-- .../home_weather_controller_test.dart | 38 +++++++++++++++++-- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/lib/features/home/presentation/home_weather_controller.dart b/lib/features/home/presentation/home_weather_controller.dart index a6935cea3..00093884a 100644 --- a/lib/features/home/presentation/home_weather_controller.dart +++ b/lib/features/home/presentation/home_weather_controller.dart @@ -117,11 +117,9 @@ class HomeWeatherController extends ChangeNotifier { return; } - if (_hasSyncedRegion) { - final callback = onRealtimeInvalidated; - if (callback != null) { - unawaited(callback()); - } + final callback = onRealtimeInvalidated; + if (callback != null) { + unawaited(callback()); } _hasSyncedRegion = true; diff --git a/test/features/home/presentation/home_weather_controller_test.dart b/test/features/home/presentation/home_weather_controller_test.dart index ff757ca58..28032f625 100644 --- a/test/features/home/presentation/home_weather_controller_test.dart +++ b/test/features/home/presentation/home_weather_controller_test.dart @@ -14,7 +14,7 @@ import 'package:dpip/features/weather/domain/weather_realtime.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('initial valid-region sync does not invalidate realtime', () async { + test('initial valid-region sync invalidates persisted realtime', () async { final regions = _savedRegions(['660'])..select(2); addTearDown(regions.dispose); var invalidationCount = 0; @@ -32,10 +32,10 @@ void main() { await _waitUntilSettled(controller); expect(regions.selectedCode, '660'); - expect(invalidationCount, 0); + expect(invalidationCount, 1); }); - test('initial null-region sync does not invalidate realtime', () { + test('initial null-region sync invalidates persisted realtime', () { final regions = _savedRegions(['660']); addTearDown(regions.dispose); var invalidationCount = 0; @@ -51,9 +51,35 @@ void main() { addTearDown(controller.dispose); expect(regions.selectedCode, isNull); - expect(invalidationCount, 0); + expect(invalidationCount, 1); }); + test( + 'initial valid-region sync clears before publishing fresh data', + () async { + final regions = _savedRegions(['660'])..select(2); + addTearDown(regions.dispose); + final events = []; + final controller = HomeWeatherController( + _FakeWeatherRepository(onRealtime: (_, _) async => Ok(_weather())), + const _FakeHourTrendRepository(), + regions, + _directory, + onRealtimeLoaded: (regionCode, _) async { + events.add('loaded:$regionCode'); + }, + onRealtimeInvalidated: () async { + events.add('invalidated'); + }, + ); + addTearDown(controller.dispose); + + await _waitUntilSettled(controller); + + expect(events, ['invalidated', 'loaded:660']); + }, + ); + test('valid region change invalidates realtime exactly once', () async { final regions = _savedRegions(['660', '100'])..select(2); addTearDown(regions.dispose); @@ -69,6 +95,7 @@ void main() { ); addTearDown(controller.dispose); await _waitUntilSettled(controller); + invalidationCount = 0; regions.select(3); await _waitUntilSettled(controller); @@ -119,6 +146,7 @@ void main() { ); addTearDown(controller.dispose); await _waitUntilSettled(controller); + invalidationCount = 0; regions.select(0); @@ -140,6 +168,7 @@ void main() { }, ); addTearDown(controller.dispose); + invalidationCount = 0; regions.select(2); await _waitUntilSettled(controller); @@ -165,6 +194,7 @@ void main() { ); addTearDown(controller.dispose); await _waitUntilSettled(controller); + invalidationCount = 0; expect(regions.addSaved('100'), isTrue); From f2c2f907090b9a9c23990c8e367f7bec16da4bee Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Thu, 17 Sep 2026 02:23:37 +0800 Subject: [PATCH 13/14] fix(weather): calibrate iOS widget timeline time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 修正未校時與裝置時間偏移造成的小工具時間誤差 Fix(en-US): Calibrate widget timing and reject unsynced weather snapshots --- .../CurrentWeatherWidgetSnapshot.swift | 21 +++ .../CurrentWeatherWidgetTimeline.swift | 64 ++++++-- ios/DPIPWidgets/DPIPWidgets.swift | 13 +- .../CurrentWeatherWidgetTests.swift | 148 ++++++++++++++++-- lib/bootstrap.dart | 2 +- lib/core/realtime/app_time.dart | 35 ++++- .../current_weather_widget_coordinator.dart | 60 ++++++- .../current_weather_widget_snapshot.dart | 16 +- test/core/realtime/app_time_test.dart | 44 +++++- ...rrent_weather_widget_coordinator_test.dart | 96 +++++++++++- ...current_weather_widget_publisher_test.dart | 2 + .../current_weather_widget_snapshot_test.dart | 11 +- 12 files changed, 459 insertions(+), 53 deletions(-) diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index 760304644..20baaafe5 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -57,8 +57,13 @@ struct CurrentWeatherWidgetSnapshot: Decodable { let weatherCode: Int let condition: CurrentWeatherWidgetCondition let isNight: Bool + + /// The one next solar transition carried by this snapshot. let nextDayNightTransitionTime: Int + /// Calibrated/server time minus device time, in milliseconds. + let calibratedTimeOffsetMilliseconds: Int + let temperature: Double? let humidity: Int? let rain: Double? @@ -74,6 +79,7 @@ struct CurrentWeatherWidgetSnapshot: Decodable { case condition case isNight case nextDayNightTransitionTime + case calibratedTimeOffsetMilliseconds case temperature case humidity case rain @@ -90,6 +96,7 @@ struct CurrentWeatherWidgetSnapshot: Decodable { condition: CurrentWeatherWidgetCondition, isNight: Bool, nextDayNightTransitionTime: Int, + calibratedTimeOffsetMilliseconds: Int, temperature: Double?, humidity: Int?, rain: Double? @@ -104,6 +111,8 @@ struct CurrentWeatherWidgetSnapshot: Decodable { self.condition = condition self.isNight = isNight self.nextDayNightTransitionTime = nextDayNightTransitionTime + self.calibratedTimeOffsetMilliseconds = + calibratedTimeOffsetMilliseconds self.temperature = temperature self.humidity = humidity self.rain = rain @@ -168,6 +177,18 @@ struct CurrentWeatherWidgetSnapshot: Decodable { forKey: .nextDayNightTransitionTime ) ?? 0 + if schemaVersion >= 4 { + calibratedTimeOffsetMilliseconds = try container.decode( + Int.self, + forKey: .calibratedTimeOffsetMilliseconds + ) + } else { + calibratedTimeOffsetMilliseconds = try container.decodeIfPresent( + Int.self, + forKey: .calibratedTimeOffsetMilliseconds + ) ?? 0 + } + temperature = try container.decodeIfPresent( Double.self, forKey: .temperature diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift b/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift index e61c22798..1a3916ce5 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift @@ -1,11 +1,34 @@ import Foundation struct CurrentWeatherWidgetTimelineState: Equatable { + /// Device-clock date supplied to WidgetKit for entry scheduling. let date: Date let isStale: Bool let isNight: Bool } +/// Converts between calibrated/server instants and WidgetKit's device clock. +/// +/// The serialized offset is calibrated time minus device time. Widget state +/// comparisons add it to device dates; WidgetKit scheduling subtracts it from +/// calibrated deadlines. An older snapshot decodes with a zero correction. +struct CurrentWeatherWidgetTimeCalibration { + private let calibratedMinusDevice: TimeInterval + + init(snapshot: CurrentWeatherWidgetSnapshot) { + calibratedMinusDevice = + TimeInterval(snapshot.calibratedTimeOffsetMilliseconds) / 1_000 + } + + func calibratedDate(fromDeviceDate date: Date) -> Date { + date.addingTimeInterval(calibratedMinusDevice) + } + + func deviceDate(forCalibratedDate date: Date) -> Date { + date.addingTimeInterval(-calibratedMinusDevice) + } +} + enum CurrentWeatherWidgetTimeline { static func state( snapshot: CurrentWeatherWidgetSnapshot?, @@ -20,6 +43,12 @@ enum CurrentWeatherWidgetTimeline { ) } + let calibration = CurrentWeatherWidgetTimeCalibration( + snapshot: snapshot + ) + let calibratedDate = calibration.calibratedDate( + fromDeviceDate: date + ) let observationDate = Date( timeIntervalSince1970: TimeInterval(snapshot.observationTime) ) @@ -27,43 +56,52 @@ enum CurrentWeatherWidgetTimeline { return CurrentWeatherWidgetTimelineState( date: date, - isStale: date >= staleAt, - isNight: isNight(for: snapshot, at: date) + isStale: calibratedDate >= staleAt, + isNight: isNight(for: snapshot, at: calibratedDate) ) } static func states( snapshot: CurrentWeatherWidgetSnapshot?, - now: Date, + deviceNow: Date, staleAfter: TimeInterval ) -> [CurrentWeatherWidgetTimelineState] { guard let snapshot else { return [ state( snapshot: nil, - at: now, + at: deviceNow, staleAfter: staleAfter ) ] } + let calibration = CurrentWeatherWidgetTimeCalibration( + snapshot: snapshot + ) let observationDate = Date( timeIntervalSince1970: TimeInterval(snapshot.observationTime) ) - let staleAt = observationDate.addingTimeInterval(staleAfter) - let transitionAt = Date( - timeIntervalSince1970: - TimeInterval(snapshot.nextDayNightTransitionTime) + let staleAt = calibration.deviceDate( + forCalibratedDate: observationDate.addingTimeInterval(staleAfter) + ) + let transitionAt = calibration.deviceDate( + forCalibratedDate: Date( + timeIntervalSince1970: + TimeInterval(snapshot.nextDayNightTransitionTime) + ) ) - var dates = [now] + var dates = [deviceNow] - if staleAt > now { + if staleAt > deviceNow { dates.append(staleAt) } if snapshot.nextDayNightTransitionTime > 0, - transitionAt > now { + transitionAt > deviceNow { + // A snapshot deliberately carries only its next solar transition. + // A later day/night cycle requires a fresh publish from the app. dates.append(transitionAt) } @@ -80,7 +118,7 @@ enum CurrentWeatherWidgetTimeline { private static func isNight( for snapshot: CurrentWeatherWidgetSnapshot, - at date: Date + at calibratedDate: Date ) -> Bool { guard snapshot.nextDayNightTransitionTime > 0 else { return snapshot.isNight @@ -91,7 +129,7 @@ enum CurrentWeatherWidgetTimeline { TimeInterval(snapshot.nextDayNightTransitionTime) ) - return date >= transitionAt + return calibratedDate >= transitionAt ? !snapshot.isNight : snapshot.isNight } diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 500c56b5f..92764a149 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -19,10 +19,10 @@ struct DPIPWidgetProvider: TimelineProvider { completion: @escaping (DPIPWidgetEntry) -> Void ) { let snapshot = snapshotStore.loadCurrentWeatherSnapshot() - let now = Date.now + let deviceNow = Date.now let state = CurrentWeatherWidgetTimeline.state( snapshot: snapshot, - at: now, + at: deviceNow, staleAfter: staleAfter ) @@ -40,11 +40,11 @@ struct DPIPWidgetProvider: TimelineProvider { in context: Context, completion: @escaping (Timeline) -> Void ) { - let now = Date() + let deviceNow = Date() let snapshot = snapshotStore.loadCurrentWeatherSnapshot() let entries = CurrentWeatherWidgetTimeline.states( snapshot: snapshot, - now: now, + deviceNow: deviceNow, staleAfter: staleAfter ) .map { state in @@ -59,6 +59,8 @@ struct DPIPWidgetProvider: TimelineProvider { completion( Timeline( entries: entries, + // The app owns refreshes. This timeline projects only the + // stale deadline and one solar transition in the snapshot. policy: .never ) ) @@ -204,7 +206,7 @@ struct DPIPWidgets_Previews: PreviewProvider { private static let previewEntry = DPIPWidgetEntry( date: .now, snapshot: CurrentWeatherWidgetSnapshot( - schemaVersion: 3, + schemaVersion: 4, regionCode: "660", regionName: "西屯區", observationTime: 0, @@ -214,6 +216,7 @@ struct DPIPWidgets_Previews: PreviewProvider { condition: .clear, isNight: true, nextDayNightTransitionTime: 1_789_562_700, + calibratedTimeOffsetMilliseconds: 0, temperature: 28.4, humidity: 76, rain: 0 diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift index 02ece233a..b406f780b 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -2,11 +2,11 @@ import Foundation import XCTest final class CurrentWeatherWidgetSnapshotTests: XCTestCase { - func testDecodesSchemaVersionThreeSnapshot() throws { + func testDecodesSchemaVersionFourSnapshot() throws { let snapshot = try decode( """ { - "schemaVersion": 3, + "schemaVersion": 4, "regionCode": "660", "regionName": "西屯區", "observationTime": 1789567200, @@ -16,6 +16,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { "condition": "clear", "isNight": true, "nextDayNightTransitionTime": 1789562700, + "calibratedTimeOffsetMilliseconds": -300000, "temperature": 28.5, "humidity": null, "rain": 0.0 @@ -23,7 +24,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { """ ) - XCTAssertEqual(snapshot.schemaVersion, 3) + XCTAssertEqual(snapshot.schemaVersion, 4) XCTAssertEqual(snapshot.regionCode, "660") XCTAssertEqual(snapshot.regionName, "西屯區") XCTAssertEqual(snapshot.observationTime, 1_789_567_200) @@ -33,16 +34,17 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { XCTAssertEqual(snapshot.condition, .clear) XCTAssertTrue(snapshot.isNight) XCTAssertEqual(snapshot.nextDayNightTransitionTime, 1_789_562_700) + XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, -300_000) XCTAssertEqual(snapshot.temperature, 28.5) XCTAssertNil(snapshot.humidity) XCTAssertEqual(snapshot.rain, 0) } - func testDecodesOlderSnapshotWithDayNightDefaults() throws { + func testDecodesSchemaVersionThreeSnapshotWithZeroCalibration() throws { let snapshot = try decode( """ { - "schemaVersion": 2, + "schemaVersion": 3, "regionCode": "660", "regionName": "西屯區", "observationTime": 1789567200, @@ -50,6 +52,8 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { "weather": "晴", "weatherCode": 100, "condition": "clear", + "isNight": true, + "nextDayNightTransitionTime": 1789562700, "temperature": null, "humidity": null, "rain": null @@ -57,13 +61,62 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { """ ) - XCTAssertFalse(snapshot.isNight) - XCTAssertEqual(snapshot.nextDayNightTransitionTime, 0) + XCTAssertTrue(snapshot.isNight) + XCTAssertEqual(snapshot.nextDayNightTransitionTime, 1_789_562_700) + XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, 0) XCTAssertNil(snapshot.temperature) XCTAssertNil(snapshot.humidity) XCTAssertNil(snapshot.rain) } + func testDecodesSchemaVersionTwoSnapshotWithLegacyDefaults() throws { + let snapshot = try decode( + """ + { + "schemaVersion": 2, + "regionCode": "660", + "regionName": "西屯區", + "observationTime": 1789567200, + "stationName": "西屯", + "weather": "晴", + "weatherCode": 100, + "condition": "clear", + "temperature": null, + "humidity": null, + "rain": null + } + """ + ) + + XCTAssertFalse(snapshot.isNight) + XCTAssertEqual(snapshot.nextDayNightTransitionTime, 0) + XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, 0) + } + + func testSchemaVersionFourRequiresCalibration() { + XCTAssertThrowsError( + try decode( + """ + { + "schemaVersion": 4, + "regionCode": "660", + "regionName": "西屯區", + "observationTime": 1789567200, + "stationName": "西屯", + "weather": "晴", + "weatherCode": 100, + "condition": "clear", + "isNight": false, + "nextDayNightTransitionTime": 1789562700, + "temperature": null, + "humidity": null, + "rain": null + } + """ + ) + ) + } + func testUnknownConditionDecodesAsUnknown() throws { let snapshot = try decode( """ @@ -139,7 +192,7 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { isNight: false, transitionTime: 11_200 ), - now: now, + deviceNow: now, staleAfter: staleAfter ) @@ -153,6 +206,65 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { ) } + func testDeviceClockAheadUsesNegativeOffsetForScheduling() { + let states = CurrentWeatherWidgetTimeline.states( + snapshot: snapshot( + observationTime: 10_000, + isNight: false, + transitionTime: 11_200, + offsetMilliseconds: -300_000 + ), + deviceNow: date(10_300), + staleAfter: staleAfter + ) + + XCTAssertEqual( + states, + [ + state(at: 10_300, isStale: false, isNight: false), + state(at: 11_500, isStale: false, isNight: true), + state(at: 12_100, isStale: true, isNight: true), + ] + ) + } + + func testDeviceClockBehindUsesPositiveOffsetForScheduling() { + let states = CurrentWeatherWidgetTimeline.states( + snapshot: snapshot( + observationTime: 10_000, + isNight: false, + transitionTime: 11_200, + offsetMilliseconds: 300_000 + ), + deviceNow: date(9_700), + staleAfter: staleAfter + ) + + XCTAssertEqual( + states, + [ + state(at: 9_700, isStale: false, isNight: false), + state(at: 10_900, isStale: false, isNight: true), + state(at: 11_500, isStale: true, isNight: true), + ] + ) + } + + func testStaleBoundaryUsesCalibratedTime() { + let state = CurrentWeatherWidgetTimeline.state( + snapshot: snapshot( + observationTime: 10_000, + isNight: false, + transitionTime: 0, + offsetMilliseconds: -300_000 + ), + at: date(12_100), + staleAfter: staleAfter + ) + + XCTAssertTrue(state.isStale) + } + func testStaleBeforeDayToNightTransition() { let states = CurrentWeatherWidgetTimeline.states( snapshot: snapshot( @@ -160,7 +272,7 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { isNight: false, transitionTime: 11_200 ), - now: date(10_000), + deviceNow: date(10_000), staleAfter: staleAfter ) @@ -181,7 +293,7 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { isNight: true, transitionTime: 11_200 ), - now: date(10_000), + deviceNow: date(10_000), staleAfter: staleAfter ) @@ -197,7 +309,7 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { isNight: false, transitionTime: 11_800 ), - now: date(10_000), + deviceNow: date(10_000), staleAfter: staleAfter ) @@ -217,7 +329,7 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { isNight: false, transitionTime: 9_999 ), - now: date(10_000), + deviceNow: date(10_000), staleAfter: staleAfter ) @@ -233,7 +345,7 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { isNight: false, transitionTime: 0 ), - now: date(10_000), + deviceNow: date(10_000), staleAfter: staleAfter ) @@ -250,7 +362,7 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { isNight: true, transitionTime: 0 ), - now: date(10_000), + deviceNow: date(10_000), staleAfter: staleAfter ) @@ -266,7 +378,7 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { func testNoSnapshotProducesOneEmptyState() { let states = CurrentWeatherWidgetTimeline.states( snapshot: nil, - now: date(10_000), + deviceNow: date(10_000), staleAfter: staleAfter ) @@ -294,10 +406,11 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { private func snapshot( observationTime: Int, isNight: Bool, - transitionTime: Int + transitionTime: Int, + offsetMilliseconds: Int = 0 ) -> CurrentWeatherWidgetSnapshot { CurrentWeatherWidgetSnapshot( - schemaVersion: 3, + schemaVersion: 4, regionCode: "660", regionName: "西屯區", observationTime: observationTime, @@ -307,6 +420,7 @@ final class CurrentWeatherWidgetTimelineTests: XCTestCase { condition: .clear, isNight: isNight, nextDayNightTransitionTime: transitionTime, + calibratedTimeOffsetMilliseconds: offsetMilliseconds, temperature: 28, humidity: 76, rain: 0 diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 5aaf747f5..c523aa7d2 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -269,7 +269,7 @@ Future bootstrap() async { NtpTimeSource(), ); AppTime.install(serverClock); - serverClock.sync().ignore(); + AppTime.sync().ignore(); final realtimeService = RealtimeService(serverClock); // Push: best-effort and off the first frame — a missing push environment or diff --git a/lib/core/realtime/app_time.dart b/lib/core/realtime/app_time.dart index 45200bcc1..e4b2181f0 100644 --- a/lib/core/realtime/app_time.dart +++ b/lib/core/realtime/app_time.dart @@ -20,9 +20,13 @@ abstract final class AppTime { AppTime._(); static ServerClock? _clock; + static Future? _syncInFlight; /// Wires the shared calibrated clock. Called once at bootstrap. - static void install(ServerClock clock) => _clock = clock; + static void install(ServerClock clock) { + _clock = clock; + _syncInFlight = null; + } /// Calibrated current time in UTC (device time until the first sync). static DateTime get utc => _clock?.now() ?? DateTime.now().toUtc(); @@ -39,6 +43,12 @@ abstract final class AppTime { /// Whether the clock has completed at least one NTP sync. static bool get isSynced => _clock?.isSynced ?? false; + /// The correction added to device time to obtain calibrated time. + /// + /// A positive value means the device clock is behind the server; a negative + /// value means it is ahead. Before the first sync the correction is zero. + static Duration get calibratedTimeOffset => _clock?.offset ?? Duration.zero; + /// Re-expresses a timestamp minted by the **device** clock in calibrated /// time, so it can be compared with [utc]. /// @@ -51,8 +61,27 @@ abstract final class AppTime { /// Before the first sync the correction is zero and this is the identity, as /// it should be: with no calibration the device clock is all there is. static DateTime fromDevice(DateTime deviceStamp) => - deviceStamp.toUtc().add(_clock?.offset ?? Duration.zero); + deviceStamp.toUtc().add(calibratedTimeOffset); /// Forces an immediate resync (best-effort; no-op before [install]). - static Future sync() async => _clock?.sync(); + /// + /// Concurrent callers share the same attempt. In particular, a Widget + /// publish that arrives while bootstrap's initial sync is pending must wait + /// for that attempt instead of launching a second NTP request. + static Future sync() { + final clock = _clock; + if (clock == null) return Future.value(); + + final pending = _syncInFlight; + if (pending != null) return pending; + + late final Future sync; + sync = clock.sync().whenComplete(() { + if (identical(_syncInFlight, sync)) { + _syncInFlight = null; + } + }); + _syncInFlight = sync; + return sync; + } } diff --git a/lib/features/weather/current_weather_widget_coordinator.dart b/lib/features/weather/current_weather_widget_coordinator.dart index d8920d7f1..2b1d6d801 100644 --- a/lib/features/weather/current_weather_widget_coordinator.dart +++ b/lib/features/weather/current_weather_widget_coordinator.dart @@ -1,23 +1,59 @@ import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/core/weather/solar_time.dart'; import 'package:dpip/features/weather/data/current_weather_widget_publisher.dart'; import 'package:dpip/features/weather/domain/current_weather_widget_snapshot.dart'; import 'package:dpip/features/weather/domain/current_weather_widget_sync.dart'; import 'package:dpip/features/weather/domain/weather_realtime.dart'; -import 'package:dpip/core/realtime/app_time.dart'; -import 'package:dpip/core/weather/solar_time.dart'; + +typedef CurrentWeatherWidgetTime = ({ + DateTime calibratedNow, + Duration calibratedTimeOffset, +}); + +CurrentWeatherWidgetTime _appTime() => ( + calibratedNow: AppTime.utc, + calibratedTimeOffset: AppTime.calibratedTimeOffset, +); + +bool _isAppTimeSynced() => AppTime.isSynced; + +Future _syncAppTime() => AppTime.sync(); final class CurrentWeatherWidgetCoordinator implements CurrentWeatherWidgetSync { CurrentWeatherWidgetCoordinator( + RegionStore regions, + TownDirectory directory, + CurrentWeatherWidgetPublisher publisher, { + CurrentWeatherWidgetTime Function() time = _appTime, + bool Function() isTimeSynced = _isAppTimeSynced, + Future Function() syncTime = _syncAppTime, + }) : this._withTime( + regions, + directory, + publisher, + time, + isTimeSynced, + syncTime, + ); + + CurrentWeatherWidgetCoordinator._withTime( this._regions, this._directory, this._publisher, + this._time, + this._isTimeSynced, + this._syncTime, ); final RegionStore _regions; final TownDirectory _directory; final CurrentWeatherWidgetPublisher _publisher; + final CurrentWeatherWidgetTime Function() _time; + final bool Function() _isTimeSynced; + final Future Function() _syncTime; @override Future publish({ @@ -34,7 +70,23 @@ final class CurrentWeatherWidgetCoordinator return; } - final now = AppTime.utc; + if (!_isTimeSynced()) { + await _syncTime(); + if (!_isTimeSynced()) { + return; + } + + // The selected region may have changed while clock synchronization was + // pending. A late callback must not publish for the old selection. + if (_regions.selectedCode != regionCode) { + return; + } + } + + // Both values are sampled only after the first successful sync, so schema + // v4 never encodes unsynchronized device time as calibrated time. + final time = _time(); + final now = time.calibratedNow; final isNight = isNightAt(now, latitude: town.lat, longitude: town.lng); @@ -50,6 +102,8 @@ final class CurrentWeatherWidgetCoordinator weather: weather, isNight: isNight, nextDayNightTransitionTime: nextTransition.millisecondsSinceEpoch ~/ 1000, + calibratedTimeOffsetMilliseconds: + time.calibratedTimeOffset.inMilliseconds, ); await _publisher.publish(snapshot); diff --git a/lib/features/weather/domain/current_weather_widget_snapshot.dart b/lib/features/weather/domain/current_weather_widget_snapshot.dart index 707ba5561..5902909d4 100644 --- a/lib/features/weather/domain/current_weather_widget_snapshot.dart +++ b/lib/features/weather/domain/current_weather_widget_snapshot.dart @@ -14,7 +14,7 @@ enum CurrentWeatherWidgetCondition { final class CurrentWeatherWidgetSnapshot { const CurrentWeatherWidgetSnapshot({ - this.schemaVersion = 3, + this.schemaVersion = 4, required this.regionCode, required this.regionName, required this.observationTime, @@ -24,6 +24,7 @@ final class CurrentWeatherWidgetSnapshot { required this.condition, required this.isNight, required this.nextDayNightTransitionTime, + required this.calibratedTimeOffsetMilliseconds, this.temperature, this.humidity, this.rain, @@ -42,8 +43,18 @@ final class CurrentWeatherWidgetSnapshot { final int weatherCode; final CurrentWeatherWidgetCondition condition; final bool isNight; + + /// The next solar transition known when this snapshot was written. + /// + /// This is intentionally one transition, not an indefinite solar schedule. final int nextDayNightTransitionTime; + /// Calibrated/server time minus device time, in milliseconds. + /// + /// Swift adds this value to a device-clock `Date` to reconstruct calibrated + /// time, and subtracts it from calibrated deadlines for WidgetKit scheduling. + final int calibratedTimeOffsetMilliseconds; + final double? temperature; final int? humidity; final double? rain; @@ -60,6 +71,7 @@ final class CurrentWeatherWidgetSnapshot { 'condition': condition.name, 'isNight': isNight, 'nextDayNightTransitionTime': nextDayNightTransitionTime, + 'calibratedTimeOffsetMilliseconds': calibratedTimeOffsetMilliseconds, 'temperature': temperature, 'humidity': humidity, 'rain': rain, @@ -86,6 +98,7 @@ CurrentWeatherWidgetSnapshot createCurrentWeatherWidgetSnapshot({ required WeatherRealtime weather, required bool isNight, required int nextDayNightTransitionTime, + required int calibratedTimeOffsetMilliseconds, }) { return CurrentWeatherWidgetSnapshot( regionCode: regionCode, @@ -97,6 +110,7 @@ CurrentWeatherWidgetSnapshot createCurrentWeatherWidgetSnapshot({ condition: currentWeatherWidgetCondition(weather.data.weatherCode), isNight: isNight, nextDayNightTransitionTime: nextDayNightTransitionTime, + calibratedTimeOffsetMilliseconds: calibratedTimeOffsetMilliseconds, temperature: weather.data.temperature, humidity: weather.data.humidity, rain: weather.data.rain, diff --git a/test/core/realtime/app_time_test.dart b/test/core/realtime/app_time_test.dart index 4749292a2..47b69dc4f 100644 --- a/test/core/realtime/app_time_test.dart +++ b/test/core/realtime/app_time_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/realtime/clock.dart'; @@ -25,22 +27,56 @@ class _FakeSource implements ServerTimeSource { Future> serverTimeMs() async => Ok(ms); } +class _ControlledSource implements ServerTimeSource { + final requests = >>[]; + + @override + Future> serverTimeMs() { + final request = Completer>(); + requests.add(request); + return request.future; + } +} + void main() { final device = DateTime.utc(2026, 1, 1, 0, 0, 0); - test('delegates to the installed clock; utc8 is utc + 8h', () async { + test('exposes calibrated time and calibrated-minus-device offset', () async { final clock = ServerClock( _FakeClock(device), _FakeElapsed(), - _FakeSource(device.millisecondsSinceEpoch), + _FakeSource( + device.add(const Duration(seconds: 5)).millisecondsSinceEpoch, + ), ); await clock.sync(); AppTime.install(clock); expect(AppTime.isSynced, isTrue); - expect(AppTime.utc, device); - expect(AppTime.utc8, device.add(const Duration(hours: 8))); + expect(AppTime.utc, device.add(const Duration(seconds: 5))); + expect(AppTime.calibratedTimeOffset, const Duration(seconds: 5)); + expect(AppTime.utc8, device.add(const Duration(hours: 8, seconds: 5))); // UTC+8 wall-clock fields (Taipei), independent of the device timezone. expect(AppTime.utc8.hour, 8); }); + + test('deduplicates concurrent sync calls', () async { + final source = _ControlledSource(); + final clock = ServerClock(_FakeClock(device), _FakeElapsed(), source); + AppTime.install(clock); + + final first = AppTime.sync(); + final second = AppTime.sync(); + + expect(source.requests, hasLength(1)); + expect(second, same(first)); + + source.requests.single.complete( + Ok(device.add(const Duration(seconds: 5)).millisecondsSinceEpoch), + ); + await Future.wait([first, second]); + + expect(AppTime.isSynced, isTrue); + expect(AppTime.calibratedTimeOffset, const Duration(seconds: 5)); + }); } diff --git a/test/features/weather/current_weather_widget_coordinator_test.dart b/test/features/weather/current_weather_widget_coordinator_test.dart index 3f063372d..61af1549c 100644 --- a/test/features/weather/current_weather_widget_coordinator_test.dart +++ b/test/features/weather/current_weather_widget_coordinator_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:dpip/core/error/result.dart'; @@ -11,7 +12,7 @@ import 'package:dpip/features/weather/domain/weather_realtime.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('publishes a snapshot for the matching selected region', () async { + test('already synced publishes immediately with calibrated offset', () async { final regions = RegionStore( SettingsStore.inMemory({ 'home.savedRegionCodes': ['660'], @@ -19,31 +20,118 @@ void main() { ); final directory = _directoryWithXitun(); final writer = _FakeWidgetSnapshotWriter(); + var syncCallCount = 0; final coordinator = CurrentWeatherWidgetCoordinator( regions, directory, CurrentWeatherWidgetPublisher(writer), + time: () => ( + calibratedNow: DateTime.utc(2026, 9, 16, 4), + calibratedTimeOffset: const Duration(minutes: -5), + ), + isTimeSynced: () => true, + syncTime: () async { + syncCallCount += 1; + }, ); regions.select(2); await coordinator.publish(regionCode: '660', weather: _weather()); - expect(writer.called, isTrue); + expect(syncCallCount, 0); + expect(writer.writeCallCount, 1); expect(writer.writtenKind, WidgetSnapshotKind.currentWeather); final decoded = jsonDecode(writer.writtenJson!) as Map; - expect(decoded['schemaVersion'], 3); + expect(decoded['schemaVersion'], 4); expect(decoded['regionCode'], '660'); expect(decoded['regionName'], '西屯區'); expect(decoded['stationName'], '西屯'); expect(decoded['weather'], '多雲'); expect(decoded['isNight'], isA()); expect(decoded['nextDayNightTransitionTime'], isA()); + expect(decoded['calibratedTimeOffsetMilliseconds'], -300_000); expect(decoded['temperature'], 28.4); }); + test('waits for initial sync then publishes exactly once', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['660'], + }), + ); + final writer = _FakeWidgetSnapshotWriter(); + final sync = Completer(); + var isSynced = false; + var timeCallCount = 0; + var syncCallCount = 0; + final coordinator = CurrentWeatherWidgetCoordinator( + regions, + _directoryWithXitun(), + CurrentWeatherWidgetPublisher(writer), + time: () { + timeCallCount += 1; + return ( + calibratedNow: DateTime.utc(2026, 9, 16, 4), + calibratedTimeOffset: const Duration(minutes: 3), + ); + }, + isTimeSynced: () => isSynced, + syncTime: () { + syncCallCount += 1; + return sync.future; + }, + ); + regions.select(2); + + final publish = coordinator.publish(regionCode: '660', weather: _weather()); + + expect(syncCallCount, 1); + expect(timeCallCount, 0); + expect(writer.writeCallCount, 0); + + isSynced = true; + sync.complete(); + await publish; + + expect(timeCallCount, 1); + expect(writer.writeCallCount, 1); + final decoded = jsonDecode(writer.writtenJson!) as Map; + expect(decoded['calibratedTimeOffsetMilliseconds'], 180_000); + }); + + test('does not publish when initial sync fails', () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['660'], + }), + ); + final writer = _FakeWidgetSnapshotWriter(); + var timeCallCount = 0; + final coordinator = CurrentWeatherWidgetCoordinator( + regions, + _directoryWithXitun(), + CurrentWeatherWidgetPublisher(writer), + time: () { + timeCallCount += 1; + return ( + calibratedNow: DateTime.utc(2026, 9, 16, 4), + calibratedTimeOffset: Duration.zero, + ); + }, + isTimeSynced: () => false, + syncTime: () async {}, + ); + regions.select(2); + + await coordinator.publish(regionCode: '660', weather: _weather()); + + expect(timeCallCount, 0); + expect(writer.writeCallCount, 0); + }); + test('does not publish when the selected region does not match', () async { final regions = RegionStore( SettingsStore.inMemory({ @@ -139,6 +227,7 @@ WeatherRealtime _weather() { final class _FakeWidgetSnapshotWriter implements WidgetSnapshotWriter { bool called = false; + int writeCallCount = 0; WidgetSnapshotKind? writtenKind; String? writtenJson; int clearCallCount = 0; @@ -157,6 +246,7 @@ final class _FakeWidgetSnapshotWriter implements WidgetSnapshotWriter { required String json, }) async { called = true; + writeCallCount += 1; writtenKind = kind; writtenJson = json; diff --git a/test/features/weather/current_weather_widget_publisher_test.dart b/test/features/weather/current_weather_widget_publisher_test.dart index 54a244471..16a58596e 100644 --- a/test/features/weather/current_weather_widget_publisher_test.dart +++ b/test/features/weather/current_weather_widget_publisher_test.dart @@ -46,6 +46,7 @@ void main() { condition: .cloudy, isNight: false, nextDayNightTransitionTime: 1_789_562_700, + calibratedTimeOffsetMilliseconds: 0, temperature: 28.4, humidity: 76, rain: 0.0, @@ -77,6 +78,7 @@ void main() { condition: .cloudy, isNight: false, nextDayNightTransitionTime: 1_789_562_700, + calibratedTimeOffsetMilliseconds: 0, temperature: null, humidity: null, rain: null, diff --git a/test/features/weather/current_weather_widget_snapshot_test.dart b/test/features/weather/current_weather_widget_snapshot_test.dart index 701a7ad63..4ff031f52 100644 --- a/test/features/weather/current_weather_widget_snapshot_test.dart +++ b/test/features/weather/current_weather_widget_snapshot_test.dart @@ -97,9 +97,10 @@ void main() { weather: weather, isNight: false, nextDayNightTransitionTime: 1_789_562_700, + calibratedTimeOffsetMilliseconds: -300_000, ); - expect(snapshot.schemaVersion, 3); + expect(snapshot.schemaVersion, 4); expect(snapshot.regionCode, '660'); expect(snapshot.regionName, '西屯區'); expect(snapshot.observationTime, 1789398000); @@ -109,6 +110,7 @@ void main() { expect(snapshot.condition, CurrentWeatherWidgetCondition.thunderstorm); expect(snapshot.isNight, isFalse); expect(snapshot.nextDayNightTransitionTime, 1_789_562_700); + expect(snapshot.calibratedTimeOffsetMilliseconds, -300_000); expect(snapshot.temperature, 28.4); expect(snapshot.humidity, 76); expect(snapshot.rain, 0.0); @@ -116,11 +118,12 @@ void main() { final json = jsonEncode(snapshot.toJson()); final decoded = jsonDecode(json) as Map; - expect(decoded['schemaVersion'], 3); + expect(decoded['schemaVersion'], 4); expect(decoded['regionCode'], '660'); expect(decoded['condition'], 'thunderstorm'); expect(decoded['isNight'], isFalse); expect(decoded['nextDayNightTransitionTime'], 1_789_562_700); + expect(decoded['calibratedTimeOffsetMilliseconds'], -300_000); expect(decoded['temperature'], 28.4); }); @@ -135,6 +138,7 @@ void main() { condition: .cloudy, isNight: true, nextDayNightTransitionTime: 1_789_562_700, + calibratedTimeOffsetMilliseconds: 300_000, temperature: null, humidity: null, rain: null, @@ -147,7 +151,7 @@ void main() { final decoded = jsonDecode(json) as Map; expect(decoded, { - 'schemaVersion': 3, + 'schemaVersion': 4, 'regionCode': '660', 'regionName': '西屯區', 'observationTime': 1789398000, @@ -157,6 +161,7 @@ void main() { 'condition': 'cloudy', 'isNight': true, 'nextDayNightTransitionTime': 1_789_562_700, + 'calibratedTimeOffsetMilliseconds': 300_000, 'temperature': null, 'humidity': null, 'rain': null, From 4542adab74df54daaf49c4db92503118ed2cca92 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Thu, 17 Sep 2026 02:30:10 +0800 Subject: [PATCH 14/14] fix(weather): localize iOS widget conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 天氣小工具改用本地化的語意天氣狀態 Fix(en-US): Show localized semantic weather conditions in iOS widgets --- .../CurrentWeatherWidgetSnapshot.swift | 26 ++++ ios/DPIPWidgets/DPIPWidgets.swift | 2 +- ios/DPIPWidgets/Localizable.xcstrings | 136 ++++++++++++++++++ .../CurrentWeatherWidgetTests.swift | 17 +++ 4 files changed, 180 insertions(+), 1 deletion(-) diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index 20baaafe5..d266caf1b 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI enum CurrentWeatherWidgetCondition: String, Decodable { case clear @@ -10,6 +11,31 @@ enum CurrentWeatherWidgetCondition: String, Decodable { case fog case unknown + var localizedDisplayName: LocalizedStringKey { + LocalizedStringKey(displayNameLocalizationKey) + } + + var displayNameLocalizationKey: String { + switch self { + case .clear: + return "weather.clear" + case .cloudy: + return "weather.cloudy" + case .overcast: + return "weather.overcast" + case .rain: + return "weather.rain" + case .thunderstorm: + return "weather.thunderstorm" + case .snow: + return "weather.snow" + case .fog: + return "weather.fog" + case .unknown: + return "weather.unknown" + } + } + func systemImageName(isNight: Bool) -> String { switch self { case .clear: diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 92764a149..c67471b63 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -96,7 +96,7 @@ struct DPIPWidgetsEntryView : View { isNight: entry.isNight )) - Text(snapshot.weather) + Text(snapshot.condition.localizedDisplayName) .lineLimit(1) .minimumScaleFactor(0.8) } diff --git a/ios/DPIPWidgets/Localizable.xcstrings b/ios/DPIPWidgets/Localizable.xcstrings index 71103afac..62d6f883b 100644 --- a/ios/DPIPWidgets/Localizable.xcstrings +++ b/ios/DPIPWidgets/Localizable.xcstrings @@ -1,6 +1,142 @@ { "sourceLanguage" : "en", "strings" : { + "weather.clear" : { + "comment" : "Localized current weather condition for clear skies.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "晴" + } + } + } + }, + "weather.cloudy" : { + "comment" : "Localized current weather condition for cloudy skies.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cloudy" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "多雲" + } + } + } + }, + "weather.fog" : { + "comment" : "Localized current weather condition for fog.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fog" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "霧" + } + } + } + }, + "weather.overcast" : { + "comment" : "Localized current weather condition for overcast skies.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overcast" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "陰" + } + } + } + }, + "weather.rain" : { + "comment" : "Localized current weather condition for rain.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rain" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "雨" + } + } + } + }, + "weather.snow" : { + "comment" : "Localized current weather condition for snow.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Snow" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "雪" + } + } + } + }, + "weather.thunderstorm" : { + "comment" : "Localized current weather condition for thunderstorms.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thunderstorm" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "雷雨" + } + } + } + }, + "weather.unknown" : { + "comment" : "Localized fallback when the current weather condition is unknown.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知" + } + } + } + }, "widget.current_weather" : { "comment" : "Display name for the current weather Widget in the Widget Gallery.", "localizations" : { diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift index b406f780b..3bf447cd7 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -150,6 +150,23 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { } final class CurrentWeatherWidgetConditionTests: XCTestCase { + func testEveryConditionHasItsOwnLocalizationKey() { + let expected: [(CurrentWeatherWidgetCondition, String)] = [ + (.clear, "weather.clear"), + (.cloudy, "weather.cloudy"), + (.overcast, "weather.overcast"), + (.rain, "weather.rain"), + (.thunderstorm, "weather.thunderstorm"), + (.snow, "weather.snow"), + (.fog, "weather.fog"), + (.unknown, "weather.unknown"), + ] + + for (condition, key) in expected { + XCTAssertEqual(condition.displayNameLocalizationKey, key) + } + } + func testClearAndCloudyUseDayNightSymbols() { XCTAssertEqual( CurrentWeatherWidgetCondition.clear.systemImageName(isNight: false),