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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.exptech.dpip.dpip.widgets</string>
</array>
</dict>
</plist>
22 changes: 22 additions & 0 deletions ios/DPIPWidgetIntentsExtension/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsRestrictedWhileLocked</key>
<array/>
<key>IntentsSupported</key>
<array>
<string>WeatherWidgetConfigurationIntent</string>
</array>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.intents-service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).IntentHandler</string>
</dict>
</dict>
</plist>
39 changes: 39 additions & 0 deletions ios/DPIPWidgetIntentsExtension/IntentHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Intents

final class IntentHandler: INExtension,
WeatherWidgetConfigurationIntentHandling
{
override func handler(for intent: INIntent) -> Any {
return self
}

func provideLocationOptionsCollection(
for intent: WeatherWidgetConfigurationIntent,
with completion: @escaping (
INObjectCollection<WidgetLocation>?,
Error?
) -> 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,
currentLocationDisplayString: currentLocationDisplayString
).map {
WidgetLocation(
identifier: $0.identifier,
display: $0.displayString
)
}

completion(
INObjectCollection(items: locations),
nil
)
}
}
23 changes: 23 additions & 0 deletions ios/DPIPWidgetIntentsExtension/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"sourceLanguage" : "en",
"strings" : {
"intent.current_location" : {
"comment" : "Current-location option in weather widget configuration.",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Current Location"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "所在地"
}
}
}
}
},
"version" : "1.0"
}
54 changes: 54 additions & 0 deletions ios/DPIPWidgetIntentsExtension/WidgetLocationCatalog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

struct WidgetLocationCatalog: Decodable {
let schemaVersion: Int
let locations: [WidgetLocationCatalogLocation]
}

struct WidgetLocationCatalogLocation: Decodable {
let regionCode: String
let displayName: String
let administrativeAreaName: String
let latitude: Double
let longitude: Double
}

struct WidgetLocationCatalogStore {
private let appGroupIdentifier =
"group.com.exptech.dpip.dpip.widgets"

func load() -> WidgetLocationCatalog? {
guard let containerURL =
FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupIdentifier
)
else {
return nil
}

let url = containerURL
.appendingPathComponent("WidgetSnapshots")
.appendingPathComponent("location-catalog.json")

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

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)。


return Self.decode(data)
}

static func decode(_ data: Data) -> WidgetLocationCatalog? {
guard let catalog = try? JSONDecoder().decode(
WidgetLocationCatalog.self,
from: data
) else {
return nil
}

guard catalog.schemaVersion == 1 else {
return nil
}

return catalog
}
}
35 changes: 35 additions & 0 deletions ios/DPIPWidgetIntentsExtension/WidgetLocationOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Foundation

struct WidgetLocationOption: Equatable {
let identifier: String
let displayString: String
}

func makeWidgetLocationOptions(
from catalog: WidgetLocationCatalog?,
currentLocationDisplayString: String
) -> [WidgetLocationOption] {
var options = [
WidgetLocationOption(
identifier: "current-location",
displayString: currentLocationDisplayString
)
]

guard let catalog else {
return options
}

options.append(
contentsOf: catalog.locations.map { location in
WidgetLocationOption(
identifier: "region:\(location.regionCode)",
displayString:
"\(location.displayName) — "
+ location.administrativeAreaName
Comment on lines +27 to +29

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

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)

)
}
)

return options
}
23 changes: 22 additions & 1 deletion ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ enum CurrentWeatherWidgetCondition: String, Decodable {

struct CurrentWeatherWidgetSnapshot: Decodable {
let schemaVersion: Int
let sourceIdentifier: String?

let regionCode: String
let regionName: String
Expand All @@ -96,6 +97,7 @@ struct CurrentWeatherWidgetSnapshot: Decodable {

private enum CodingKeys: String, CodingKey {
case schemaVersion
case sourceIdentifier
case regionCode
case regionName
case observationTime
Expand All @@ -113,6 +115,7 @@ struct CurrentWeatherWidgetSnapshot: Decodable {

init(
schemaVersion: Int,
sourceIdentifier: String? = nil,
regionCode: String,
regionName: String,
observationTime: Int,
Expand All @@ -128,6 +131,7 @@ struct CurrentWeatherWidgetSnapshot: Decodable {
rain: Double?
) {
self.schemaVersion = schemaVersion
self.sourceIdentifier = sourceIdentifier
self.regionCode = regionCode
self.regionName = regionName
self.observationTime = observationTime
Expand All @@ -143,7 +147,6 @@ struct CurrentWeatherWidgetSnapshot: Decodable {
self.humidity = humidity
self.rain = rain
}

init(from decoder: Decoder) throws {
let container = try decoder.container(
keyedBy: CodingKeys.self
Expand All @@ -154,6 +157,24 @@ struct CurrentWeatherWidgetSnapshot: Decodable {
forKey: .schemaVersion
)

guard (1...5).contains(schemaVersion) else {
throw DecodingError.dataCorruptedError(
forKey: .schemaVersion,
in: container,
debugDescription:
"Unsupported current-weather snapshot schema version."
)
}

if schemaVersion >= 5 {
sourceIdentifier = try container.decode(
String.self,
forKey: .sourceIdentifier
)
} else {
sourceIdentifier = nil
}

regionCode = try container.decode(
String.self,
forKey: .regionCode
Expand Down
37 changes: 31 additions & 6 deletions ios/DPIPWidgets/DPIPWidgets.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import WidgetKit
import SwiftUI
import Intents

struct DPIPWidgetProvider: TimelineProvider {
struct DPIPWidgetProvider: IntentTimelineProvider {
typealias Intent = WeatherWidgetConfigurationIntent
private let staleAfter: TimeInterval = 30 * 60
private let snapshotStore = WidgetSnapshotStore()

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

return snapshotStore.loadCurrentWeatherSnapshot(
for: target
)
}

func placeholder(in context: Context) -> DPIPWidgetEntry {
DPIPWidgetEntry(
date: .now,
Expand All @@ -15,11 +29,13 @@ struct DPIPWidgetProvider: TimelineProvider {
}

func getSnapshot(
for configuration: WeatherWidgetConfigurationIntent,
in context: Context,
completion: @escaping (DPIPWidgetEntry) -> Void
) {
let snapshot = snapshotStore.loadCurrentWeatherSnapshot()
let snapshot = snapshot(for: configuration)
let deviceNow = Date.now

let state = CurrentWeatherWidgetTimeline.state(
snapshot: snapshot,
at: deviceNow,
Expand All @@ -37,11 +53,13 @@ struct DPIPWidgetProvider: TimelineProvider {
}

func getTimeline(
for configuration: WeatherWidgetConfigurationIntent,
in context: Context,
completion: @escaping (Timeline<DPIPWidgetEntry>) -> Void
) {
let deviceNow = Date()
let snapshot = snapshotStore.loadCurrentWeatherSnapshot()
let snapshot = snapshot(for: configuration)

let entries = CurrentWeatherWidgetTimeline.states(
snapshot: snapshot,
deviceNow: deviceNow,
Expand Down Expand Up @@ -177,7 +195,11 @@ struct DPIPWidgets: Widget {
let kind: String = "DPIPWidgets"

var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: DPIPWidgetProvider()) { entry in
IntentConfiguration(
kind: kind,
intent: WeatherWidgetConfigurationIntent.self,
provider: DPIPWidgetProvider()
) { entry in
if #available(iOS 17.0, *) {
DPIPWidgetsEntryView(entry: entry)
.widgetURL(URL(string: "dpip:///home"))
Expand All @@ -199,7 +221,8 @@ struct DPIPWidgets_Previews: PreviewProvider {
private static let previewEntry = DPIPWidgetEntry(
date: .now,
snapshot: CurrentWeatherWidgetSnapshot(
schemaVersion: 4,
schemaVersion: 5,
sourceIdentifier: "current-location",
regionCode: "660",
regionName: "西屯區",
observationTime: 0,
Expand All @@ -213,7 +236,9 @@ struct DPIPWidgets_Previews: PreviewProvider {
temperature: 28.4,
humidity: 76,
rain: 0
),isStale: true, isNight: true
),
isStale: true,
isNight: true
)

static var previews: some View {
Expand Down
Loading