Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions ios/DPIPWidgets/DPIPWidgetProviderSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,77 @@ struct DPIPWidgetTimelinePlanner: Sendable {
}
}

/// In-memory presentation only; never passed to a cache writer or planner.
struct DPIPWidgetSnapshotContent: Sendable {
let snapshot: CurrentWeatherWidgetSnapshot?
let forecast: ForecastWidgetSnapshot?
}

enum DPIPWidgetGalleryPreview {
/// Use a complete sample, rather than attaching invented forecast values
/// to a real observation. Also used by Canvas and WidgetKit placeholders.
static func sample(at date: Date) -> DPIPWidgetSnapshotContent {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Asia/Taipei")!
let hour = calendar.component(.hour, from: date)
let isNight = hour < 6 || hour >= 18
let transition = calendar.nextDate(
after: date,
matching: DateComponents(hour: isNight ? 6 : 18, minute: 0),
matchingPolicy: .nextTime
)!
let nextHour = calendar.dateInterval(of: .hour, for: date)!.end
let conditions: [(String, Int, Double, Int)] = [
("晴", 100, 28, 0),
("多雲", 200, 28, 10),
("多雲", 200, 27, 20),
("多雲有雨", 206, 27, 60),
("多雲有雨", 206, 26, 60),
]
let points = conditions.enumerated().map { index, value in
let pointDate = nextHour.addingTimeInterval(Double(index) * 3_600)
let pointHour = calendar.component(.hour, from: pointDate)
return ForecastWidgetPoint(
time: String(format: "%02d:00", pointHour),
temperature: value.2,
weather: value.0,
weatherCode: value.1,
pop: value.3
)!
}
let milliseconds = Int64(date.timeIntervalSince1970 * 1_000)
return DPIPWidgetSnapshotContent(
snapshot: CurrentWeatherWidgetSnapshot(
schemaVersion: 7,
sourceIdentifier: "region:407",
regionCode: "407",
regionName: String(localized: "widget.preview_location"),
observationTime: Int(date.timeIntervalSince1970),
stationName: "西屯",
weather: "晴",
weatherCode: 100,
condition: .clear,
isNight: isNight,
nextDayNightTransitionTime: Int(transition.timeIntervalSince1970),
calibratedTimeOffsetMilliseconds: 0,
temperature: 28,
humidity: 76,
rain: 0,
windDirection: "北北西",
windSpeed: 1.3,
apparentTemperature: 30
),
forecast: ForecastWidgetSnapshot(
sourceIdentifier: "region:407",
regionCode: "407",
updateTime: milliseconds,
receivedAt: milliseconds,
points: points
)
)
}
}

struct DPIPWidgetProviderDependencies: Sendable {
typealias LoadSnapshot = DPIPWidgetTimelinePlanner.LoadSnapshot
typealias LoadForecast = DPIPWidgetTimelinePlanner.LoadForecast
Expand All @@ -302,6 +373,49 @@ struct DPIPWidgetProviderDependencies: Sendable {
loadSnapshot(target)
}

/// WidgetKit snapshots remain cache-only. The gallery fallback is selected
/// here, outside getTimeline's planner and all persistence dependencies.
func snapshotContent(
for target: WidgetLocationTarget,
at date: Date,
family: WidgetFamily,
isPreview: Bool
) -> DPIPWidgetSnapshotContent {
let current = snapshot(for: target)
let forecast = forecastSnapshot(
for: target, currentSnapshot: current, at: date, family: family
)
let cached = DPIPWidgetSnapshotContent(snapshot: current, forecast: forecast)
guard isPreview, target.sourceIdentifier != nil else { return cached }

// Small has no forecast: only the current-weather half needs a sample,
// and only when the cache holds nothing for this target.
guard ForecastWidgetFamilyPolicy.supportsForecast(family) else {
if let current, target.matches(snapshot: current) { return cached }
return DPIPWidgetSnapshotContent(
snapshot: DPIPWidgetGalleryPreview.sample(at: date).snapshot,
forecast: nil
)
}

if let current, target.matches(snapshot: current), let forecast,
forecast.sourceIdentifier == target.sourceIdentifier,
forecast.regionCode == current.regionCode,
ForecastWidgetExpiry.isUsable(
forecast, at: date,
calibratedTimeOffsetMilliseconds:
current.calibratedTimeOffsetMilliseconds
), !ForecastWidgetFamilyPolicy.visiblePoints(
in: forecast,
at: CurrentWeatherWidgetTimeCalibration(snapshot: current)
.calibratedDate(fromDeviceDate: date),
family: family
).isEmpty {
return cached
}
return DPIPWidgetGalleryPreview.sample(at: date)
}

func forecastSnapshot(
for target: WidgetLocationTarget,
currentSnapshot: CurrentWeatherWidgetSnapshot?,
Expand Down
134 changes: 33 additions & 101 deletions ios/DPIPWidgets/DPIPWidgets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,20 @@ struct DPIPWidgetProvider: IntentTimelineProvider {
self.dependencies = dependencies
}

private func snapshot(
for configuration: WeatherWidgetConfigurationIntent
) -> CurrentWeatherWidgetSnapshot? {
let target = WidgetLocationTarget(
identifier: configuration.location?.identifier
)

return dependencies.snapshot(for: target)
}

func placeholder(in context: Context) -> DPIPWidgetEntry {
DPIPWidgetEntry(
date: .now,
snapshot: nil,
let date = Date.now
let sample = DPIPWidgetGalleryPreview.sample(at: date)
let content = DPIPWidgetSnapshotContent(
snapshot: sample.snapshot,
forecast: ForecastWidgetFamilyPolicy.supportsForecast(context.family)
? sample.forecast : nil
)
return DPIPWidgetEntry(
date: date,
snapshot: content.snapshot,
isStale: false,
isNight: false,
isNight: content.snapshot?.isNight ?? false,
forecast: content.forecast
)
}

Expand All @@ -38,12 +36,19 @@ struct DPIPWidgetProvider: IntentTimelineProvider {
in context: Context,
completion: @escaping (DPIPWidgetEntry) -> Void
) {
let snapshot = snapshot(for: configuration)
let deviceNow = Date.now
let target = WidgetLocationTarget(
identifier: configuration.location?.identifier
)

let content = dependencies.snapshotContent(
for: target,
at: deviceNow,
family: context.family,
isPreview: context.isPreview
)
let snapshot = content.snapshot

let state = CurrentWeatherWidgetTimeline.state(
snapshot: snapshot,
at: deviceNow,
Expand All @@ -55,12 +60,7 @@ struct DPIPWidgetProvider: IntentTimelineProvider {
snapshot: snapshot,
isStale: state.isStale,
isNight: state.isNight,
forecast: dependencies.forecastSnapshot(
for: target,
currentSnapshot: snapshot,
at: deviceNow,
family: context.family
)
forecast: content.forecast
)

completion(entry)
Expand Down Expand Up @@ -768,98 +768,30 @@ struct DPIPWidgets: Widget {
struct DPIPWidgets_Previews: PreviewProvider {
private static let previewNow = Date()

private static let previewTransitionTime = Int(
previewNow
.addingTimeInterval(6 * 60 * 60)
.timeIntervalSince1970
)

private static let previewForecast = ForecastWidgetSnapshot(
sourceIdentifier: "current-location",
regionCode: "407",

updateTime: 1_790_316_445_925,
receivedAt: 1_790_316_600_000,

points: [
ForecastWidgetPoint(
time: "01:00",
temperature: 26.0,
weather: "晴",
weatherCode: 100,
pop: 0
)!,
ForecastWidgetPoint(
time: "02:00",
temperature: 26.0,
weather: "多雲",
weatherCode: 200,
pop: 10
)!,
ForecastWidgetPoint(
time: "03:00",
temperature: 25.0,
weather: "多雲有雨",
weatherCode: 206,
pop: 60
)!,
ForecastWidgetPoint(
time: "04:00",
temperature: 25.0,
weather: "多雲有雷雨",
weatherCode: 214,
pop: 80
)!,
ForecastWidgetPoint(
time: "05:00",
temperature: 24.0,
weather: "多雲有霧",
weatherCode: 205,
pop: 20
)!,
] )
private static let previewContent = DPIPWidgetGalleryPreview.sample(at: previewNow)

private static let previewEntry = DPIPWidgetEntry(
date: previewNow,
snapshot: CurrentWeatherWidgetSnapshot(
schemaVersion: 7,
sourceIdentifier: "current-location",
regionCode: "407",
regionName: "西屯區",
observationTime: Int(previewNow.timeIntervalSince1970),
stationName: "西屯",
weather: "晴",
weatherCode: 100,
condition: .clear,
isNight: true,
nextDayNightTransitionTime: previewTransitionTime,
calibratedTimeOffsetMilliseconds: 0,
temperature: 28.0,
humidity: 76,
rain: 0.0,
windDirection: "北北西",
windSpeed: 1.3,
apparentTemperature: 30.2
),
snapshot: previewContent.snapshot,
isStale: false,
isNight: true,
forecast: previewForecast
isNight: previewContent.snapshot?.isNight ?? false,
forecast: previewContent.forecast
)

private static let missingOptionalDataEntry = DPIPWidgetEntry(
date: .now,
date: previewNow,
snapshot: CurrentWeatherWidgetSnapshot(
schemaVersion: 7,
sourceIdentifier: "current-location",
sourceIdentifier: "region:407",
regionCode: "407",
regionName: "西屯區",
observationTime: Int(Date().timeIntervalSince1970),
regionName: String(localized: "widget.preview_location"),
observationTime: Int(previewNow.timeIntervalSince1970),
stationName: "西屯",
weather: "多雲",
weatherCode: 200,
condition: .cloudy,
isNight: false,
nextDayNightTransitionTime: 1_789_562_700,
isNight: previewContent.snapshot!.isNight,
nextDayNightTransitionTime: previewContent.snapshot!.nextDayNightTransitionTime,
calibratedTimeOffsetMilliseconds: 0,
temperature: 28.0,
humidity: 76,
Expand All @@ -869,8 +801,8 @@ struct DPIPWidgets_Previews: PreviewProvider {
apparentTemperature: nil,
),
isStale: false,
isNight: false,
forecast: previewForecast
isNight: previewContent.snapshot!.isNight,
forecast: previewContent.forecast
)

@ViewBuilder
Expand Down
36 changes: 36 additions & 0 deletions ios/DPIPWidgets/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,42 @@
}
}
},
"widget.preview_location" : {
"comment" : "Example location label for a complete Widget Gallery sample; never a real observation.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sample Location"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "プレビュー地点"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "미리보기 위치"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "预览地点"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "預覽地點"
}
}
}
},
"widget.rainfall" : {
"extractionState" : "manual",
"localizations" : {
Expand Down
Loading