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,