Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f993038
chore(weather): add native widget refresh foundations
YinCheng0106 Sep 20, 2026
b3ac34b
chore(weather): add native widget weather client
YinCheng0106 Sep 20, 2026
15b0a41
test(widget): cover location catalog edge cases
YinCheng0106 Sep 20, 2026
e198dcf
chore(weather): add native widget solar parity
YinCheng0106 Sep 20, 2026
7803a38
chore(weather): add native widget snapshot generation
YinCheng0106 Sep 20, 2026
7c41a7e
chore(weather): add native widget snapshot pipeline
YinCheng0106 Sep 20, 2026
9906ec5
chore(weather): add native widget calibrated clock
YinCheng0106 Sep 20, 2026
4978f2e
chore(weather): add saved widget weather refresh service
YinCheng0106 Sep 20, 2026
f7b20a9
chore(weather): enable saved widget self refresh
YinCheng0106 Sep 20, 2026
fdcc4e2
chore(weather): add widget refresh diagnostics
YinCheng0106 Sep 21, 2026
45a0860
chore(weather): tune widget refresh interval
YinCheng0106 Sep 21, 2026
9e537b9
chore(weather): add widget current location client
YinCheng0106 Sep 21, 2026
4392d85
chore(weather): add widget township resolver
YinCheng0106 Sep 21, 2026
1ce3655
chore(weather): enable current location widget refresh
YinCheng0106 Sep 21, 2026
7160ca1
fix(weather): prevent stale widget snapshot replacement
YinCheng0106 Sep 21, 2026
08594b0
chore(weather): complete widget refresh Sendable contracts
YinCheng0106 Sep 21, 2026
2930bb8
refactor(weather): consolidate widget refresh pipeline
YinCheng0106 Sep 22, 2026
cbe4550
feat(weather): indicate current-location widgets
YinCheng0106 Sep 22, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ jobs:
- name: Build info
run: bash tool/check/build_info.sh

- name: Widget township resources
run: bash tool/check/widget_township_resources.sh

- name: Codegen is up to date
run: |
bash tool/dev/codegen.sh
Expand Down
29 changes: 24 additions & 5 deletions ios/DPIPWidgetIntentsExtension/IntentHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ final class IntentHandler: INExtension,
return self
}

func defaultLocation(
for intent: WeatherWidgetConfigurationIntent
) -> WidgetLocation? {
makeCurrentWidgetLocation()
}

func provideLocationOptionsCollection(
for intent: WeatherWidgetConfigurationIntent,
with completion: @escaping (
Expand All @@ -15,11 +21,6 @@ final class IntentHandler: INExtension,
) -> Void
) {
let catalog = WidgetLocationCatalogStore().load()
let currentLocationDisplayString = String(
localized: "intent.current_location",
bundle: .main,
comment: "Current-location option in weather widget configuration."
)

let locations = makeWidgetLocationOptions(
from: catalog,
Expand All @@ -36,4 +37,22 @@ final class IntentHandler: INExtension,
nil
)
}

private func makeCurrentWidgetLocation() -> WidgetLocation {
let option = makeCurrentWidgetLocationOption(
displayString: currentLocationDisplayString
)
return WidgetLocation(
identifier: option.identifier,
display: option.displayString
)
}

private var currentLocationDisplayString: String {
String(
localized: "intent.current_location",
bundle: .main,
comment: "Current-location option in weather widget configuration."
)
}
}
54 changes: 0 additions & 54 deletions ios/DPIPWidgetIntentsExtension/WidgetLocationCatalog.swift

This file was deleted.

12 changes: 10 additions & 2 deletions ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@ struct WidgetLocationOption: Equatable {
let displayString: String
}

func makeCurrentWidgetLocationOption(
displayString: String
) -> WidgetLocationOption {
WidgetLocationOption(
identifier: "current-location",
displayString: displayString
)
}

func makeWidgetLocationOptions(
from catalog: WidgetLocationCatalog?,
currentLocationDisplayString: String
) -> [WidgetLocationOption] {
var options = [
WidgetLocationOption(
identifier: "current-location",
makeCurrentWidgetLocationOption(
displayString: currentLocationDisplayString
)
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
struct CurrentLocationCurrentWeatherWidgetRefreshService: Sendable {
typealias AcquireLocation = @MainActor @Sendable () async
-> WidgetCurrentLocationResult
typealias ResolveTownship = @Sendable (
WidgetCurrentLocation
) -> WidgetResolvedWeatherLocation?
typealias BeginWrite = @Sendable (
CurrentWeatherSnapshotAddress
) throws -> CurrentWeatherSnapshotWriteToken

private let acquireLocation: AcquireLocation
private let resolveTownship: ResolveTownship
private let beginWrite: BeginWrite
private let pipeline: CurrentWeatherWidgetRefreshPipeline

init(
acquireLocation: @escaping AcquireLocation,
resolver: WidgetTownshipResolver,
weatherClient: CurrentWeatherClient,
clock: WidgetServerClock,
writer: CurrentWeatherWidgetSnapshotWriter
) {
self.init(
acquireLocation: acquireLocation,
resolveTownship: { currentLocation in
resolver.resolve(currentLocation)
},
beginWrite: { address in
try writer.beginWrite(for: address)
},
pipeline: CurrentWeatherWidgetRefreshPipeline(
weatherClient: weatherClient,
clock: clock,
writer: writer
)
)
}

init(
acquireLocation: @escaping AcquireLocation,
resolveTownship: @escaping ResolveTownship,
beginWrite: @escaping BeginWrite,
pipeline: CurrentWeatherWidgetRefreshPipeline
) {
self.acquireLocation = acquireLocation
self.resolveTownship = resolveTownship
self.beginWrite = beginWrite
self.pipeline = pipeline
}

func refresh() async -> CurrentWeatherWidgetRefreshResult {
let writeToken: CurrentWeatherSnapshotWriteToken
do {
writeToken = try beginWrite(.currentLocation)
} catch {
return .failed
}

#if DEBUG
WidgetWeatherRefreshDiagnostics.log(
"current location acquisition started"
)
#endif

let currentLocationResult = await acquireLocation()
let currentLocation: WidgetCurrentLocation
switch currentLocationResult {
case .acquired(let acquiredLocation):
currentLocation = acquiredLocation
case .unavailable, .timedOut:
return .unavailable
case .failed:
return .failed
}

#if DEBUG
WidgetWeatherRefreshDiagnostics.log("township resolution started")
#endif
guard let location = resolveTownship(currentLocation),
location.address == .currentLocation
else {
#if DEBUG
WidgetWeatherRefreshDiagnostics.log(
"township resolution unavailable"
)
#endif
return .unavailable
}
#if DEBUG
WidgetWeatherRefreshDiagnostics.log(
"township resolved regionCode=\(location.regionCode)"
)
#endif

return await pipeline.refresh(
location: location,
writeToken: writeToken
)
}
}
87 changes: 87 additions & 0 deletions ios/DPIPWidgets/CurrentWeatherClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Foundation

enum CurrentWeatherClientError: Error, Equatable {
case invalidCoordinate
case invalidURL
case invalidResponse
case httpStatus(Int)
case responseTooLarge
}

struct CurrentWeatherClient: Sendable {
private static let scheme = "https"
private static let host = "api.core-tnn1.exptech.dev"
private static let realtimePath = "/api/v5/meteor/weather/realtime"

private static let requestTimeout: TimeInterval = 6
private static let maximumResponseSize = 128 * 1024

private let session: URLSession

init(session: URLSession = .shared) {
self.session = session
}

func makeURL(latitude: Double, longitude: Double) throws -> URL {
guard latitude.isFinite,
longitude.isFinite,
(-90.0...90.0).contains(latitude),
(-180.0...180.0).contains(longitude)
else {
throw CurrentWeatherClientError.invalidCoordinate
}

var components = URLComponents()
components.scheme = Self.scheme
components.host = Self.host
components.path = "\(Self.realtimePath)/\(latitude),\(longitude)"

guard let url = components.url else {
throw CurrentWeatherClientError.invalidURL
}

return url
}

func fetch(
latitude: Double,
longitude: Double
) async throws -> CurrentWeatherRemoteDTO? {
let url = try makeURL(
latitude: latitude,
longitude: longitude
)

var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = Self.requestTimeout

let (data, response) = try await session.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw CurrentWeatherClientError.invalidResponse
}

guard httpResponse.statusCode == 200 else {
throw CurrentWeatherClientError.httpStatus(
httpResponse.statusCode
)
}

guard data.count <= Self.maximumResponseSize else {
throw CurrentWeatherClientError.responseTooLarge
}

let jsonObject = try JSONSerialization.jsonObject(with: data)

if let object = jsonObject as? [String: Any],
object.isEmpty {
return nil
}

return try JSONDecoder().decode(
CurrentWeatherRemoteDTO.self,
from: data
)
}
}
Loading
Loading