Skip to content

feat(weather): add configurable iOS weather widget locations - #579

Merged
whes1015 merged 1 commit into
mainfrom
feat/ios-widget-intent-configuration
Oct 7, 2026
Merged

whes1015 merged 1 commit into
mainfrom
feat/ios-widget-intent-configuration

Conversation

@YinCheng0106

Copy link
Copy Markdown
Member

這個 PR 做了什麼

讓每個 iOS Weather Widget instance 可以獨立選擇:

  • 所在地 / Current Location
  • DPIP 已儲存的地點

並將目前天氣 snapshot 從單一全域檔案擴充為 per-location cache,使多個 Widget 可以同時顯示不同地點。

此 PR 是 Widget 開發堆疊的第三階段,建立可設定 Widget 與 per-location snapshot contract;Widget Extension 自行抓取天氣則由後續 PR 處理。

主要變更

Widget configuration

  • 新增 WeatherWidgetConfigurationIntent
  • 新增 Intents Extension
  • Widget 改用 IntentConfiguration
  • 動態讀取 App Group location catalog
  • 支援 en-US / zh-Hant
  • Current Location 在繁中顯示為「所在地」

Location target

穩定 identifier:

  • current-location
  • region:<regionCode>

無效或未知 identifier 會 fail closed,不會自動 fallback 到所在地。

Per-location snapshot

目前天氣 snapshot schema 升級至 v5,新增:

sourceIdentifier

檔案範例:

  • WidgetSnapshots/current-weather/current-location.json
  • WidgetSnapshots/current-weather/region-242.json

每個 Widget instance 只讀取與自身 target 相符的 snapshot。

Migration

舊的:

WidgetSnapshots/current-weather.json

暫時保留 migration fallback,但只有來源可以被證明與 Widget target 相符時才會顯示。

安全與一致性

  • saved region identifier 僅接受 3 個 ASCII 數字。
  • 無效 identifier / 路徑穿越輸入會拒絕。
  • saved target 同時核對 sourceIdentifier 與 payload regionCode。
  • schema v5 缺少 sourceIdentifier 會拒絕。
  • v2–v4 可維持 decode 相容,但無法證明來源時不會被錯誤顯示。
  • 未知未來 schema 會 fail closed。
  • current weather write 會依 sourceIdentifier 寫入對應 per-location cache。

已人工驗證

可同時建立多個 Widget,分別顯示不同地點,例如:

  • 所在地
  • 新莊區
  • 沙鹿區

且彼此 snapshot 不會互相覆蓋。

測試涵蓋

  • Intent dynamic options
  • location catalog decoding
  • identifier parsing / validation
  • path traversal protection
  • schema v2–v5 compatibility
  • per-location snapshot addressing
  • mismatched target rejection
  • migration fallback
  • multi-instance isolation
  • MethodChannel write contract
  • native snapshot file handling

不包含

目前 Widget Extension 尚不會自行向天氣 API 取得資料。

若該地點尚未由 DPIP App 載入並發布 snapshot,Widget 不會自行更新。

此 PR 不包含:

  • native weather networking
  • CoreLocation self-refresh
  • WidgetKit refresh scheduling
  • stale-while-revalidate
  • Medium / Large Widget
  • hourly / weekly forecast UI
  • onboarding

Native self-refresh 將由下一支 PR:

feat/ios-widget-weather-refresh

處理。

PR Stack

依賴:

後續:

  • feat/ios-widget-weather-refresh
    • native weather fetch
    • Widget self-refresh(開發中)

此 PR 目前以 stacked PR 方式建立,base 是 feat/ios-widget-configuration。待父 PR 合併後再 rebase / retarget。

檢查清單

  • Per-widget location configuration 已建立
  • Intents Extension 已建立
  • Per-location snapshot cache 已建立
  • Schema v5 / migration / validation tests 已加入
  • 多 Widget 不同地點已人工驗證
  • 父 PR 合併後 rebase / retarget
  • 合併前重新執行 repository gates

@YinCheng0106 YinCheng0106 self-assigned this Sep 20, 2026
@YinCheng0106 YinCheng0106 added the ios Issues or pull requests related to the iOS platform. label Sep 20, 2026
@YinCheng0106
YinCheng0106 added this pull request to stack #581 September 20, 2026 16:07
@YinCheng0106 YinCheng0106 added this to the v26.3 milestone Sep 20, 2026
@codecov

codecov Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 66.43%. Comparing base (580a042) to head (2e6173c).

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@YinCheng0106
YinCheng0106 marked this pull request as ready for review September 22, 2026 03:01
Copilot AI balanced review requested due to automatic review settings September 22, 2026 03:01

This comment was marked as low quality.

@lowrt lowrt modified the milestones: v26.3, v26.4 Sep 23, 2026
@YinCheng0106
YinCheng0106 force-pushed the feat/ios-widget-intent-configuration branch 3 times, most recently from 05eb988 to 8b1da0e Compare September 28, 2026 07:07
@YinCheng0106
YinCheng0106 force-pushed the feat/ios-widget-intent-configuration branch from 8b1da0e to 09bf6fb Compare September 28, 2026 07:42
lowrt
lowrt previously approved these changes Oct 2, 2026

@lowrt lowrt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@whes1015
whes1015 force-pushed the feat/ios-widget-intent-configuration branch from 09bf6fb to d5cf12a Compare October 2, 2026 23:17
@YinCheng0106 YinCheng0106 reopened this Oct 6, 2026
@whes1015
whes1015 dismissed lowrt’s stale review October 7, 2026 18:32

The merge-base changed after approval.

@whes1015
whes1015 force-pushed the feat/ios-widget-intent-configuration branch from d5cf12a to 2e6173c Compare October 7, 2026 18:32
Base automatically changed from feat/ios-widget-configuration to main October 7, 2026 18:45
Platform: ios

New(zh-Hant): 天氣小工具支援每個實例選擇所在地或已儲存地區,並使用獨立地區快取
New(en-US): Allow each iOS weather widget to select Current Location or a saved region with independent per-location snapshots
@whes1015
whes1015 force-pushed the feat/ios-widget-intent-configuration branch from 2e6173c to 0ed4cfd Compare October 7, 2026 19:54
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

🔍 OpenCodeReview found 8 issue(s) in this PR.

  • ✅ Successfully posted inline: 8 comment(s)

Comment on lines +33 to +35
guard let data = try? Data(contentsOf: url) else {
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
WidgetLocationCatalogStore 的 load() 與 decode(_:) 方法使用了 try? 來處理檔案讀取與 JSON 解碼,這會導致錯誤被靜默忽略。雖然在 Intent Extension 中,當載入失敗時退回預設選項(僅顯示「目前位置」)是一種穩健的 UX 決策,但這會增加除錯難度,使開發者難以區分是檔案不存在、權限問題還是 JSON 格式不正確。建議改用 do-catch 並在錯誤發生時進行記錄(例如使用 os_log 或 Logger)。

Comment on lines +27 to +29
displayString:
"\(location.displayName) — "
+ location.administrativeAreaName

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
地點顯示名稱的組合方式使用硬編碼的字串拼接 ("\(location.displayName) — \(location.administrativeAreaName)"),這未考慮不同語言對地址排列順序或分隔符號的需求。建議使用本地化的字串格式化工具(例如 String(format:) 或在 .strings 檔案中定義帶有佔位符的格式字串)來處理。

Comment on lines +27 to +29
displayString:
"\(location.displayName) — "
+ location.administrativeAreaName

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
使用者可見的字串使用了字串拼接(String interpolation and concatenation),這會導致在地化(Localization)困難。在不同的語言中,顯示名稱與行政區之間的順序或分隔符號(如 ' — ')可能需要不同。建議使用在地化的格式化字串(Localized format string)來確保不同語言都能正確顯示。

Suggestion:

Suggested change
displayString:
"\(location.displayName) — "
+ location.administrativeAreaName
displayString: String(format: NSLocalizedString("widget.location.display_format", comment: "Format for displaying location in widget options"), location.displayName, location.administrativeAreaName)

Comment on lines +49 to +59
func matches(
snapshot: CurrentWeatherWidgetSnapshot
) -> Bool {
guard
let sourceIdentifier = snapshot.sourceIdentifier,
let address = CurrentWeatherSnapshotAddress(
sourceIdentifier: sourceIdentifier
)
else {
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
遷移邏輯會失效。在 WidgetLocationTarget.matches(snapshot:) 中,使用了 guard let sourceIdentifier = snapshot.sourceIdentifier。由於舊版 (schema < 5) 的快照其 sourceIdentifier 為 nil,這會導致 matches 直接回傳 false。這會連帶導致 WidgetSnapshotStore.loadCurrentWeatherSnapshot(for:) 中的遷移回退機制 (fallback) 無法運作,因為即使找到了舊版的全域快照,target.matches(snapshot: legacySnapshot) 也會因為上述原因永遠回傳 false。

Suggestion:

Suggested change
func matches(
snapshot: CurrentWeatherWidgetSnapshot
) -> Bool {
guard
let sourceIdentifier = snapshot.sourceIdentifier,
let address = CurrentWeatherSnapshotAddress(
sourceIdentifier: sourceIdentifier
)
else {
return false
}
func matches(
snapshot: CurrentWeatherWidgetSnapshot
) -> Bool {
guard let snapshotSourceIdentifier = snapshot.sourceIdentifier else {
// 舊版快照 (schema < 5) 沒有 sourceIdentifier,應僅匹配預設的 .currentLocation
return self == .currentLocation
}
guard let address = CurrentWeatherSnapshotAddress(
sourceIdentifier: snapshotSourceIdentifier
) else {
return false
}

Comment on lines +94 to +105
if let url = currentWeatherSnapshotURL(
for: sourceIdentifier
),
let data = try? Data(contentsOf: url),
let snapshot = try? JSONDecoder().decode(
CurrentWeatherWidgetSnapshot.self,
from: data
),
target.matches(snapshot: snapshot)
{
return snapshot
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
在 WidgetSnapshotStore.loadCurrentWeatherSnapshot(for:) 中,讀取檔案 (Data(contentsOf:)) 與解碼 JSON (JSONDecoder().decode(...)) 都使用了 try?。這會導致當檔案因損毀、權限問題或 schema 不相容而無法讀取時,錯誤會被靜默忽略並直接跳轉到 fallback 邏輯。雖然在 Widget 背景程序中靜默失敗是常見做法,但這會讓開發者難以察覺底層儲存異常。建議考慮使用 do-catch 並在適當的情況下記錄錯誤,以利除錯。

Comment on lines 50 to +52
'kind': kind.name,
'json': json,
'sourceIdentifier': ?sourceIdentifier,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · critical
?sourceIdentifier 是無效的 Dart 語法,會導致編譯錯誤。此外,為了符合測試中對 arguments 的預期(即當 sourceIdentifier 為 null 時,Map 中不應包含該 key),建議使用 if (sourceIdentifier != null) 語法。

Suggestion:

Suggested change
'kind': kind.name,
'json': json,
'sourceIdentifier': ?sourceIdentifier,
'kind': kind.name,
'json': json,
if (sourceIdentifier != null) 'sourceIdentifier': sourceIdentifier,

Comment on lines 50 to +52
'kind': kind.name,
'json': json,
'sourceIdentifier': ?sourceIdentifier,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · critical
'sourceIdentifier': ?sourceIdentifier 是一個語法錯誤。在 Dart 中,這是不合法的語法。如果你想將 sourceIdentifier 的值傳遞給 Map,應該直接寫 'sourceIdentifier': sourceIdentifier。如果你希望僅在 sourceIdentifier 不為 null 時才將該鍵值對加入 Map,可以使用集合中的 if 語句:if (sourceIdentifier != null) 'sourceIdentifier': sourceIdentifier。

Suggestion:

Suggested change
'kind': kind.name,
'json': json,
'sourceIdentifier': ?sourceIdentifier,
'kind': kind.name,
'json': json,
if (sourceIdentifier != null) 'sourceIdentifier': sourceIdentifier,

Comment on lines 375 to 380
Future<Result<void>> write({
required WidgetSnapshotKind kind,
required String json,
String? sourceIdentifier,
}) async {
writes.add((kind: kind, json: json));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
Mock 類別 _RecordingWidgetSnapshotWriter 與 _BlockingFirstWriteWidgetSnapshotWriter 雖然在 write 方法中接收了 sourceIdentifier 參數,但卻沒有將其存入 writes 列表中。這會導致在測試中使用這些 Mock 物件時,無法驗證 sourceIdentifier 是否被正確傳遞。

@whes1015
whes1015 merged commit 5f4d94c into main Oct 7, 2026
@whes1015
whes1015 deleted the feat/ios-widget-intent-configuration branch October 7, 2026 21:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ios Issues or pull requests related to the iOS platform. size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants