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
2 changes: 2 additions & 0 deletions ios/DPIPWidgets/CurrentWeatherWidgetTimeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ struct CurrentWeatherWidgetTimeCalibration {
}

enum CurrentWeatherWidgetTimeline {
static let defaultStaleAfter: TimeInterval = 30 * 60

static func state(
snapshot: CurrentWeatherWidgetSnapshot?,
at date: Date,
Expand Down
29 changes: 28 additions & 1 deletion ios/DPIPWidgets/DPIPWidgetEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,32 @@ struct DPIPWidgetEntry: TimelineEntry {

var widgetURL: URL { locationTarget.widgetURL }

/// Bind the action to configuration, never to cached or gallery weather.
/// Availability of Button(intent:) is separately gated in the view.
func forecastRefreshIdentifier(for family: WidgetFamily) -> String? {
guard ForecastWidgetFamilyPolicy.supportsForecast(family),
(upcomingForecastPoints(for: family).isEmpty
|| forecastFreshness == .stale),
let identifier = locationTarget.sourceIdentifier,
WidgetLocationTarget(identifier: identifier) == locationTarget else {
return nil
}
return identifier
}

/// Evaluated at this entry's own device date, independently of the
/// Current Weather stale flag and observation-based hour selection.
var forecastFreshness: ForecastWidgetExpiry.Freshness? {
guard let snapshot, let forecast,
forecast.sourceIdentifier == snapshot.sourceIdentifier,
forecast.regionCode == snapshot.regionCode else { return nil }
return ForecastWidgetExpiry.freshness(
of: forecast, at: date,
calibratedTimeOffsetMilliseconds:
snapshot.calibratedTimeOffsetMilliseconds
)
}

init(date: Date, snapshot: CurrentWeatherWidgetSnapshot?,
isStale: Bool, isNight: Bool,
forecast: ForecastWidgetSnapshot? = nil,
Expand Down Expand Up @@ -38,7 +64,8 @@ struct DPIPWidgetEntry: TimelineEntry {
snapshot: snapshot
).calibratedDate(fromDeviceDate: date)
return ForecastWidgetFamilyPolicy.visiblePoints(
in: forecast, at: calibratedDate, family: family
in: forecast, at: calibratedDate,
observationTime: snapshot.observationTime, family: family
)
}
}
35 changes: 23 additions & 12 deletions ios/DPIPWidgets/DPIPWidgetProviderSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@ enum ForecastWidgetFamilyPolicy {
/// - Parameter calibratedDate: a calibrated instant, never a WidgetKit
/// entry date. Point labels are Taipei wall-clock readings of calibrated
/// time, so a device-clock date shifts the selection by the offset.
/// - Parameter observationTime: Current Weather's Unix seconds, already in
/// the calibrated domain. Forecast publication is never a selector.
static func visiblePoints(
in forecast: ForecastWidgetSnapshot,
at calibratedDate: Date,
observationTime: Int?,
family: WidgetFamily
) -> [ForecastWidgetPoint] {
guard supportsForecast(family) else { return [] }
return ForecastWidgetSelection.upcomingPoints(
forecast.points, at: calibratedDate, limit: 5
forecast.points, at: calibratedDate,
observationTime: observationTime, limit: 5
)
}
}
Expand Down Expand Up @@ -240,17 +244,23 @@ struct DPIPWidgetTimelinePlanner: Sendable {
normalReloadDate: reloadDate,
expiry: expiry
)
// Reload eligibility is not an execution guarantee. Supply the
// expiry state even when it falls after the requested reload.
if expiry > deviceNow,
!states.contains(where: { $0.date == expiry }) {
states.append(CurrentWeatherWidgetTimeline.state(
snapshot: snapshot,
at: expiry,
staleAfter: staleAfter
))
states.sort { $0.date < $1.date }
// Reload eligibility is not an execution guarantee. Schedule both
// publication transitions even beyond the requested network reload.
let stale = ForecastWidgetExpiry.staleDate(
for: forecast,
calibratedTimeOffsetMilliseconds:
snapshot.calibratedTimeOffsetMilliseconds
)
for boundary in [stale, expiry] where boundary > deviceNow {
if !states.contains(where: { $0.date == boundary }) {
states.append(CurrentWeatherWidgetTimeline.state(
snapshot: snapshot,
at: boundary,
staleAfter: staleAfter
))
}
}
states.sort { $0.date < $1.date }
}

#if DEBUG
Expand Down Expand Up @@ -416,6 +426,7 @@ struct DPIPWidgetProviderDependencies: Sendable {
in: forecast,
at: CurrentWeatherWidgetTimeCalibration(snapshot: current)
.calibratedDate(fromDeviceDate: date),
observationTime: current.observationTime,
family: family
).isEmpty {
return cached
Expand All @@ -439,7 +450,7 @@ struct DPIPWidgetProviderDependencies: Sendable {
enum DPIPWidgetProviderRuntime {
private static let appGroupIdentifier =
"group.com.exptech.dpip.dpip.widgets"
private static let staleAfter: TimeInterval = 30 * 60
private static let staleAfter = CurrentWeatherWidgetTimeline.defaultStaleAfter

#if DEBUG
static let refreshInterval: TimeInterval = 60
Expand Down
79 changes: 71 additions & 8 deletions ios/DPIPWidgets/DPIPWidgets.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import WidgetKit
import SwiftUI
import Intents
import AppIntents

struct DPIPWidgetProvider: IntentTimelineProvider {
typealias Intent = WeatherWidgetConfigurationIntent
private let staleAfter: TimeInterval = 30 * 60
private let staleAfter = CurrentWeatherWidgetTimeline.defaultStaleAfter
private let dependencies: DPIPWidgetProviderDependencies

init(
Expand Down Expand Up @@ -848,6 +849,35 @@ struct HourlyForecastSection: View {

var body: some View {
let points = entry.upcomingForecastPoints(for: family)
if !points.isEmpty, entry.forecastFreshness == .stale {
HStack(spacing: 4) {
VStack(alignment: .leading, spacing: 4) {
forecastContent(points: points)
Text("widget.forecast_outdated", bundle: localizationBundle)
.font(.caption2)
.foregroundStyle(.secondary)
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.forecastInvalidatableContent()
refreshButton
}
} else if !points.isEmpty {
forecastContent(points: points)
} else if ForecastWidgetFamilyPolicy.supportsForecast(family) {
unavailableContent
.font(.caption)
.foregroundStyle(.secondary)
// This secondary status shares a fixed forecast area. Bound
// only its growth; never shrink the current-weather content.
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
.frame(maxWidth: .infinity, alignment: .leading)
}
}

@ViewBuilder
private func forecastContent(points: [ForecastWidgetPoint]) -> some View {
let compact = family == .systemMedium
if !points.isEmpty, dynamicTypeSize.isAccessibilitySize {
// Fixed WidgetKit cards cannot scroll. Prioritize the nearest hour(s)
Expand Down Expand Up @@ -886,14 +916,36 @@ struct HourlyForecastSection: View {
}
}
}
} else if ForecastWidgetFamilyPolicy.supportsForecast(family) {
}
}

@ViewBuilder
private var unavailableContent: some View {
if #available(iOS 17.0, *),
entry.forecastRefreshIdentifier(for: family) != nil {
HStack(spacing: 4) {
unavailableLabel
.frame(maxWidth: .infinity, alignment: .leading)
.forecastInvalidatableContent()
refreshButton
}
} else {
unavailableLabel
.font(.caption)
.foregroundStyle(.secondary)
// This secondary status shares a fixed forecast area. Bound
// only its growth; never shrink the current-weather content.
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
.frame(maxWidth: .infinity, alignment: .leading)
}
}

@ViewBuilder
private var refreshButton: some View {
if #available(iOS 17.0, *),
let identifier = entry.forecastRefreshIdentifier(for: family) {
Button(intent: RefreshForecastIntent(locationIdentifier: identifier)) {
Image(systemName: "arrow.clockwise")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("widget.refresh_forecast", bundle: localizationBundle))
.accessibilityHint(Text("widget.refresh_forecast_hint", bundle: localizationBundle))
}
}

Expand All @@ -917,6 +969,17 @@ struct HourlyForecastSection: View {
}
}

private extension View {
@ViewBuilder
func forecastInvalidatableContent() -> some View {
if #available(iOS 17.0, *) {
invalidatableContent()
} else {
self
}
}
}

struct DPIPWidgetsEntryView : View {
@Environment(\.widgetFamily) private var family

Expand Down
68 changes: 46 additions & 22 deletions ios/DPIPWidgets/ForecastWidgetExpiry.swift
Original file line number Diff line number Diff line change
@@ -1,30 +1,54 @@
import Foundation

enum ForecastWidgetExpiry {
/// Device-clock cache/display deadline. Neither input is a point valid time.
/// Publication uses server time while receipt uses device time; compare
/// them in calibrated time before scheduling the deadline on WidgetKit.
enum Freshness: Equatable, Sendable {
case fresh, stale, expired
}

static let freshDuration: TimeInterval = 30 * 60
static let hardExpiryDuration: TimeInterval = 120 * 60
/// Small server/clock disagreement is tolerated; a publication further
/// ahead is ineligible, rather than granting an unbounded cache lifetime.
static let maximumFutureSkew: TimeInterval = 2 * 60

/// Device-clock boundaries derived only from the API publication instant.
/// Receipt and hourly point selection never determine publication age.
static func staleDate(
for snapshot: ForecastWidgetSnapshot,
calibratedTimeOffsetMilliseconds: Int = 0
) -> Date {
publicationDeviceDate(snapshot, calibratedTimeOffsetMilliseconds)
.addingTimeInterval(freshDuration)
}

static func date(
for snapshot: ForecastWidgetSnapshot,
calibratedTimeOffsetMilliseconds: Int = 0
) -> Date {
let offset = TimeInterval(calibratedTimeOffsetMilliseconds) / 1_000
let publishedCalibrated = Date(
timeIntervalSince1970: TimeInterval(snapshot.updateTime) / 1_000
)
let receivedDevice = Date(
timeIntervalSince1970: TimeInterval(snapshot.receivedAt) / 1_000
publicationDeviceDate(snapshot, calibratedTimeOffsetMilliseconds)
.addingTimeInterval(hardExpiryDuration)
}

static func freshness(
of snapshot: ForecastWidgetSnapshot,
at deviceDate: Date,
calibratedTimeOffsetMilliseconds: Int = 0
) -> Freshness {
let publication = publicationDeviceDate(
snapshot, calibratedTimeOffsetMilliseconds
)
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Asia/Taipei")!
let nextTaipeiHour = calendar.dateInterval(
of: .hour,
for: receivedDevice.addingTimeInterval(offset)
)!.end
return min(
publishedCalibrated.addingTimeInterval(30 * 60),
nextTaipeiHour
).addingTimeInterval(-offset)
let age = deviceDate.timeIntervalSince(publication)
guard snapshot.updateTime > 0, age.isFinite,
age >= -maximumFutureSkew,
age < hardExpiryDuration else { return .expired }
return age < freshDuration ? .fresh : .stale
}

private static func publicationDeviceDate(
_ snapshot: ForecastWidgetSnapshot, _ offsetMilliseconds: Int
) -> Date {
Date(timeIntervalSince1970: TimeInterval(snapshot.updateTime) / 1_000)
.addingTimeInterval(-TimeInterval(offsetMilliseconds) / 1_000)
}

/// All dates are in the device clock domain. Bound the lead to two
Expand All @@ -49,9 +73,9 @@ enum ForecastWidgetExpiry {
at deviceDate: Date,
calibratedTimeOffsetMilliseconds: Int = 0
) -> Bool {
deviceDate < self.date(
for: snapshot,
freshness(
of: snapshot, at: deviceDate,
calibratedTimeOffsetMilliseconds: calibratedTimeOffsetMilliseconds
)
) != .expired
}
}
38 changes: 35 additions & 3 deletions ios/DPIPWidgets/ForecastWidgetSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,40 @@ enum ForecastWidgetSelection {
return nil
}

// Keep this distance helper usable for the current hour's day/night
// presentation. upcomingPoints separately requires the next whole hour.
if forecastMinutes == hour * 60 { return 0 }

// A clock label has no date. A past label may mean tomorrow only
// within this short horizon; otherwise it is too ambiguous to show.
// The current minute remains eligible for an hourly forecast.
// Keep future distances relative to the instant (not the hour start)
// to preserve the 12-hour horizon and day/night transition timing.
let currentMinutes = hour * 60 + minute
let distance = (forecastMinutes - currentMinutes + minutesPerDay)
% minutesPerDay
return distance <= maximumFutureMinutes ? distance : nil
}

/// Start strictly after the reference's Taipei hour; retain API labels and
/// original order for equal distances, with a 12-hour reference horizon.
static func upcomingPoints(
_ points: [ForecastWidgetPoint],
at calibratedDate: Date,
observationTime: Int? = nil,
limit: Int
) -> [ForecastWidgetPoint] {
guard limit > 0 else { return [] }
let reference = selectionReference(
observationTime: observationTime, at: calibratedDate
)
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = taipei
let minutesUntilNextHour = 60 - calendar.component(.minute, from: reference)
return Array(points.enumerated().compactMap { index, point
-> (index: Int, distance: Int, point: ForecastWidgetPoint)? in
guard let distance = minutesAhead(
for: point.time, at: calibratedDate
) else {
for: point.time, at: reference
), distance >= minutesUntilNextHour else {
return nil
}
return (index, distance, point)
Expand All @@ -56,4 +70,22 @@ enum ForecastWidgetSelection {
return lhs.index < rhs.index
}.prefix(limit).map { $0.point })
}

/// Observation epochs are already calibrated Unix seconds; never add the
/// device offset to them. Reuse Current Weather's stale boundary. Missing,
/// invalid, future or stale observations use the next calibrated-clock hour
/// instead, so a failed weather refresh cannot keep clearly past hours.
/// Publication/receipt timestamps only govern expiry, never selection.
private static func selectionReference(
observationTime: Int?, at calibratedDate: Date
) -> Date {
guard let observationTime, observationTime > 0 else {
return calibratedDate
}
let age = calibratedDate.timeIntervalSince1970 - TimeInterval(observationTime)
guard age >= 0, age < CurrentWeatherWidgetTimeline.defaultStaleAfter else {
return calibratedDate
}
return Date(timeIntervalSince1970: TimeInterval(observationTime))
}
}
2 changes: 1 addition & 1 deletion ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ struct ForecastWidgetSnapshotStore: Sendable {
// A late response for an older API publication cannot replace
// a newer same-region forecast in this target's cache.
// Receiving the same publication again must not move its
// first-receipt hour boundary and resurrect expired values.
// original receipt or resurrect a hard-expired publication.
let existingAttributes = try? FileManager.default.attributesOfItem(
atPath: coordinatedURL.path
)
Expand Down
Loading
Loading