diff --git a/ios/DPIPWidgetIntentsExtension/DPIPWidgetIntentsExtension.entitlements b/ios/DPIPWidgetIntentsExtension/DPIPWidgetIntentsExtension.entitlements new file mode 100644 index 000000000..ace03de41 --- /dev/null +++ b/ios/DPIPWidgetIntentsExtension/DPIPWidgetIntentsExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.exptech.dpip.dpip.widgets + + + diff --git a/ios/DPIPWidgetIntentsExtension/Info.plist b/ios/DPIPWidgetIntentsExtension/Info.plist new file mode 100644 index 000000000..afc646f7e --- /dev/null +++ b/ios/DPIPWidgetIntentsExtension/Info.plist @@ -0,0 +1,22 @@ + + + + + NSExtension + + NSExtensionAttributes + + IntentsRestrictedWhileLocked + + IntentsSupported + + WeatherWidgetConfigurationIntent + + + NSExtensionPointIdentifier + com.apple.intents-service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).IntentHandler + + + diff --git a/ios/DPIPWidgetIntentsExtension/IntentHandler.swift b/ios/DPIPWidgetIntentsExtension/IntentHandler.swift new file mode 100644 index 000000000..e35aebd9f --- /dev/null +++ b/ios/DPIPWidgetIntentsExtension/IntentHandler.swift @@ -0,0 +1,39 @@ +import Intents + +final class IntentHandler: INExtension, + WeatherWidgetConfigurationIntentHandling +{ + override func handler(for intent: INIntent) -> Any { + return self + } + + func provideLocationOptionsCollection( + for intent: WeatherWidgetConfigurationIntent, + with completion: @escaping ( + INObjectCollection?, + Error? + ) -> Void + ) { + let catalog = WidgetLocationCatalogStore().load() + let currentLocationDisplayString = String( + localized: "intent.current_location", + bundle: .main, + comment: "Current-location option in weather widget configuration." + ) + + let locations = makeWidgetLocationOptions( + from: catalog, + currentLocationDisplayString: currentLocationDisplayString + ).map { + WidgetLocation( + identifier: $0.identifier, + display: $0.displayString + ) + } + + completion( + INObjectCollection(items: locations), + nil + ) + } +} diff --git a/ios/DPIPWidgetIntentsExtension/Localizable.xcstrings b/ios/DPIPWidgetIntentsExtension/Localizable.xcstrings new file mode 100644 index 000000000..abe12850b --- /dev/null +++ b/ios/DPIPWidgetIntentsExtension/Localizable.xcstrings @@ -0,0 +1,23 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "intent.current_location" : { + "comment" : "Current-location option in weather widget configuration.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current Location" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "所在地" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/ios/DPIPWidgetIntentsExtension/WidgetLocationCatalog.swift b/ios/DPIPWidgetIntentsExtension/WidgetLocationCatalog.swift new file mode 100644 index 000000000..7bee14cae --- /dev/null +++ b/ios/DPIPWidgetIntentsExtension/WidgetLocationCatalog.swift @@ -0,0 +1,54 @@ +import Foundation + +struct WidgetLocationCatalog: Decodable { + let schemaVersion: Int + let locations: [WidgetLocationCatalogLocation] +} + +struct WidgetLocationCatalogLocation: Decodable { + let regionCode: String + let displayName: String + let administrativeAreaName: String + let latitude: Double + let longitude: Double +} + +struct WidgetLocationCatalogStore { + private let appGroupIdentifier = + "group.com.exptech.dpip.dpip.widgets" + + func load() -> WidgetLocationCatalog? { + guard let containerURL = + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + else { + return nil + } + + let url = containerURL + .appendingPathComponent("WidgetSnapshots") + .appendingPathComponent("location-catalog.json") + + guard let data = try? Data(contentsOf: url) else { + return nil + } + + return Self.decode(data) + } + + static func decode(_ data: Data) -> WidgetLocationCatalog? { + guard let catalog = try? JSONDecoder().decode( + WidgetLocationCatalog.self, + from: data + ) else { + return nil + } + + guard catalog.schemaVersion == 1 else { + return nil + } + + return catalog + } +} diff --git a/ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift b/ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift new file mode 100644 index 000000000..74ffbdf5f --- /dev/null +++ b/ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift @@ -0,0 +1,35 @@ +import Foundation + +struct WidgetLocationOption: Equatable { + let identifier: String + let displayString: String +} + +func makeWidgetLocationOptions( + from catalog: WidgetLocationCatalog?, + currentLocationDisplayString: String +) -> [WidgetLocationOption] { + var options = [ + WidgetLocationOption( + identifier: "current-location", + displayString: currentLocationDisplayString + ) + ] + + guard let catalog else { + return options + } + + options.append( + contentsOf: catalog.locations.map { location in + WidgetLocationOption( + identifier: "region:\(location.regionCode)", + displayString: + "\(location.displayName) — " + + location.administrativeAreaName + ) + } + ) + + return options +} diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index d266caf1b..5b7436329 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -71,6 +71,7 @@ enum CurrentWeatherWidgetCondition: String, Decodable { struct CurrentWeatherWidgetSnapshot: Decodable { let schemaVersion: Int + let sourceIdentifier: String? let regionCode: String let regionName: String @@ -96,6 +97,7 @@ struct CurrentWeatherWidgetSnapshot: Decodable { private enum CodingKeys: String, CodingKey { case schemaVersion + case sourceIdentifier case regionCode case regionName case observationTime @@ -113,6 +115,7 @@ struct CurrentWeatherWidgetSnapshot: Decodable { init( schemaVersion: Int, + sourceIdentifier: String? = nil, regionCode: String, regionName: String, observationTime: Int, @@ -128,6 +131,7 @@ struct CurrentWeatherWidgetSnapshot: Decodable { rain: Double? ) { self.schemaVersion = schemaVersion + self.sourceIdentifier = sourceIdentifier self.regionCode = regionCode self.regionName = regionName self.observationTime = observationTime @@ -143,7 +147,6 @@ struct CurrentWeatherWidgetSnapshot: Decodable { self.humidity = humidity self.rain = rain } - init(from decoder: Decoder) throws { let container = try decoder.container( keyedBy: CodingKeys.self @@ -154,6 +157,24 @@ struct CurrentWeatherWidgetSnapshot: Decodable { forKey: .schemaVersion ) + guard (1...5).contains(schemaVersion) else { + throw DecodingError.dataCorruptedError( + forKey: .schemaVersion, + in: container, + debugDescription: + "Unsupported current-weather snapshot schema version." + ) + } + + if schemaVersion >= 5 { + sourceIdentifier = try container.decode( + String.self, + forKey: .sourceIdentifier + ) + } else { + sourceIdentifier = nil + } + regionCode = try container.decode( String.self, forKey: .regionCode diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 4d57de148..51b3bc3ea 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -1,10 +1,24 @@ import WidgetKit import SwiftUI +import Intents -struct DPIPWidgetProvider: TimelineProvider { +struct DPIPWidgetProvider: IntentTimelineProvider { + typealias Intent = WeatherWidgetConfigurationIntent private let staleAfter: TimeInterval = 30 * 60 private let snapshotStore = WidgetSnapshotStore() + private func snapshot( + for configuration: WeatherWidgetConfigurationIntent + ) -> CurrentWeatherWidgetSnapshot? { + let target = WidgetLocationTarget( + identifier: configuration.location?.identifier + ) + + return snapshotStore.loadCurrentWeatherSnapshot( + for: target + ) + } + func placeholder(in context: Context) -> DPIPWidgetEntry { DPIPWidgetEntry( date: .now, @@ -15,11 +29,13 @@ struct DPIPWidgetProvider: TimelineProvider { } func getSnapshot( + for configuration: WeatherWidgetConfigurationIntent, in context: Context, completion: @escaping (DPIPWidgetEntry) -> Void ) { - let snapshot = snapshotStore.loadCurrentWeatherSnapshot() + let snapshot = snapshot(for: configuration) let deviceNow = Date.now + let state = CurrentWeatherWidgetTimeline.state( snapshot: snapshot, at: deviceNow, @@ -37,11 +53,13 @@ struct DPIPWidgetProvider: TimelineProvider { } func getTimeline( + for configuration: WeatherWidgetConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> Void ) { let deviceNow = Date() - let snapshot = snapshotStore.loadCurrentWeatherSnapshot() + let snapshot = snapshot(for: configuration) + let entries = CurrentWeatherWidgetTimeline.states( snapshot: snapshot, deviceNow: deviceNow, @@ -177,7 +195,11 @@ struct DPIPWidgets: Widget { let kind: String = "DPIPWidgets" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: DPIPWidgetProvider()) { entry in + IntentConfiguration( + kind: kind, + intent: WeatherWidgetConfigurationIntent.self, + provider: DPIPWidgetProvider() + ) { entry in if #available(iOS 17.0, *) { DPIPWidgetsEntryView(entry: entry) .widgetURL(URL(string: "dpip:///home")) @@ -199,7 +221,8 @@ struct DPIPWidgets_Previews: PreviewProvider { private static let previewEntry = DPIPWidgetEntry( date: .now, snapshot: CurrentWeatherWidgetSnapshot( - schemaVersion: 4, + schemaVersion: 5, + sourceIdentifier: "current-location", regionCode: "660", regionName: "西屯區", observationTime: 0, @@ -213,7 +236,9 @@ struct DPIPWidgets_Previews: PreviewProvider { temperature: 28.4, humidity: 76, rain: 0 - ),isStale: true, isNight: true + ), + isStale: true, + isNight: true ) static var previews: some View { diff --git a/ios/DPIPWidgets/Localizable.xcstrings b/ios/DPIPWidgets/Localizable.xcstrings index 62d6f883b..ea018a31f 100644 --- a/ios/DPIPWidgets/Localizable.xcstrings +++ b/ios/DPIPWidgets/Localizable.xcstrings @@ -1,8 +1,27 @@ { "sourceLanguage" : "en", "strings" : { + "-- mm" : { + + }, + "--%" : { + + }, + "--°" : { + + }, + "%.0f°" : { + + }, + "%.1f mm" : { + + }, + "%lld%%" : { + + }, "weather.clear" : { "comment" : "Localized current weather condition for clear skies.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -20,6 +39,7 @@ }, "weather.cloudy" : { "comment" : "Localized current weather condition for cloudy skies.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -37,6 +57,7 @@ }, "weather.fog" : { "comment" : "Localized current weather condition for fog.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -54,6 +75,7 @@ }, "weather.overcast" : { "comment" : "Localized current weather condition for overcast skies.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -71,6 +93,7 @@ }, "weather.rain" : { "comment" : "Localized current weather condition for rain.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -88,6 +111,7 @@ }, "weather.snow" : { "comment" : "Localized current weather condition for snow.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -105,6 +129,7 @@ }, "weather.thunderstorm" : { "comment" : "Localized current weather condition for thunderstorms.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -122,6 +147,7 @@ }, "weather.unknown" : { "comment" : "Localized fallback when the current weather condition is unknown.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -160,13 +186,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Shows current weather for the area selected in DPIP." + "value" : "Shows current weather for the location configured for this widget." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "顯示 DPIP 所選地區的目前天氣。" + "value" : "顯示此小工具所設定地區的目前天氣。" } } } diff --git a/ios/DPIPWidgets/WeatherWidgetConfiguration.intentdefinition b/ios/DPIPWidgets/WeatherWidgetConfiguration.intentdefinition new file mode 100644 index 000000000..c6e05c3b6 --- /dev/null +++ b/ios/DPIPWidgets/WeatherWidgetConfiguration.intentdefinition @@ -0,0 +1,185 @@ + + + + + INEnums + + INIntentDefinitionModelVersion + 1.2 + INIntentDefinitionNamespace + OFhAEM + INIntentDefinitionSystemVersion + 25G83 + INIntentDefinitionToolsBuildVersion + 17F113 + INIntentDefinitionToolsVersion + 26.6 + INIntents + + + INIntentCategory + information + INIntentDescriptionID + ZjfOUb + INIntentEligibleForWidgets + + INIntentIneligibleForSuggestions + + INIntentLastParameterTag + 2 + INIntentName + WeatherWidgetConfiguration + INIntentParameters + + + INIntentParameterConfigurable + + INIntentParameterDisplayName + Location + INIntentParameterDisplayNameID + DaIeN6 + INIntentParameterDisplayPriority + 1 + INIntentParameterName + location + INIntentParameterObjectType + WidgetLocation + INIntentParameterObjectTypeNamespace + OFhAEM + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + There are ${count} options matching ‘${location}’. + INIntentParameterPromptDialogFormatStringID + 2S3UQT + INIntentParameterPromptDialogType + DisambiguationIntroduction + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Just to confirm, you wanted ‘${location}’? + INIntentParameterPromptDialogFormatStringID + DOe8sM + INIntentParameterPromptDialogType + Confirmation + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterTag + 2 + INIntentParameterType + Object + + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeName + failure + + + + INIntentTitle + Weather Widget Configuration + INIntentTitleID + aDbkHe + INIntentType + Custom + INIntentVerb + View + + + INTypes + + + INTypeDisplayName + Widget Location + INTypeDisplayNameID + waoyke + INTypeLastPropertyTag + 99 + INTypeName + WidgetLocation + INTypeProperties + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 1 + INTypePropertyName + identifier + INTypePropertyTag + 1 + INTypePropertyType + String + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 2 + INTypePropertyName + displayString + INTypePropertyTag + 2 + INTypePropertyType + String + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 3 + INTypePropertyName + pronunciationHint + INTypePropertyTag + 3 + INTypePropertyType + String + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 4 + INTypePropertyName + alternativeSpeakableMatches + INTypePropertySupportsMultipleValues + + INTypePropertyTag + 4 + INTypePropertyType + SpeakableString + + + + + + diff --git a/ios/DPIPWidgets/WeatherWidgetConfiguration.intentdefinition.xcstrings b/ios/DPIPWidgets/WeatherWidgetConfiguration.intentdefinition.xcstrings new file mode 100644 index 000000000..6f4f090a0 --- /dev/null +++ b/ios/DPIPWidgets/WeatherWidgetConfiguration.intentdefinition.xcstrings @@ -0,0 +1,91 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "2S3UQT" : { + "comment" : "Introduction shown when multiple locations match a configuration choice.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There are ${count} options matching ‘${location}’." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "有 ${count} 個符合「${location}」的選項。" + } + } + } + }, + "DaIeN6" : { + "comment" : "Title of the location parameter in weather widget configuration.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "地點" + } + } + } + }, + "DOe8sM" : { + "comment" : "Confirmation shown for a selected widget location.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Just to confirm, you wanted ‘${location}’?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "確認一下,你要的是「${location}」嗎?" + } + } + } + }, + "aDbkHe" : { + "comment" : "Title of the weather widget configuration intent.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weather Widget Configuration" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "天氣小工具設定" + } + } + } + }, + "waoyke" : { + "comment" : "Display name of a saved widget location.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget Location" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "小工具地點" + } + } + } + } + }, + "version" : "1.0" +} diff --git a/ios/DPIPWidgets/WidgetLocationTarget.swift b/ios/DPIPWidgets/WidgetLocationTarget.swift new file mode 100644 index 000000000..3f7c86781 --- /dev/null +++ b/ios/DPIPWidgets/WidgetLocationTarget.swift @@ -0,0 +1,76 @@ +import Foundation + +enum WidgetLocationTarget: Equatable { + case currentLocation + case saved(regionCode: String) + case invalid(identifier: String) + + init(identifier: String?) { + guard let identifier else { + self = .currentLocation + return + } + + guard let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: identifier + ) else { + self = .invalid(identifier: identifier) + return + } + + switch address { + case .currentLocation: + self = .currentLocation + + case .saved(let regionCode): + self = .saved(regionCode: regionCode) + } + } +} + +extension WidgetLocationTarget { + var sourceIdentifier: String? { + switch self { + case .currentLocation: + return CurrentWeatherSnapshotAddress + .currentLocation + .sourceIdentifier + + case .saved(let regionCode): + return CurrentWeatherSnapshotAddress + .saved(regionCode: regionCode) + .sourceIdentifier + + case .invalid: + return nil + } + } + + func matches( + snapshot: CurrentWeatherWidgetSnapshot + ) -> Bool { + guard + let sourceIdentifier = snapshot.sourceIdentifier, + let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: sourceIdentifier + ) + else { + return false + } + + switch (self, address) { + case (.currentLocation, .currentLocation): + return true + + case let ( + .saved(regionCode), + .saved(snapshotRegionCode) + ): + return snapshotRegionCode == regionCode + && snapshot.regionCode == regionCode + + default: + return false + } + } +} diff --git a/ios/DPIPWidgets/WidgetSnapshotStore.swift b/ios/DPIPWidgets/WidgetSnapshotStore.swift index 00e45e0b7..76cfa55cc 100644 --- a/ios/DPIPWidgets/WidgetSnapshotStore.swift +++ b/ios/DPIPWidgets/WidgetSnapshotStore.swift @@ -53,4 +53,66 @@ struct WidgetSnapshotStore { from: data ) } + + func currentWeatherSnapshotURL( + for sourceIdentifier: String + ) -> URL? { + guard let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: sourceIdentifier + ) else { + return nil + } + + guard let appGroupContainerURL = + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: + appGroupIdentifier + ) + else { + return nil + } + + return appGroupContainerURL + .appendingPathComponent( + "WidgetSnapshots", + isDirectory: true + ) + .appendingPathComponent( + "current-weather", + isDirectory: true + ) + .appendingPathComponent(address.filename) + } + + func loadCurrentWeatherSnapshot( + for target: WidgetLocationTarget + ) -> CurrentWeatherWidgetSnapshot? { + guard let sourceIdentifier = target.sourceIdentifier else { + return nil + } + + if let url = currentWeatherSnapshotURL( + for: sourceIdentifier + ), + let data = try? Data(contentsOf: url), + let snapshot = try? JSONDecoder().decode( + CurrentWeatherWidgetSnapshot.self, + from: data + ), + target.matches(snapshot: snapshot) + { + return snapshot + } + + // Temporary migration fallback: + // older app versions wrote one global current-weather.json. + guard + let legacySnapshot = loadCurrentWeatherSnapshot(), + target.matches(snapshot: legacySnapshot) + else { + return nil + } + + return legacySnapshot + } } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b2888dba8..90ad709f2 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -12,13 +12,16 @@ 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 */; }; + 270FCEA5305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 270FCEA4305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift */; }; + 270FCEF5305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 270FCEF4305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift */; }; 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, ); }; }; + 27CACBA3305BD77F0046F79C /* Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 27CACBA2305BD77F0046F79C /* Intents.framework */; }; + 27CACBAA305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 27CACBA1305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 27CACC3E305C64AA0046F79C /* WidgetLocationIntentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CACC3D305C64AA0046F79C /* WidgetLocationIntentTests.swift */; }; + 27CACC80305C67530046F79C /* WidgetLocationCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */; }; 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 */; }; @@ -41,6 +44,9 @@ CAC4EF00000000000000C101 /* BackgroundExecutionPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000C102 /* BackgroundExecutionPlugin.swift */; }; CAC4EF00000000000000D001 /* StorageScanPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000D002 /* StorageScanPlugin.swift */; }; CAC4EF00000000000000E101 /* WidgetSnapshotPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000E102 /* WidgetSnapshotPlugin.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 */; }; 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 */; }; @@ -55,6 +61,13 @@ remoteGlobalIDString = 2715FCD430570C1C0014DC8A; remoteInfo = DPIPWidgetsExtension; }; + 27CACBA8305BD77F0046F79C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 27CACBA0305BD77F0046F79C; + remoteInfo = DPIPWidgetIntentsExtension; + }; 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -71,6 +84,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + 27CACBAA305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex in Embed Foundation Extensions */, 2715FCE530570C1D0014DC8A /* DPIPWidgetsExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; @@ -91,15 +105,18 @@ /* 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 = ""; }; + 270FCEA4305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWeatherSnapshotAddressTests.swift; sourceTree = ""; }; + 270FCEF4305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSnapshotFileTests.swift; 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 = ""; }; + 27CACBA1305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DPIPWidgetIntentsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 27CACBA2305BD77F0046F79C /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; + 27CACC3D305C64AA0046F79C /* WidgetLocationIntentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationIntentTests.swift; sourceTree = ""; }; + 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationCatalogTests.swift; 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 = ""; }; @@ -137,12 +154,36 @@ CAC4EF00000000000000D002 /* StorageScanPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageScanPlugin.swift; 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 = ""; }; + 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 = ""; }; DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; 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 */ + 270FCEA1305CD07F003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + CurrentWeatherSnapshotAddress.swift, + ); + target = 2715FCD430570C1C0014DC8A /* DPIPWidgetsExtension */; + }; + 270FCEA3305CD1DD003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + CurrentWeatherSnapshotAddress.swift, + ); + target = 97C146ED1CF9000F007C117D /* Runner */; + }; + 270FCEA6305CD2D6003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + CurrentWeatherSnapshotAddress.swift, + ); + target = 331C8080294A63A400263BE5 /* RunnerTests */; + }; 2715FCEA30570C1D0014DC8A /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -150,10 +191,50 @@ ); target = 2715FCD430570C1C0014DC8A /* DPIPWidgetsExtension */; }; + 27CACB9C305BD2A60046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + WeatherWidgetConfiguration.intentdefinition, + WeatherWidgetConfiguration.intentdefinition.xcstrings, + ); + target = 97C146ED1CF9000F007C117D /* Runner */; + }; + 27CACBAF305BD77F0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 27CACBA0305BD77F0046F79C /* DPIPWidgetIntentsExtension */; + }; + 27CACBB1305BD7E60046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + WeatherWidgetConfiguration.intentdefinition, + WeatherWidgetConfiguration.intentdefinition.xcstrings, + ); + target = 27CACBA0305BD77F0046F79C /* DPIPWidgetIntentsExtension */; + }; + 27CACC5E305C66560046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + WidgetLocationCatalog.swift, + WidgetLocationOptions.swift, + ); + target = 331C8080294A63A400263BE5 /* RunnerTests */; + }; + 27CACD20305C6E2D0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + WidgetLocationTarget.swift, + ); + target = 331C8080294A63A400263BE5 /* RunnerTests */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 2715FCDB30570C1C0014DC8A /* DPIPWidgets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2715FCEA30570C1D0014DC8A /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = DPIPWidgets; sourceTree = ""; }; + 270FCE9B305CD066003D1E62 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (270FCEA1305CD07F003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA3305CD1DD003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 270FCEA6305CD2D6003D1E62 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; + 2715FCDB30570C1C0014DC8A /* DPIPWidgets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2715FCEA30570C1D0014DC8A /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACB9C305BD2A60046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACD20305C6E2D0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACBB1305BD7E60046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = DPIPWidgets; sourceTree = ""; }; + 27CACBA4305BD77F0046F79C /* DPIPWidgetIntentsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (27CACC5E305C66560046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 27CACBAF305BD77F0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = DPIPWidgetIntentsExtension; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -166,6 +247,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 27CACB9E305BD77F0046F79C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 27CACBA3305BD77F0046F79C /* Intents.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -189,6 +278,7 @@ children = ( 2715FCD730570C1C0014DC8A /* WidgetKit.framework */, 2715FCD930570C1C0014DC8A /* SwiftUI.framework */, + 27CACBA2305BD77F0046F79C /* Intents.framework */, ); name = Frameworks; sourceTree = ""; @@ -200,6 +290,10 @@ D91A00000000000000000001 /* CurrentWeatherWidgetTests.swift */, D91A00000000000000000002 /* CurrentWeatherWidgetSnapshot.swift */, D91A00000000000000000003 /* CurrentWeatherWidgetTimeline.swift */, + 27CACC3D305C64AA0046F79C /* WidgetLocationIntentTests.swift */, + 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */, + 270FCEA4305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift */, + 270FCEF4305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -219,10 +313,12 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + 270FCE9B305CD066003D1E62 /* Shared */, 272EFB373057247600B78F5D /* DPIPWidgetsExtension.entitlements */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 2715FCDB30570C1C0014DC8A /* DPIPWidgets */, + 27CACBA4305BD77F0046F79C /* DPIPWidgetIntentsExtension */, 2715FCD630570C1C0014DC8A /* Frameworks */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, @@ -235,6 +331,7 @@ 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, 2715FCD530570C1C0014DC8A /* DPIPWidgetsExtension.appex */, + 27CACBA1305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex */, ); name = Products; sourceTree = ""; @@ -303,6 +400,29 @@ productReference = 2715FCD530570C1C0014DC8A /* DPIPWidgetsExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + 27CACBA0305BD77F0046F79C /* DPIPWidgetIntentsExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 27CACBAB305BD77F0046F79C /* Build configuration list for PBXNativeTarget "DPIPWidgetIntentsExtension" */; + buildPhases = ( + 27CACB9D305BD77F0046F79C /* Sources */, + 27CACB9E305BD77F0046F79C /* Frameworks */, + 27CACB9F305BD77F0046F79C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 270FCE9B305CD066003D1E62 /* Shared */, + 27CACBA4305BD77F0046F79C /* DPIPWidgetIntentsExtension */, + ); + name = DPIPWidgetIntentsExtension; + packageProductDependencies = ( + ); + productName = DPIPWidgetIntentsExtension; + productReference = 27CACBA1305BD77F0046F79C /* DPIPWidgetIntentsExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 331C8080294A63A400263BE5 /* RunnerTests */ = { isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; @@ -337,6 +457,7 @@ ); dependencies = ( 2715FCE430570C1D0014DC8A /* PBXTargetDependency */, + 27CACBA9305BD77F0046F79C /* PBXTargetDependency */, ); name = Runner; packageProductDependencies = ( @@ -360,6 +481,9 @@ 2715FCD430570C1C0014DC8A = { CreatedOnToolsVersion = 26.6; }; + 27CACBA0305BD77F0046F79C = { + CreatedOnToolsVersion = 26.6; + }; 331C8080294A63A400263BE5 = { CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; @@ -393,6 +517,7 @@ 2715FCD430570C1C0014DC8A /* DPIPWidgetsExtension */, 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + 27CACBA0305BD77F0046F79C /* DPIPWidgetIntentsExtension */, ); }; /* End PBXProject section */ @@ -405,6 +530,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 27CACB9F305BD77F0046F79C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807F294A63A400263BE5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -479,13 +611,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 27CACB9D305BD77F0046F79C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807D294A63A400263BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 270FCEA5305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift in Sources */, 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, D91A00000000000000000011 /* CurrentWeatherWidgetTests.swift in Sources */, D91A00000000000000000012 /* CurrentWeatherWidgetSnapshot.swift in Sources */, + 27CACC80305C67530046F79C /* WidgetLocationCatalogTests.swift in Sources */, + 270FCEF5305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift in Sources */, + 27CACC3E305C64AA0046F79C /* WidgetLocationIntentTests.swift in Sources */, D91A00000000000000000013 /* CurrentWeatherWidgetTimeline.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -519,6 +662,11 @@ target = 2715FCD430570C1C0014DC8A /* DPIPWidgetsExtension */; targetProxy = 2715FCE330570C1D0014DC8A /* PBXContainerItemProxy */; }; + 27CACBA9305BD77F0046F79C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 27CACBA0305BD77F0046F79C /* DPIPWidgetIntentsExtension */; + targetProxy = 27CACBA8305BD77F0046F79C /* PBXContainerItemProxy */; + }; 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 97C146ED1CF9000F007C117D /* Runner */; @@ -767,6 +915,132 @@ }; name = Profile; }; + 27CACBAC305BD77F0046F79C /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + 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 = DPIPWidgetIntentsExtension/DPIPWidgetIntentsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 98Q7JARYZF; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DPIPWidgetIntentsExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DPIPWidgetIntentsExtension; + 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 = "$(FLUTTER_BUILD_NAME)"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.DPIPWidgetIntentsExtension; + 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; + }; + 27CACBAD305BD77F0046F79C /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + 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 = DPIPWidgetIntentsExtension/DPIPWidgetIntentsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 98Q7JARYZF; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DPIPWidgetIntentsExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DPIPWidgetIntentsExtension; + 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 = "$(FLUTTER_BUILD_NAME)"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.DPIPWidgetIntentsExtension; + 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; + }; + 27CACBAE305BD77F0046F79C /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + 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 = DPIPWidgetIntentsExtension/DPIPWidgetIntentsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 98Q7JARYZF; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DPIPWidgetIntentsExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DPIPWidgetIntentsExtension; + 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 = "$(FLUTTER_BUILD_NAME)"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.DPIPWidgetIntentsExtension; + 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 = { @@ -989,6 +1263,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 27CACBAB305BD77F0046F79C /* Build configuration list for PBXNativeTarget "DPIPWidgetIntentsExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 27CACBAC305BD77F0046F79C /* Debug */, + 27CACBAD305BD77F0046F79C /* Release */, + 27CACBAE305BD77F0046F79C /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 47c5df755..be590ae19 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,19 +2,6 @@ - DPIPWidgetAppGroupIdentifier - group.com.exptech.dpip.dpip.widgets - NSAppTransportSecurity - - NSAllowsLocalNetworking - - - NSLocationWhenInUseUsageDescription - DPIP 需要你的位置,以提供你所在地的地震、天氣等災害警報。 - NSLocationAlwaysAndWhenInUseUsageDescription - DPIP 需要在背景持續存取你的位置,即使關閉 App 也能為你所在地推送地震、天氣等災害警報。 - NSBluetoothAlwaysUsageDescription - DPIP 需要藍牙以連線 Meshtastic LoRa 無線電,在通訊中斷時收發緊急訊息。 CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -35,27 +22,41 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleURLName + com.exptech.dpip.dpip + CFBundleURLSchemes + + dpip + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) - CFBundleURLTypes - - - CFBundleURLName - com.exptech.dpip.dpip - CFBundleURLSchemes - - dpip - - - + DPIPWidgetAppGroupIdentifier + group.com.exptech.dpip.dpip.widgets ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS - UIBackgroundModes + MLNIdeographicFontFamilyName + PingFang TC + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSBluetoothAlwaysUsageDescription + DPIP 需要藍牙以連線 Meshtastic LoRa 無線電,在通訊中斷時收發緊急訊息。 + NSLocationAlwaysAndWhenInUseUsageDescription + DPIP 需要在背景持續存取你的位置,即使關閉 App 也能為你所在地推送地震、天氣等災害警報。 + NSLocationWhenInUseUsageDescription + DPIP 需要你的位置,以提供你所在地的地震、天氣等災害警報。 + NSUserActivityTypes - remote-notification - location + WeatherWidgetConfigurationIntent UIApplicationSceneManifest @@ -80,6 +81,11 @@ UIApplicationSupportsIndirectInputEvents + UIBackgroundModes + + remote-notification + location + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -97,7 +103,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - MLNIdeographicFontFamilyName - PingFang TC diff --git a/ios/Runner/WidgetSnapshotPlugin.swift b/ios/Runner/WidgetSnapshotPlugin.swift index 0bf45b86d..1f919160d 100644 --- a/ios/Runner/WidgetSnapshotPlugin.swift +++ b/ios/Runner/WidgetSnapshotPlugin.swift @@ -69,13 +69,32 @@ enum WidgetSnapshotFile { return data } - static func replace(_ data: Data, kind: WidgetSnapshotKind, in container: URL) throws { - let directory = container.appendingPathComponent("WidgetSnapshots", isDirectory: true) + static func replace( + _ data: Data, + kind: WidgetSnapshotKind, + sourceIdentifier: String? = nil, + in container: URL + ) throws { + let destination: URL + 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) + destination = try snapshotURL( + kind: kind, + sourceIdentifier: sourceIdentifier, + in: container + ) + + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + try data.write( + to: destination, + options: .atomic + ) + } catch let error as WidgetSnapshotError { + throw error } catch { throw WidgetSnapshotError.writeFailed } @@ -92,6 +111,46 @@ enum WidgetSnapshotFile { throw WidgetSnapshotError.writeFailed } } + + static func snapshotURL( + kind: WidgetSnapshotKind, + sourceIdentifier: String?, + in container: URL + ) throws -> URL { + let directory = container.appendingPathComponent( + "WidgetSnapshots", + isDirectory: true + ) + + switch kind { + case .currentWeather: + guard + let sourceIdentifier, + let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: sourceIdentifier + ) + else { + throw WidgetSnapshotError.invalidPayload + } + + return directory + .appendingPathComponent( + "current-weather", + isDirectory: true + ) + .appendingPathComponent(address.filename) + + case .weatherForecast: + return directory.appendingPathComponent( + kind.filename + ) + + case .locationCatalog: + return directory.appendingPathComponent( + kind.filename + ) + } + } } /// Infrastructure-only Flutter bridge. It never interprets domain JSON. @@ -124,6 +183,8 @@ public final class WidgetSnapshotPlugin: NSObject, FlutterPlugin { return } + let sourceIdentifier = arguments["sourceIdentifier"] as? String + let kind: WidgetSnapshotKind let data: Data do { @@ -148,11 +209,18 @@ public final class WidgetSnapshotPlugin: NSObject, FlutterPlugin { } do { - try WidgetSnapshotFile.replace(data, kind: kind, in: container) - if let widgetKind = kind.widgetKind { - WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) - }; - DispatchQueue.main.async { result(nil) } + try WidgetSnapshotFile.replace( + data, + kind: kind, + sourceIdentifier: sourceIdentifier, + in: container + ) + if let widgetKind = kind.widgetKind { + WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) + } + DispatchQueue.main.async { result(nil) } + } catch let error as WidgetSnapshotError { + DispatchQueue.main.async { result(self.flutterError(error)) } } catch { DispatchQueue.main.async { result(self.flutterError(.writeFailed)) } } @@ -189,9 +257,9 @@ public final class WidgetSnapshotPlugin: NSObject, FlutterPlugin { do { try WidgetSnapshotFile.clear(kind, in: container) - if let widgetKind = kind.widgetKind { - WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) - } + if let widgetKind = kind.widgetKind { + WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) + } DispatchQueue.main.async { result(nil) } } catch { DispatchQueue.main.async { result(self.flutterError(.writeFailed)) } diff --git a/ios/RunnerTests/CurrentWeatherSnapshotAddressTests.swift b/ios/RunnerTests/CurrentWeatherSnapshotAddressTests.swift new file mode 100644 index 000000000..6118aace0 --- /dev/null +++ b/ios/RunnerTests/CurrentWeatherSnapshotAddressTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import Runner + +final class CurrentWeatherSnapshotAddressTests: XCTestCase { + func testCurrentLocationAddress() { + let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: "current-location" + ) + + XCTAssertEqual(address, .currentLocation) + XCTAssertEqual( + address?.sourceIdentifier, + "current-location" + ) + XCTAssertEqual( + address?.filename, + "current-location.json" + ) + } + + func testSavedRegionAddress() { + let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: "region:220" + ) + + XCTAssertEqual( + address, + .saved(regionCode: "220") + ) + XCTAssertEqual( + address?.sourceIdentifier, + "region:220" + ) + XCTAssertEqual( + address?.filename, + "region-220.json" + ) + } + + func testRejectsInvalidSavedRegionIdentifiers() { + XCTAssertNil( + CurrentWeatherSnapshotAddress( + sourceIdentifier: "region:" + ) + ) + + XCTAssertNil( + CurrentWeatherSnapshotAddress( + sourceIdentifier: "region:22" + ) + ) + + XCTAssertNil( + CurrentWeatherSnapshotAddress( + sourceIdentifier: "region:2200" + ) + ) + + XCTAssertNil( + CurrentWeatherSnapshotAddress( + sourceIdentifier: "region:abc" + ) + ) + + XCTAssertNil( + CurrentWeatherSnapshotAddress( + sourceIdentifier: "region:220" + ) + ) + + XCTAssertNil( + CurrentWeatherSnapshotAddress( + sourceIdentifier: "region:٢٢٠" + ) + ) + + XCTAssertNil( + CurrentWeatherSnapshotAddress( + sourceIdentifier: "region:../" + ) + ) + } + + func testRejectsUnknownIdentifier() { + XCTAssertNil( + CurrentWeatherSnapshotAddress( + sourceIdentifier: "unknown" + ) + ) + } +} diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift index 3bf447cd7..7ec4cb963 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -2,6 +2,85 @@ import Foundation import XCTest final class CurrentWeatherWidgetSnapshotTests: XCTestCase { + func testDecodesSchemaVersionFiveSnapshot() throws { + let snapshot = try decode( + """ + { + "schemaVersion": 5, + "sourceIdentifier": "region:220", + "regionCode": "220", + "regionName": "板橋區", + "observationTime": 1789567200, + "stationName": "板橋", + "weather": "晴", + "weatherCode": 100, + "condition": "clear", + "isNight": false, + "nextDayNightTransitionTime": 1789562700, + "calibratedTimeOffsetMilliseconds": 0, + "temperature": 28.5, + "humidity": 70, + "rain": 0.0 + } + """ + ) + + XCTAssertEqual(snapshot.schemaVersion, 5) + XCTAssertEqual(snapshot.sourceIdentifier, "region:220") + XCTAssertEqual(snapshot.regionCode, "220") + } + + func testSchemaVersionFiveRequiresSourceIdentifier() { + XCTAssertThrowsError( + try decode( + """ + { + "schemaVersion": 5, + "regionCode": "220", + "regionName": "板橋區", + "observationTime": 1789567200, + "stationName": "板橋", + "weather": "晴", + "weatherCode": 100, + "condition": "clear", + "isNight": false, + "nextDayNightTransitionTime": 1789562700, + "calibratedTimeOffsetMilliseconds": 0, + "temperature": null, + "humidity": null, + "rain": null + } + """ + ) + ) + } + + func testRejectsUnsupportedSchemaVersion() { + XCTAssertThrowsError( + try decode( + """ + { + "schemaVersion": 6, + "sourceIdentifier": "region:220", + "regionCode": "220", + "regionName": "板橋區", + "observationTime": 1789567200, + "stationName": "板橋", + "weather": "晴", + "weatherCode": 100, + "condition": "clear", + "isNight": false, + "nextDayNightTransitionTime": 1789562700, + "calibratedTimeOffsetMilliseconds": 0, + "temperature": null, + "humidity": null, + "rain": null + } + """ + ) + ) + } + func testDecodesSchemaVersionFourSnapshot() throws { let snapshot = try decode( """ @@ -25,6 +104,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { ) XCTAssertEqual(snapshot.schemaVersion, 4) + XCTAssertNil(snapshot.sourceIdentifier) XCTAssertEqual(snapshot.regionCode, "660") XCTAssertEqual(snapshot.regionName, "西屯區") XCTAssertEqual(snapshot.observationTime, 1_789_567_200) diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index f1385a739..435d33bf8 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -55,15 +55,33 @@ final class RunnerTests: XCTestCase { 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") + let directory = container.appendingPathComponent("WidgetSnapshots") + let legacyTarget = directory.appendingPathComponent("current-weather.json") + let perLocationTarget = try WidgetSnapshotFile.snapshotURL( + kind: kind, + sourceIdentifier: "region:220", + in: container + ) XCTAssertNoThrow(try WidgetSnapshotFile.clear(kind, in: container)) - try WidgetSnapshotFile.replace(data, kind: kind, in: container) - XCTAssertTrue(FileManager.default.fileExists(atPath: target.path)) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + try data.write(to: legacyTarget, options: .atomic) + try WidgetSnapshotFile.replace( + data, + kind: kind, + sourceIdentifier: "region:220", + in: container + ) + XCTAssertTrue(FileManager.default.fileExists(atPath: legacyTarget.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: perLocationTarget.path)) try WidgetSnapshotFile.clear(kind, in: container) - XCTAssertFalse(FileManager.default.fileExists(atPath: target.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: legacyTarget.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: perLocationTarget.path)) XCTAssertNoThrow(try WidgetSnapshotFile.clear(kind, in: container)) } diff --git a/ios/RunnerTests/WidgetLocationCatalogTests.swift b/ios/RunnerTests/WidgetLocationCatalogTests.swift new file mode 100644 index 000000000..55672317d --- /dev/null +++ b/ios/RunnerTests/WidgetLocationCatalogTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import Runner + +final class WidgetLocationCatalogTests: XCTestCase { + func testDecodesSchemaVersionOneCatalog() throws { + let json = """ + { + "schemaVersion": 1, + "locations": [ + { + "regionCode": "220", + "displayName": "板橋區", + "administrativeAreaName": "新北市", + "latitude": 25.0096156, + "longitude": 121.4592358 + } + ] + } + """ + + let catalog = WidgetLocationCatalogStore.decode( + Data(json.utf8) + ) + + XCTAssertNotNil(catalog) + XCTAssertEqual(catalog?.schemaVersion, 1) + XCTAssertEqual(catalog?.locations.count, 1) + XCTAssertEqual(catalog?.locations[0].regionCode, "220") + XCTAssertEqual(catalog?.locations[0].displayName, "板橋區") + XCTAssertEqual( + catalog?.locations[0].administrativeAreaName, + "新北市" + ) + } + + func testRejectsUnsupportedSchemaVersion() { + let json = """ + { + "schemaVersion": 2, + "locations": [] + } + """ + + XCTAssertNil( + WidgetLocationCatalogStore.decode( + Data(json.utf8) + ) + ) + } + + func testRejectsMalformedCatalog() { + let json = """ + { + "schemaVersion": 1, + "locations": [ + { + "regionCode": "220" + } + ] + } + """ + + XCTAssertNil( + WidgetLocationCatalogStore.decode( + Data(json.utf8) + ) + ) + } +} diff --git a/ios/RunnerTests/WidgetLocationIntentTests.swift b/ios/RunnerTests/WidgetLocationIntentTests.swift new file mode 100644 index 000000000..69efaf0db --- /dev/null +++ b/ios/RunnerTests/WidgetLocationIntentTests.swift @@ -0,0 +1,255 @@ +import XCTest +@testable import Runner + +final class WidgetLocationIntentTests: XCTestCase { + private func makeSnapshot( + sourceIdentifier: String?, + regionCode: String, + schemaVersion: Int = 5 + ) -> CurrentWeatherWidgetSnapshot { + CurrentWeatherWidgetSnapshot( + schemaVersion: schemaVersion, + sourceIdentifier: sourceIdentifier, + regionCode: regionCode, + regionName: "測試地區", + observationTime: 1_789_567_200, + stationName: "測試站", + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: false, + nextDayNightTransitionTime: 1_789_562_700, + calibratedTimeOffsetMilliseconds: 0, + temperature: 28, + humidity: 70, + rain: 0 + ) + } + + func testMissingCatalogFallsBackToCurrentLocation() { + let options = makeWidgetLocationOptions( + from: nil, + currentLocationDisplayString: "Current Location" + ) + + XCTAssertEqual( + options, + [ + WidgetLocationOption( + identifier: "current-location", + displayString: "Current Location" + ) + ] + ) + } + + func testCurrentLocationUsesLocalizedDisplayString() { + let options = makeWidgetLocationOptions( + from: nil, + currentLocationDisplayString: "所在地" + ) + + XCTAssertEqual(options.first?.displayString, "所在地") + } + + func testCatalogLocationsPreserveSavedOrder() { + let catalog = WidgetLocationCatalog( + schemaVersion: 1, + locations: [ + WidgetLocationCatalogLocation( + regionCode: "220", + displayName: "板橋區", + administrativeAreaName: "新北市", + latitude: 25.0096156, + longitude: 121.4592358 + ), + WidgetLocationCatalogLocation( + regionCode: "302", + displayName: "竹北市", + administrativeAreaName: "新竹縣", + latitude: 24.8395807, + longitude: 121.0040235 + ) + ] + ) + + let options = makeWidgetLocationOptions( + from: catalog, + currentLocationDisplayString: "Current Location" + ) + + XCTAssertEqual( + options, + [ + WidgetLocationOption( + identifier: "current-location", + displayString: "Current Location" + ), + WidgetLocationOption( + identifier: "region:220", + displayString: "板橋區 — 新北市" + ), + WidgetLocationOption( + identifier: "region:302", + displayString: "竹北市 — 新竹縣" + ) + ] + ) + } + + func testLocationTargetDefaultsToCurrentLocation() { + XCTAssertEqual( + WidgetLocationTarget(identifier: nil), + .currentLocation + ) + } + + func testLocationTargetParsesCurrentLocation() { + XCTAssertEqual( + WidgetLocationTarget( + identifier: "current-location" + ), + .currentLocation + ) + } + + func testLocationTargetParsesSavedRegion() { + XCTAssertEqual( + WidgetLocationTarget( + identifier: "region:220" + ), + .saved(regionCode: "220") + ) + } + + func testLocationTargetRejectsMalformedRegion() { + for identifier in [ + "region:", + "region:22", + "region:2200", + "region:abc", + "region:220", + "region:../../secret", + ] { + XCTAssertEqual( + WidgetLocationTarget(identifier: identifier), + .invalid(identifier: identifier) + ) + } + } + + func testLocationTargetRejectsUnknownIdentifier() { + XCTAssertEqual( + WidgetLocationTarget( + identifier: "something-else" + ), + .invalid(identifier: "something-else") + ) + } + + func testCurrentLocationMatchesCurrentLocationSnapshot() { + let target = WidgetLocationTarget( + identifier: "current-location" + ) + + let snapshot = makeSnapshot( + sourceIdentifier: "current-location", + regionCode: "220" + ) + + XCTAssertTrue( + target.matches(snapshot: snapshot) + ) + } + + func testSavedLocationMatchesSameSavedRegion() { + let target = WidgetLocationTarget( + identifier: "region:220" + ) + + let snapshot = makeSnapshot( + sourceIdentifier: "region:220", + regionCode: "220" + ) + + XCTAssertTrue( + target.matches(snapshot: snapshot) + ) + } + + func testSavedLocationRejectsDifferentSavedRegion() { + let target = WidgetLocationTarget( + identifier: "region:220" + ) + + let snapshot = makeSnapshot( + sourceIdentifier: "region:302", + regionCode: "302" + ) + + XCTAssertFalse( + target.matches(snapshot: snapshot) + ) + } + + func testSavedLocationRejectsMismatchedPayloadRegionCode() { + let target = WidgetLocationTarget( + identifier: "region:220" + ) + + let snapshot = makeSnapshot( + sourceIdentifier: "region:220", + regionCode: "302" + ) + + XCTAssertFalse( + target.matches(snapshot: snapshot) + ) + } + + func testCurrentLocationDoesNotMatchSavedSnapshot() { + let target = WidgetLocationTarget( + identifier: "current-location" + ) + + let snapshot = makeSnapshot( + sourceIdentifier: "region:220", + regionCode: "220" + ) + + XCTAssertFalse( + target.matches(snapshot: snapshot) + ) + } + + func testLegacySnapshotWithoutSourceDoesNotMatch() { + let target = WidgetLocationTarget( + identifier: "current-location" + ) + + let snapshot = makeSnapshot( + sourceIdentifier: nil, + regionCode: "220", + schemaVersion: 4 + ) + + XCTAssertFalse( + target.matches(snapshot: snapshot) + ) + } + + func testInvalidTargetNeverMatchesSnapshot() { + let target = WidgetLocationTarget( + identifier: "something-else" + ) + + let snapshot = makeSnapshot( + sourceIdentifier: "current-location", + regionCode: "220" + ) + + XCTAssertFalse( + target.matches(snapshot: snapshot) + ) + } +} diff --git a/ios/RunnerTests/WidgetSnapshotFileTests.swift b/ios/RunnerTests/WidgetSnapshotFileTests.swift new file mode 100644 index 000000000..f561f2516 --- /dev/null +++ b/ios/RunnerTests/WidgetSnapshotFileTests.swift @@ -0,0 +1,93 @@ +import XCTest +@testable import Runner + +final class WidgetSnapshotFileTests: XCTestCase { + func testLocationCatalogDoesNotRequireSourceIdentifier() throws { + let container = URL( + fileURLWithPath: "/tmp/widget-test", + isDirectory: true + ) + + let url = try WidgetSnapshotFile.snapshotURL( + kind: .locationCatalog, + sourceIdentifier: nil, + in: container + ) + + XCTAssertEqual( + url.path, + "/tmp/widget-test/WidgetSnapshots/location-catalog.json" + ) + } + + func testCurrentLocationSnapshotURL() throws { + let container = URL( + fileURLWithPath: "/tmp/widget-test", + isDirectory: true + ) + + let url = try WidgetSnapshotFile.snapshotURL( + kind: .currentWeather, + sourceIdentifier: "current-location", + in: container + ) + + XCTAssertEqual( + url.path, + "/tmp/widget-test/WidgetSnapshots/current-weather/current-location.json" + ) + } + + func testSavedRegionSnapshotURL() throws { + let container = URL( + fileURLWithPath: "/tmp/widget-test", + isDirectory: true + ) + + let url = try WidgetSnapshotFile.snapshotURL( + kind: .currentWeather, + sourceIdentifier: "region:220", + in: container + ) + + XCTAssertEqual( + url.path, + "/tmp/widget-test/WidgetSnapshots/current-weather/region-220.json" + ) + } + + func testCurrentWeatherRejectsMissingSourceIdentifier() { + let container = URL( + fileURLWithPath: "/tmp/widget-test", + isDirectory: true + ) + + XCTAssertThrowsError( + try WidgetSnapshotFile.snapshotURL( + kind: .currentWeather, + sourceIdentifier: nil, + in: container + ) + ) { error in + XCTAssertEqual( + error as? WidgetSnapshotError, + .invalidPayload + ) + } + } + + func testCurrentWeatherRejectsInvalidSourceIdentifier() { + let container = URL( + fileURLWithPath: "/tmp/widget-test", + isDirectory: true + ) + + XCTAssertThrowsError( + try WidgetSnapshotFile.snapshotURL( + kind: .currentWeather, + sourceIdentifier: "region:../../secret", + in: container + ) + ) + } +} diff --git a/ios/Shared/CurrentWeatherSnapshotAddress.swift b/ios/Shared/CurrentWeatherSnapshotAddress.swift new file mode 100644 index 000000000..08bac3144 --- /dev/null +++ b/ios/Shared/CurrentWeatherSnapshotAddress.swift @@ -0,0 +1,58 @@ +enum CurrentWeatherSnapshotAddress: Equatable { + case currentLocation + case saved(regionCode: String) + + init?(sourceIdentifier: String) { + if sourceIdentifier == "current-location" { + self = .currentLocation + return + } + + let prefix = "region:" + + guard sourceIdentifier.hasPrefix(prefix) else { + return nil + } + + let regionCode = String( + sourceIdentifier.dropFirst(prefix.count) + ) + + guard Self.isValidRegionCode(regionCode) else { + return nil + } + + self = .saved(regionCode: regionCode) + } + + var sourceIdentifier: String { + switch self { + case .currentLocation: + return "current-location" + + case .saved(let regionCode): + return "region:\(regionCode)" + } + } + + var filename: String { + switch self { + case .currentLocation: + return "current-location.json" + + case .saved(let regionCode): + return "region-\(regionCode).json" + } + } + + private static func isValidRegionCode( + _ regionCode: String + ) -> Bool { + let bytes = regionCode.utf8 + + return bytes.count == 3 + && bytes.allSatisfy { byte in + byte >= 48 && byte <= 57 + } + } +} diff --git a/lib/core/platform/widget_snapshot_writer.dart b/lib/core/platform/widget_snapshot_writer.dart index 62d01b809..7b2cb93b1 100644 --- a/lib/core/platform/widget_snapshot_writer.dart +++ b/lib/core/platform/widget_snapshot_writer.dart @@ -14,6 +14,7 @@ abstract interface class WidgetSnapshotWriter { Future> write({ required WidgetSnapshotKind kind, required String json, + String? sourceIdentifier, }); Future> clear({required WidgetSnapshotKind kind}); @@ -33,6 +34,7 @@ final class IosWidgetSnapshotWriter implements WidgetSnapshotWriter { Future> write({ required WidgetSnapshotKind kind, required String json, + String? sourceIdentifier, }) async { if (!_isSupportedPlatform) { return const Err( @@ -47,6 +49,7 @@ final class IosWidgetSnapshotWriter implements WidgetSnapshotWriter { await _channel.invokeMethod('write', { 'kind': kind.name, 'json': json, + 'sourceIdentifier': ?sourceIdentifier, }); return const Ok(null); } on MissingPluginException { diff --git a/lib/features/weather/current_weather_widget_coordinator.dart b/lib/features/weather/current_weather_widget_coordinator.dart index 2b1d6d801..0121f2eb1 100644 --- a/lib/features/weather/current_weather_widget_coordinator.dart +++ b/lib/features/weather/current_weather_widget_coordinator.dart @@ -1,5 +1,6 @@ import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/core/settings/home_area.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'; @@ -96,7 +97,18 @@ final class CurrentWeatherWidgetCoordinator longitude: town.lng, ); + final sourceIdentifier = switch (_regions.selected) { + CurrentArea(:final code) when code == regionCode => 'current-location', + SavedArea(:final code) when code == regionCode => 'region:$code', + _ => null, + }; + + if (sourceIdentifier == null) { + return; + } + final snapshot = createCurrentWeatherWidgetSnapshot( + sourceIdentifier: sourceIdentifier, regionCode: regionCode, regionName: town.townName, weather: weather, diff --git a/lib/features/weather/data/current_weather_widget_publisher.dart b/lib/features/weather/data/current_weather_widget_publisher.dart index a3efda40c..522ac7db2 100644 --- a/lib/features/weather/data/current_weather_widget_publisher.dart +++ b/lib/features/weather/data/current_weather_widget_publisher.dart @@ -12,7 +12,11 @@ final class CurrentWeatherWidgetPublisher { Future> publish(CurrentWeatherWidgetSnapshot snapshot) { final json = jsonEncode(snapshot.toJson()); - return _writer.write(kind: WidgetSnapshotKind.currentWeather, json: json); + return _writer.write( + kind: WidgetSnapshotKind.currentWeather, + json: json, + sourceIdentifier: snapshot.sourceIdentifier, + ); } Future> clear() { diff --git a/lib/features/weather/domain/current_weather_widget_snapshot.dart b/lib/features/weather/domain/current_weather_widget_snapshot.dart index 5902909d4..734507c3d 100644 --- a/lib/features/weather/domain/current_weather_widget_snapshot.dart +++ b/lib/features/weather/domain/current_weather_widget_snapshot.dart @@ -14,7 +14,8 @@ enum CurrentWeatherWidgetCondition { final class CurrentWeatherWidgetSnapshot { const CurrentWeatherWidgetSnapshot({ - this.schemaVersion = 4, + this.schemaVersion = 5, + required this.sourceIdentifier, required this.regionCode, required this.regionName, required this.observationTime, @@ -32,6 +33,8 @@ final class CurrentWeatherWidgetSnapshot { final int schemaVersion; + final String sourceIdentifier; + final String regionCode; final String regionName; @@ -62,6 +65,7 @@ final class CurrentWeatherWidgetSnapshot { Map toJson() { return { 'schemaVersion': schemaVersion, + 'sourceIdentifier': sourceIdentifier, 'regionCode': regionCode, 'regionName': regionName, 'observationTime': observationTime, @@ -93,6 +97,7 @@ CurrentWeatherWidgetCondition currentWeatherWidgetCondition(int code) { } CurrentWeatherWidgetSnapshot createCurrentWeatherWidgetSnapshot({ + required String sourceIdentifier, required String regionCode, required String regionName, required WeatherRealtime weather, @@ -101,6 +106,7 @@ CurrentWeatherWidgetSnapshot createCurrentWeatherWidgetSnapshot({ required int calibratedTimeOffsetMilliseconds, }) { return CurrentWeatherWidgetSnapshot( + sourceIdentifier: sourceIdentifier, regionCode: regionCode, regionName: regionName, observationTime: weather.time, diff --git a/test/core/platform/widget_location_catalog_coordinator_test.dart b/test/core/platform/widget_location_catalog_coordinator_test.dart index 6bebc75cb..140206d27 100644 --- a/test/core/platform/widget_location_catalog_coordinator_test.dart +++ b/test/core/platform/widget_location_catalog_coordinator_test.dart @@ -375,6 +375,7 @@ final class _RecordingWidgetSnapshotWriter implements WidgetSnapshotWriter { Future> write({ required WidgetSnapshotKind kind, required String json, + String? sourceIdentifier, }) async { writes.add((kind: kind, json: json)); return const Ok(null); @@ -397,6 +398,7 @@ final class _BlockingFirstWriteWidgetSnapshotWriter Future> write({ required WidgetSnapshotKind kind, required String json, + String? sourceIdentifier, }) async { writes.add((kind: kind, json: json)); diff --git a/test/core/platform/widget_snapshot_writer_test.dart b/test/core/platform/widget_snapshot_writer_test.dart index c96c2f0a8..5c7b7f51a 100644 --- a/test/core/platform/widget_snapshot_writer_test.dart +++ b/test/core/platform/widget_snapshot_writer_test.dart @@ -40,6 +40,29 @@ void main() { }, ); + test('writes current weather with a source identifier without exposing a file path', () async { + final writer = IosWidgetSnapshotWriter( + channel: channel, + isSupportedPlatform: true, + ); + + const json = '{"schemaVersion":5}'; + + final result = await writer.write( + kind: WidgetSnapshotKind.currentWeather, + json: json, + sourceIdentifier: 'region:220', + ); + + expect(result.isOk, isTrue); + expect(calls.single.method, 'write'); + expect(calls.single.arguments, { + 'kind': 'currentWeather', + 'json': json, + 'sourceIdentifier': 'region:220', + }); + }); + test('unsupported platform does not call the native channel', () async { final writer = IosWidgetSnapshotWriter( channel: channel, @@ -185,6 +208,7 @@ void main() { channel, (_) async => throw PlatformException(code: entry.key), ); + final writer = IosWidgetSnapshotWriter( channel: channel, isSupportedPlatform: true, @@ -200,31 +224,28 @@ void main() { entry.value, ); }); - - test( - 'writes the location catalog kind without exposing a file path', - () async { - final writer = IosWidgetSnapshotWriter( - channel: channel, - isSupportedPlatform: true, - ); - - const json = '{"schemaVersion":1,"locations":[]}'; - - final result = await writer.write( - kind: WidgetSnapshotKind.locationCatalog, - json: json, - ); - - expect(result.isOk, isTrue); - expect(calls.single.method, 'write'); - expect(calls.single.arguments, { - 'kind': 'locationCatalog', - 'json': json, - }); - }, - ); } + + test( + 'writes the location catalog kind without exposing a file path', + () async { + final writer = IosWidgetSnapshotWriter( + channel: channel, + isSupportedPlatform: true, + ); + + const json = '{"schemaVersion":1,"locations":[]}'; + + final result = await writer.write( + kind: WidgetSnapshotKind.locationCatalog, + json: json, + ); + + expect(result.isOk, isTrue); + expect(calls.single.method, 'write'); + expect(calls.single.arguments, {'kind': 'locationCatalog', 'json': json}); + }, + ); } final class _ThrowingMethodChannel extends MethodChannel { diff --git a/test/features/weather/current_weather_widget_coordinator_test.dart b/test/features/weather/current_weather_widget_coordinator_test.dart index 0b535061e..53025fb63 100644 --- a/test/features/weather/current_weather_widget_coordinator_test.dart +++ b/test/features/weather/current_weather_widget_coordinator_test.dart @@ -87,10 +87,12 @@ void main() { expect(syncCallCount, 0); expect(writer.writeCallCount, 1); expect(writer.writtenKind, WidgetSnapshotKind.currentWeather); + expect(writer.writtenSourceIdentifier, 'region:660'); final decoded = jsonDecode(writer.writtenJson!) as Map; - expect(decoded['schemaVersion'], 4); + expect(decoded['schemaVersion'], 5); + expect(decoded['sourceIdentifier'], 'region:660'); expect(decoded['regionCode'], '660'); expect(decoded['regionName'], '西屯區'); expect(decoded['stationName'], '西屯'); @@ -312,6 +314,7 @@ final class _FakeWidgetSnapshotWriter implements WidgetSnapshotWriter { int writeCallCount = 0; WidgetSnapshotKind? writtenKind; String? writtenJson; + String? writtenSourceIdentifier; int clearCallCount = 0; WidgetSnapshotKind? clearedKind; @@ -326,11 +329,13 @@ final class _FakeWidgetSnapshotWriter implements WidgetSnapshotWriter { Future> write({ required WidgetSnapshotKind kind, required String json, + String? sourceIdentifier, }) async { called = true; writeCallCount += 1; writtenKind = kind; writtenJson = json; + writtenSourceIdentifier = sourceIdentifier; 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 index 16a58596e..33809c0ca 100644 --- a/test/features/weather/current_weather_widget_publisher_test.dart +++ b/test/features/weather/current_weather_widget_publisher_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; final class FakeWidgetSnapshotWriter implements WidgetSnapshotWriter { WidgetSnapshotKind? writtenKind; String? writtenJson; + String? writtenSourceIdentifier; int clearCallCount = 0; WidgetSnapshotKind? clearedKind; @@ -23,9 +24,11 @@ final class FakeWidgetSnapshotWriter implements WidgetSnapshotWriter { Future> write({ required WidgetSnapshotKind kind, required String json, + String? sourceIdentifier, }) async { writtenKind = kind; writtenJson = json; + writtenSourceIdentifier = sourceIdentifier; return const Ok(null); } @@ -37,6 +40,7 @@ void main() { final publisher = CurrentWeatherWidgetPublisher(writer); final snapshot = CurrentWeatherWidgetSnapshot( + sourceIdentifier: 'current-location', regionCode: '660', regionName: '西屯區', observationTime: 1789398000, @@ -56,6 +60,7 @@ void main() { expect(result, isA>()); expect(writer.writtenKind, WidgetSnapshotKind.currentWeather); + expect(writer.writtenSourceIdentifier, 'current-location'); final decoded = jsonDecode(writer.writtenJson!) as Map; @@ -69,6 +74,7 @@ void main() { final publisher = CurrentWeatherWidgetPublisher(writer); final snapshot = CurrentWeatherWidgetSnapshot( + sourceIdentifier: 'current-location', regionCode: '660', regionName: '西屯區', observationTime: 1789398000, diff --git a/test/features/weather/current_weather_widget_snapshot_test.dart b/test/features/weather/current_weather_widget_snapshot_test.dart index 4ff031f52..af79da30f 100644 --- a/test/features/weather/current_weather_widget_snapshot_test.dart +++ b/test/features/weather/current_weather_widget_snapshot_test.dart @@ -92,6 +92,7 @@ void main() { ); final snapshot = createCurrentWeatherWidgetSnapshot( + sourceIdentifier: 'current-location', regionCode: '660', regionName: '西屯區', weather: weather, @@ -100,7 +101,8 @@ void main() { calibratedTimeOffsetMilliseconds: -300_000, ); - expect(snapshot.schemaVersion, 4); + expect(snapshot.schemaVersion, 5); + expect(snapshot.sourceIdentifier, 'current-location'); expect(snapshot.regionCode, '660'); expect(snapshot.regionName, '西屯區'); expect(snapshot.observationTime, 1789398000); @@ -118,7 +120,8 @@ void main() { final json = jsonEncode(snapshot.toJson()); final decoded = jsonDecode(json) as Map; - expect(decoded['schemaVersion'], 4); + expect(decoded['schemaVersion'], 5); + expect(decoded['sourceIdentifier'], 'current-location'); expect(decoded['regionCode'], '660'); expect(decoded['condition'], 'thunderstorm'); expect(decoded['isNight'], isFalse); @@ -129,6 +132,7 @@ void main() { test('serializes condition name and preserves nullable weather values', () { const snapshot = CurrentWeatherWidgetSnapshot( + sourceIdentifier: 'current-location', regionCode: '660', regionName: '西屯區', observationTime: 1789398000, @@ -151,7 +155,8 @@ void main() { final decoded = jsonDecode(json) as Map; expect(decoded, { - 'schemaVersion': 4, + 'schemaVersion': 5, + 'sourceIdentifier': 'current-location', 'regionCode': '660', 'regionName': '西屯區', 'observationTime': 1789398000,