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
44 changes: 44 additions & 0 deletions ios/DPIPWidgets/DPIPWidgetEntry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import WidgetKit

/// Keeps navigation identity independent of the availability of weather data.
struct DPIPWidgetEntry: TimelineEntry {
let date: Date
let snapshot: CurrentWeatherWidgetSnapshot?
let isStale: Bool
let isNight: Bool
let forecast: ForecastWidgetSnapshot?
let locationTarget: WidgetLocationTarget

var widgetURL: URL { locationTarget.widgetURL }

init(date: Date, snapshot: CurrentWeatherWidgetSnapshot?,
isStale: Bool, isNight: Bool,
forecast: ForecastWidgetSnapshot? = nil,
locationTarget: WidgetLocationTarget = .currentLocation) {
self.date = date
self.snapshot = snapshot
self.isStale = isStale
self.isNight = isNight
self.forecast = forecast
self.locationTarget = locationTarget
}

/// Fail closed at the display boundary as well as the cache boundary.
/// Current weather remains usable independently of forecast availability.
func upcomingForecastPoints(for family: WidgetFamily) -> [ForecastWidgetPoint] {
guard let snapshot, let forecast,
forecast.sourceIdentifier == snapshot.sourceIdentifier,
forecast.regionCode == snapshot.regionCode,
ForecastWidgetExpiry.isUsable(
forecast, at: date,
calibratedTimeOffsetMilliseconds:
snapshot.calibratedTimeOffsetMilliseconds
) else { return [] }
let calibratedDate = CurrentWeatherWidgetTimeCalibration(
snapshot: snapshot
).calibratedDate(fromDeviceDate: date)
return ForecastWidgetFamilyPolicy.visiblePoints(
in: forecast, at: calibratedDate, family: family
)
}
}
47 changes: 6 additions & 41 deletions ios/DPIPWidgets/DPIPWidgets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ struct DPIPWidgetProvider: IntentTimelineProvider {
snapshot: snapshot,
isStale: state.isStale,
isNight: state.isNight,
forecast: content.forecast
forecast: content.forecast,
locationTarget: target
)

completion(entry)
Expand Down Expand Up @@ -106,7 +107,8 @@ struct DPIPWidgetProvider: IntentTimelineProvider {
snapshot: plan.snapshot,
isStale: state.isStale,
isNight: state.isNight,
forecast: plan.forecast(at: state.date)
forecast: plan.forecast(at: state.date),
locationTarget: target
)
}

Expand All @@ -120,43 +122,6 @@ struct DPIPWidgetProvider: IntentTimelineProvider {
}
}

struct DPIPWidgetEntry: TimelineEntry {
let date: Date
let snapshot: CurrentWeatherWidgetSnapshot?
let isStale: Bool
let isNight: Bool
let forecast: ForecastWidgetSnapshot?

init(date: Date, snapshot: CurrentWeatherWidgetSnapshot?,
isStale: Bool, isNight: Bool,
forecast: ForecastWidgetSnapshot? = nil) {
self.date = date
self.snapshot = snapshot
self.isStale = isStale
self.isNight = isNight
self.forecast = forecast
}

/// Fail closed at the display boundary as well as the cache boundary.
/// Current weather remains usable independently of forecast availability.
func upcomingForecastPoints(for family: WidgetFamily) -> [ForecastWidgetPoint] {
guard let snapshot, let forecast,
forecast.sourceIdentifier == snapshot.sourceIdentifier,
forecast.regionCode == snapshot.regionCode,
ForecastWidgetExpiry.isUsable(
forecast, at: date,
calibratedTimeOffsetMilliseconds:
snapshot.calibratedTimeOffsetMilliseconds
) else { return [] }
let calibratedDate = CurrentWeatherWidgetTimeCalibration(
snapshot: snapshot
).calibratedDate(fromDeviceDate: date)
return ForecastWidgetFamilyPolicy.visiblePoints(
in: forecast, at: calibratedDate, family: family
)
}
}

/// - Parameter calibratedDate: a calibrated instant, never a WidgetKit entry
/// date. `nextTransitionTime` is published in the calibrated domain, so
/// subtracting a device-clock date here would yield the calibration offset
Expand Down Expand Up @@ -985,11 +950,11 @@ struct DPIPWidgets: Widget {
) { entry in
if #available(iOS 17.0, *) {
DPIPWidgetsEntryView(entry: entry)
.widgetURL(URL(string: "dpip:///home"))
.widgetURL(entry.widgetURL)
.containerBackground(.fill.tertiary, for: .widget)
} else {
DPIPWidgetsEntryView(entry: entry)
.widgetURL(URL(string: "dpip:///home"))
.widgetURL(entry.widgetURL)
.padding()
.background()
}
Expand Down
27 changes: 27 additions & 0 deletions ios/DPIPWidgets/WidgetLocationTarget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,33 @@ enum WidgetLocationTarget: Equatable, Sendable {
}

extension WidgetLocationTarget {
// This literal is independent of configuration and always a valid URL.
private static let homeURL = URL(string: "dpip:///home")!

/// The configured target survives missing/expired weather. Never derive a
/// navigation destination from a cached observation's region.
var widgetURL: URL {
var components = URLComponents()
components.scheme = "dpip"
components.host = ""
components.path = "/home"
if let identifier = sourceIdentifier,
WidgetLocationTarget(identifier: identifier) == self {
var allowed = CharacterSet.urlQueryAllowed
allowed.remove(charactersIn: ":&=+?#%")
if let encoded = identifier.addingPercentEncoding(
withAllowedCharacters: allowed
) {
components.percentEncodedQuery = "widgetLocation=" + encoded
}
}
return Self.widgetURL(from: components)
}

static func widgetURL(from components: URLComponents) -> URL {
components.url ?? homeURL
}
Comment thread
YinCheng0106 marked this conversation as resolved.

var sourceIdentifier: String? {
switch self {
case .currentLocation:
Expand Down
1 change: 1 addition & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@
CurrentWeatherWidgetSnapshotFactory.swift,
CurrentWeatherWidgetRefreshPipeline.swift,
CurrentWeatherWidgetSnapshotWriter.swift,
DPIPWidgetEntry.swift,
DPIPWidgetProviderSupport.swift,
DPIPWidgets.swift,
Localizable.xcstrings,
Expand Down
115 changes: 115 additions & 0 deletions ios/RunnerTests/WidgetLocationIntentTests.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,123 @@
import Foundation
import WidgetKit
import XCTest
@testable import Runner

final class WidgetLocationIntentTests: XCTestCase {
func testWidgetURLsEncodeConfiguredTargets() throws {
let cases: [(WidgetLocationTarget, String)] = [
(.currentLocation, "dpip:///home?widgetLocation=current-location"),
(.saved(regionCode: "407"), "dpip:///home?widgetLocation=region%3A407"),
(.saved(regionCode: "242"), "dpip:///home?widgetLocation=region%3A242"),
(.invalid(identifier: "region:bad&other=407"), "dpip:///home"),
(.saved(regionCode: "407"), "dpip:///home")
]
for (target, expected) in cases {
XCTAssertEqual(target.widgetURL.absoluteString, expected)
let components = try XCTUnwrap(URLComponents(
url: target.widgetURL, resolvingAgainstBaseURL: false
))
if let identifier = components.queryItems?.first?.value {
XCTAssertEqual(WidgetLocationTarget(identifier: identifier), target)
}
}
}

func testEntryURLUsesConfigurationWithoutWeather() {
for target: WidgetLocationTarget in [.currentLocation, .saved(regionCode: "407")] {
for stale in [false, true] {
let entry = DPIPWidgetEntry(
date: Date(timeIntervalSince1970: 0), snapshot: nil,
isStale: stale, isNight: false, locationTarget: target
)
XCTAssertEqual(entry.widgetURL, target.widgetURL)
}
}
}

func testWidgetURLFallsBackWhenComponentsCannotFormURL() {
var components = URLComponents()
components.scheme = "dpip"
components.host = ""
// An authority requires an absolute path; this cannot form a URL.
components.path = "home"
components.queryItems = [URLQueryItem(name: "widgetLocation", value: "region:407")]
XCTAssertNil(components.url)
XCTAssertEqual(WidgetLocationTarget.widgetURL(from: components).absoluteString,
"dpip:///home")
}

func testWidgetURLRejectsMalformedCodesAndQueryInjection() throws {
for code in ["", "10", "1000", "407", "٤٠٧", "407\n",
"407&other=242", "407+242", "407?#%", "../407", "台北"] {
for target in [WidgetLocationTarget.saved(regionCode: code),
WidgetLocationTarget(identifier: "region:" + code)] {
let url = target.widgetURL
XCTAssertEqual(url.absoluteString, "dpip:///home", code)
let components = try XCTUnwrap(URLComponents(
url: url, resolvingAgainstBaseURL: false
))
XCTAssertNil(components.queryItems, code)
XCTAssertNil(components.fragment, code)
}
}
}

func testEntryURLIgnoresAnotherLocationsCachedWeather() {
let entry = DPIPWidgetEntry(
date: Date(timeIntervalSince1970: 0),
snapshot: makeSnapshot(sourceIdentifier: "region:242", regionCode: "242"),
isStale: true, isNight: false,
locationTarget: .saved(regionCode: "407")
)
XCTAssertEqual(entry.widgetURL.absoluteString,
"dpip:///home?widgetLocation=region%3A407")
}

func testForecastExpiryDoesNotChangeConfiguredNavigationTarget() throws {
let date = Date(timeIntervalSince1970: 1_789_567_200)
let sample = DPIPWidgetGalleryPreview.sample(at: date)
let forecast = try XCTUnwrap(sample.forecast)
let expiry = ForecastWidgetExpiry.date(for: forecast)
for target: WidgetLocationTarget in [.currentLocation, .saved(regionCode: "242")] {
for family: WidgetFamily in [.systemMedium, .systemLarge] {
let available = DPIPWidgetEntry(
date: date, snapshot: sample.snapshot,
isStale: false, isNight: false, forecast: sample.forecast,
locationTarget: target
)
let expired = DPIPWidgetEntry(
date: expiry, snapshot: sample.snapshot,
isStale: true, isNight: false, forecast: sample.forecast,
locationTarget: target
)
XCTAssertFalse(available.upcomingForecastPoints(for: family).isEmpty)
XCTAssertTrue(expired.upcomingForecastPoints(for: family).isEmpty)
XCTAssertEqual(available.widgetURL, target.widgetURL)
XCTAssertEqual(expired.widgetURL, target.widgetURL)
}
}
}

func testGallerySampleDoesNotDetermineConfiguredNavigationTarget() {
let date = Date(timeIntervalSince1970: 1_789_567_200)
let sample = DPIPWidgetGalleryPreview.sample(at: date)
for target: WidgetLocationTarget in [.currentLocation, .saved(regionCode: "242")] {
for family: WidgetFamily in [.systemSmall, .systemMedium, .systemLarge] {
let entry = DPIPWidgetEntry(
date: date, snapshot: sample.snapshot,
isStale: false, isNight: false,
forecast: ForecastWidgetFamilyPolicy.supportsForecast(family)
? sample.forecast : nil,
locationTarget: target
)
XCTAssertEqual(entry.snapshot?.sourceIdentifier, "region:407")
XCTAssertEqual(entry.widgetURL, target.widgetURL)
XCTAssertNotEqual(entry.widgetURL, WidgetLocationTarget.saved(regionCode: "407").widgetURL)
}
}
}

private func makeSnapshot(
sourceIdentifier: String?,
regionCode: String,
Expand Down
17 changes: 12 additions & 5 deletions lib/app/router/app_router.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:dpip/app/shell/main_shell.dart';
import 'package:dpip/app/router/widget_location_routes.dart';
import 'package:dpip/core/settings/onboarding_store.dart';
import 'package:dpip/features/data/presentation/pages/data_page.dart';
import 'package:dpip/features/earthquake/presentation/pages/earthquake_page.dart';
Expand Down Expand Up @@ -53,6 +54,7 @@ import 'package:provider/provider.dart';
/// user held on the welcome page by a failed launch would stay there all
/// session even after the completion flag came back.
final OnboardingRefreshSignal onboardingRefresh = OnboardingRefreshSignal();
final WidgetLocationRoutes _widgetLocationRoutes = WidgetLocationRoutes();

final class OnboardingRefreshSignal extends ChangeNotifier {
void fire() => notifyListeners();
Expand All @@ -75,10 +77,10 @@ final GoRouter appRouter = GoRouter(
// finishing onboarding (which flips the flag) lets a goNamed(home) through.
redirect: (context, state) {
final complete = context.read<OnboardingStore>().isComplete;
final atOnboarding = state.matchedLocation == AppRoutes.onboardingPath;
if (!complete && !atOnboarding) return AppRoutes.onboardingPath;
if (complete && atOnboarding) return AppRoutes.homePath;
return null;
return _widgetLocationRoutes.redirect(
onboardingComplete: complete,
uri: state.uri,
);
},
routes: [
GoRoute(
Expand All @@ -90,7 +92,12 @@ final GoRouter appRouter = GoRouter(
builder: (context, state, navigationShell) =>
MainShell(navigationShell: navigationShell),
branches: [
_branch(AppRoutes.homePath, AppRoutes.home, (_, _) => const HomePage()),
_branch(
AppRoutes.homePath,
AppRoutes.home,
(_, _) =>
HomePage(widgetLocationRequests: _widgetLocationRoutes.requests),
),
_branch(
AppRoutes.eventsPath,
AppRoutes.events,
Expand Down
23 changes: 23 additions & 0 deletions lib/app/router/widget_location_routes.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import 'package:dpip/shared/navigation/app_routes.dart';
import 'package:dpip/shared/navigation/widget_location_link.dart';

/// Retains the latest Widget tap through onboarding. Stripping the parameter
/// before building Home lets go_router deliver a subsequent identical URL.
final class WidgetLocationRoutes {
final requests = WidgetLocationRequests();

String? redirect({required bool onboardingComplete, required Uri uri}) {
final atHome = uri.path == AppRoutes.homePath;
final atOnboarding = uri.path == AppRoutes.onboardingPath;
if (atHome &&
uri.queryParametersAll.containsKey(WidgetLocationLink.queryKey)) {
requests.request(WidgetLocationLink.parse(uri));
return onboardingComplete ? AppRoutes.homePath : AppRoutes.onboardingPath;
}
if (!onboardingComplete && !atOnboarding) return AppRoutes.onboardingPath;
if (onboardingComplete && atOnboarding) return AppRoutes.homePath;
return null;
}

WidgetLocationLink? takePending() => requests.takePending();
}
Loading
Loading