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