From 51740842eeffed08d494900150118b15e1da59ea Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Wed, 23 Sep 2026 01:57:43 +0800 Subject: [PATCH 01/17] feat(weather): add wind data to widget snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios New(zh-Hant): 天氣小工具資料新增風向與風速 New(en-US): add wind direction and speed to weather widget data --- ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift | 35 ++++ .../CurrentWeatherWidgetSnapshot.swift | 54 +++++- .../CurrentWeatherWidgetSnapshotFactory.swift | 6 +- ...rentWeatherWidgetRefreshServiceTests.swift | 4 +- ...rentWeatherWidgetSnapshotWriterTests.swift | 4 +- .../CurrentWeatherWidgetTestFixtures.swift | 8 +- .../CurrentWeatherWidgetTests.swift | 172 ++++++++++++++---- ios/RunnerTests/DPIPWidgetProviderTests.swift | 2 +- ...rentWeatherWidgetRefreshServiceTests.swift | 6 +- .../current_weather_widget_snapshot.dart | 10 +- ...rrent_weather_widget_coordinator_test.dart | 4 +- .../current_weather_widget_snapshot_test.dart | 14 +- .../weather/domain/weather_realtime_test.dart | 33 +++- 13 files changed, 300 insertions(+), 52 deletions(-) diff --git a/ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift b/ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift index 1584f8ed9..ab11733d4 100644 --- a/ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift +++ b/ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift @@ -10,6 +10,8 @@ struct CurrentWeatherRemoteDTO: Decodable, Sendable { let temperature: Double? let humidity: Int? let rain: Double? + let windDirection: String? + let windSpeed: Double? var condition: CurrentWeatherWidgetCondition { currentWeatherWidgetCondition(for: weatherCode) @@ -31,6 +33,7 @@ struct CurrentWeatherRemoteDTO: Decodable, Sendable { let temperature: Double? let humidity: Int? let rain: Double? + let wind: Wind private enum CodingKeys: String, CodingKey { case weather @@ -38,6 +41,7 @@ struct CurrentWeatherRemoteDTO: Decodable, Sendable { case temperature case humidity case rain + case wind } init(from decoder: Decoder) throws { @@ -57,6 +61,7 @@ struct CurrentWeatherRemoteDTO: Decodable, Sendable { from: container, forKey: .rain ) + wind = try container.decode(Wind.self, forKey: .wind) } private static func decodeNullableDouble( @@ -88,6 +93,34 @@ struct CurrentWeatherRemoteDTO: Decodable, Sendable { } } + private struct Wind: Decodable, Sendable { + let direction: String? + let speed: Double? + + private enum CodingKeys: String, CodingKey { + case direction + case speed + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + direction = try container.decodeIfPresent( + String.self, + forKey: .direction + ) + + guard let value = try container.decodeIfPresent( + Double.self, + forKey: .speed + ) else { + speed = nil + return + } + + speed = value == -99 ? nil : value + } + } + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let station = try container.decode(Station.self, forKey: .station) @@ -100,6 +133,8 @@ struct CurrentWeatherRemoteDTO: Decodable, Sendable { temperature = data.temperature humidity = data.humidity rain = data.rain + windDirection = data.wind.direction + windSpeed = data.wind.speed } } diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index 14604b4b7..67ca7b1ba 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -94,6 +94,8 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { let temperature: Double? let humidity: Int? let rain: Double? + let windDirection: String? + let windSpeed: Double? private enum CodingKeys: String, CodingKey { case schemaVersion @@ -111,6 +113,8 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { case temperature case humidity case rain + case windDirection + case windSpeed } init( @@ -128,7 +132,9 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { calibratedTimeOffsetMilliseconds: Int, temperature: Double?, humidity: Int?, - rain: Double? + rain: Double?, + windDirection: String? = nil, + windSpeed: Double? = nil ) { self.schemaVersion = schemaVersion self.sourceIdentifier = sourceIdentifier @@ -146,6 +152,8 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { self.temperature = temperature self.humidity = humidity self.rain = rain + self.windDirection = windDirection + self.windSpeed = windSpeed } init(from decoder: Decoder) throws { let container = try decoder.container( @@ -157,7 +165,7 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { forKey: .schemaVersion ) - guard (1...5).contains(schemaVersion) else { + guard (1...6).contains(schemaVersion) else { throw DecodingError.dataCorruptedError( forKey: .schemaVersion, in: container, @@ -250,16 +258,30 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { Double.self, forKey: .rain ) + + if schemaVersion >= 6 { + windDirection = try container.decodeIfPresent( + String.self, + forKey: .windDirection + ) + windSpeed = try container.decodeIfPresent( + Double.self, + forKey: .windSpeed + ) + } else { + windDirection = nil + windSpeed = nil + } } func encode(to encoder: Encoder) throws { - guard schemaVersion == 5 else { + guard schemaVersion == 6 else { throw EncodingError.invalidValue( schemaVersion, .init( codingPath: encoder.codingPath, debugDescription: - "Only current-weather snapshot schema version 5 can be encoded." + "Only current-weather snapshot schema version 6 can be encoded." ) ) } @@ -270,7 +292,7 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { .init( codingPath: encoder.codingPath, debugDescription: - "Schema version 5 requires a source identifier." + "Schema version 6 requires a source identifier." ) ) } @@ -399,5 +421,27 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { forKey: .rain ) } + + if let windDirection { + try container.encode( + windDirection, + forKey: .windDirection + ) + } else { + try container.encodeNil( + forKey: .windDirection + ) + } + + if let windSpeed { + try container.encode( + windSpeed, + forKey: .windSpeed + ) + } else { + try container.encodeNil( + forKey: .windSpeed + ) + } } } diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift index 992533d64..b9769bdb6 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift @@ -19,7 +19,7 @@ enum CurrentWeatherWidgetSnapshotFactory { ) return CurrentWeatherWidgetSnapshot( - schemaVersion: 5, + schemaVersion: 6, sourceIdentifier: location.address.sourceIdentifier, regionCode: location.regionCode, regionName: location.regionName, @@ -34,7 +34,9 @@ enum CurrentWeatherWidgetSnapshotFactory { time.calibratedTimeOffsetMilliseconds, temperature: observation.temperature, humidity: observation.humidity, - rain: observation.rain + rain: observation.rain, + windDirection: observation.windDirection, + windSpeed: observation.windSpeed ) } } diff --git a/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift b/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift index a7aebd3f1..d156d0e96 100644 --- a/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift +++ b/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift @@ -42,11 +42,13 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: XCTAssertEqual(result, .refreshed) let snapshot = try XCTUnwrap(writer.snapshots.first) - XCTAssertEqual(snapshot.schemaVersion, 5) + XCTAssertEqual(snapshot.schemaVersion, 6) XCTAssertEqual(snapshot.sourceIdentifier, "current-location") XCTAssertEqual(snapshot.regionCode, "407") XCTAssertEqual(snapshot.regionName, "西屯區") XCTAssertEqual(snapshot.stationName, "西屯測站") + XCTAssertEqual(snapshot.windDirection, "南南西") + XCTAssertEqual(snapshot.windSpeed, 1.5) XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, 321) let events = recorder.events diff --git a/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift index e50085ee8..d4163f4fe 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift @@ -862,7 +862,7 @@ final class CurrentWeatherSnapshotStorageTests: XCTestCase { ) -> Data { try! JSONEncoder().encode( CurrentWeatherWidgetSnapshot( - schemaVersion: 5, + schemaVersion: 6, sourceIdentifier: sourceIdentifier, regionCode: regionCode, regionName: regionName, @@ -1189,7 +1189,7 @@ final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { } private func makeSnapshot( - schemaVersion: Int = 5, + schemaVersion: Int = 6, sourceIdentifier: String, regionCode: String, regionName: String = "西屯區" diff --git a/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift b/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift index 8dae084a5..c3b648103 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift @@ -15,7 +15,11 @@ enum CurrentWeatherWidgetTestFixtures { "weatherCode": 100, "temperature": 28.5, "humidity": 70, - "rain": 0 + "rain": 0, + "wind": { + "direction": "南南西", + "speed": 1.5 + } } } """.utf8 @@ -52,7 +56,7 @@ enum CurrentWeatherWidgetTestFixtures { sourceIdentifier: String, regionCode: String, regionName: String = "舊快取", - schemaVersion: Int = 5 + schemaVersion: Int = 6 ) -> CurrentWeatherWidgetSnapshot { CurrentWeatherWidgetSnapshot( schemaVersion: schemaVersion, diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift index 0e5ab7cec..4ec387a5a 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -21,7 +21,11 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { "temperature": 28.5, "humidity": 70, "rain": 0.0, - "wind": { "speed": 1.5, "beaufort": 1 }, + "wind": { + "direction": "南南西", + "speed": 1.5, + "beaufort": 1 + }, "gust": { "speed": 3.0, "beaufort": 2 } } } @@ -36,6 +40,8 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { XCTAssertEqual(weather.temperature, 28.5) XCTAssertEqual(weather.humidity, 70) XCTAssertEqual(weather.rain, 0) + XCTAssertEqual(weather.windDirection, "南南西") + XCTAssertEqual(weather.windSpeed, 1.5) } func testDecodesValidRainResponse() throws { @@ -56,13 +62,16 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { validJSON( temperature: "-99", humidity: "-99", - rain: "-99" + rain: "-99", + wind: #"{ "direction": "北", "speed": -99 }"# ) ) XCTAssertNil(weather.temperature) XCTAssertNil(weather.humidity) XCTAssertNil(weather.rain) + XCTAssertEqual(weather.windDirection, "北") + XCTAssertNil(weather.windSpeed) } func testMissingOptionalKeysDecodeAsNil() throws { @@ -73,7 +82,8 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { "time": 1789567200, "data": { "weather": "晴", - "weatherCode": 100 + "weatherCode": 100, + "wind": {} } } """ @@ -82,6 +92,8 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { XCTAssertNil(weather.temperature) XCTAssertNil(weather.humidity) XCTAssertNil(weather.rain) + XCTAssertNil(weather.windDirection) + XCTAssertNil(weather.windSpeed) } func testExplicitNullOptionalValuesDecodeAsNil() throws { @@ -89,13 +101,16 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { validJSON( temperature: "null", humidity: "null", - rain: "null" + rain: "null", + wind: #"{ "direction": null, "speed": null }"# ) ) XCTAssertNil(weather.temperature) XCTAssertNil(weather.humidity) XCTAssertNil(weather.rain) + XCTAssertNil(weather.windDirection) + XCTAssertNil(weather.windSpeed) } func testIntegerJSONValuesDecodeAsDouble() throws { @@ -270,7 +285,8 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { weatherCode: Int? = 100, temperature: String = "28.5", humidity: String = "70", - rain: String = "0.0" + rain: String = "0.0", + wind: String = #"{ "direction": "東", "speed": 2.5 }"# ) -> String { let weatherField = weather.map { #""weather": "\#($0)","# } ?? "" let weatherCodeField = weatherCode.map { @@ -286,7 +302,8 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { \(weatherCodeField) "temperature": \(temperature), "humidity": \(humidity), - "rain": \(rain) + "rain": \(rain), + "wind": \(wind) } } """ @@ -294,6 +311,36 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { } final class CurrentWeatherWidgetSnapshotTests: XCTestCase { + func testDecodesSchemaVersionSixSnapshot() throws { + let snapshot = try decode( + """ + { + "schemaVersion": 6, + "sourceIdentifier": "region:220", + "regionCode": "220", + "regionName": "板橋區", + "observationTime": 1789567200, + "stationName": "板橋", + "weather": "晴", + "weatherCode": 100, + "condition": "clear", + "isNight": false, + "nextDayNightTransitionTime": 1789562700, + "calibratedTimeOffsetMilliseconds": 0, + "temperature": 28.5, + "humidity": 70, + "rain": 0.0, + "windDirection": "南南西", + "windSpeed": 1.5 + } + """ + ) + + XCTAssertEqual(snapshot.schemaVersion, 6) + XCTAssertEqual(snapshot.windDirection, "南南西") + XCTAssertEqual(snapshot.windSpeed, 1.5) + } + func testDecodesSchemaVersionFiveSnapshot() throws { let snapshot = try decode( """ @@ -320,6 +367,8 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { XCTAssertEqual(snapshot.schemaVersion, 5) XCTAssertEqual(snapshot.sourceIdentifier, "region:220") XCTAssertEqual(snapshot.regionCode, "220") + XCTAssertNil(snapshot.windDirection) + XCTAssertNil(snapshot.windSpeed) } func testSchemaVersionFiveRequiresSourceIdentifier() { @@ -352,7 +401,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { try decode( """ { - "schemaVersion": 6, + "schemaVersion": 7, "sourceIdentifier": "region:220", "regionCode": "220", "regionName": "板橋區", @@ -521,13 +570,15 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { } private func makeSnapshot( - schemaVersion: Int = 5, + schemaVersion: Int = 6, sourceIdentifier: String? = "region:100", regionCode: String = "100", condition: CurrentWeatherWidgetCondition = .clear, temperature: Double? = 28.5, humidity: Int? = 70, - rain: Double? = 0 + rain: Double? = 0, + windDirection: String? = "南南西", + windSpeed: Double? = 1.5 ) -> CurrentWeatherWidgetSnapshot { CurrentWeatherWidgetSnapshot( schemaVersion: schemaVersion, @@ -544,13 +595,15 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { calibratedTimeOffsetMilliseconds: -5_000, temperature: temperature, humidity: humidity, - rain: rain + rain: rain, + windDirection: windDirection, + windSpeed: windSpeed ) } - func testEncodesSchemaVersionFiveWithAllFields() throws { + func testEncodesSchemaVersionSixWithAllFields() throws { let snapshot = CurrentWeatherWidgetSnapshot( - schemaVersion: 5, + schemaVersion: 6, sourceIdentifier: "region:100", regionCode: "100", regionName: "中正區", @@ -564,7 +617,9 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { calibratedTimeOffsetMilliseconds: -5_000, temperature: 28.5, humidity: 70, - rain: 0 + rain: 0, + windDirection: "南南西", + windSpeed: 1.5 ) let data = try JSONEncoder().encode(snapshot) @@ -575,9 +630,9 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { ) as? [String: Any] ) - XCTAssertEqual(json.count, 15) + XCTAssertEqual(json.count, 17) - XCTAssertEqual(json["schemaVersion"] as? Int, 5) + XCTAssertEqual(json["schemaVersion"] as? Int, 6) XCTAssertEqual( json["sourceIdentifier"] as? String, "region:100" @@ -634,13 +689,21 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { json["rain"] as? Double, 0 ) + XCTAssertEqual( + json["windDirection"] as? String, + "南南西" + ) + XCTAssertEqual( + json["windSpeed"] as? Double, + 1.5 + ) } func testEncodingPreservesExplicitNullWeatherValues() throws { let snapshot = CurrentWeatherWidgetSnapshot( - schemaVersion: 5, + schemaVersion: 6, sourceIdentifier: "region:100", regionCode: "100", regionName: "中正區", @@ -654,7 +717,9 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { calibratedTimeOffsetMilliseconds: 0, temperature: nil, humidity: nil, - rain: nil + rain: nil, + windDirection: nil, + windSpeed: nil ) let data = try JSONEncoder().encode(snapshot) @@ -665,22 +730,24 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { ) as? [String: Any] ) - XCTAssertEqual(json.count, 15) + XCTAssertEqual(json.count, 17) XCTAssertTrue(json["temperature"] is NSNull) XCTAssertTrue(json["humidity"] is NSNull) XCTAssertTrue(json["rain"] is NSNull) + XCTAssertTrue(json["windDirection"] is NSNull) + XCTAssertTrue(json["windSpeed"] is NSNull) } - func testSchemaVersionFourCannotBeEncoded() { - let snapshot = makeSnapshot(schemaVersion: 4) + func testSchemaVersionFiveCannotBeEncoded() { + let snapshot = makeSnapshot(schemaVersion: 5) XCTAssertThrowsError( try JSONEncoder().encode(snapshot) ) } - func testSchemaVersionFiveWithoutSourceIdentifierCannotBeEncoded() { + func testSchemaVersionSixWithoutSourceIdentifierCannotBeEncoded() { let snapshot = makeSnapshot(sourceIdentifier: nil) XCTAssertThrowsError( @@ -688,7 +755,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { ) } - func testSchemaVersionFiveRejectsMalformedSourceIdentifiers() { + func testSchemaVersionSixRejectsMalformedSourceIdentifiers() { let malformedSourceIdentifiers = [ "", "region:", @@ -733,7 +800,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { ) } - func testEncodedSchemaVersionFiveContainsExactlyContractKeys() throws { + func testEncodedSchemaVersionSixContainsExactlyContractKeys() throws { let data = try JSONEncoder().encode(makeSnapshot()) let json = try XCTUnwrap( JSONSerialization.jsonObject(with: data) @@ -758,6 +825,8 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { "temperature", "humidity", "rain", + "windDirection", + "windSpeed", ]) ) } @@ -777,9 +846,9 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { ) } - func testSchemaVersionFiveRoundTripPreservesAllFields() throws { + func testSchemaVersionSixRoundTripPreservesAllFields() throws { let original = CurrentWeatherWidgetSnapshot( - schemaVersion: 5, + schemaVersion: 6, sourceIdentifier: "region:407", regionCode: "407", regionName: "西屯區", @@ -793,7 +862,9 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { calibratedTimeOffsetMilliseconds: -300_000, temperature: 27.5, humidity: 83, - rain: 12.5 + rain: 12.5, + windDirection: "北北東", + windSpeed: 4.25 ) let encoded = try JSONEncoder().encode(original) @@ -823,6 +894,8 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { XCTAssertEqual(decoded.temperature, original.temperature) XCTAssertEqual(decoded.humidity, original.humidity) XCTAssertEqual(decoded.rain, original.rain) + XCTAssertEqual(decoded.windDirection, original.windDirection) + XCTAssertEqual(decoded.windSpeed, original.windSpeed) } } @@ -1341,7 +1414,11 @@ final class CurrentWeatherClientTests: XCTestCase { "weatherCode": 100, "temperature": 30.6, "humidity": 66, - "rain": 0 + "rain": 0, + "wind": { + "direction": "西北", + "speed": 3.25 + } } } """ @@ -1383,6 +1460,8 @@ final class CurrentWeatherClientTests: XCTestCase { XCTAssertEqual(weather.temperature, 30.6) XCTAssertEqual(weather.humidity, 66) XCTAssertEqual(weather.rain, 0) + XCTAssertEqual(weather.windDirection, "西北") + XCTAssertEqual(weather.windSpeed, 3.25) } func testFetchReturnsNilForStructurallyEmptyObjects() async throws { @@ -1855,7 +1934,7 @@ final class WidgetResolvedWeatherLocationTests: XCTestCase { } final class CurrentWeatherWidgetSnapshotFactoryTests: XCTestCase { - func testCreatesSchemaFiveSnapshotFromResolvedInputs() throws { + func testCreatesSchemaSixSnapshotFromResolvedInputs() throws { let observation = try makeObservation() let location = try XCTUnwrap( @@ -1882,7 +1961,7 @@ final class CurrentWeatherWidgetSnapshotFactoryTests: XCTestCase { time: time ) - XCTAssertEqual(snapshot.schemaVersion, 5) + XCTAssertEqual(snapshot.schemaVersion, 6) XCTAssertEqual( snapshot.sourceIdentifier, "region:100" @@ -1915,9 +1994,39 @@ final class CurrentWeatherWidgetSnapshotFactoryTests: XCTestCase { XCTAssertEqual(snapshot.temperature, 28.5) XCTAssertEqual(snapshot.humidity, 70) XCTAssertEqual(snapshot.rain, 0) + XCTAssertEqual(snapshot.windDirection, "南南西") + XCTAssertEqual(snapshot.windSpeed, 1.5) + } + + func testCreatesSnapshotWithNilWindValues() throws { + let observation = try makeObservation(wind: "{}") + let location = try XCTUnwrap( + WidgetResolvedWeatherLocation( + address: .saved(regionCode: "100"), + regionCode: "100", + regionName: "中正區", + latitude: 25.0324, + longitude: 121.5199 + ) + ) + + let snapshot = CurrentWeatherWidgetSnapshotFactory.make( + observation: observation, + location: location, + time: .init( + calibratedNowUnixMilliseconds: 1_789_567_500_000, + calibratedTimeOffsetMilliseconds: 0 + ) + ) + + XCTAssertNil(snapshot.windDirection) + XCTAssertNil(snapshot.windSpeed) } - private func makeObservation() throws + private func makeObservation( + wind: String = + #"{ "direction": "南南西", "speed": 1.5 }"# + ) throws -> CurrentWeatherRemoteDTO { let json = """ @@ -1931,7 +2040,8 @@ final class CurrentWeatherWidgetSnapshotFactoryTests: XCTestCase { "weatherCode": 100, "temperature": 28.5, "humidity": 70, - "rain": 0 + "rain": 0, + "wind": \(wind) } } """ diff --git a/ios/RunnerTests/DPIPWidgetProviderTests.swift b/ios/RunnerTests/DPIPWidgetProviderTests.swift index 67be67349..f6c8094fc 100644 --- a/ios/RunnerTests/DPIPWidgetProviderTests.swift +++ b/ios/RunnerTests/DPIPWidgetProviderTests.swift @@ -285,7 +285,7 @@ final class DPIPWidgetProviderTests: XCTestCase { } return CurrentWeatherWidgetSnapshot( - schemaVersion: 5, + schemaVersion: 6, sourceIdentifier: target.sourceIdentifier, regionCode: regionCode, regionName: "測試地區", diff --git a/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift b/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift index f9a482df4..21d04d390 100644 --- a/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift +++ b/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift @@ -90,7 +90,7 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { } } - func testSuccessfulRefreshWritesSchemaFiveResolvedSnapshot() async throws { + func testSuccessfulRefreshWritesSchemaSixResolvedSnapshot() async throws { let observation = try makeObservation() let weather = ScriptedCurrentWeather( result: .success(observation) @@ -112,7 +112,7 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { XCTAssertEqual(result, .refreshed) let snapshot = try XCTUnwrap(writer.snapshots.first) - XCTAssertEqual(snapshot.schemaVersion, 5) + XCTAssertEqual(snapshot.schemaVersion, 6) XCTAssertEqual(snapshot.sourceIdentifier, "region:242") XCTAssertEqual(snapshot.regionCode, "242") XCTAssertEqual(snapshot.regionName, "新莊區") @@ -123,6 +123,8 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { XCTAssertEqual(snapshot.temperature, observation.temperature) XCTAssertEqual(snapshot.humidity, observation.humidity) XCTAssertEqual(snapshot.rain, observation.rain) + XCTAssertEqual(snapshot.windDirection, observation.windDirection) + XCTAssertEqual(snapshot.windSpeed, observation.windSpeed) XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, 5_000) let coordinates = await weather.coordinates XCTAssertEqual(coordinates.count, 1) diff --git a/lib/features/weather/domain/current_weather_widget_snapshot.dart b/lib/features/weather/domain/current_weather_widget_snapshot.dart index 734507c3d..17ccbc23e 100644 --- a/lib/features/weather/domain/current_weather_widget_snapshot.dart +++ b/lib/features/weather/domain/current_weather_widget_snapshot.dart @@ -14,7 +14,7 @@ enum CurrentWeatherWidgetCondition { final class CurrentWeatherWidgetSnapshot { const CurrentWeatherWidgetSnapshot({ - this.schemaVersion = 5, + this.schemaVersion = 6, required this.sourceIdentifier, required this.regionCode, required this.regionName, @@ -29,6 +29,8 @@ final class CurrentWeatherWidgetSnapshot { this.temperature, this.humidity, this.rain, + this.windDirection, + this.windSpeed, }); final int schemaVersion; @@ -61,6 +63,8 @@ final class CurrentWeatherWidgetSnapshot { final double? temperature; final int? humidity; final double? rain; + final String? windDirection; + final double? windSpeed; Map toJson() { return { @@ -79,6 +83,8 @@ final class CurrentWeatherWidgetSnapshot { 'temperature': temperature, 'humidity': humidity, 'rain': rain, + 'windDirection': windDirection, + 'windSpeed': windSpeed, }; } } @@ -120,5 +126,7 @@ CurrentWeatherWidgetSnapshot createCurrentWeatherWidgetSnapshot({ temperature: weather.data.temperature, humidity: weather.data.humidity, rain: weather.data.rain, + windDirection: weather.data.wind.direction, + windSpeed: weather.data.wind.speed, ); } diff --git a/test/features/weather/current_weather_widget_coordinator_test.dart b/test/features/weather/current_weather_widget_coordinator_test.dart index 53025fb63..fa044d873 100644 --- a/test/features/weather/current_weather_widget_coordinator_test.dart +++ b/test/features/weather/current_weather_widget_coordinator_test.dart @@ -91,7 +91,7 @@ void main() { final decoded = jsonDecode(writer.writtenJson!) as Map; - expect(decoded['schemaVersion'], 5); + expect(decoded['schemaVersion'], 6); expect(decoded['sourceIdentifier'], 'region:660'); expect(decoded['regionCode'], '660'); expect(decoded['regionName'], '西屯區'); @@ -101,6 +101,8 @@ void main() { expect(decoded['nextDayNightTransitionTime'], isA()); expect(decoded['calibratedTimeOffsetMilliseconds'], -300_000); expect(decoded['temperature'], 28.4); + expect(decoded['windDirection'], '北'); + expect(decoded['windSpeed'], 1.5); }); test('waits for initial sync then publishes exactly once', () async { diff --git a/test/features/weather/current_weather_widget_snapshot_test.dart b/test/features/weather/current_weather_widget_snapshot_test.dart index af79da30f..74daf663a 100644 --- a/test/features/weather/current_weather_widget_snapshot_test.dart +++ b/test/features/weather/current_weather_widget_snapshot_test.dart @@ -101,7 +101,7 @@ void main() { calibratedTimeOffsetMilliseconds: -300_000, ); - expect(snapshot.schemaVersion, 5); + expect(snapshot.schemaVersion, 6); expect(snapshot.sourceIdentifier, 'current-location'); expect(snapshot.regionCode, '660'); expect(snapshot.regionName, '西屯區'); @@ -116,11 +116,13 @@ void main() { expect(snapshot.temperature, 28.4); expect(snapshot.humidity, 76); expect(snapshot.rain, 0.0); + expect(snapshot.windDirection, '北'); + expect(snapshot.windSpeed, 1.5); final json = jsonEncode(snapshot.toJson()); final decoded = jsonDecode(json) as Map; - expect(decoded['schemaVersion'], 5); + expect(decoded['schemaVersion'], 6); expect(decoded['sourceIdentifier'], 'current-location'); expect(decoded['regionCode'], '660'); expect(decoded['condition'], 'thunderstorm'); @@ -128,6 +130,8 @@ void main() { expect(decoded['nextDayNightTransitionTime'], 1_789_562_700); expect(decoded['calibratedTimeOffsetMilliseconds'], -300_000); expect(decoded['temperature'], 28.4); + expect(decoded['windDirection'], '北'); + expect(decoded['windSpeed'], 1.5); }); test('serializes condition name and preserves nullable weather values', () { @@ -146,6 +150,8 @@ void main() { temperature: null, humidity: null, rain: null, + windDirection: null, + windSpeed: null, ); expect(snapshot.isNight, isTrue); @@ -155,7 +161,7 @@ void main() { final decoded = jsonDecode(json) as Map; expect(decoded, { - 'schemaVersion': 5, + 'schemaVersion': 6, 'sourceIdentifier': 'current-location', 'regionCode': '660', 'regionName': '西屯區', @@ -170,6 +176,8 @@ void main() { 'temperature': null, 'humidity': null, 'rain': null, + 'windDirection': null, + 'windSpeed': null, }); }); } diff --git a/test/features/weather/domain/weather_realtime_test.dart b/test/features/weather/domain/weather_realtime_test.dart index 95e1ea404..522c30f7a 100644 --- a/test/features/weather/domain/weather_realtime_test.dart +++ b/test/features/weather/domain/weather_realtime_test.dart @@ -22,12 +22,14 @@ void main() { 'data': { 'weather': '陰', 'weatherCode': 300, - 'wind': {'speed': -99, 'beaufort': -99}, + 'wind': {'direction': '南南西', 'speed': 1.5, 'beaufort': 1}, 'gust': {'speed': -99, 'beaufort': -99}, }, }); expect(realtime.id, 'C0X160'); + expect(realtime.data.wind.direction, '南南西'); + expect(realtime.data.wind.speed, 1.5); }); test('an already-6-char id is left untouched', () { @@ -50,5 +52,34 @@ void main() { }); expect(realtime.id, '467410'); + expect(realtime.data.wind.speed, isNull); + }); + + test('nullable and missing sustained wind values decode as null', () { + for (final wind in [ + {'direction': null, 'speed': null}, + {}, + ]) { + final realtime = WeatherRealtime.fromJson({ + 'id': '467410', + 'station': { + 'name': '臺南', + 'lat': 23.0, + 'lon': 120.2, + 'altitude': 40, + 'distance': 1.2, + }, + 'time': 0, + 'data': { + 'weather': '晴', + 'weatherCode': 100, + 'wind': wind, + 'gust': {}, + }, + }); + + expect(realtime.data.wind.direction, isNull); + expect(realtime.data.wind.speed, isNull); + } }); } From 07a5ff5ac65da3bc7e2f7265a3bb9a7d1b1f754c Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Thu, 24 Sep 2026 02:26:36 +0800 Subject: [PATCH 02/17] feat(weather): add widget apparent temperature data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios New(zh-Hant): 天氣小工具資料新增目前體感溫度 New(en-US): add current apparent temperature to weather widget data --- .../CurrentApparentTemperature.swift | 20 +++ .../CurrentWeatherWidgetSnapshot.swift | 34 +++- .../CurrentWeatherWidgetSnapshotFactory.swift | 9 +- ios/Runner.xcodeproj/project.pbxproj | 1 + ...rentWeatherWidgetRefreshServiceTests.swift | 7 +- ...rentWeatherWidgetSnapshotWriterTests.swift | 34 +++- .../CurrentWeatherWidgetTestFixtures.swift | 7 +- .../CurrentWeatherWidgetTests.swift | 160 +++++++++++++++--- ios/RunnerTests/DPIPWidgetProviderTests.swift | 2 +- ...rentWeatherWidgetRefreshServiceTests.swift | 28 ++- .../weather/domain/apparent_temperature.dart | 27 +++ .../current_weather_widget_snapshot.dart | 11 +- ...rrent_weather_widget_coordinator_test.dart | 44 ++++- .../current_weather_widget_snapshot_test.dart | 46 ++++- .../domain/apparent_temperature_test.dart | 160 ++++++++++++++++++ 15 files changed, 542 insertions(+), 48 deletions(-) create mode 100644 ios/DPIPWidgets/CurrentApparentTemperature.swift create mode 100644 lib/features/weather/domain/apparent_temperature.dart create mode 100644 test/features/weather/domain/apparent_temperature_test.dart diff --git a/ios/DPIPWidgets/CurrentApparentTemperature.swift b/ios/DPIPWidgets/CurrentApparentTemperature.swift new file mode 100644 index 000000000..27945200c --- /dev/null +++ b/ios/DPIPWidgets/CurrentApparentTemperature.swift @@ -0,0 +1,20 @@ +import Foundation + +/// CWA outdoor, ventilated, shaded apparent temperature in degrees Celsius. +func currentApparentTemperature( + temperature: Double?, + humidity: Int?, + windSpeed: Double? +) -> Double? { + guard let temperature, let humidity, let windSpeed, + temperature.isFinite, windSpeed.isFinite, + (0...100).contains(humidity), windSpeed >= 0 else { + return nil + } + + let vapourPressure = Double(humidity) / 100 * 6.105 + * exp(17.27 * temperature / (237.7 + temperature)) + let apparentTemperature = 1.04 * temperature + 0.2 * vapourPressure + - 0.65 * windSpeed - 2.7 + return apparentTemperature.isFinite ? apparentTemperature : nil +} diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index 67ca7b1ba..262c5592a 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -96,6 +96,7 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { let rain: Double? let windDirection: String? let windSpeed: Double? + let apparentTemperature: Double? private enum CodingKeys: String, CodingKey { case schemaVersion @@ -115,6 +116,7 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { case rain case windDirection case windSpeed + case apparentTemperature } init( @@ -134,7 +136,8 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { humidity: Int?, rain: Double?, windDirection: String? = nil, - windSpeed: Double? = nil + windSpeed: Double? = nil, + apparentTemperature: Double? = nil ) { self.schemaVersion = schemaVersion self.sourceIdentifier = sourceIdentifier @@ -154,6 +157,7 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { self.rain = rain self.windDirection = windDirection self.windSpeed = windSpeed + self.apparentTemperature = apparentTemperature } init(from decoder: Decoder) throws { let container = try decoder.container( @@ -165,7 +169,7 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { forKey: .schemaVersion ) - guard (1...6).contains(schemaVersion) else { + guard (1...7).contains(schemaVersion) else { throw DecodingError.dataCorruptedError( forKey: .schemaVersion, in: container, @@ -272,16 +276,25 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { windDirection = nil windSpeed = nil } + + if schemaVersion >= 7 { + apparentTemperature = try container.decodeIfPresent( + Double.self, + forKey: .apparentTemperature + ) + } else { + apparentTemperature = nil + } } func encode(to encoder: Encoder) throws { - guard schemaVersion == 6 else { + guard schemaVersion == 7 else { throw EncodingError.invalidValue( schemaVersion, .init( codingPath: encoder.codingPath, debugDescription: - "Only current-weather snapshot schema version 6 can be encoded." + "Only current-weather snapshot schema version 7 can be encoded." ) ) } @@ -292,7 +305,7 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { .init( codingPath: encoder.codingPath, debugDescription: - "Schema version 6 requires a source identifier." + "Schema version 7 requires a source identifier." ) ) } @@ -443,5 +456,16 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { forKey: .windSpeed ) } + + if let apparentTemperature { + try container.encode( + apparentTemperature, + forKey: .apparentTemperature + ) + } else { + try container.encodeNil( + forKey: .apparentTemperature + ) + } } } diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift index b9769bdb6..971c72060 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshotFactory.swift @@ -19,7 +19,7 @@ enum CurrentWeatherWidgetSnapshotFactory { ) return CurrentWeatherWidgetSnapshot( - schemaVersion: 6, + schemaVersion: 7, sourceIdentifier: location.address.sourceIdentifier, regionCode: location.regionCode, regionName: location.regionName, @@ -36,7 +36,12 @@ enum CurrentWeatherWidgetSnapshotFactory { humidity: observation.humidity, rain: observation.rain, windDirection: observation.windDirection, - windSpeed: observation.windSpeed + windSpeed: observation.windSpeed, + apparentTemperature: currentApparentTemperature( + temperature: observation.temperature, + humidity: observation.humidity, + windSpeed: observation.windSpeed + ) ) } } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1e53a28ef..474d10068 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -251,6 +251,7 @@ 27CACD20305C6E2D0046F79C /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + CurrentApparentTemperature.swift, CurrentWeatherClient.swift, CurrentLocationCurrentWeatherWidgetRefreshService.swift, CurrentWeatherSnapshotTime.swift, diff --git a/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift b/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift index d156d0e96..f777d7a0a 100644 --- a/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift +++ b/ios/RunnerTests/CurrentLocationCurrentWeatherWidgetRefreshServiceTests.swift @@ -42,13 +42,18 @@ final class CurrentLocationCurrentWeatherWidgetRefreshServiceTests: XCTAssertEqual(result, .refreshed) let snapshot = try XCTUnwrap(writer.snapshots.first) - XCTAssertEqual(snapshot.schemaVersion, 6) + XCTAssertEqual(snapshot.schemaVersion, 7) XCTAssertEqual(snapshot.sourceIdentifier, "current-location") XCTAssertEqual(snapshot.regionCode, "407") XCTAssertEqual(snapshot.regionName, "西屯區") XCTAssertEqual(snapshot.stationName, "西屯測站") XCTAssertEqual(snapshot.windDirection, "南南西") XCTAssertEqual(snapshot.windSpeed, 1.5) + XCTAssertEqual( + try XCTUnwrap(snapshot.apparentTemperature), + 31.39512521394631, + accuracy: 1e-9 + ) XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, 321) let events = recorder.events diff --git a/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift index d4163f4fe..b1b11d95a 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift @@ -84,6 +84,30 @@ final class CurrentWeatherSnapshotStorageTests: XCTestCase { XCTAssertTrue(isDirectory.boolValue) } + func testSchemaSevenApparentTemperatureSurvivesStorageRoundTrip() throws { + let address = CurrentWeatherSnapshotAddress.saved(regionCode: "407") + let token = try storage.beginWrite(for: address) + XCTAssertEqual( + try storage.replace( + snapshotData( + sourceIdentifier: "region:407", + regionCode: "407", + observationTime: 100, + apparentTemperature: 27.089955502470914 + ), + using: token + ), + .written + ) + let cached = try cachedSnapshot(for: address) + XCTAssertEqual(cached.schemaVersion, 7) + XCTAssertEqual( + try XCTUnwrap(cached.apparentTemperature), + 27.089955502470914, + accuracy: 1e-9 + ) + } + func testNewerObservationTimeWins() throws { let address = CurrentWeatherSnapshotAddress.saved( regionCode: "407" @@ -858,11 +882,12 @@ final class CurrentWeatherSnapshotStorageTests: XCTestCase { sourceIdentifier: String, regionCode: String, regionName: String = "西屯區", - observationTime: Int + observationTime: Int, + apparentTemperature: Double? = nil ) -> Data { try! JSONEncoder().encode( CurrentWeatherWidgetSnapshot( - schemaVersion: 6, + schemaVersion: 7, sourceIdentifier: sourceIdentifier, regionCode: regionCode, regionName: regionName, @@ -876,7 +901,8 @@ final class CurrentWeatherSnapshotStorageTests: XCTestCase { calibratedTimeOffsetMilliseconds: 0, temperature: 25, humidity: 60, - rain: 0 + rain: 0, + apparentTemperature: apparentTemperature ) ) } @@ -1189,7 +1215,7 @@ final class CurrentWeatherWidgetSnapshotWriterTests: XCTestCase { } private func makeSnapshot( - schemaVersion: Int = 6, + schemaVersion: Int = 7, sourceIdentifier: String, regionCode: String, regionName: String = "西屯區" diff --git a/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift b/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift index c3b648103..4a663ebec 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTestFixtures.swift @@ -3,7 +3,8 @@ import Foundation enum CurrentWeatherWidgetTestFixtures { static func observation( stationName: String = "西屯測站", - time: Int = 1_710_900_000 + time: Int = 1_710_900_000, + windSpeed: String = "1.5" ) throws -> CurrentWeatherRemoteDTO { let data = Data( """ @@ -18,7 +19,7 @@ enum CurrentWeatherWidgetTestFixtures { "rain": 0, "wind": { "direction": "南南西", - "speed": 1.5 + "speed": \(windSpeed) } } } @@ -56,7 +57,7 @@ enum CurrentWeatherWidgetTestFixtures { sourceIdentifier: String, regionCode: String, regionName: String = "舊快取", - schemaVersion: Int = 6 + schemaVersion: Int = 7 ) -> CurrentWeatherWidgetSnapshot { CurrentWeatherWidgetSnapshot( schemaVersion: schemaVersion, diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift index 4ec387a5a..e3780889c 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -310,7 +310,54 @@ final class CurrentWeatherRemoteDTOTests: XCTestCase { } } +final class CurrentApparentTemperatureTests: XCTestCase { + func testMatchesFixedCWAFormulaParityVectors() { + // Project goldens from the published formula, shared with Dart tests. + let vectors: [(Double, Int, Double, Double)] = [ + (30, 80, 2, 33.9659458296072), + (12, 65, 1.2, 10.820012239693988), + (25, 60, 0, 27.089955502470914), + (25, 60, 8, 21.889955502470915), + (32, 10, 2, 30.227599518688184), + (32, 95, 2, 38.28219542753772), + (-5, 70, 3, -9.26026582286526), + (20, 0, 1, 17.450000000000003), + (20, 100, 1, 22.114536136797756), + ] + + for (temperature, humidity, windSpeed, expected) in vectors { + let actual = currentApparentTemperature( + temperature: temperature, + humidity: humidity, + windSpeed: windSpeed + ) + XCTAssertEqual(actual ?? .nan, expected, accuracy: 1e-9) + } + } + + func testRejectsMissingAndInvalidInputs() { + XCTAssertNil(currentApparentTemperature(temperature: nil, humidity: 60, windSpeed: 1)) + XCTAssertNil(currentApparentTemperature(temperature: 25, humidity: nil, windSpeed: 1)) + XCTAssertNil(currentApparentTemperature(temperature: 25, humidity: 60, windSpeed: nil)) + for humidity in [-1, 101] { + XCTAssertNil(currentApparentTemperature(temperature: 25, humidity: humidity, windSpeed: 1)) + } + XCTAssertNil(currentApparentTemperature(temperature: 25, humidity: 60, windSpeed: -0.1)) + for nonFinite in [Double.nan, .infinity, -.infinity] { + XCTAssertNil(currentApparentTemperature(temperature: nonFinite, humidity: 60, windSpeed: 1)) + XCTAssertNil(currentApparentTemperature(temperature: 25, humidity: 60, windSpeed: nonFinite)) + } + XCTAssertNil(currentApparentTemperature(temperature: -237.700000001, humidity: 100, windSpeed: 1)) + } +} + final class CurrentWeatherWidgetSnapshotTests: XCTestCase { + func testDecodesSchemaVersionSevenSnapshot() throws { + let encoded = try JSONEncoder().encode(makeSnapshot()) + let snapshot = try JSONDecoder().decode(CurrentWeatherWidgetSnapshot.self, from: encoded) + XCTAssertEqual(snapshot.schemaVersion, 7) + XCTAssertEqual(snapshot.apparentTemperature, 31.39512521394631) + } func testDecodesSchemaVersionSixSnapshot() throws { let snapshot = try decode( """ @@ -339,6 +386,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { XCTAssertEqual(snapshot.schemaVersion, 6) XCTAssertEqual(snapshot.windDirection, "南南西") XCTAssertEqual(snapshot.windSpeed, 1.5) + XCTAssertNil(snapshot.apparentTemperature) } func testDecodesSchemaVersionFiveSnapshot() throws { @@ -369,6 +417,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { XCTAssertEqual(snapshot.regionCode, "220") XCTAssertNil(snapshot.windDirection) XCTAssertNil(snapshot.windSpeed) + XCTAssertNil(snapshot.apparentTemperature) } func testSchemaVersionFiveRequiresSourceIdentifier() { @@ -401,7 +450,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { try decode( """ { - "schemaVersion": 7, + "schemaVersion": 8, "sourceIdentifier": "region:220", "regionCode": "220", "regionName": "板橋區", @@ -459,6 +508,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { XCTAssertEqual(snapshot.temperature, 28.5) XCTAssertNil(snapshot.humidity) XCTAssertEqual(snapshot.rain, 0) + XCTAssertNil(snapshot.apparentTemperature) } func testDecodesSchemaVersionThreeSnapshotWithZeroCalibration() throws { @@ -488,6 +538,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { XCTAssertNil(snapshot.temperature) XCTAssertNil(snapshot.humidity) XCTAssertNil(snapshot.rain) + XCTAssertNil(snapshot.apparentTemperature) } func testDecodesSchemaVersionTwoSnapshotWithLegacyDefaults() throws { @@ -512,6 +563,24 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { XCTAssertFalse(snapshot.isNight) XCTAssertEqual(snapshot.nextDayNightTransitionTime, 0) XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, 0) + XCTAssertNil(snapshot.apparentTemperature) + } + + func testDecodesSchemaVersionOneWithNilApparentTemperature() throws { + let snapshot = try decode( + """ + { + "schemaVersion": 1, + "regionCode": "660", + "regionName": "西屯區", + "observationTime": 1789567200, + "stationName": "西屯", + "weather": "晴", + "weatherCode": 100 + } + """ + ) + XCTAssertNil(snapshot.apparentTemperature) } func testSchemaVersionFourRequiresCalibration() { @@ -570,7 +639,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { } private func makeSnapshot( - schemaVersion: Int = 6, + schemaVersion: Int = 7, sourceIdentifier: String? = "region:100", regionCode: String = "100", condition: CurrentWeatherWidgetCondition = .clear, @@ -578,7 +647,8 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { humidity: Int? = 70, rain: Double? = 0, windDirection: String? = "南南西", - windSpeed: Double? = 1.5 + windSpeed: Double? = 1.5, + apparentTemperature: Double? = 31.39512521394631 ) -> CurrentWeatherWidgetSnapshot { CurrentWeatherWidgetSnapshot( schemaVersion: schemaVersion, @@ -597,13 +667,14 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { humidity: humidity, rain: rain, windDirection: windDirection, - windSpeed: windSpeed + windSpeed: windSpeed, + apparentTemperature: apparentTemperature ) } - func testEncodesSchemaVersionSixWithAllFields() throws { + func testEncodesSchemaVersionSevenWithAllFields() throws { let snapshot = CurrentWeatherWidgetSnapshot( - schemaVersion: 6, + schemaVersion: 7, sourceIdentifier: "region:100", regionCode: "100", regionName: "中正區", @@ -619,7 +690,8 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { humidity: 70, rain: 0, windDirection: "南南西", - windSpeed: 1.5 + windSpeed: 1.5, + apparentTemperature: 31.39512521394631 ) let data = try JSONEncoder().encode(snapshot) @@ -630,9 +702,9 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { ) as? [String: Any] ) - XCTAssertEqual(json.count, 17) + XCTAssertEqual(json.count, 18) - XCTAssertEqual(json["schemaVersion"] as? Int, 6) + XCTAssertEqual(json["schemaVersion"] as? Int, 7) XCTAssertEqual( json["sourceIdentifier"] as? String, "region:100" @@ -697,13 +769,18 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { json["windSpeed"] as? Double, 1.5 ) + XCTAssertEqual( + try XCTUnwrap(json["apparentTemperature"] as? Double), + 31.39512521394631, + accuracy: 1e-9 + ) } func testEncodingPreservesExplicitNullWeatherValues() throws { let snapshot = CurrentWeatherWidgetSnapshot( - schemaVersion: 6, + schemaVersion: 7, sourceIdentifier: "region:100", regionCode: "100", regionName: "中正區", @@ -730,13 +807,19 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { ) as? [String: Any] ) - XCTAssertEqual(json.count, 17) + XCTAssertEqual(json.count, 18) XCTAssertTrue(json["temperature"] is NSNull) XCTAssertTrue(json["humidity"] is NSNull) XCTAssertTrue(json["rain"] is NSNull) XCTAssertTrue(json["windDirection"] is NSNull) XCTAssertTrue(json["windSpeed"] is NSNull) + XCTAssertTrue(json["apparentTemperature"] is NSNull) + let decoded = try JSONDecoder().decode( + CurrentWeatherWidgetSnapshot.self, + from: data + ) + XCTAssertNil(decoded.apparentTemperature) } func testSchemaVersionFiveCannotBeEncoded() { @@ -747,7 +830,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { ) } - func testSchemaVersionSixWithoutSourceIdentifierCannotBeEncoded() { + func testSchemaVersionSevenWithoutSourceIdentifierCannotBeEncoded() { let snapshot = makeSnapshot(sourceIdentifier: nil) XCTAssertThrowsError( @@ -755,7 +838,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { ) } - func testSchemaVersionSixRejectsMalformedSourceIdentifiers() { + func testSchemaVersionSevenRejectsMalformedSourceIdentifiers() { let malformedSourceIdentifiers = [ "", "region:", @@ -800,7 +883,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { ) } - func testEncodedSchemaVersionSixContainsExactlyContractKeys() throws { + func testEncodedSchemaVersionSevenContainsExactlyContractKeys() throws { let data = try JSONEncoder().encode(makeSnapshot()) let json = try XCTUnwrap( JSONSerialization.jsonObject(with: data) @@ -827,6 +910,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { "rain", "windDirection", "windSpeed", + "apparentTemperature", ]) ) } @@ -846,9 +930,9 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { ) } - func testSchemaVersionSixRoundTripPreservesAllFields() throws { + func testSchemaVersionSevenRoundTripPreservesAllFields() throws { let original = CurrentWeatherWidgetSnapshot( - schemaVersion: 6, + schemaVersion: 7, sourceIdentifier: "region:407", regionCode: "407", regionName: "西屯區", @@ -864,7 +948,8 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { humidity: 83, rain: 12.5, windDirection: "北北東", - windSpeed: 4.25 + windSpeed: 4.25, + apparentTemperature: 30.123456789 ) let encoded = try JSONEncoder().encode(original) @@ -896,6 +981,7 @@ final class CurrentWeatherWidgetSnapshotTests: XCTestCase { XCTAssertEqual(decoded.rain, original.rain) XCTAssertEqual(decoded.windDirection, original.windDirection) XCTAssertEqual(decoded.windSpeed, original.windSpeed) + XCTAssertEqual(decoded.apparentTemperature, original.apparentTemperature) } } @@ -1934,7 +2020,7 @@ final class WidgetResolvedWeatherLocationTests: XCTestCase { } final class CurrentWeatherWidgetSnapshotFactoryTests: XCTestCase { - func testCreatesSchemaSixSnapshotFromResolvedInputs() throws { + func testCreatesSchemaSevenSnapshotFromResolvedInputs() throws { let observation = try makeObservation() let location = try XCTUnwrap( @@ -1961,7 +2047,7 @@ final class CurrentWeatherWidgetSnapshotFactoryTests: XCTestCase { time: time ) - XCTAssertEqual(snapshot.schemaVersion, 6) + XCTAssertEqual(snapshot.schemaVersion, 7) XCTAssertEqual( snapshot.sourceIdentifier, "region:100" @@ -1996,6 +2082,11 @@ final class CurrentWeatherWidgetSnapshotFactoryTests: XCTestCase { XCTAssertEqual(snapshot.rain, 0) XCTAssertEqual(snapshot.windDirection, "南南西") XCTAssertEqual(snapshot.windSpeed, 1.5) + XCTAssertEqual( + try XCTUnwrap(snapshot.apparentTemperature), + 31.39512521394631, + accuracy: 1e-9 + ) } func testCreatesSnapshotWithNilWindValues() throws { @@ -2021,9 +2112,36 @@ final class CurrentWeatherWidgetSnapshotFactoryTests: XCTestCase { XCTAssertNil(snapshot.windDirection) XCTAssertNil(snapshot.windSpeed) + XCTAssertNil(snapshot.apparentTemperature) + } + + func testMissingApparentInputDoesNotFailSnapshotGeneration() throws { + let cases: [(String, String, String)] = [ + ("null", "70", #"{ "speed": 1.5 }"#), + ("28.5", "null", #"{ "speed": 1.5 }"#), + ("28.5", "70", "{}"), + ] + let location = CurrentWeatherWidgetTestFixtures.resolvedLocation() + + for (temperature, humidity, wind) in cases { + let observation = try makeObservation( + temperature: temperature, + humidity: humidity, + wind: wind + ) + let snapshot = CurrentWeatherWidgetSnapshotFactory.make( + observation: observation, + location: location, + time: CurrentWeatherWidgetTestFixtures.snapshotTime() + ) + XCTAssertEqual(snapshot.schemaVersion, 7) + XCTAssertNil(snapshot.apparentTemperature) + } } private func makeObservation( + temperature: String = "28.5", + humidity: String = "70", wind: String = #"{ "direction": "南南西", "speed": 1.5 }"# ) throws @@ -2038,8 +2156,8 @@ final class CurrentWeatherWidgetSnapshotFactoryTests: XCTestCase { "data": { "weather": "晴", "weatherCode": 100, - "temperature": 28.5, - "humidity": 70, + "temperature": \(temperature), + "humidity": \(humidity), "rain": 0, "wind": \(wind) } diff --git a/ios/RunnerTests/DPIPWidgetProviderTests.swift b/ios/RunnerTests/DPIPWidgetProviderTests.swift index f6c8094fc..2847dff72 100644 --- a/ios/RunnerTests/DPIPWidgetProviderTests.swift +++ b/ios/RunnerTests/DPIPWidgetProviderTests.swift @@ -285,7 +285,7 @@ final class DPIPWidgetProviderTests: XCTestCase { } return CurrentWeatherWidgetSnapshot( - schemaVersion: 6, + schemaVersion: 7, sourceIdentifier: target.sourceIdentifier, regionCode: regionCode, regionName: "測試地區", diff --git a/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift b/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift index 21d04d390..266caf91a 100644 --- a/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift +++ b/ios/RunnerTests/SavedCurrentWeatherWidgetRefreshServiceTests.swift @@ -90,7 +90,7 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { } } - func testSuccessfulRefreshWritesSchemaSixResolvedSnapshot() async throws { + func testSuccessfulRefreshWritesSchemaSevenResolvedSnapshot() async throws { let observation = try makeObservation() let weather = ScriptedCurrentWeather( result: .success(observation) @@ -112,7 +112,7 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { XCTAssertEqual(result, .refreshed) let snapshot = try XCTUnwrap(writer.snapshots.first) - XCTAssertEqual(snapshot.schemaVersion, 6) + XCTAssertEqual(snapshot.schemaVersion, 7) XCTAssertEqual(snapshot.sourceIdentifier, "region:242") XCTAssertEqual(snapshot.regionCode, "242") XCTAssertEqual(snapshot.regionName, "新莊區") @@ -125,6 +125,11 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { XCTAssertEqual(snapshot.rain, observation.rain) XCTAssertEqual(snapshot.windDirection, observation.windDirection) XCTAssertEqual(snapshot.windSpeed, observation.windSpeed) + XCTAssertEqual( + try XCTUnwrap(snapshot.apparentTemperature), + 31.39512521394631, + accuracy: 1e-9 + ) XCTAssertEqual(snapshot.calibratedTimeOffsetMilliseconds, 5_000) let coordinates = await weather.coordinates XCTAssertEqual(coordinates.count, 1) @@ -132,6 +137,25 @@ final class SavedCurrentWeatherWidgetRefreshServiceTests: XCTestCase { XCTAssertEqual(coordinates.first?.longitude, 121.4500307) } + func testMissingWindStillRefreshesWithNilApparentTemperature() async throws { + let weather = ScriptedCurrentWeather( + result: .success(try CurrentWeatherWidgetTestFixtures.observation( + windSpeed: "null" + )) + ) + let writer = CurrentWeatherSnapshotWriterSpy() + let service = try makeService( + codes: ["242"], + weather: weather, + writeSnapshot: writer.write + ) + + let result = await service.refresh(target: .saved(regionCode: "242")) + XCTAssertEqual(result, .refreshed) + let snapshot = try XCTUnwrap(writer.snapshots.first) + XCTAssertNil(snapshot.apparentTemperature) + } + func testFirstClockSyncFailureDoesNotWrite() async throws { let weather = ScriptedCurrentWeather( result: .success(try makeObservation()) diff --git a/lib/features/weather/domain/apparent_temperature.dart b/lib/features/weather/domain/apparent_temperature.dart new file mode 100644 index 000000000..1ee6c46bd --- /dev/null +++ b/lib/features/weather/domain/apparent_temperature.dart @@ -0,0 +1,27 @@ +import 'dart:math' as math; + +/// CWA outdoor, ventilated, shaded apparent temperature in degrees Celsius. +double? currentApparentTemperature({ + required double? temperature, + required int? humidity, + required double? windSpeed, +}) { + if (temperature == null || + humidity == null || + windSpeed == null || + !temperature.isFinite || + !windSpeed.isFinite || + humidity < 0 || + humidity > 100 || + windSpeed < 0) { + return null; + } + + final vapourPressure = + (humidity / 100) * + 6.105 * + math.exp(17.27 * temperature / (237.7 + temperature)); + final apparentTemperature = + 1.04 * temperature + 0.2 * vapourPressure - 0.65 * windSpeed - 2.7; + return apparentTemperature.isFinite ? apparentTemperature : null; +} diff --git a/lib/features/weather/domain/current_weather_widget_snapshot.dart b/lib/features/weather/domain/current_weather_widget_snapshot.dart index 17ccbc23e..1f4f4e7f7 100644 --- a/lib/features/weather/domain/current_weather_widget_snapshot.dart +++ b/lib/features/weather/domain/current_weather_widget_snapshot.dart @@ -1,4 +1,5 @@ import 'package:dpip/core/weather/weather_code.dart'; +import 'package:dpip/features/weather/domain/apparent_temperature.dart'; import 'package:dpip/features/weather/domain/weather_realtime.dart'; enum CurrentWeatherWidgetCondition { @@ -14,7 +15,7 @@ enum CurrentWeatherWidgetCondition { final class CurrentWeatherWidgetSnapshot { const CurrentWeatherWidgetSnapshot({ - this.schemaVersion = 6, + this.schemaVersion = 7, required this.sourceIdentifier, required this.regionCode, required this.regionName, @@ -31,6 +32,7 @@ final class CurrentWeatherWidgetSnapshot { this.rain, this.windDirection, this.windSpeed, + this.apparentTemperature, }); final int schemaVersion; @@ -65,6 +67,7 @@ final class CurrentWeatherWidgetSnapshot { final double? rain; final String? windDirection; final double? windSpeed; + final double? apparentTemperature; Map toJson() { return { @@ -85,6 +88,7 @@ final class CurrentWeatherWidgetSnapshot { 'rain': rain, 'windDirection': windDirection, 'windSpeed': windSpeed, + 'apparentTemperature': apparentTemperature, }; } } @@ -128,5 +132,10 @@ CurrentWeatherWidgetSnapshot createCurrentWeatherWidgetSnapshot({ rain: weather.data.rain, windDirection: weather.data.wind.direction, windSpeed: weather.data.wind.speed, + apparentTemperature: currentApparentTemperature( + temperature: weather.data.temperature, + humidity: weather.data.humidity, + windSpeed: weather.data.wind.speed, + ), ); } diff --git a/test/features/weather/current_weather_widget_coordinator_test.dart b/test/features/weather/current_weather_widget_coordinator_test.dart index fa044d873..f5db219f3 100644 --- a/test/features/weather/current_weather_widget_coordinator_test.dart +++ b/test/features/weather/current_weather_widget_coordinator_test.dart @@ -91,7 +91,7 @@ void main() { final decoded = jsonDecode(writer.writtenJson!) as Map; - expect(decoded['schemaVersion'], 6); + expect(decoded['schemaVersion'], 7); expect(decoded['sourceIdentifier'], 'region:660'); expect(decoded['regionCode'], '660'); expect(decoded['regionName'], '西屯區'); @@ -103,8 +103,42 @@ void main() { expect(decoded['temperature'], 28.4); expect(decoded['windDirection'], '北'); expect(decoded['windSpeed'], 1.5); + expect(decoded['apparentTemperature'], closeTo(31.722497337967848, 1e-9)); }); + test( + 'publishes snapshot with null apparent temperature when wind is missing', + () async { + final regions = RegionStore( + SettingsStore.inMemory({ + 'home.savedRegionCodes': ['660'], + }), + ); + final writer = _FakeWidgetSnapshotWriter(); + final coordinator = CurrentWeatherWidgetCoordinator( + regions, + _directoryWithXitun(), + CurrentWeatherWidgetPublisher(writer), + time: () => ( + calibratedNow: DateTime.utc(2026, 9, 16, 4), + calibratedTimeOffset: Duration.zero, + ), + isTimeSynced: () => true, + ); + regions.select(2); + + await coordinator.publish( + regionCode: '660', + weather: _weather(windSpeed: null), + ); + + expect(writer.writeCallCount, 1); + final decoded = jsonDecode(writer.writtenJson!) as Map; + expect(decoded['windSpeed'], isNull); + expect(decoded['apparentTemperature'], isNull); + }, + ); + test('waits for initial sync then publishes exactly once', () async { final regions = RegionStore( SettingsStore.inMemory({ @@ -288,7 +322,7 @@ TownDirectory _directoryWithXitun() { }); } -WeatherRealtime _weather() { +WeatherRealtime _weather({double? windSpeed = 1.5}) { return WeatherRealtime( id: 'C0X160', station: const WeatherRealtimeStation( @@ -299,14 +333,14 @@ WeatherRealtime _weather() { distance: 1.2, ), time: 1789398000, - data: const WeatherRealtimeData( + data: WeatherRealtimeData( weather: '多雲', weatherCode: 200, temperature: 28.4, humidity: 76, rain: 0.0, - wind: WeatherWind(direction: '北', speed: 1.5, beaufort: 1), - gust: WeatherWind(speed: 3.0, beaufort: 2), + wind: WeatherWind(direction: '北', speed: windSpeed, beaufort: 1), + gust: const WeatherWind(speed: 3.0, beaufort: 2), ), ); } diff --git a/test/features/weather/current_weather_widget_snapshot_test.dart b/test/features/weather/current_weather_widget_snapshot_test.dart index 74daf663a..44e6135de 100644 --- a/test/features/weather/current_weather_widget_snapshot_test.dart +++ b/test/features/weather/current_weather_widget_snapshot_test.dart @@ -101,7 +101,7 @@ void main() { calibratedTimeOffsetMilliseconds: -300_000, ); - expect(snapshot.schemaVersion, 6); + expect(snapshot.schemaVersion, 7); expect(snapshot.sourceIdentifier, 'current-location'); expect(snapshot.regionCode, '660'); expect(snapshot.regionName, '西屯區'); @@ -118,11 +118,12 @@ void main() { expect(snapshot.rain, 0.0); expect(snapshot.windDirection, '北'); expect(snapshot.windSpeed, 1.5); + expect(snapshot.apparentTemperature, closeTo(31.722497337967848, 1e-9)); final json = jsonEncode(snapshot.toJson()); final decoded = jsonDecode(json) as Map; - expect(decoded['schemaVersion'], 6); + expect(decoded['schemaVersion'], 7); expect(decoded['sourceIdentifier'], 'current-location'); expect(decoded['regionCode'], '660'); expect(decoded['condition'], 'thunderstorm'); @@ -132,6 +133,7 @@ void main() { expect(decoded['temperature'], 28.4); expect(decoded['windDirection'], '北'); expect(decoded['windSpeed'], 1.5); + expect(decoded['apparentTemperature'], closeTo(31.722497337967848, 1e-9)); }); test('serializes condition name and preserves nullable weather values', () { @@ -161,7 +163,7 @@ void main() { final decoded = jsonDecode(json) as Map; expect(decoded, { - 'schemaVersion': 6, + 'schemaVersion': 7, 'sourceIdentifier': 'current-location', 'regionCode': '660', 'regionName': '西屯區', @@ -178,6 +180,44 @@ void main() { 'rain': null, 'windDirection': null, 'windSpeed': null, + 'apparentTemperature': null, }); }); + + test( + 'missing sustained wind keeps snapshot and nil apparent temperature', + () { + final weather = WeatherRealtime( + id: 'C0X160', + station: const WeatherRealtimeStation( + name: '西屯', + latitude: 24.18, + longitude: 120.64, + altitude: 85, + distance: 1.2, + ), + time: 1789398000, + data: const WeatherRealtimeData( + weather: '多雲', + weatherCode: 200, + temperature: 28.4, + humidity: 76, + wind: WeatherWind(), + gust: WeatherWind(speed: 3), + ), + ); + final snapshot = createCurrentWeatherWidgetSnapshot( + sourceIdentifier: 'current-location', + regionCode: '660', + regionName: '西屯區', + weather: weather, + isNight: false, + nextDayNightTransitionTime: 1789562700, + calibratedTimeOffsetMilliseconds: 0, + ); + expect(snapshot.windSpeed, isNull); + expect(snapshot.apparentTemperature, isNull); + expect(snapshot.toJson()['apparentTemperature'], isNull); + }, + ); } diff --git a/test/features/weather/domain/apparent_temperature_test.dart b/test/features/weather/domain/apparent_temperature_test.dart new file mode 100644 index 000000000..6f77d2f04 --- /dev/null +++ b/test/features/weather/domain/apparent_temperature_test.dart @@ -0,0 +1,160 @@ +import 'package:dpip/features/weather/domain/apparent_temperature.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('matches fixed CWA-formula parity vectors', () { + // Project goldens from the published formula, shared with Swift tests. + const vectors = + < + ({ + double temperature, + int humidity, + double windSpeed, + double expected, + }) + >[ + ( + temperature: 30, + humidity: 80, + windSpeed: 2, + expected: 33.9659458296072, + ), + ( + temperature: 12, + humidity: 65, + windSpeed: 1.2, + expected: 10.820012239693988, + ), + ( + temperature: 25, + humidity: 60, + windSpeed: 0, + expected: 27.089955502470914, + ), + ( + temperature: 25, + humidity: 60, + windSpeed: 8, + expected: 21.889955502470915, + ), + ( + temperature: 32, + humidity: 10, + windSpeed: 2, + expected: 30.227599518688184, + ), + ( + temperature: 32, + humidity: 95, + windSpeed: 2, + expected: 38.28219542753772, + ), + ( + temperature: -5, + humidity: 70, + windSpeed: 3, + expected: -9.26026582286526, + ), + ( + temperature: 20, + humidity: 0, + windSpeed: 1, + expected: 17.450000000000003, + ), + ( + temperature: 20, + humidity: 100, + windSpeed: 1, + expected: 22.114536136797756, + ), + ]; + + for (final vector in vectors) { + expect( + currentApparentTemperature( + temperature: vector.temperature, + humidity: vector.humidity, + windSpeed: vector.windSpeed, + ), + closeTo(vector.expected, 1e-9), + reason: '$vector', + ); + } + }); + + test('rejects missing and invalid inputs', () { + const valid = (temperature: 25.0, humidity: 60, windSpeed: 1.0); + expect( + currentApparentTemperature( + temperature: null, + humidity: valid.humidity, + windSpeed: valid.windSpeed, + ), + isNull, + ); + expect( + currentApparentTemperature( + temperature: valid.temperature, + humidity: null, + windSpeed: valid.windSpeed, + ), + isNull, + ); + expect( + currentApparentTemperature( + temperature: valid.temperature, + humidity: valid.humidity, + windSpeed: null, + ), + isNull, + ); + for (final humidity in [-1, 101]) { + expect( + currentApparentTemperature( + temperature: valid.temperature, + humidity: humidity, + windSpeed: valid.windSpeed, + ), + isNull, + ); + } + expect( + currentApparentTemperature( + temperature: valid.temperature, + humidity: valid.humidity, + windSpeed: -0.1, + ), + isNull, + ); + for (final nonFinite in [ + double.nan, + double.infinity, + double.negativeInfinity, + ]) { + expect( + currentApparentTemperature( + temperature: nonFinite, + humidity: valid.humidity, + windSpeed: valid.windSpeed, + ), + isNull, + ); + expect( + currentApparentTemperature( + temperature: valid.temperature, + humidity: valid.humidity, + windSpeed: nonFinite, + ), + isNull, + ); + } + expect( + currentApparentTemperature( + temperature: -237.700000001, + humidity: 100, + windSpeed: 1, + ), + isNull, + ); + }); +} From 8a7c7b733f579088503d2565370556d93815906b Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Wed, 23 Sep 2026 10:14:22 +0800 Subject: [PATCH 03/17] refactor(weather): split widget family layout --- ios/DPIPWidgets/DPIPWidgets.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index e0ca3157a..f319a6dd0 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -116,7 +116,7 @@ struct DPIPWidgetEntry: TimelineEntry { let isNight: Bool } -struct DPIPWidgetsEntryView : View { +private struct SmallCurrentWeatherView: View { let entry: DPIPWidgetEntry var body: some View { @@ -224,6 +224,22 @@ struct DPIPWidgetsEntryView : View { } } +struct DPIPWidgetsEntryView : View { + @Environment(\.widgetFamily) private var family + + let entry: DPIPWidgetEntry + + var body: some View { + switch family { + case .systemSmall: + SmallCurrentWeatherView(entry: entry) + + default: + SmallCurrentWeatherView(entry: entry) + } + } +} + struct DPIPWidgets: Widget { let kind: String = "DPIPWidgets" From 760b96af65bcb460b96d8abe513bde68f614e725 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Fri, 25 Sep 2026 11:50:17 +0800 Subject: [PATCH 04/17] feat(weather): add medium iOS weather widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios New(zh-Hant): 新增中型 iOS 天氣小工具 New(en-US): add a medium iOS weather widget --- ios/DPIPWidgets/DPIPWidgets.swift | 293 ++++++++++++++++++++++++-- ios/DPIPWidgets/Localizable.xcstrings | 12 +- 2 files changed, 279 insertions(+), 26 deletions(-) diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index f319a6dd0..8f4aef0dd 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -224,6 +224,176 @@ private struct SmallCurrentWeatherView: View { } } +private struct MediumCurrentWeatherView: View { + let entry: DPIPWidgetEntry + + var body: some View { + if let snapshot = entry.snapshot { + let observationDate = Date( + timeIntervalSince1970: TimeInterval(snapshot.observationTime) + ) + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + HStack(spacing: 4) { + Text(snapshot.regionName) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.8) + .layoutPriority(1) + + if snapshot.sourceIdentifier == "current-location" { + Image(systemName: "location.fill") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize() + } + } + + Spacer() + + Text(observationDate, style: .time) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + .frame(height: 4) + + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 0) { + if let temperature = snapshot.temperature { + Text("\(temperature, specifier: "%.0f")°") + .font(.system( + size: 42, + weight: .semibold, + design: .rounded + )) + .lineLimit(1) + .minimumScaleFactor(0.8) + } else { + Text("—°") + .font(.system( + size: 42, + weight: .semibold, + design: .rounded + )) + .foregroundStyle(.secondary) + } + + if let apparentTemperature = snapshot.apparentTemperature { + HStack(spacing: 4) { + Text("widget.feels_like") + Text("\(apparentTemperature, specifier: "%.0f")°") + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + HStack(spacing: 6) { + Image(systemName: snapshot.condition.systemImageName( + isNight: entry.isNight + )) + .font(.title2) + + Text(snapshot.condition.localizedDisplayName) + .font(.subheadline) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .foregroundStyle(.secondary) + } + + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text("widget.humidity") + .font(.caption2) + .foregroundStyle(.secondary) + + if let humidity = snapshot.humidity { + Text("\(humidity)%") + .font(.caption) + .fontWeight(.medium) + } else { + Text("--%") + .font(.caption) + .fontWeight(.medium) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .center, spacing: 2) { + Text("widget.rainfall") + .font(.caption2) + .foregroundStyle(.secondary) + + if let rain = snapshot.rain { + Text("\(rain, specifier: "%.1f") mm") + .font(.caption) + .fontWeight(.medium) + } else { + Text("-- mm") + .font(.caption) + .fontWeight(.medium) + } + } + .frame(maxWidth: .infinity, alignment: .center) + + VStack(alignment: .trailing, spacing: 2) { + Text("widget.wind") + .font(.caption2) + .foregroundStyle(.secondary) + + if let direction = snapshot.windDirection, + let speed = snapshot.windSpeed { + Text("\(direction) \(speed, specifier: "%.1f") m/s") + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + .minimumScaleFactor(0.75) + } else if let speed = snapshot.windSpeed { + Text("\(speed, specifier: "%.1f") m/s") + .font(.caption) + .fontWeight(.medium) + } else if let direction = snapshot.windDirection { + Text(direction) + .font(.caption) + .fontWeight(.medium) + } else { + Text("--") + .font(.caption) + .fontWeight(.medium) + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + } else { + VStack(spacing: 8) { + Image(systemName: "cloud.fill") + .font(.title) + + Text("widget.no_weather_data") + .font(.caption) + } + .foregroundStyle(.secondary) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + } + } +} + struct DPIPWidgetsEntryView : View { @Environment(\.widgetFamily) private var family @@ -234,6 +404,9 @@ struct DPIPWidgetsEntryView : View { case .systemSmall: SmallCurrentWeatherView(entry: entry) + case .systemMedium: + MediumCurrentWeatherView(entry: entry) + default: SmallCurrentWeatherView(entry: entry) } @@ -260,7 +433,10 @@ struct DPIPWidgets: Widget { .background() } } - .supportedFamilies([.systemSmall]) + .supportedFamilies([ + .systemSmall, + .systemMedium + ]) .configurationDisplayName("widget.current_weather") .description("widget.current_weather_description") } @@ -270,39 +446,116 @@ struct DPIPWidgets_Previews: PreviewProvider { private static let previewEntry = DPIPWidgetEntry( date: .now, snapshot: CurrentWeatherWidgetSnapshot( - schemaVersion: 5, + schemaVersion: 7, sourceIdentifier: "current-location", - regionCode: "660", + regionCode: "407", regionName: "西屯區", - observationTime: 0, + observationTime: Int(Date().timeIntervalSince1970), stationName: "西屯", weather: "晴", weatherCode: 100, condition: .clear, - isNight: true, + isNight: false, + nextDayNightTransitionTime: 1_789_562_700, + calibratedTimeOffsetMilliseconds: 0, + temperature: 28.0, + humidity: 76, + rain: 0.0, + windDirection: "北北西", + windSpeed: 1.3, + apparentTemperature: 30.2 + ), + isStale: false, + isNight: false + ) + + private static let missingOptionalDataEntry = DPIPWidgetEntry( + date: .now, + snapshot: CurrentWeatherWidgetSnapshot( + schemaVersion: 7, + sourceIdentifier: "current-location", + regionCode: "407", + regionName: "西屯區", + observationTime: Int(Date().timeIntervalSince1970), + stationName: "西屯", + weather: "多雲", + weatherCode: 200, + condition: .cloudy, + isNight: false, nextDayNightTransitionTime: 1_789_562_700, calibratedTimeOffsetMilliseconds: 0, - temperature: 28.4, + temperature: 28.0, humidity: 76, - rain: 0 + rain: 0.0, + windDirection: nil, + windSpeed: nil, + apparentTemperature: nil ), - isStale: true, - isNight: true + isStale: false, + isNight: false ) + @ViewBuilder + private static func previewView( + entry: DPIPWidgetEntry + ) -> some View { + if #available(iOSApplicationExtension 17.0, *) { + DPIPWidgetsEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } else { + DPIPWidgetsEntryView(entry: entry) + .padding() + .background() + } + } + static var previews: some View { Group { - if #available(iOSApplicationExtension 17.0, *) { - DPIPWidgetsEntryView(entry: previewEntry) - .containerBackground(.fill.tertiary, for: .widget) - } else { - DPIPWidgetsEntryView(entry: previewEntry) - .padding() - .background() - } + previewView(entry: previewEntry) + .previewDisplayName("Small") + .previewContext( + WidgetPreviewContext(family: .systemSmall) + ) + + previewView(entry: previewEntry) + .previewDisplayName("Medium") + .previewContext( + WidgetPreviewContext(family: .systemMedium) + ) + + previewView(entry: missingOptionalDataEntry) + .previewDisplayName("Medium — Missing Optional Data") + .previewContext( + WidgetPreviewContext(family: .systemMedium) + ) + + previewView(entry: previewEntry) + .environment(\.locale, Locale(identifier: "en")) + .previewDisplayName("Medium — English") + .previewContext( + WidgetPreviewContext(family: .systemMedium) + ) + + previewView(entry: previewEntry) + .environment(\.locale, Locale(identifier: "ja")) + .previewDisplayName("Medium — Japanese") + .previewContext( + WidgetPreviewContext(family: .systemMedium) + ) + + previewView(entry: previewEntry) + .environment(\.locale, Locale(identifier: "ko")) + .previewDisplayName("Medium — Korean") + .previewContext( + WidgetPreviewContext(family: .systemMedium) + ) + + previewView(entry: previewEntry) + .environment(\.locale, Locale(identifier: "zh-Hans")) + .previewDisplayName("Medium — Simplified Chinese") + .previewContext( + WidgetPreviewContext(family: .systemMedium) + ) } - .previewContext( - WidgetPreviewContext(family: .systemSmall) - ) } } diff --git a/ios/DPIPWidgets/Localizable.xcstrings b/ios/DPIPWidgets/Localizable.xcstrings index ea018a31f..df5650bbe 100644 --- a/ios/DPIPWidgets/Localizable.xcstrings +++ b/ios/DPIPWidgets/Localizable.xcstrings @@ -2,22 +2,22 @@ "sourceLanguage" : "en", "strings" : { "-- mm" : { - + "shouldTranslate" : false }, "--%" : { - + "shouldTranslate" : false }, "--°" : { - + "shouldTranslate" : false }, "%.0f°" : { - + "shouldTranslate" : false }, "%.1f mm" : { - + "shouldTranslate" : false }, "%lld%%" : { - + "shouldTranslate" : false }, "weather.clear" : { "comment" : "Localized current weather condition for clear skies.", From 6881050af48ab904e76b7cc4cb2c9c676129300c Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Fri, 25 Sep 2026 19:18:13 +0800 Subject: [PATCH 05/17] feat(weather): add native iOS widget forecast data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios New(zh-Hant): 新增 iOS 天氣小工具原生逐時預報資料支援 New(en-US): add native hourly forecast data support for iOS weather widgets --- .../DPIPWidgetProviderSupport.swift | 121 +++- ios/DPIPWidgets/DPIPWidgets.swift | 28 +- ios/DPIPWidgets/ForecastClient.swift | 51 ++ ios/DPIPWidgets/ForecastRemoteDTO.swift | 39 ++ ios/DPIPWidgets/ForecastWidgetExpiry.swift | 24 + .../ForecastWidgetRefreshService.swift | 75 +++ ios/DPIPWidgets/ForecastWidgetSnapshot.swift | 117 ++++ .../ForecastWidgetSnapshotStore.swift | 90 +++ ios/Runner.xcodeproj/project.pbxproj | 10 + ios/RunnerTests/ForecastWidgetTests.swift | 522 ++++++++++++++++++ 10 files changed, 1070 insertions(+), 7 deletions(-) create mode 100644 ios/DPIPWidgets/ForecastClient.swift create mode 100644 ios/DPIPWidgets/ForecastRemoteDTO.swift create mode 100644 ios/DPIPWidgets/ForecastWidgetExpiry.swift create mode 100644 ios/DPIPWidgets/ForecastWidgetRefreshService.swift create mode 100644 ios/DPIPWidgets/ForecastWidgetSnapshot.swift create mode 100644 ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift create mode 100644 ios/RunnerTests/ForecastWidgetTests.swift diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift index 4dd5d63c6..7768a0c04 100644 --- a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -1,4 +1,5 @@ import Foundation +import WidgetKit #if DEBUG import OSLog @@ -52,8 +53,17 @@ enum WidgetWeatherRefreshDiagnostics { struct DPIPWidgetTimelinePlan: Sendable { let snapshot: CurrentWeatherWidgetSnapshot? + let forecast: ForecastWidgetSnapshot? let states: [CurrentWeatherWidgetTimelineState] let reloadDate: Date + + func forecast(at date: Date) -> ForecastWidgetSnapshot? { + guard let forecast, + ForecastWidgetExpiry.isUsable(forecast, at: date) else { + return nil + } + return forecast + } } struct DPIPWidgetTimelinePlanner: Sendable { @@ -65,6 +75,12 @@ struct DPIPWidgetTimelinePlanner: Sendable { ) async -> CurrentWeatherWidgetRefreshResult typealias RefreshCurrent = @Sendable () async -> CurrentWeatherWidgetRefreshResult + typealias LoadForecast = @Sendable ( + WidgetLocationTarget, String, Date + ) -> ForecastWidgetSnapshot? + typealias RefreshForecast = @Sendable ( + WidgetLocationTarget, String + ) async -> Void typealias Now = @Sendable () -> Date private let staleAfter: TimeInterval @@ -72,6 +88,8 @@ struct DPIPWidgetTimelinePlanner: Sendable { private let loadSnapshot: LoadSnapshot private let refreshSaved: RefreshSaved private let refreshCurrent: RefreshCurrent + private let loadForecast: LoadForecast + private let refreshForecast: RefreshForecast private let now: Now init( @@ -80,6 +98,8 @@ struct DPIPWidgetTimelinePlanner: Sendable { loadSnapshot: @escaping LoadSnapshot, refreshSaved: @escaping RefreshSaved, refreshCurrent: @escaping RefreshCurrent, + loadForecast: @escaping LoadForecast = { _, _, _ in nil }, + refreshForecast: @escaping RefreshForecast = { _, _ in }, now: @escaping Now ) { self.staleAfter = staleAfter @@ -87,11 +107,14 @@ struct DPIPWidgetTimelinePlanner: Sendable { self.loadSnapshot = loadSnapshot self.refreshSaved = refreshSaved self.refreshCurrent = refreshCurrent + self.loadForecast = loadForecast + self.refreshForecast = refreshForecast self.now = now } func plan( - for target: WidgetLocationTarget + for target: WidgetLocationTarget, + family: WidgetFamily = .systemSmall ) async -> DPIPWidgetTimelinePlan { #if DEBUG let snapshotBeforeRefresh: CurrentWeatherWidgetSnapshot? @@ -144,14 +167,44 @@ struct DPIPWidgetTimelinePlanner: Sendable { // Always reload after the refresh attempt. Failed refreshes leave the // same-location cache untouched, so this also provides SWR behavior. - let snapshot = loadSnapshot(target) + var snapshot = loadSnapshot(target) + if family == .systemLarge, + let current = snapshot, + target.matches(snapshot: current) { + await refreshForecast(target, current.regionCode) + // Re-read after the forecast await to reject a late township A + // response if Current Location has since moved to township B. + snapshot = loadSnapshot(target) + } let deviceNow = now() - let states = CurrentWeatherWidgetTimeline.states( + var states = CurrentWeatherWidgetTimeline.states( snapshot: snapshot, deviceNow: deviceNow, staleAfter: staleAfter ) let reloadDate = deviceNow.addingTimeInterval(refreshInterval) + let forecast: ForecastWidgetSnapshot? + if family == .systemLarge, + let snapshot, + target.matches(snapshot: snapshot) { + forecast = loadForecast( + target, snapshot.regionCode, deviceNow + ) + } else { + forecast = nil + } + if let forecast { + let expiry = ForecastWidgetExpiry.date(for: forecast) + if expiry > deviceNow, expiry < reloadDate, + !states.contains(where: { $0.date == expiry }) { + states.append(CurrentWeatherWidgetTimeline.state( + snapshot: snapshot, + at: expiry, + staleAfter: staleAfter + )) + states.sort { $0.date < $1.date } + } + } #if DEBUG WidgetWeatherRefreshDiagnostics.log( @@ -180,6 +233,7 @@ struct DPIPWidgetTimelinePlanner: Sendable { return DPIPWidgetTimelinePlan( snapshot: snapshot, + forecast: forecast, states: states, reloadDate: reloadDate ) @@ -188,15 +242,37 @@ struct DPIPWidgetTimelinePlanner: Sendable { struct DPIPWidgetProviderDependencies: Sendable { typealias LoadSnapshot = DPIPWidgetTimelinePlanner.LoadSnapshot + typealias LoadForecast = DPIPWidgetTimelinePlanner.LoadForecast let loadSnapshot: LoadSnapshot let timelinePlanner: DPIPWidgetTimelinePlanner + let loadForecast: LoadForecast + + init(loadSnapshot: @escaping LoadSnapshot, + timelinePlanner: DPIPWidgetTimelinePlanner, + loadForecast: @escaping LoadForecast = { _, _, _ in nil }) { + self.loadSnapshot = loadSnapshot + self.timelinePlanner = timelinePlanner + self.loadForecast = loadForecast + } func snapshot( for target: WidgetLocationTarget ) -> CurrentWeatherWidgetSnapshot? { loadSnapshot(target) } + + func forecastSnapshot( + for target: WidgetLocationTarget, + currentSnapshot: CurrentWeatherWidgetSnapshot?, + at date: Date, + family: WidgetFamily + ) -> ForecastWidgetSnapshot? { + guard family == .systemLarge, + let currentSnapshot, + target.matches(snapshot: currentSnapshot) else { return nil } + return loadForecast(target, currentSnapshot.regionCode, date) + } } enum DPIPWidgetProviderRuntime { @@ -223,6 +299,36 @@ enum DPIPWidgetProviderRuntime { target in snapshotStore.loadCurrentWeatherSnapshot(for: target) } + let forecastStore = containerURL.map { + ForecastWidgetSnapshotStore(containerURL: $0) + } + let loadForecast: DPIPWidgetTimelinePlanner.LoadForecast = { + target, regionCode, date in + forecastStore?.load( + for: target, regionCode: regionCode, at: date + ) + } + let forecastClient = ForecastClient() + let forecastRefresh = ForecastWidgetRefreshService( + fetch: { regionCode in + try await forecastClient.fetch(regionCode: regionCode) + }, + loadCurrent: loadSnapshot, + savedIsResolved: { target in + guard let containerURL else { return false } + let catalog = WidgetLocationCatalogStore( + containerURL: containerURL + ).load() + return SavedWidgetLocationResolver( + catalog: catalog + ).resolve(target: target) != nil + }, + write: { snapshot, target in + guard let forecastStore else { return false } + return try forecastStore.write(snapshot, for: target) + }, + now: { Date.now } + ) let weatherClient = CurrentWeatherClient() let serverClock = WidgetServerClock() @@ -311,8 +417,15 @@ enum DPIPWidgetProviderRuntime { loadSnapshot: loadSnapshot, refreshSaved: refreshSaved, refreshCurrent: refreshCurrent, + loadForecast: loadForecast, + refreshForecast: { target, regionCode in + await forecastRefresh.refresh( + target: target, regionCode: regionCode + ) + }, now: { Date.now } - ) + ), + loadForecast: loadForecast ) }() } diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 8f4aef0dd..160bdae9a 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -40,6 +40,9 @@ struct DPIPWidgetProvider: IntentTimelineProvider { ) { let snapshot = snapshot(for: configuration) let deviceNow = Date.now + let target = WidgetLocationTarget( + identifier: configuration.location?.identifier + ) let state = CurrentWeatherWidgetTimeline.state( snapshot: snapshot, @@ -51,7 +54,13 @@ struct DPIPWidgetProvider: IntentTimelineProvider { date: state.date, snapshot: snapshot, isStale: state.isStale, - isNight: state.isNight + isNight: state.isNight, + forecast: dependencies.forecastSnapshot( + for: target, + currentSnapshot: snapshot, + at: deviceNow, + family: context.family + ) ) completion(entry) @@ -88,14 +97,16 @@ struct DPIPWidgetProvider: IntentTimelineProvider { Task { let plan = await dependencies.timelinePlanner.plan( - for: target + for: target, + family: context.family ) let entries = plan.states.map { state in DPIPWidgetEntry( date: state.date, snapshot: plan.snapshot, isStale: state.isStale, - isNight: state.isNight + isNight: state.isNight, + forecast: plan.forecast(at: state.date) ) } @@ -114,6 +125,17 @@ struct DPIPWidgetEntry: TimelineEntry { 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 + } } private struct SmallCurrentWeatherView: View { diff --git a/ios/DPIPWidgets/ForecastClient.swift b/ios/DPIPWidgets/ForecastClient.swift new file mode 100644 index 000000000..8417633aa --- /dev/null +++ b/ios/DPIPWidgets/ForecastClient.swift @@ -0,0 +1,51 @@ +import Foundation + +enum ForecastClientError: Error, Equatable { + case invalidRegionCode + case invalidURL + case invalidResponse + case httpStatus(Int) + case responseTooLarge +} + +struct ForecastClient: Sendable { + 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(regionCode: String) throws -> URL { + guard WidgetResolvedWeatherLocationValidation + .isValidRegionCode(regionCode) else { + throw ForecastClientError.invalidRegionCode + } + var components = URLComponents() + components.scheme = "https" + components.host = "api.core-tnn1.exptech.dev" + components.path = "/api/v5/meteor/weather/forecast/\(regionCode)" + guard let url = components.url else { + throw ForecastClientError.invalidURL + } + return url + } + + func fetch(regionCode: String) async throws -> ForecastRemoteDTO { + var request = URLRequest(url: try makeURL(regionCode: regionCode)) + request.httpMethod = "GET" + request.timeoutInterval = Self.requestTimeout + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { + throw ForecastClientError.invalidResponse + } + guard response.statusCode == 200 else { + throw ForecastClientError.httpStatus(response.statusCode) + } + guard data.count <= Self.maximumResponseSize else { + throw ForecastClientError.responseTooLarge + } + return try JSONDecoder().decode(ForecastRemoteDTO.self, from: data) + } +} diff --git a/ios/DPIPWidgets/ForecastRemoteDTO.swift b/ios/DPIPWidgets/ForecastRemoteDTO.swift new file mode 100644 index 000000000..48d849e56 --- /dev/null +++ b/ios/DPIPWidgets/ForecastRemoteDTO.swift @@ -0,0 +1,39 @@ +import Foundation + +/// The narrow v5 township forecast response. Point labels are clock labels, +/// never dates; usable points retain their API order. +struct ForecastRemoteDTO: Decodable, Sendable { + /// API publication time, Unix milliseconds. + let updateTime: Int64 + let points: [ForecastWidgetPoint] + + private enum CodingKeys: String, CodingKey { + case updateTime + case forecast + } + + private struct UsablePoint: Decodable { + let point: ForecastWidgetPoint? + + init(from decoder: Decoder) throws { + point = try? ForecastWidgetPoint(from: decoder) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + updateTime = try container.decode(Int64.self, forKey: .updateTime) + guard updateTime > 0 else { + throw DecodingError.dataCorruptedError( + forKey: .updateTime, + in: container, + debugDescription: "Invalid forecast publication time." + ) + } + let forecast = try container.decode( + [UsablePoint].self, + forKey: .forecast + ) + points = Array(forecast.compactMap(\.point).prefix(4)) + } +} diff --git a/ios/DPIPWidgets/ForecastWidgetExpiry.swift b/ios/DPIPWidgets/ForecastWidgetExpiry.swift new file mode 100644 index 000000000..682d15bbd --- /dev/null +++ b/ios/DPIPWidgets/ForecastWidgetExpiry.swift @@ -0,0 +1,24 @@ +import Foundation + +enum ForecastWidgetExpiry { + /// Cache/display deadline only. Neither input is a forecast-point time. + static func date(for snapshot: ForecastWidgetSnapshot) -> Date { + let published = Date( + timeIntervalSince1970: TimeInterval(snapshot.updateTime) / 1_000 + ) + let received = Date( + timeIntervalSince1970: TimeInterval(snapshot.receivedAt) / 1_000 + ) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Taipei")! + let nextTaipeiHour = calendar.dateInterval( + of: .hour, + for: received + )!.end + return min(published.addingTimeInterval(30 * 60), nextTaipeiHour) + } + + static func isUsable(_ snapshot: ForecastWidgetSnapshot, at date: Date) -> Bool { + date < self.date(for: snapshot) + } +} diff --git a/ios/DPIPWidgets/ForecastWidgetRefreshService.swift b/ios/DPIPWidgets/ForecastWidgetRefreshService.swift new file mode 100644 index 000000000..ad2e9af3c --- /dev/null +++ b/ios/DPIPWidgets/ForecastWidgetRefreshService.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Forecast failures are independent of current-weather refresh and cache. +struct ForecastWidgetRefreshService: Sendable { + typealias Fetch = @Sendable (String) async throws -> ForecastRemoteDTO + typealias LoadCurrent = @Sendable (WidgetLocationTarget) + -> CurrentWeatherWidgetSnapshot? + typealias SavedIsResolved = @Sendable (WidgetLocationTarget) -> Bool + typealias Write = @Sendable (ForecastWidgetSnapshot, + WidgetLocationTarget) throws -> Bool + typealias Now = @Sendable () -> Date + + private let fetch: Fetch + private let loadCurrent: LoadCurrent + private let savedIsResolved: SavedIsResolved + private let write: Write + private let now: Now + + init(fetch: @escaping Fetch, loadCurrent: @escaping LoadCurrent, + savedIsResolved: @escaping SavedIsResolved, + write: @escaping Write, now: @escaping Now) { + self.fetch = fetch + self.loadCurrent = loadCurrent + self.savedIsResolved = savedIsResolved + self.write = write + self.now = now + } + + func refresh(target: WidgetLocationTarget, + regionCode: String) async { + guard matchesCurrent(target: target, regionCode: regionCode), + isResolved(target) else { return } + + let response: ForecastRemoteDTO + do { + response = try await fetch(regionCode) + } catch { + return + } + + // Recheck after await: a Current Location request for A may complete + // after the current-weather snapshot has moved to township B. + guard matchesCurrent(target: target, regionCode: regionCode), + isResolved(target), !response.points.isEmpty else { return } + let receivedAt = Int64(now().timeIntervalSince1970 * 1_000) + guard let snapshot = ForecastWidgetSnapshot( + sourceIdentifier: target.sourceIdentifier ?? "", + regionCode: regionCode, + updateTime: response.updateTime, + receivedAt: receivedAt, + points: response.points + ), ForecastWidgetExpiry.isUsable(snapshot, at: now()) else { return } + _ = try? write(snapshot, target) + } + + private func matchesCurrent(target: WidgetLocationTarget, + regionCode: String) -> Bool { + guard let current = loadCurrent(target), + target.matches(snapshot: current), + current.sourceIdentifier == target.sourceIdentifier, + current.regionCode == regionCode else { return false } + return true + } + + private func isResolved(_ target: WidgetLocationTarget) -> Bool { + switch target { + case .saved: + return savedIsResolved(target) + case .currentLocation: + return true + case .invalid: + return false + } + } +} diff --git a/ios/DPIPWidgets/ForecastWidgetSnapshot.swift b/ios/DPIPWidgets/ForecastWidgetSnapshot.swift new file mode 100644 index 000000000..1d1f4830a --- /dev/null +++ b/ios/DPIPWidgets/ForecastWidgetSnapshot.swift @@ -0,0 +1,117 @@ +import Foundation + +struct ForecastWidgetPoint: Codable, Equatable, Sendable { + /// API-supplied HH:mm label only; no date or hour offset is inferred. + let time: String + /// Forecast air temperature in degrees Celsius. + let temperature: Double + let weather: String + let weatherCode: Int + /// Probability of precipitation in percent. Invalid/missing PoP is nil. + let pop: Int? + + init?(time: String, temperature: Double, weather: String, + weatherCode: Int, pop: Int?) { + guard Self.isValidClockLabel(time), temperature.isFinite, + !weather.isEmpty else { + return nil + } + self.time = time + self.temperature = temperature + self.weather = weather + self.weatherCode = weatherCode + self.pop = pop.flatMap { (0...100).contains($0) ? $0 : nil } + } + + private enum CodingKeys: String, CodingKey { + case time, temperature, weather, weatherCode, pop + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let time = try container.decode(String.self, forKey: .time) + let temperature = try container.decode(Double.self, forKey: .temperature) + let weather = try container.decode(String.self, forKey: .weather) + let weatherCode = try container.decode(Int.self, forKey: .weatherCode) + let pop = try? container.decode(Int.self, forKey: .pop) + guard let point = Self( + time: time, + temperature: temperature, + weather: weather, + weatherCode: weatherCode, + pop: pop + ) else { + throw DecodingError.dataCorruptedError( + forKey: .time, + in: container, + debugDescription: "Unusable forecast point." + ) + } + self = point + } + + private static func isValidClockLabel(_ value: String) -> Bool { + let bytes = Array(value.utf8) + guard bytes.count == 5, bytes[2] == 58, + [0, 1, 3, 4].allSatisfy({ (48...57).contains(bytes[$0]) }) + else { return false } + let hour = Int(bytes[0] - 48) * 10 + Int(bytes[1] - 48) + let minute = Int(bytes[3] - 48) * 10 + Int(bytes[4] - 48) + return hour < 24 && minute < 60 + } +} + +struct ForecastWidgetSnapshot: Codable, Sendable { + static let schemaVersion = 1 + + let schemaVersion: Int + let sourceIdentifier: String + let regionCode: String + /// API publication time, Unix milliseconds. Not a point valid time. + let updateTime: Int64 + /// Local accepted-response time, Unix milliseconds. Not a point valid time. + let receivedAt: Int64 + let points: [ForecastWidgetPoint] + + init?(sourceIdentifier: String, regionCode: String, updateTime: Int64, + receivedAt: Int64, points: [ForecastWidgetPoint]) { + guard let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: sourceIdentifier + ), WidgetResolvedWeatherLocationValidation.isValidRegionCode(regionCode), + updateTime > 0, receivedAt > 0, + (1...4).contains(points.count) else { return nil } + if case .saved(let code) = address, code != regionCode { return nil } + self.schemaVersion = Self.schemaVersion + self.sourceIdentifier = sourceIdentifier + self.regionCode = regionCode + self.updateTime = updateTime + self.receivedAt = receivedAt + self.points = points + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion, sourceIdentifier, regionCode, updateTime + case receivedAt, points + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let schema = try container.decode(Int.self, forKey: .schemaVersion) + let source = try container.decode(String.self, forKey: .sourceIdentifier) + let region = try container.decode(String.self, forKey: .regionCode) + let updated = try container.decode(Int64.self, forKey: .updateTime) + let received = try container.decode(Int64.self, forKey: .receivedAt) + let points = try container.decode([ForecastWidgetPoint].self, forKey: .points) + guard schema == Self.schemaVersion, + let snapshot = Self(sourceIdentifier: source, regionCode: region, + updateTime: updated, receivedAt: received, + points: points) else { + throw DecodingError.dataCorruptedError( + forKey: .schemaVersion, + in: container, + debugDescription: "Unsupported or invalid forecast snapshot." + ) + } + self = snapshot + } +} diff --git a/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift b/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift new file mode 100644 index 000000000..cbe4f011f --- /dev/null +++ b/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Independent per-target forecast cache in the App Group. +struct ForecastWidgetSnapshotStore: Sendable { + private static let maximumPayloadSize = 128 * 1024 + let containerURL: URL + + func snapshotURL(for target: WidgetLocationTarget) -> URL? { + guard let source = target.sourceIdentifier, + let address = CurrentWeatherSnapshotAddress( + sourceIdentifier: source + ) else { return nil } + return containerURL + .appendingPathComponent("WidgetSnapshots", isDirectory: true) + .appendingPathComponent("hourly-forecast", isDirectory: true) + .appendingPathComponent(address.filename) + } + + func load( + for target: WidgetLocationTarget, + regionCode: String, + at date: Date + ) -> ForecastWidgetSnapshot? { + guard let url = snapshotURL(for: target), + let attributes = try? FileManager.default.attributesOfItem( + atPath: url.path + ), + let size = attributes[.size] as? NSNumber, + size.intValue <= Self.maximumPayloadSize, + let data = try? Data(contentsOf: url), + data.count <= Self.maximumPayloadSize, + let snapshot = try? JSONDecoder().decode( + ForecastWidgetSnapshot.self, + from: data + ), + snapshot.sourceIdentifier == target.sourceIdentifier, + snapshot.regionCode == regionCode, + ForecastWidgetExpiry.isUsable(snapshot, at: date) + else { return nil } + return snapshot + } + + @discardableResult + func write(_ snapshot: ForecastWidgetSnapshot, + for target: WidgetLocationTarget) throws -> Bool { + guard snapshot.sourceIdentifier == target.sourceIdentifier, + let url = snapshotURL(for: target) else { return false } + let data = try JSONEncoder().encode(snapshot) + guard data.count <= Self.maximumPayloadSize else { return false } + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let coordinator = NSFileCoordinator(filePresenter: nil) + var coordinationError: NSError? + var result: Result? + coordinator.coordinate( + writingItemAt: url, + options: .forReplacing, + error: &coordinationError + ) { coordinatedURL in + result = Result { + // A late response for an older API publication cannot replace + // a newer same-region forecast in this target's cache. + let existingAttributes = try? FileManager.default.attributesOfItem( + atPath: coordinatedURL.path + ) + let existingSize = (existingAttributes?[.size] + as? NSNumber)?.intValue + if let existingSize, + existingSize <= Self.maximumPayloadSize, + let existingData = try? Data(contentsOf: coordinatedURL), + let existing = try? JSONDecoder().decode( + ForecastWidgetSnapshot.self, + from: existingData + ), existing.sourceIdentifier == snapshot.sourceIdentifier, + existing.regionCode == snapshot.regionCode, + existing.updateTime > snapshot.updateTime { + return false + } + try data.write(to: coordinatedURL, options: .atomic) + return true + } + } + if let result { return try result.get() } + if let coordinationError { throw coordinationError } + return false + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 474d10068..ecbbed6a2 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ E1EBD3F0B2991F7D60DF56C5 /* Sounds/weather.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 762B2690D1D999F97D84162C /* Sounds/weather.aiff */; }; E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */; }; E7A200000000000000000001 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */; }; + E9F000000000000000000001 /* ForecastWidgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F000000000000000000002 /* ForecastWidgetTests.swift */; }; F4E200000000000000000001 /* DPIPWidgetProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E200000000000000000002 /* DPIPWidgetProviderTests.swift */; }; F4F200000000000000000001 /* WidgetTownshipResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F200000000000000000002 /* WidgetTownshipResolverTests.swift */; }; F56D5186166227E46D96919D /* Sounds/info.aiff in Resources */ = {isa = PBXBuildFile; fileRef = F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */; }; @@ -179,6 +180,7 @@ E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWeatherWidgetSnapshotWriterTests.swift; sourceTree = ""; }; E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedCurrentWeatherWidgetRefreshServiceTests.swift; sourceTree = ""; }; F4E200000000000000000002 /* DPIPWidgetProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPIPWidgetProviderTests.swift; sourceTree = ""; }; + E9F000000000000000000002 /* ForecastWidgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastWidgetTests.swift; sourceTree = ""; }; F4F200000000000000000002 /* WidgetTownshipResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetTownshipResolverTests.swift; sourceTree = ""; }; F9BCED8E5498E9FD7A454E8F /* Sounds/info.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/info.aiff; sourceTree = ""; }; FD769D73A7C4BE3619C1F9FB /* Sounds/report.aiff */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = audio.aiff; path = Sounds/report.aiff; sourceTree = ""; }; @@ -259,6 +261,12 @@ CurrentWeatherWidgetRefreshPipeline.swift, CurrentWeatherWidgetSnapshotWriter.swift, DPIPWidgetProviderSupport.swift, + ForecastClient.swift, + ForecastRemoteDTO.swift, + ForecastWidgetExpiry.swift, + ForecastWidgetRefreshService.swift, + ForecastWidgetSnapshot.swift, + ForecastWidgetSnapshotStore.swift, SavedCurrentWeatherWidgetRefreshService.swift, SavedWidgetLocationResolver.swift, WidgetCurrentLocationClient.swift, @@ -356,6 +364,7 @@ E7A100000000000000000002 /* CurrentWeatherWidgetSnapshotWriterTests.swift */, E7A200000000000000000002 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift */, F4E200000000000000000002 /* DPIPWidgetProviderTests.swift */, + E9F000000000000000000002 /* ForecastWidgetTests.swift */, F4F200000000000000000002 /* WidgetTownshipResolverTests.swift */, ); path = RunnerTests; @@ -703,6 +712,7 @@ E7A100000000000000000001 /* CurrentWeatherWidgetSnapshotWriterTests.swift in Sources */, E7A200000000000000000001 /* SavedCurrentWeatherWidgetRefreshServiceTests.swift in Sources */, F4E200000000000000000001 /* DPIPWidgetProviderTests.swift in Sources */, + E9F000000000000000000001 /* ForecastWidgetTests.swift in Sources */, F4F200000000000000000001 /* WidgetTownshipResolverTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/RunnerTests/ForecastWidgetTests.swift b/ios/RunnerTests/ForecastWidgetTests.swift new file mode 100644 index 000000000..29a6925b1 --- /dev/null +++ b/ios/RunnerTests/ForecastWidgetTests.swift @@ -0,0 +1,522 @@ +import Foundation +import WidgetKit +import XCTest + +final class ForecastRemoteDTOTests: XCTestCase { + func testDecodesFirstFourUsablePointsInAPIOrderAcrossMidnight() throws { + let dto = try forecastDTO( + times: ["bad", "23:00", "00:00", "01:00", "02:00", "03:00"] + ) + XCTAssertEqual(dto.updateTime, 1_790_336_400_000) + XCTAssertEqual(dto.points.map(\.time), + ["23:00", "00:00", "01:00", "02:00"]) + } + + func testFewerThanFourAndEmptyForecast() throws { + XCTAssertEqual(try forecastDTO(times: ["12:00"]).points.count, 1) + XCTAssertTrue(try forecastDTO(times: []).points.isEmpty) + } + + func testMalformedTimeAndTemperatureAreSkipped() throws { + let data = Data(""" + {"updateTime":1790336400000,"forecast":[ + {"time":"24:00","temperature":20,"weather":"晴","weatherCode":100,"pop":10}, + {"time":"12:60","temperature":20,"weather":"晴","weatherCode":100,"pop":10}, + {"time":"12:00","temperature":"bad","weather":"晴","weatherCode":100,"pop":10}, + {"time":"13:00","temperature":21,"weather":"晴","weatherCode":100,"pop":10} + ]} + """.utf8) + let dto = try JSONDecoder().decode(ForecastRemoteDTO.self, from: data) + XCTAssertEqual(dto.points.map(\.time), ["13:00"]) + } + + func testInvalidPopBecomesNilWithoutDiscardingPoint() throws { + let data = Data(""" + {"updateTime":1790336400000,"forecast":[ + {"time":"10:00","temperature":20,"weather":"晴","weatherCode":100,"pop":-1}, + {"time":"11:00","temperature":21,"weather":"晴","weatherCode":100,"pop":101}, + {"time":"12:00","temperature":22,"weather":"晴","weatherCode":100,"pop":"bad"}, + {"time":"13:00","temperature":23,"weather":"晴","weatherCode":100,"pop":50} + ]} + """.utf8) + let dto = try JSONDecoder().decode(ForecastRemoteDTO.self, from: data) + XCTAssertEqual(dto.points.map(\.pop), [nil, nil, nil, 50]) + } +} + +final class ForecastClientTests: XCTestCase { + private var session: URLSession! + private var client: ForecastClient! + + override func setUp() { + super.setUp() + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ForecastMockURLProtocol.self] + session = URLSession(configuration: configuration) + client = ForecastClient(session: session) + } + + override func tearDown() { + ForecastMockURLProtocol.handler = nil + session.invalidateAndCancel() + session = nil + client = nil + super.tearDown() + } + + func testSuccessfulGETAndDecode() async throws { + ForecastMockURLProtocol.handler = { request in + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.absoluteString, + "https://api.core-tnn1.exptech.dev/api/v5/meteor/weather/forecast/407") + return (self.response(for: request, status: 200), + forecastJSON(times: ["23:00", "00:00"])) + } + let dto = try await client.fetch(regionCode: "407") + XCTAssertEqual(dto.points.map(\.time), ["23:00", "00:00"]) + } + + func testRejectsInvalidRegionCodeBeforeRequest() { + for code in ["40", "0407", "4A7", "٤٠٧", "../"] { + XCTAssertThrowsError(try client.makeURL(regionCode: code)) + } + } + + func testNon200Fails() async { + ForecastMockURLProtocol.handler = { request in + (self.response(for: request, status: 503), Data()) + } + do { + _ = try await client.fetch(regionCode: "407") + XCTFail("Expected HTTP error") + } catch let error as ForecastClientError { + XCTAssertEqual(error, .httpStatus(503)) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testOversizedResponseFails() async { + ForecastMockURLProtocol.handler = { request in + (self.response(for: request, status: 200), Data(repeating: 65, + count: 128 * 1024 + 1)) + } + do { + _ = try await client.fetch(regionCode: "407") + XCTFail("Expected response-size error") + } catch let error as ForecastClientError { + XCTAssertEqual(error, .responseTooLarge) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + private func response(for request: URLRequest, status: Int) -> HTTPURLResponse { + HTTPURLResponse(url: request.url!, statusCode: status, + httpVersion: nil, headerFields: nil)! + } +} + +final class ForecastWidgetSnapshotTests: XCTestCase { + func testSchemaV1RoundTripRetainsIdentityAndMillisecondTimes() throws { + let original = forecastSnapshot() + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ForecastWidgetSnapshot.self, + from: data) + XCTAssertEqual(decoded.schemaVersion, 1) + XCTAssertEqual(decoded.sourceIdentifier, "region:407") + XCTAssertEqual(decoded.regionCode, "407") + XCTAssertEqual(decoded.updateTime, 1_790_336_400_000) + XCTAssertEqual(decoded.receivedAt, 1_790_337_060_000) + XCTAssertEqual(decoded.points.map(\.time), + ["23:00", "00:00", "01:00", "02:00"]) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) + as? [String: Any]) + let point = try XCTUnwrap((json["points"] as? [[String: Any]])?.first) + XCTAssertNil(point["date"]) + XCTAssertNil(point["validAt"]) + } + + func testRejectsFutureSchemaAndSavedRegionMismatch() throws { + let data = try JSONEncoder().encode(forecastSnapshot()) + var json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) + as? [String: Any]) + json["schemaVersion"] = 2 + XCTAssertThrowsError(try JSONDecoder().decode( + ForecastWidgetSnapshot.self, + from: JSONSerialization.data(withJSONObject: json) + )) + XCTAssertNil(ForecastWidgetSnapshot( + sourceIdentifier: "region:407", regionCode: "242", + updateTime: 1_790_336_400_000, + receivedAt: 1_790_337_060_000, + points: forecastSnapshot().points + )) + } +} + +final class ForecastWidgetExpiryTests: XCTestCase { + func testPublicationPlusThirtyMinutesWins() { + let snapshot = forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:21:00Z") + ) + XCTAssertEqual(ForecastWidgetExpiry.date(for: snapshot), + instant("2026-09-25T07:40:00Z")) + XCTAssertTrue(ForecastWidgetExpiry.isUsable( + snapshot, at: instant("2026-09-25T07:39:59Z"))) + XCTAssertFalse(ForecastWidgetExpiry.isUsable( + snapshot, at: instant("2026-09-25T07:40:00Z"))) + } + + func testNextTaipeiHourWinsAndIgnoresDeviceTimezone() { + let snapshot = forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:40:00Z"), + receivedAt: milliseconds("2026-09-25T07:50:00Z") + ) + let expected = instant("2026-09-25T08:00:00Z") + let original = NSTimeZone.default + defer { NSTimeZone.default = original } + NSTimeZone.default = TimeZone(identifier: "America/Los_Angeles")! + XCTAssertEqual(ForecastWidgetExpiry.date(for: snapshot), expected) + NSTimeZone.default = TimeZone(identifier: "Pacific/Auckland")! + XCTAssertEqual(ForecastWidgetExpiry.date(for: snapshot), expected) + XCTAssertFalse(ForecastWidgetExpiry.isUsable(snapshot, at: expected)) + } +} + +final class ForecastWidgetSnapshotStoreTests: XCTestCase { + func testTargetIsolationAndRegionMatching() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ForecastWidgetSnapshotStore(containerURL: directory) + let saved = WidgetLocationTarget.saved(regionCode: "407") + let other = WidgetLocationTarget.saved(regionCode: "242") + let current = WidgetLocationTarget.currentLocation + let date = instant("2026-09-25T07:21:00Z") + let snapshot = forecastSnapshot( + receivedAt: milliseconds("2026-09-25T07:21:00Z")) + XCTAssertTrue(try store.write(snapshot, for: saved)) + XCTAssertEqual(store.load(for: saved, regionCode: "407", at: date)? + .points.first?.time, "23:00") + XCTAssertNil(store.load(for: other, regionCode: "242", at: date)) + XCTAssertNil(store.load(for: saved, regionCode: "242", at: date)) + XCTAssertFalse(try store.write(snapshot, for: other)) + let otherSnapshot = forecastSnapshot( + sourceIdentifier: "region:242", regionCode: "242", + receivedAt: milliseconds("2026-09-25T07:21:00Z")) + XCTAssertTrue(try store.write(otherSnapshot, for: other)) + XCTAssertNotNil(store.load(for: other, regionCode: "242", at: date)) + XCTAssertNotNil(store.load(for: saved, regionCode: "407", at: date)) + + let currentSnapshot = forecastSnapshot( + sourceIdentifier: "current-location", regionCode: "407", + receivedAt: milliseconds("2026-09-25T07:21:00Z")) + XCTAssertTrue(try store.write(currentSnapshot, for: current)) + XCTAssertNotNil(store.load(for: current, regionCode: "407", at: date)) + XCTAssertNil(store.load(for: current, regionCode: "242", at: date)) + XCTAssertNotEqual(store.snapshotURL(for: saved), + store.snapshotURL(for: current)) + } + + func testCorruptAndExpiredCacheAreRejected() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ForecastWidgetSnapshotStore(containerURL: directory) + let target = WidgetLocationTarget.saved(regionCode: "407") + let snapshot = forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:21:00Z")) + try store.write(snapshot, for: target) + XCTAssertNil(store.load(for: target, regionCode: "407", + at: instant("2026-09-25T07:40:00Z"))) + let url = try XCTUnwrap(store.snapshotURL(for: target)) + try Data("broken".utf8).write(to: url, options: .atomic) + XCTAssertNil(store.load(for: target, regionCode: "407", + at: instant("2026-09-25T07:21:00Z"))) + try Data(repeating: 65, count: 128 * 1024 + 1) + .write(to: url, options: .atomic) + XCTAssertNil(store.load(for: target, regionCode: "407", + at: instant("2026-09-25T07:21:00Z"))) + } +} + +final class ForecastWidgetProviderTests: XCTestCase { + func testOnlyLargeRefreshesAndSnapshotPathIsCacheOnly() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let state = ForecastPlannerState(current: currentSnapshot(target: target)) + let planner = makePlanner(state: state) + _ = await planner.plan(for: target, family: .systemSmall) + _ = await planner.plan(for: target, family: .systemMedium) + XCTAssertEqual(state.forecastRefreshCount, 0) + _ = await planner.plan(for: target, family: .systemLarge) + XCTAssertEqual(state.forecastRefreshCount, 1) + XCTAssertEqual(state.requestedRegions, ["407"]) + let dependencies = DPIPWidgetProviderDependencies( + loadSnapshot: { state.loadCurrent($0) }, + timelinePlanner: planner, + loadForecast: { state.loadForecast($0, $1, $2) } + ) + _ = dependencies.snapshot(for: target) + _ = dependencies.forecastSnapshot( + for: target, currentSnapshot: state.loadCurrent(target), + at: instant("2026-09-25T07:21:00Z"), family: .systemLarge + ) + XCTAssertEqual(state.forecastRefreshCount, 1) + } + + func testForecastFailureKeepsCurrentAndValidCacheFallback() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let state = ForecastPlannerState( + current: currentSnapshot(target: target), + forecast: forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:21:00Z")) + ) + let plan = await makePlanner(state: state).plan( + for: target, family: .systemLarge + ) + XCTAssertEqual(plan.snapshot?.regionCode, "407") + XCTAssertNotNil(plan.forecast) + let expiry = instant("2026-09-25T07:40:00Z") + XCTAssertEqual(plan.states.count, 3) + XCTAssertTrue(plan.states.contains { $0.date == expiry }) + XCTAssertNotNil(plan.forecast(at: plan.states[0].date)) + XCTAssertNil(plan.forecast(at: expiry)) + XCTAssertEqual(plan.snapshot?.stationName, "station") + } + + func testExpiredAndMismatchedCacheNeverAttaches() async { + let target = WidgetLocationTarget.currentLocation + let current = currentSnapshot(target: target, regionCode: "242") + let state = ForecastPlannerState( + current: current, + forecast: forecastSnapshot( + sourceIdentifier: "current-location", regionCode: "407", + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:21:00Z")) + ) + let regionMismatch = await makePlanner(state: state).plan( + for: target, family: .systemLarge + ) + XCTAssertNil(regionMismatch.forecast) + state.forecast = forecastSnapshot( + sourceIdentifier: "region:242", regionCode: "242", + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:21:00Z")) + let sourceMismatch = await makePlanner(state: state).plan( + for: target, family: .systemLarge + ) + XCTAssertNil(sourceMismatch.forecast) + state.forecast = forecastSnapshot( + sourceIdentifier: "current-location", regionCode: "242", + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:21:00Z")) + let latePlanner = makePlanner( + state: state, now: instant("2026-09-25T07:40:00Z") + ) + let expired = await latePlanner.plan(for: target, + family: .systemLarge) + XCTAssertNil(expired.forecast) + } + + func testLateCurrentLocationResponseForAIsNotWrittenAfterB() async { + let target = WidgetLocationTarget.currentLocation + let state = ForecastPlannerState( + current: currentSnapshot(target: target, regionCode: "407")) + let gate = AsyncOperationGate() + let dto = try! forecastDTO(times: ["23:00"]) + let service = ForecastWidgetRefreshService( + fetch: { _ in await gate.wait(); return dto }, + loadCurrent: { state.loadCurrent($0) }, + savedIsResolved: { _ in true }, + write: { snapshot, _ in state.forecast = snapshot; return true }, + now: { instant("2026-09-25T07:21:00Z") } + ) + let task = Task { await service.refresh(target: target, + regionCode: "407") } + while !(await gate.hasStarted) { await Task.yield() } + state.current = currentSnapshot(target: target, regionCode: "242") + await gate.open() + await task.value + XCTAssertNil(state.forecast) + } + + func testUnresolvedSavedTargetNeverStartsForecastRequest() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let state = ForecastPlannerState(current: currentSnapshot(target: target)) + let calls = ForecastCallCounter() + let service = ForecastWidgetRefreshService( + fetch: { _ in + calls.increment() + return try forecastDTO(times: ["12:00"]) + }, + loadCurrent: { state.loadCurrent($0) }, + savedIsResolved: { _ in false }, + write: { _, _ in true }, + now: { instant("2026-09-25T07:21:00Z") } + ) + await service.refresh(target: target, regionCode: "407") + XCTAssertEqual(calls.count, 0) + } + + private func makePlanner( + state: ForecastPlannerState, + now date: Date = instant("2026-09-25T07:21:00Z") + ) -> DPIPWidgetTimelinePlanner { + DPIPWidgetTimelinePlanner( + staleAfter: 30 * 60, + refreshInterval: 20 * 60, + loadSnapshot: { state.loadCurrent($0) }, + refreshSaved: { _ in .failed }, + refreshCurrent: { .failed }, + loadForecast: { state.loadForecast($0, $1, $2) }, + refreshForecast: { await state.refreshForecast($0, $1) }, + now: { date } + ) + } +} + +private final class ForecastCallCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + var count: Int { lock.withLock { value } } + func increment() { lock.withLock { value += 1 } } +} + +private final class ForecastPlannerState: @unchecked Sendable { + private let lock = NSLock() + private var storedCurrent: CurrentWeatherWidgetSnapshot? + private var storedForecast: ForecastWidgetSnapshot? + private var regions: [String] = [] + + init(current: CurrentWeatherWidgetSnapshot?, + forecast: ForecastWidgetSnapshot? = nil) { + storedCurrent = current + storedForecast = forecast + } + + var current: CurrentWeatherWidgetSnapshot? { + get { lock.withLock { storedCurrent } } + set { lock.withLock { storedCurrent = newValue } } + } + var forecast: ForecastWidgetSnapshot? { + get { lock.withLock { storedForecast } } + set { lock.withLock { storedForecast = newValue } } + } + var forecastRefreshCount: Int { lock.withLock { regions.count } } + var requestedRegions: [String] { lock.withLock { regions } } + + func loadCurrent(_ target: WidgetLocationTarget) + -> CurrentWeatherWidgetSnapshot? { + current + } + + func loadForecast(_ target: WidgetLocationTarget, + _ regionCode: String, + _ date: Date) -> ForecastWidgetSnapshot? { + guard let forecast, + forecast.sourceIdentifier == target.sourceIdentifier, + forecast.regionCode == regionCode, + ForecastWidgetExpiry.isUsable(forecast, at: date) else { + return nil + } + return forecast + } + + func refreshForecast(_ target: WidgetLocationTarget, + _ regionCode: String) async { + lock.withLock { regions.append(regionCode) } + } +} + +private final class ForecastMockURLProtocol: URLProtocol { + static var handler: ((URLRequest) throws -> (URLResponse, Data))? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + override func startLoading() { + do { + guard let handler = Self.handler else { + throw URLError(.badServerResponse) + } + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, + cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + override func stopLoading() {} +} + +private func forecastDTO(times: [String]) throws -> ForecastRemoteDTO { + try JSONDecoder().decode(ForecastRemoteDTO.self, + from: forecastJSON(times: times)) +} + +private func forecastJSON(times: [String]) -> Data { + let points = times.map { time in + ["time": time, "temperature": 22, "weather": "晴", + "weatherCode": 100, "pop": 20] as [String: Any] + } + return try! JSONSerialization.data(withJSONObject: [ + "updateTime": 1_790_336_400_000 as Int64, + "forecast": points, + ]) +} + +private func forecastSnapshot( + sourceIdentifier: String = "region:407", + regionCode: String = "407", + updateTime: Int64 = 1_790_336_400_000, + receivedAt: Int64 = 1_790_337_060_000 +) -> ForecastWidgetSnapshot { + ForecastWidgetSnapshot( + sourceIdentifier: sourceIdentifier, + regionCode: regionCode, + updateTime: updateTime, + receivedAt: receivedAt, + points: ["23:00", "00:00", "01:00", "02:00"].map { + ForecastWidgetPoint(time: $0, temperature: 22, + weather: "晴", weatherCode: 100, + pop: 20)! + } + )! +} + +private func currentSnapshot(target: WidgetLocationTarget, + regionCode: String = "407") + -> CurrentWeatherWidgetSnapshot { + CurrentWeatherWidgetSnapshot( + schemaVersion: 7, + sourceIdentifier: target.sourceIdentifier, + regionCode: regionCode, + regionName: "測試地區", + observationTime: Int(instant("2026-09-25T07:21:00Z") + .timeIntervalSince1970), + stationName: "station", + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: false, + nextDayNightTransitionTime: 0, + calibratedTimeOffsetMilliseconds: 0, + temperature: 22, + humidity: 50, + rain: 0 + ) +} + +private func instant(_ string: String) -> Date { + ISO8601DateFormatter().date(from: string)! +} + +private func milliseconds(_ string: String) -> Int64 { + Int64(instant(string).timeIntervalSince1970 * 1_000) +} From 57e9a839f463b01f986658b3c9badcd935468a04 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Sat, 26 Sep 2026 08:58:08 +0800 Subject: [PATCH 06/17] feat(weather): add medium and large iOS weather widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios New(zh-Hant): 新增中型與大型 iOS 天氣小工具及逐時預報 New(en-US): add medium and large iOS weather widgets with hourly forecast --- .../CurrentWeatherWidgetSnapshot.swift | 274 ++++ .../DPIPWidgetProviderSupport.swift | 18 +- ios/DPIPWidgets/DPIPWidgets.swift | 510 ++++++- ios/DPIPWidgets/ForecastRemoteDTO.swift | 2 +- ios/DPIPWidgets/ForecastWidgetSnapshot.swift | 6 +- ios/DPIPWidgets/Localizable.xcstrings | 1182 ++++++++++++++++- .../CurrentWeatherWidgetTests.swift | 312 +++++ ios/RunnerTests/ForecastWidgetTests.swift | 138 +- 8 files changed, 2341 insertions(+), 101 deletions(-) diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index 262c5592a..6963b8642 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -69,6 +69,266 @@ enum CurrentWeatherWidgetCondition: String, Decodable, Sendable { } } +enum WidgetWeatherCondition: CaseIterable, Equatable, Sendable { + case clear + case cloudy + case overcast + case fog + case rain + case sleet + case snow + case hail + case thunder + case thunderstorm + case unknown + + init(weatherCode: Int, weather: String) { + switch weatherCode % 100 { + case 1, 2, 5: + self = .fog + return + case 3, 4, 19: + self = .thunder + return + case 6, 11: + self = .rain + return + case 7, 12: + self = .sleet + return + case 8, 9, 10, 15: + self = .snow + return + case 13, 16, 18: + self = .hail + return + case 14, 17: + self = .thunderstorm + return + default: + break + } + + switch weatherCode / 100 { + case 1: + self = .clear + case 2: + self = .cloudy + case 3: + self = .overcast + default: + self = Self.fallbackCondition(weather: weather) + } + } + + private static func fallbackCondition(weather: String) -> Self { + if weather.contains("雷"), weather.contains("雨") { + return .thunderstorm + } + if weather.contains("雹") { + return .hail + } + if weather.contains("雨"), weather.contains("雪") { + return .sleet + } + if weather.contains("雪") { + return .snow + } + if weather.contains("雷") { + return .thunder + } + if weather.contains("雨") { + return .rain + } + if weather.contains("霧") + || weather.contains("靄") + || weather.contains("霾") { + return .fog + } + if weather.contains("晴") { + return .clear + } + if weather.contains("多雲") { + return .cloudy + } + if weather.contains("陰") { + return .overcast + } + return .unknown + } + + var localizedDisplayName: LocalizedStringKey { + LocalizedStringKey(displayNameLocalizationKey) + } + + var displayNameLocalizationKey: String { + switch self { + case .clear: + return "weather.clear" + case .cloudy: + return "weather.cloudy" + case .overcast: + return "weather.overcast" + case .rain: + return "weather.rain" + case .sleet: + return "weather.sleet" + case .hail: + return "weather.hail" + case .thunder: + return "weather.thunder" + case .thunderstorm: + return "weather.thunderstorm" + case .snow: + return "weather.snow" + case .fog: + return "weather.fog" + case .unknown: + return "weather.unknown" + } + } + + func systemImageName(isNight: Bool) -> String { + switch self { + case .clear: + return isNight + ? "moon.stars.fill" + : "sun.max.fill" + + case .cloudy: + return isNight + ? "cloud.moon.fill" + : "cloud.sun.fill" + + case .overcast: + return "cloud.fill" + + case .rain: + return "cloud.rain.fill" + + case .sleet: + return "cloud.sleet.fill" + + case .hail: + return "cloud.hail.fill" + + case .thunder: + return "bolt.fill" + + case .thunderstorm: + return "cloud.bolt.rain.fill" + + case .snow: + return "cloud.snow.fill" + + case .fog: + return "cloud.fog.fill" + + case .unknown: + return "cloud.fill" + } + } +} + +enum WidgetWindDirection: CaseIterable, Equatable, Sendable { + case north + case northNortheast + case northeast + case eastNortheast + case east + case eastSoutheast + case southeast + case southSoutheast + case south + case southSouthwest + case southwest + case westSouthwest + case west + case westNorthwest + case northwest + case northNorthwest + + init?(rawDirection: String) { + switch rawDirection.trimmingCharacters(in: .whitespacesAndNewlines) { + case "北", "N": + self = .north + case "北北東", "NNE": + self = .northNortheast + case "東北", "NE": + self = .northeast + case "東北東", "ENE": + self = .eastNortheast + case "東", "E": + self = .east + case "東南東", "ESE": + self = .eastSoutheast + case "東南", "SE": + self = .southeast + case "南南東", "SSE": + self = .southSoutheast + case "南", "S": + self = .south + case "南南西", "SSW": + self = .southSouthwest + case "西南", "SW": + self = .southwest + case "西南西", "WSW": + self = .westSouthwest + case "西", "W": + self = .west + case "西北西", "WNW": + self = .westNorthwest + case "西北", "NW": + self = .northwest + case "北北西", "NNW": + self = .northNorthwest + default: + return nil + } + } + + var localizedDisplayName: LocalizedStringKey { + LocalizedStringKey(displayNameLocalizationKey) + } + + var displayNameLocalizationKey: String { + switch self { + case .north: + return "wind.direction.n" + case .northNortheast: + return "wind.direction.nne" + case .northeast: + return "wind.direction.ne" + case .eastNortheast: + return "wind.direction.ene" + case .east: + return "wind.direction.e" + case .eastSoutheast: + return "wind.direction.ese" + case .southeast: + return "wind.direction.se" + case .southSoutheast: + return "wind.direction.sse" + case .south: + return "wind.direction.s" + case .southSouthwest: + return "wind.direction.ssw" + case .southwest: + return "wind.direction.sw" + case .westSouthwest: + return "wind.direction.wsw" + case .west: + return "wind.direction.w" + case .westNorthwest: + return "wind.direction.wnw" + case .northwest: + return "wind.direction.nw" + case .northNorthwest: + return "wind.direction.nnw" + } + } +} + struct CurrentWeatherWidgetSnapshot: Codable, Sendable { let schemaVersion: Int let sourceIdentifier: String? @@ -98,6 +358,20 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { let windSpeed: Double? let apparentTemperature: Double? + var presentationCondition: WidgetWeatherCondition { + WidgetWeatherCondition(weatherCode: weatherCode, weather: weather) + } + + var presentationWindDirection: WidgetWindDirection? { + guard let windDirection else { + return nil + } + + return WidgetWindDirection( + rawDirection: windDirection + ) + } + private enum CodingKeys: String, CodingKey { case schemaVersion case sourceIdentifier diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift index 7768a0c04..1d1dd79f0 100644 --- a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -1,5 +1,17 @@ import Foundation import WidgetKit + +enum ForecastWidgetFamilyPolicy { + static func supportsForecast(_ family: WidgetFamily) -> Bool { + switch family { + case .systemMedium, .systemLarge: + return true + default: + return false + } + } +} + #if DEBUG import OSLog @@ -168,7 +180,7 @@ struct DPIPWidgetTimelinePlanner: Sendable { // Always reload after the refresh attempt. Failed refreshes leave the // same-location cache untouched, so this also provides SWR behavior. var snapshot = loadSnapshot(target) - if family == .systemLarge, + if ForecastWidgetFamilyPolicy.supportsForecast(family), let current = snapshot, target.matches(snapshot: current) { await refreshForecast(target, current.regionCode) @@ -184,7 +196,7 @@ struct DPIPWidgetTimelinePlanner: Sendable { ) let reloadDate = deviceNow.addingTimeInterval(refreshInterval) let forecast: ForecastWidgetSnapshot? - if family == .systemLarge, + if ForecastWidgetFamilyPolicy.supportsForecast(family), let snapshot, target.matches(snapshot: snapshot) { forecast = loadForecast( @@ -268,7 +280,7 @@ struct DPIPWidgetProviderDependencies: Sendable { at date: Date, family: WidgetFamily ) -> ForecastWidgetSnapshot? { - guard family == .systemLarge, + guard ForecastWidgetFamilyPolicy.supportsForecast(family), let currentSnapshot, target.matches(snapshot: currentSnapshot) else { return nil } return loadForecast(target, currentSnapshot.regionCode, date) diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 160bdae9a..f812f19ed 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -138,6 +138,67 @@ struct DPIPWidgetEntry: TimelineEntry { } } +private func forecastIsNight( + pointTime: String, + entryDate: Date, + isCurrentlyNight: Bool, + nextTransitionTime: Int +) -> Bool { + let parts = pointTime.split(separator: ":") + guard parts.count == 2, + let hour = Int(parts[0]), + let minute = Int(parts[1]), + (0...23).contains(hour), + (0...59).contains(minute) + else { + return isCurrentlyNight + } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Taipei")! + + let currentComponents = calendar.dateComponents( + [.hour, .minute], + from: entryDate + ) + + guard let currentHour = currentComponents.hour, + let currentMinute = currentComponents.minute + else { + return isCurrentlyNight + } + + let currentMinutes = currentHour * 60 + currentMinute + let forecastMinutes = hour * 60 + minute + + var minutesAhead = forecastMinutes - currentMinutes + + if minutesAhead < 0 { + minutesAhead += 24 * 60 + } + + guard minutesAhead <= 12 * 60 else { + return isCurrentlyNight + } + + let transitionDate = Date( + timeIntervalSince1970: TimeInterval(nextTransitionTime) + ) + + let minutesUntilTransition = + transitionDate.timeIntervalSince(entryDate) / 60 + + guard minutesUntilTransition >= 0 else { + return isCurrentlyNight + } + + if Double(minutesAhead) < minutesUntilTransition { + return isCurrentlyNight + } + + return !isCurrentlyNight +} + private struct SmallCurrentWeatherView: View { let entry: DPIPWidgetEntry @@ -174,12 +235,12 @@ private struct SmallCurrentWeatherView: View { Spacer() VStack(alignment: .center, spacing: 2) { - Image(systemName: snapshot.condition.systemImageName( + Image(systemName: snapshot.presentationCondition.systemImageName( isNight: entry.isNight )) .font(.title) - Text(snapshot.condition.localizedDisplayName) + Text(snapshot.presentationCondition.localizedDisplayName) .lineLimit(1) .minimumScaleFactor(0.8) .font(.caption2) @@ -255,7 +316,7 @@ private struct MediumCurrentWeatherView: View { timeIntervalSince1970: TimeInterval(snapshot.observationTime) ) - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline) { HStack(spacing: 4) { Text(snapshot.regionName) @@ -280,15 +341,12 @@ private struct MediumCurrentWeatherView: View { .lineLimit(1) } - Spacer() - .frame(height: 4) - HStack(alignment: .center) { - VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 0) { if let temperature = snapshot.temperature { Text("\(temperature, specifier: "%.0f")°") .font(.system( - size: 42, + size: 38, weight: .semibold, design: .rounded )) @@ -297,7 +355,7 @@ private struct MediumCurrentWeatherView: View { } else { Text("—°") .font(.system( - size: 42, + size: 38, weight: .semibold, design: .rounded )) @@ -317,12 +375,12 @@ private struct MediumCurrentWeatherView: View { Spacer() HStack(spacing: 6) { - Image(systemName: snapshot.condition.systemImageName( + Image(systemName: snapshot.presentationCondition.systemImageName( isNight: entry.isNight )) .font(.title2) - Text(snapshot.condition.localizedDisplayName) + Text(snapshot.presentationCondition.localizedDisplayName) .font(.subheadline) .lineLimit(1) .minimumScaleFactor(0.8) @@ -330,68 +388,238 @@ private struct MediumCurrentWeatherView: View { .foregroundStyle(.secondary) } - HStack(alignment: .top, spacing: 8) { - VStack(alignment: .leading, spacing: 2) { - Text("widget.humidity") - .font(.caption2) - .foregroundStyle(.secondary) + if let forecast = entry.forecast, + !forecast.points.isEmpty { + HourlyForecastSection( + forecast: forecast, + compact: true, + entryDate: entry.date, + isCurrentlyNight: entry.isNight, + nextTransitionTime: snapshot.nextDayNightTransitionTime + ) + } + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + } else { + VStack(spacing: 8) { + Image(systemName: "cloud.fill") + .font(.title) - if let humidity = snapshot.humidity { - Text("\(humidity)%") - .font(.caption) - .fontWeight(.medium) - } else { - Text("--%") - .font(.caption) - .fontWeight(.medium) + Text("widget.no_weather_data") + .font(.caption) + } + .foregroundStyle(.secondary) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + } + } +} + +private struct LargeCurrentWeatherView: View { + let entry: DPIPWidgetEntry + + var body: some View { + if let snapshot = entry.snapshot { + let observationDate = Date( + timeIntervalSince1970: TimeInterval(snapshot.observationTime) + ) + + let transitionDate = Date( + timeIntervalSince1970: + TimeInterval(snapshot.nextDayNightTransitionTime) + ) + + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + HStack(spacing: 4) { + Text(snapshot.regionName) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.8) + .layoutPriority(1) + + if snapshot.sourceIdentifier == "current-location" { + Image(systemName: "location.fill") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize() } } - .frame(maxWidth: .infinity, alignment: .leading) - VStack(alignment: .center, spacing: 2) { - Text("widget.rainfall") - .font(.caption2) - .foregroundStyle(.secondary) + Spacer() - if let rain = snapshot.rain { - Text("\(rain, specifier: "%.1f") mm") - .font(.caption) - .fontWeight(.medium) + Text(observationDate, style: .time) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 0) { + if let temperature = snapshot.temperature { + Text("\(temperature, specifier: "%.0f")°") + .font(.system( + size: 42, + weight: .semibold, + design: .rounded + )) + .lineLimit(1) + .minimumScaleFactor(0.8) } else { - Text("-- mm") - .font(.caption) - .fontWeight(.medium) + Text("—°") + .font(.system( + size: 42, + weight: .semibold, + design: .rounded + )) + .foregroundStyle(.secondary) } - } - .frame(maxWidth: .infinity, alignment: .center) - VStack(alignment: .trailing, spacing: 2) { - Text("widget.wind") - .font(.caption2) + if let apparentTemperature = snapshot.apparentTemperature { + HStack(spacing: 4) { + Text("widget.feels_like") + Text("\(apparentTemperature, specifier: "%.0f")°") + } + .font(.caption) .foregroundStyle(.secondary) + } + } + + Spacer() + + HStack(spacing: 6) { + Image(systemName: snapshot.presentationCondition.systemImageName( + isNight: entry.isNight + )) + .font(.title) - if let direction = snapshot.windDirection, - let speed = snapshot.windSpeed { - Text("\(direction) \(speed, specifier: "%.1f") m/s") + Text(snapshot.presentationCondition.localizedDisplayName) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .foregroundStyle(.secondary) + } + + Spacer() + + if let forecast = entry.forecast, + !forecast.points.isEmpty { + HourlyForecastSection( + forecast: forecast, + compact: false, + entryDate: entry.date, + isCurrentlyNight: entry.isNight, + nextTransitionTime: snapshot.nextDayNightTransitionTime + ) + + let forecastUpdateDate = Date( + timeIntervalSince1970: + TimeInterval(forecast.updateTime) / 1000 + ) + } + + Spacer() + + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("widget.humidity") + .font(.caption2) + .foregroundStyle(.secondary) + + if let humidity = snapshot.humidity { + Text("\(humidity)%") + .font(.caption) + .fontWeight(.medium) + } else { + Text("--%") + .font(.caption) + .fontWeight(.medium) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 2) { + Text("widget.rainfall") + .font(.caption2) + .foregroundStyle(.secondary) + + if let rain = snapshot.rain { + Text("\(rain, specifier: "%.1f") mm") + .font(.caption) + .fontWeight(.medium) + } else { + Text("-- mm") + .font(.caption) + .fontWeight(.medium) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 2) { + Text(entry.isNight ? "widget.sunrise" : "widget.sunset") + .font(.caption2) + .foregroundStyle(.secondary) + + Text(transitionDate, style: .time) .font(.caption) .fontWeight(.medium) .lineLimit(1) - .minimumScaleFactor(0.75) - } else if let speed = snapshot.windSpeed { - Text("\(speed, specifier: "%.1f") m/s") - .font(.caption) - .fontWeight(.medium) - } else if let direction = snapshot.windDirection { - Text(direction) - .font(.caption) - .fontWeight(.medium) - } else { - Text("--") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text("widget.wind") + .font(.caption2) + .foregroundStyle(.secondary) + + if let windSpeed = snapshot.windSpeed { + HStack(spacing: 4) { + if let direction = snapshot.presentationWindDirection { + Text(direction.localizedDisplayName) + .font(.caption) + .fontWeight(.medium) + } else if let windDirection = snapshot.windDirection { + Text(windDirection) + .font(.caption) + .fontWeight(.medium) + } + + Text("\(windSpeed, specifier: "%.1f") m/s") + } + } else if let windDirection = snapshot.windDirection { + Text(windDirection) + .font(.caption) + .fontWeight(.medium) + } else { + Text("--") + .font(.caption) + .fontWeight(.medium) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 2) { + Text("widget.station") + .font(.caption2) + .foregroundStyle(.secondary) + + Text(snapshot.stationName.isEmpty ? "--" : snapshot.stationName) .font(.caption) .fontWeight(.medium) + .lineLimit(1) } + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .trailing) } } .frame( @@ -410,12 +638,99 @@ private struct MediumCurrentWeatherView: View { .foregroundStyle(.secondary) .frame( maxWidth: .infinity, - maxHeight: .infinity + maxHeight: .infinity, ) } } } +private struct HourlyForecastPointView: View { + let point: ForecastWidgetPoint + let compact: Bool + + let entryDate: Date + let isCurrentlyNight: Bool + let nextTransitionTime: Int + + var body: some View { + let isNight = forecastIsNight( + pointTime: point.time, + entryDate: entryDate, + isCurrentlyNight: isCurrentlyNight, + nextTransitionTime: nextTransitionTime + ) + + VStack(spacing: compact ? 2 : 5) { + Text(point.time) + .font(compact ? .caption2 : .caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + + Image( + systemName: point.presentationCondition.systemImageName( + isNight: isNight + ) + ) + .font(compact ? .title3 : .title2) + + Text("\(point.temperature, specifier: "%.0f")°") + .font(compact ? .caption : .subheadline) + .fontWeight(.semibold) + .lineLimit(1) + + if let pop = point.pop { + Text("\(pop)%") + .font(compact ? .caption2 : .caption) + .foregroundStyle(.secondary) + } else { + Text("—") + .font(compact ? .caption2 : .caption) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity) + } +} + +private struct HourlyForecastSection: View { + let forecast: ForecastWidgetSnapshot + let compact: Bool + + let entryDate: Date + let isCurrentlyNight: Bool + let nextTransitionTime: Int + + var body: some View { + VStack( + alignment: .leading, + spacing: compact ? 4 : 12 + ) { + HStack( + alignment: .top, + spacing: compact ? 4 : 8 + ) { + ForEach( + Array(forecast.points.enumerated()), + id: \.offset + ) { _, point in + HourlyForecastPointView( + point: point, + compact: compact, + entryDate: entryDate, + isCurrentlyNight: isCurrentlyNight, + nextTransitionTime: nextTransitionTime + ) + .frame( + maxWidth: .infinity, + alignment: .center + ) + } + } + } + } +} + struct DPIPWidgetsEntryView : View { @Environment(\.widgetFamily) private var family @@ -429,6 +744,9 @@ struct DPIPWidgetsEntryView : View { case .systemMedium: MediumCurrentWeatherView(entry: entry) + case .systemLarge: + LargeCurrentWeatherView(entry: entry) + default: SmallCurrentWeatherView(entry: entry) } @@ -457,7 +775,8 @@ struct DPIPWidgets: Widget { } .supportedFamilies([ .systemSmall, - .systemMedium + .systemMedium, + .systemLarge ]) .configurationDisplayName("widget.current_weather") .description("widget.current_weather_description") @@ -465,20 +784,73 @@ struct DPIPWidgets: Widget { } struct DPIPWidgets_Previews: PreviewProvider { + private static let previewNow = Date() + + private static let previewTransitionTime = Int( + previewNow + .addingTimeInterval(6 * 60 * 60) + .timeIntervalSince1970 + ) + + private static let previewForecast = ForecastWidgetSnapshot( + sourceIdentifier: "current-location", + regionCode: "407", + + updateTime: 1_790_316_445_925, + receivedAt: 1_790_316_600_000, + + points: [ + ForecastWidgetPoint( + time: "01:00", + temperature: 26.0, + weather: "晴", + weatherCode: 100, + pop: 0 + )!, + ForecastWidgetPoint( + time: "02:00", + temperature: 26.0, + weather: "多雲", + weatherCode: 200, + pop: 10 + )!, + ForecastWidgetPoint( + time: "03:00", + temperature: 25.0, + weather: "多雲有雨", + weatherCode: 206, + pop: 60 + )!, + ForecastWidgetPoint( + time: "04:00", + temperature: 25.0, + weather: "多雲有雷雨", + weatherCode: 214, + pop: 80 + )!, + ForecastWidgetPoint( + time: "05:00", + temperature: 24.0, + weather: "多雲有霧", + weatherCode: 205, + pop: 20 + )!, + ] ) + private static let previewEntry = DPIPWidgetEntry( - date: .now, + date: previewNow, snapshot: CurrentWeatherWidgetSnapshot( schemaVersion: 7, sourceIdentifier: "current-location", regionCode: "407", regionName: "西屯區", - observationTime: Int(Date().timeIntervalSince1970), + observationTime: Int(previewNow.timeIntervalSince1970), stationName: "西屯", weather: "晴", weatherCode: 100, condition: .clear, - isNight: false, - nextDayNightTransitionTime: 1_789_562_700, + isNight: true, + nextDayNightTransitionTime: previewTransitionTime, calibratedTimeOffsetMilliseconds: 0, temperature: 28.0, humidity: 76, @@ -488,7 +860,8 @@ struct DPIPWidgets_Previews: PreviewProvider { apparentTemperature: 30.2 ), isStale: false, - isNight: false + isNight: true, + forecast: previewForecast ) private static let missingOptionalDataEntry = DPIPWidgetEntry( @@ -511,10 +884,11 @@ struct DPIPWidgets_Previews: PreviewProvider { rain: 0.0, windDirection: nil, windSpeed: nil, - apparentTemperature: nil + apparentTemperature: nil, ), isStale: false, - isNight: false + isNight: false, + forecast: previewForecast ) @ViewBuilder @@ -545,6 +919,12 @@ struct DPIPWidgets_Previews: PreviewProvider { WidgetPreviewContext(family: .systemMedium) ) + previewView(entry: previewEntry) + .previewDisplayName("Large") + .previewContext( + WidgetPreviewContext(family: .systemLarge) + ) + previewView(entry: missingOptionalDataEntry) .previewDisplayName("Medium — Missing Optional Data") .previewContext( diff --git a/ios/DPIPWidgets/ForecastRemoteDTO.swift b/ios/DPIPWidgets/ForecastRemoteDTO.swift index 48d849e56..e2194d319 100644 --- a/ios/DPIPWidgets/ForecastRemoteDTO.swift +++ b/ios/DPIPWidgets/ForecastRemoteDTO.swift @@ -34,6 +34,6 @@ struct ForecastRemoteDTO: Decodable, Sendable { [UsablePoint].self, forKey: .forecast ) - points = Array(forecast.compactMap(\.point).prefix(4)) + points = Array(forecast.compactMap(\.point).prefix(5)) } } diff --git a/ios/DPIPWidgets/ForecastWidgetSnapshot.swift b/ios/DPIPWidgets/ForecastWidgetSnapshot.swift index 1d1f4830a..3d0807cb2 100644 --- a/ios/DPIPWidgets/ForecastWidgetSnapshot.swift +++ b/ios/DPIPWidgets/ForecastWidgetSnapshot.swift @@ -10,6 +10,10 @@ struct ForecastWidgetPoint: Codable, Equatable, Sendable { /// Probability of precipitation in percent. Invalid/missing PoP is nil. let pop: Int? + var presentationCondition: WidgetWeatherCondition { + WidgetWeatherCondition(weatherCode: weatherCode, weather: weather) + } + init?(time: String, temperature: Double, weather: String, weatherCode: Int, pop: Int?) { guard Self.isValidClockLabel(time), temperature.isFinite, @@ -79,7 +83,7 @@ struct ForecastWidgetSnapshot: Codable, Sendable { sourceIdentifier: sourceIdentifier ), WidgetResolvedWeatherLocationValidation.isValidRegionCode(regionCode), updateTime > 0, receivedAt > 0, - (1...4).contains(points.count) else { return nil } + (1...5).contains(points.count) else { return nil } if case .saved(let code) = address, code != regionCode { return nil } self.schemaVersion = Self.schemaVersion self.sourceIdentifier = sourceIdentifier diff --git a/ios/DPIPWidgets/Localizable.xcstrings b/ios/DPIPWidgets/Localizable.xcstrings index df5650bbe..95293fcd9 100644 --- a/ios/DPIPWidgets/Localizable.xcstrings +++ b/ios/DPIPWidgets/Localizable.xcstrings @@ -29,6 +29,24 @@ "value" : "Clear" } }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "晴れ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "맑음" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "晴" + } + }, "zh-Hant" : { "stringUnit" : { "state" : "translated", @@ -47,6 +65,24 @@ "value" : "Cloudy" } }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "くもり" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "구름 많음" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "多云" + } + }, "zh-Hant" : { "stringUnit" : { "state" : "translated", @@ -65,6 +101,24 @@ "value" : "Fog" } }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "霧" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "안개" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "雾" + } + }, "zh-Hant" : { "stringUnit" : { "state" : "translated", @@ -73,6 +127,42 @@ } } }, + "weather.hail" : { + "comment" : "Localized weather condition for hail.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hail" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ひょう" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "우박" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "冰雹" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "冰雹" + } + } + } + }, "weather.overcast" : { "comment" : "Localized current weather condition for overcast skies.", "extractionState" : "stale", @@ -83,6 +173,24 @@ "value" : "Overcast" } }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "曇天" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "흐림" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "阴" + } + }, "zh-Hant" : { "stringUnit" : { "state" : "translated", @@ -101,6 +209,24 @@ "value" : "Rain" } }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "雨" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "雨" + } + }, "zh-Hant" : { "stringUnit" : { "state" : "translated", @@ -109,6 +235,42 @@ } } }, + "weather.sleet" : { + "comment" : "Localized weather condition for mixed rain and snow.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sleet" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "みぞれ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진눈깨비" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "雨雪" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "雨雪" + } + } + } + }, "weather.snow" : { "comment" : "Localized current weather condition for snow.", "extractionState" : "stale", @@ -119,6 +281,24 @@ "value" : "Snow" } }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "雪" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "눈" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "雪" + } + }, "zh-Hant" : { "stringUnit" : { "state" : "translated", @@ -127,6 +307,42 @@ } } }, + "weather.thunder" : { + "comment" : "Localized weather condition for thunder or lightning without precipitation.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thunder" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "雷" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "천둥·번개" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "雷电" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "雷電" + } + } + } + }, "weather.thunderstorm" : { "comment" : "Localized current weather condition for thunderstorms.", "extractionState" : "stale", @@ -137,6 +353,24 @@ "value" : "Thunderstorm" } }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "雷雨" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "뇌우" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "雷雨" + } + }, "zh-Hant" : { "stringUnit" : { "state" : "translated", @@ -155,6 +389,24 @@ "value" : "Unknown" } }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "不明" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알 수 없음" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知" + } + }, "zh-Hant" : { "stringUnit" : { "state" : "translated", @@ -172,6 +424,24 @@ "value" : "Current Weather" } }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在の天気" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 날씨" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前天气" + } + }, "zh-Hant" : { "stringUnit" : { "state" : "translated", @@ -189,6 +459,24 @@ "value" : "Shows current weather for the location configured for this widget." } }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このウィジェットに設定された場所の現在の天気を表示します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 위젯에 설정된 위치의 현재 날씨를 표시합니다." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示此小组件所设定地区的当前天气。" + } + }, "zh-Hant" : { "stringUnit" : { "state" : "translated", @@ -197,40 +485,916 @@ } } }, - "widget.no_weather_data" : { - "comment" : "Message shown when the Widget has no current weather snapshot.", + "widget.feels_like" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "No weather data" + "value" : "Feels Like" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "体感" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "체감" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "体感" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "尚無天氣資料" + "value" : "體感" } } } }, - "widget.stale" : { - "comment" : "Label shown when the current weather snapshot is outdated.", + "widget.forecast_updated" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Outdated" + "value" : "Forecast Updated" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "予報更新" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예보 업데이트" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预报更新" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "較舊" + "value" : "預報更新" + } + } + } + }, + "widget.humidity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Humidity" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "湿度" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "습도" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "湿度" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "濕度" + } + } + } + }, + "widget.no_weather_data" : { + "comment" : "Message shown when the Widget has no current weather snapshot.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No weather data" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "天気データなし" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "날씨 데이터 없음" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无天气数据" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚無天氣資料" + } + } + } + }, + "widget.rainfall" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rainfall" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "降雨量" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "강우량" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "雨量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "雨量" + } + } + } + }, + "widget.stale" : { + "comment" : "Label shown when the current weather snapshot is outdated.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Outdated" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "古いデータ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오래된 정보" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已过时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "較舊" + } + } + } + }, + "widget.station" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Station" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "観測所" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "관측소" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "观测站" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "觀測站" + } + } + } + }, + "widget.sunrise" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sunrise" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日の出" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일출" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日出" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "日出" + } + } + } + }, + "widget.sunset" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sunset" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日の入り" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일몰" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日落" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "日落" + } + } + } + }, + "widget.wind" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wind" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "風" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "바람" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "风" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "風" + } + } + } + }, + "wind.direction.e" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "東" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "동" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "东" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "東" + } + } + } + }, + "wind.direction.ene" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "ENE" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "東北東" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "동북동" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "东北东" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "東北東" + } + } + } + }, + "wind.direction.ese" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "ESE" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "東南東" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "동남동" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "东南东" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "東南東" + } + } + } + }, + "wind.direction.n" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "N" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "北" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "북" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "北" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "北" + } + } + } + }, + "wind.direction.ne" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NE" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "北東" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "북동" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "北东" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "北東" + } + } + } + }, + "wind.direction.nne" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NNE" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "北北東" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "북북동" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "北北东" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "北北東" + } + } + } + }, + "wind.direction.nnw" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NNW" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "北北西" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "북북서" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "北北西" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "北北西" + } + } + } + }, + "wind.direction.nw" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NW" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "北西" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "북서" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "北西" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "北西" + } + } + } + }, + "wind.direction.s" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "S" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "南" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "남" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "南" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "南" + } + } + } + }, + "wind.direction.se" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SE" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "南東" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "남동" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "南东" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "南東" + } + } + } + }, + "wind.direction.sse" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSE" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "南南東" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "남남동" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "南南东" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "南南東" + } + } + } + }, + "wind.direction.ssw" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSW" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "南南西" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "남남서" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "南南西" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "南南西" + } + } + } + }, + "wind.direction.sw" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SW" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "南西" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "남서" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "南西" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "南西" + } + } + } + }, + "wind.direction.w" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "W" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "西" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "西" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "西" + } + } + } + }, + "wind.direction.wnw" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "WNW" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "西北西" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서북서" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "西北西" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "西北西" + } + } + } + }, + "wind.direction.wsw" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "WSW" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "西南西" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서남서" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "西南西" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "西南西" } } } } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/ios/RunnerTests/CurrentWeatherWidgetTests.swift b/ios/RunnerTests/CurrentWeatherWidgetTests.swift index e3780889c..b1f6bb471 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetTests.swift @@ -1034,6 +1034,318 @@ final class CurrentWeatherWidgetConditionTests: XCTestCase { } } +final class WidgetWeatherConditionTests: XCTestCase { + func testWeatherCodesMapToSharedPresentationConditions() { + let expected: [(Int, WidgetWeatherCondition)] = [ + (100, .clear), + (200, .cloudy), + (300, .overcast), + (205, .fog), + (206, .rain), + (208, .snow), + (211, .rain), + (214, .thunderstorm), + (217, .thunderstorm), + (203, .thunder), + (204, .thunder), + (219, .thunder), + (207, .sleet), + (212, .sleet), + (213, .hail), + (216, .hail), + (218, .hail), + (215, .snow), + ] + + for (weatherCode, condition) in expected { + XCTAssertEqual( + WidgetWeatherCondition( + weatherCode: weatherCode, + weather: "raw text must not override a known code" + ), + condition, + "weatherCode=\(weatherCode)" + ) + } + } + + func testRawWeatherFallbackClassifiesWithoutBecomingDisplayText() { + let expected: [(String, WidgetWeatherCondition)] = [ + ("午後雷雨", .thunderstorm), + ("雷雪", .snow), + ("雷雹", .hail), + ("冰雹", .hail), + ("雷聲", .thunder), + ("雨雪", .sleet), + ("降雪", .snow), + ("有雨", .rain), + ("濃霧", .fog), + ("晴", .clear), + ("多雲", .cloudy), + ("陰", .overcast), + ("API 原始描述", .unknown), + ] + + for (weather, condition) in expected { + let mapped = WidgetWeatherCondition( + weatherCode: 0, + weather: weather + ) + XCTAssertEqual(mapped, condition, "weather=\(weather)") + XCTAssertNotEqual(mapped.displayNameLocalizationKey, weather) + XCTAssertTrue(mapped.displayNameLocalizationKey.hasPrefix("weather.")) + } + } + + func testEveryPresentationConditionHasItsOwnLocalizationKey() { + let expected: [(WidgetWeatherCondition, String)] = [ + (.clear, "weather.clear"), + (.cloudy, "weather.cloudy"), + (.overcast, "weather.overcast"), + (.fog, "weather.fog"), + (.rain, "weather.rain"), + (.sleet, "weather.sleet"), + (.snow, "weather.snow"), + (.hail, "weather.hail"), + (.thunder, "weather.thunder"), + (.thunderstorm, "weather.thunderstorm"), + (.unknown, "weather.unknown"), + ] + + XCTAssertEqual(expected.count, WidgetWeatherCondition.allCases.count) + for (condition, key) in expected { + XCTAssertEqual(condition.displayNameLocalizationKey, key) + } + } + + func testClearAndCloudyUseDayNightSymbols() { + XCTAssertEqual( + WidgetWeatherCondition.clear.systemImageName(isNight: false), + "sun.max.fill" + ) + XCTAssertEqual( + WidgetWeatherCondition.clear.systemImageName(isNight: true), + "moon.stars.fill" + ) + XCTAssertEqual( + WidgetWeatherCondition.cloudy.systemImageName(isNight: false), + "cloud.sun.fill" + ) + XCTAssertEqual( + WidgetWeatherCondition.cloudy.systemImageName(isNight: true), + "cloud.moon.fill" + ) + } + + func testPhenomenonSymbolsDoNotDependOnDayNight() { + let expected: [(WidgetWeatherCondition, String)] = [ + (.overcast, "cloud.fill"), + (.fog, "cloud.fog.fill"), + (.rain, "cloud.rain.fill"), + (.sleet, "cloud.sleet.fill"), + (.snow, "cloud.snow.fill"), + (.hail, "cloud.hail.fill"), + (.thunder, "bolt.fill"), + (.thunderstorm, "cloud.bolt.rain.fill"), + (.unknown, "cloud.fill"), + ] + + for (condition, symbol) in expected { + XCTAssertEqual(condition.systemImageName(isNight: false), symbol) + XCTAssertEqual(condition.systemImageName(isNight: true), symbol) + } + } + + func testCurrentAndForecastUseSamePresentationConditionForHail() throws { + let current = CurrentWeatherWidgetSnapshot( + schemaVersion: 7, + sourceIdentifier: "region:407", + regionCode: "407", + regionName: "西屯區", + observationTime: 1_789_567_200, + stationName: "西屯", + weather: "冰雹", + weatherCode: 213, + condition: .rain, + isNight: false, + nextDayNightTransitionTime: 1_789_562_700, + calibratedTimeOffsetMilliseconds: 0, + temperature: 20, + humidity: 80, + rain: 5 + ) + let forecast = try XCTUnwrap(ForecastWidgetPoint( + time: "12:00", + temperature: 20, + weather: "冰雹", + weatherCode: 213, + pop: 80 + )) + + XCTAssertEqual(current.condition, .rain) + XCTAssertEqual(current.presentationCondition, .hail) + XCTAssertEqual(forecast.presentationCondition, .hail) + XCTAssertEqual( + current.presentationCondition, + forecast.presentationCondition + ) + + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(current)) + as? [String: Any] + ) + XCTAssertEqual(json["schemaVersion"] as? Int, 7) + XCTAssertEqual(json["condition"] as? String, "rain") + } + + func testPersistedConditionRawValuesRemainUnchanged() { + let conditions: [CurrentWeatherWidgetCondition] = [ + .clear, .cloudy, .overcast, .fog, .rain, .snow, + .thunderstorm, .unknown, + ] + XCTAssertEqual( + conditions.map(\.rawValue), + [ + "clear", "cloudy", "overcast", "fog", "rain", "snow", + "thunderstorm", "unknown", + ] + ) + } + + func testNewLocalizationKeysHaveEveryRequiredLocale() throws { + let catalogURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("DPIPWidgets/Localizable.xcstrings") + let catalog = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(contentsOf: catalogURL)) + as? [String: Any] + ) + let strings = try XCTUnwrap(catalog["strings"] as? [String: Any]) + let expected: [String: [String: String]] = [ + "weather.sleet": [ + "en": "Sleet", "zh-Hant": "雨雪", "zh-Hans": "雨雪", + "ja": "みぞれ", "ko": "진눈깨비", + ], + "weather.hail": [ + "en": "Hail", "zh-Hant": "冰雹", "zh-Hans": "冰雹", + "ja": "ひょう", "ko": "우박", + ], + "weather.thunder": [ + "en": "Thunder", "zh-Hant": "雷電", "zh-Hans": "雷电", + "ja": "雷", "ko": "천둥·번개", + ], + ] + + for (key, translations) in expected { + let item = try XCTUnwrap(strings[key] as? [String: Any]) + let localizations = try XCTUnwrap( + item["localizations"] as? [String: Any] + ) + XCTAssertEqual(Set(localizations.keys), Set(translations.keys), key) + for (locale, value) in translations { + let localization = try XCTUnwrap( + localizations[locale] as? [String: Any] + ) + let stringUnit = try XCTUnwrap( + localization["stringUnit"] as? [String: Any] + ) + XCTAssertEqual(stringUnit["value"] as? String, value, key) + } + } + } +} + +final class WidgetWindDirectionTests: XCTestCase { + func testAllSixteenDirectionsMapFromRawValues() { + let expected: [(String, WidgetWindDirection)] = [ + ("北", .north), + ("北北東", .northNortheast), + ("東北", .northeast), + ("東北東", .eastNortheast), + ("東", .east), + ("東南東", .eastSoutheast), + ("東南", .southeast), + ("南南東", .southSoutheast), + ("南", .south), + ("南南西", .southSouthwest), + ("西南", .southwest), + ("西南西", .westSouthwest), + ("西", .west), + ("西北西", .westNorthwest), + ("西北", .northwest), + ("北北西", .northNorthwest), + ] + + XCTAssertEqual( + expected.count, + WidgetWindDirection.allCases.count + ) + + for (rawDirection, direction) in expected { + XCTAssertEqual( + WidgetWindDirection( + rawDirection: rawDirection + ), + direction, + rawDirection + ) + } + } + + func testEnglishAbbreviationsAreAlsoAccepted() { + let expected: [(String, WidgetWindDirection)] = [ + ("N", .north), + ("NNE", .northNortheast), + ("NE", .northeast), + ("ENE", .eastNortheast), + ("E", .east), + ("ESE", .eastSoutheast), + ("SE", .southeast), + ("SSE", .southSoutheast), + ("S", .south), + ("SSW", .southSouthwest), + ("SW", .southwest), + ("WSW", .westSouthwest), + ("W", .west), + ("WNW", .westNorthwest), + ("NW", .northwest), + ("NNW", .northNorthwest), + ] + + for (rawDirection, direction) in expected { + XCTAssertEqual( + WidgetWindDirection( + rawDirection: rawDirection + ), + direction + ) + } + } + + func testEveryWindDirectionHasUniqueLocalizationKey() { + let keys = WidgetWindDirection.allCases.map( + \.displayNameLocalizationKey + ) + + XCTAssertEqual(keys.count, 16) + XCTAssertEqual(Set(keys).count, 16) + XCTAssertTrue( + keys.allSatisfy { + $0.hasPrefix("wind.direction.") + } + ) + } + + func testUnknownWindDirectionReturnsNil() { + XCTAssertNil( + WidgetWindDirection( + rawDirection: "unknown-direction" + ) + ) + } +} + final class CurrentWeatherWidgetTimelineTests: XCTestCase { private let staleAfter: TimeInterval = 30 * 60 diff --git a/ios/RunnerTests/ForecastWidgetTests.swift b/ios/RunnerTests/ForecastWidgetTests.swift index 29a6925b1..eeab8cd27 100644 --- a/ios/RunnerTests/ForecastWidgetTests.swift +++ b/ios/RunnerTests/ForecastWidgetTests.swift @@ -3,17 +3,18 @@ import WidgetKit import XCTest final class ForecastRemoteDTOTests: XCTestCase { - func testDecodesFirstFourUsablePointsInAPIOrderAcrossMidnight() throws { + func testDecodesFirstFiveUsablePointsInAPIOrderAcrossMidnight() throws { let dto = try forecastDTO( - times: ["bad", "23:00", "00:00", "01:00", "02:00", "03:00"] + times: ["bad", "23:00", "00:00", "01:00", "02:00", "03:00", "04:00"] ) XCTAssertEqual(dto.updateTime, 1_790_336_400_000) XCTAssertEqual(dto.points.map(\.time), - ["23:00", "00:00", "01:00", "02:00"]) + ["23:00", "00:00", "01:00", "02:00", "03:00"]) } - func testFewerThanFourAndEmptyForecast() throws { - XCTAssertEqual(try forecastDTO(times: ["12:00"]).points.count, 1) + func testFewerThanFiveUsablePointsRemainUnchanged() throws { + XCTAssertEqual(try forecastDTO(times: ["12:00", "13:00"]).points.map(\.time), + ["12:00", "13:00"]) XCTAssertTrue(try forecastDTO(times: []).points.isEmpty) } @@ -118,6 +119,24 @@ final class ForecastClientTests: XCTestCase { } final class ForecastWidgetSnapshotTests: XCTestCase { + func testAcceptsFivePointsAndRejectsMoreThanFive() { + XCTAssertNotNil(forecastSnapshot(times: [ + "10:00", "11:00", "12:00", "13:00", "14:00", + ])) + let sixPoints = ["10:00", "11:00", "12:00", "13:00", "14:00", "15:00"] + .map { + ForecastWidgetPoint(time: $0, temperature: 22, + weather: "晴", weatherCode: 100, + pop: 20)! + } + XCTAssertNil(ForecastWidgetSnapshot( + sourceIdentifier: "region:407", regionCode: "407", + updateTime: 1_790_336_400_000, + receivedAt: 1_790_337_060_000, + points: sixPoints + )) + } + func testSchemaV1RoundTripRetainsIdentityAndMillisecondTimes() throws { let original = forecastSnapshot() let data = try JSONEncoder().encode(original) @@ -129,7 +148,7 @@ final class ForecastWidgetSnapshotTests: XCTestCase { XCTAssertEqual(decoded.updateTime, 1_790_336_400_000) XCTAssertEqual(decoded.receivedAt, 1_790_337_060_000) XCTAssertEqual(decoded.points.map(\.time), - ["23:00", "00:00", "01:00", "02:00"]) + ["23:00", "00:00", "01:00", "02:00", "03:00"]) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) let point = try XCTUnwrap((json["points"] as? [[String: Any]])?.first) @@ -244,30 +263,76 @@ final class ForecastWidgetSnapshotStoreTests: XCTestCase { } final class ForecastWidgetProviderTests: XCTestCase { - func testOnlyLargeRefreshesAndSnapshotPathIsCacheOnly() async { + func testForecastFamilyPolicySupportsOnlyMediumAndLarge() { + XCTAssertFalse(ForecastWidgetFamilyPolicy.supportsForecast(.systemSmall)) + XCTAssertTrue(ForecastWidgetFamilyPolicy.supportsForecast(.systemMedium)) + XCTAssertTrue(ForecastWidgetFamilyPolicy.supportsForecast(.systemLarge)) + XCTAssertFalse(ForecastWidgetFamilyPolicy.supportsForecast(.systemExtraLarge)) + } + + func testSmallDoesNotRefreshForecast() async { let target = WidgetLocationTarget.saved(regionCode: "407") let state = ForecastPlannerState(current: currentSnapshot(target: target)) let planner = makePlanner(state: state) _ = await planner.plan(for: target, family: .systemSmall) - _ = await planner.plan(for: target, family: .systemMedium) XCTAssertEqual(state.forecastRefreshCount, 0) - _ = await planner.plan(for: target, family: .systemLarge) + XCTAssertTrue(state.requestedRegions.isEmpty) + } + + func testMediumAndLargeEachRefreshForecast() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let state = ForecastPlannerState(current: currentSnapshot(target: target)) + let planner = makePlanner(state: state) + _ = await planner.plan(for: target, family: .systemMedium) XCTAssertEqual(state.forecastRefreshCount, 1) XCTAssertEqual(state.requestedRegions, ["407"]) + _ = await planner.plan(for: target, family: .systemLarge) + XCTAssertEqual(state.forecastRefreshCount, 2) + XCTAssertEqual(state.requestedRegions, ["407", "407"]) + } + + func testMediumSnapshotReadsMatchingCacheWithoutNetworkRequest() { + let target = WidgetLocationTarget.saved(regionCode: "407") + let state = ForecastPlannerState( + current: currentSnapshot(target: target), + forecast: forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:21:00Z")) + ) + let planner = makePlanner(state: state) let dependencies = DPIPWidgetProviderDependencies( loadSnapshot: { state.loadCurrent($0) }, timelinePlanner: planner, loadForecast: { state.loadForecast($0, $1, $2) } ) - _ = dependencies.snapshot(for: target) - _ = dependencies.forecastSnapshot( + let forecast = dependencies.forecastSnapshot( for: target, currentSnapshot: state.loadCurrent(target), - at: instant("2026-09-25T07:21:00Z"), family: .systemLarge + at: instant("2026-09-25T07:21:00Z"), family: .systemMedium + ) + XCTAssertNotNil(forecast) + XCTAssertEqual(state.forecastRefreshCount, 0) + } + + func testMediumIndependentlyRefreshesAndAttachesForecastWithoutLarge() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let refreshed = forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:21:00Z")) + let state = ForecastPlannerState( + current: currentSnapshot(target: target), + forecastAfterRefresh: refreshed ) + + let plan = await makePlanner(state: state).plan( + for: target, family: .systemMedium + ) + XCTAssertEqual(state.forecastRefreshCount, 1) + XCTAssertEqual(state.requestedRegions, ["407"]) + XCTAssertEqual(plan.forecast?.updateTime, refreshed.updateTime) } - func testForecastFailureKeepsCurrentAndValidCacheFallback() async { + func testMediumForecastFailureKeepsCurrentAndValidCacheFallback() async { let target = WidgetLocationTarget.saved(regionCode: "407") let state = ForecastPlannerState( current: currentSnapshot(target: target), @@ -276,7 +341,7 @@ final class ForecastWidgetProviderTests: XCTestCase { receivedAt: milliseconds("2026-09-25T07:21:00Z")) ) let plan = await makePlanner(state: state).plan( - for: target, family: .systemLarge + for: target, family: .systemMedium ) XCTAssertEqual(plan.snapshot?.regionCode, "407") XCTAssertNotNil(plan.forecast) @@ -288,7 +353,7 @@ final class ForecastWidgetProviderTests: XCTestCase { XCTAssertEqual(plan.snapshot?.stationName, "station") } - func testExpiredAndMismatchedCacheNeverAttaches() async { + func testMediumRejectsExpiredAndMismatchedCache() async { let target = WidgetLocationTarget.currentLocation let current = currentSnapshot(target: target, regionCode: "242") let state = ForecastPlannerState( @@ -299,7 +364,7 @@ final class ForecastWidgetProviderTests: XCTestCase { receivedAt: milliseconds("2026-09-25T07:21:00Z")) ) let regionMismatch = await makePlanner(state: state).plan( - for: target, family: .systemLarge + for: target, family: .systemMedium ) XCTAssertNil(regionMismatch.forecast) state.forecast = forecastSnapshot( @@ -307,7 +372,7 @@ final class ForecastWidgetProviderTests: XCTestCase { updateTime: milliseconds("2026-09-25T07:10:00Z"), receivedAt: milliseconds("2026-09-25T07:21:00Z")) let sourceMismatch = await makePlanner(state: state).plan( - for: target, family: .systemLarge + for: target, family: .systemMedium ) XCTAssertNil(sourceMismatch.forecast) state.forecast = forecastSnapshot( @@ -318,10 +383,30 @@ final class ForecastWidgetProviderTests: XCTestCase { state: state, now: instant("2026-09-25T07:40:00Z") ) let expired = await latePlanner.plan(for: target, - family: .systemLarge) + family: .systemMedium) XCTAssertNil(expired.forecast) } + func testLargeForecastFallbackAndExpiryBehaviorRemainsUnchanged() async { + let target = WidgetLocationTarget.saved(regionCode: "407") + let state = ForecastPlannerState( + current: currentSnapshot(target: target), + forecast: forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:21:00Z")) + ) + + let plan = await makePlanner(state: state).plan( + for: target, family: .systemLarge + ) + + let expiry = instant("2026-09-25T07:40:00Z") + XCTAssertEqual(state.forecastRefreshCount, 1) + XCTAssertNotNil(plan.forecast) + XCTAssertTrue(plan.states.contains { $0.date == expiry }) + XCTAssertNil(plan.forecast(at: expiry)) + } + func testLateCurrentLocationResponseForAIsNotWrittenAfterB() async { let target = WidgetLocationTarget.currentLocation let state = ForecastPlannerState( @@ -390,12 +475,15 @@ private final class ForecastPlannerState: @unchecked Sendable { private let lock = NSLock() private var storedCurrent: CurrentWeatherWidgetSnapshot? private var storedForecast: ForecastWidgetSnapshot? + private let forecastAfterRefresh: ForecastWidgetSnapshot? private var regions: [String] = [] init(current: CurrentWeatherWidgetSnapshot?, - forecast: ForecastWidgetSnapshot? = nil) { + forecast: ForecastWidgetSnapshot? = nil, + forecastAfterRefresh: ForecastWidgetSnapshot? = nil) { storedCurrent = current storedForecast = forecast + self.forecastAfterRefresh = forecastAfterRefresh } var current: CurrentWeatherWidgetSnapshot? { @@ -428,7 +516,12 @@ private final class ForecastPlannerState: @unchecked Sendable { func refreshForecast(_ target: WidgetLocationTarget, _ regionCode: String) async { - lock.withLock { regions.append(regionCode) } + lock.withLock { + regions.append(regionCode) + if let forecastAfterRefresh { + storedForecast = forecastAfterRefresh + } + } } } @@ -475,14 +568,15 @@ private func forecastSnapshot( sourceIdentifier: String = "region:407", regionCode: String = "407", updateTime: Int64 = 1_790_336_400_000, - receivedAt: Int64 = 1_790_337_060_000 + receivedAt: Int64 = 1_790_337_060_000, + times: [String] = ["23:00", "00:00", "01:00", "02:00", "03:00"] ) -> ForecastWidgetSnapshot { ForecastWidgetSnapshot( sourceIdentifier: sourceIdentifier, regionCode: regionCode, updateTime: updateTime, receivedAt: receivedAt, - points: ["23:00", "00:00", "01:00", "02:00"].map { + points: times.map { ForecastWidgetPoint(time: $0, temperature: 22, weather: "晴", weatherCode: 100, pop: 20)! From b8ceea1cb26f26fde50966978566ea1186834fad Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Sat, 26 Sep 2026 17:35:25 +0800 Subject: [PATCH 07/17] fix(weather): make large widget wind speed text consistent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 修正大型天氣小工具的風速文字樣式不一致 Fix(en-US): fix inconsistent wind speed text styling in the large weather widget --- ios/DPIPWidgets/DPIPWidgets.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index f812f19ed..1ffb7c0c5 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -595,6 +595,8 @@ private struct LargeCurrentWeatherView: View { } Text("\(windSpeed, specifier: "%.1f") m/s") + .font(.caption) + .fontWeight(.medium) } } else if let windDirection = snapshot.windDirection { Text(windDirection) From 98ae861bca00ba5c259fad7e5ecf8f6444f73d5a Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Sun, 27 Sep 2026 09:36:12 +0800 Subject: [PATCH 08/17] fix(weather): skip unusable widget forecast points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 修正天氣小工具預報缺值時會出現空白時段 Fix(en-US): fix blank forecast slots when widget forecast data is incomplete --- .../DPIPWidgetProviderSupport.swift | 11 ++ ios/DPIPWidgets/DPIPWidgets.swift | 85 +++------ ios/DPIPWidgets/ForecastRemoteDTO.swift | 4 +- ios/DPIPWidgets/ForecastWidgetSelection.swift | 49 +++++ ios/DPIPWidgets/ForecastWidgetSnapshot.swift | 15 +- ios/Runner.xcodeproj/project.pbxproj | 1 + ios/RunnerTests/ForecastWidgetTests.swift | 170 +++++++++++++++++- 7 files changed, 264 insertions(+), 71 deletions(-) create mode 100644 ios/DPIPWidgets/ForecastWidgetSelection.swift diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift index 1d1dd79f0..babc08e36 100644 --- a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -10,6 +10,17 @@ enum ForecastWidgetFamilyPolicy { return false } } + + static func visiblePoints( + in forecast: ForecastWidgetSnapshot, + at date: Date, + family: WidgetFamily + ) -> [ForecastWidgetPoint] { + guard supportsForecast(family) else { return [] } + return ForecastWidgetSelection.upcomingPoints( + forecast.points, at: date, limit: 5 + ) + } } #if DEBUG diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 1ffb7c0c5..17f589c94 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -144,40 +144,9 @@ private func forecastIsNight( isCurrentlyNight: Bool, nextTransitionTime: Int ) -> Bool { - let parts = pointTime.split(separator: ":") - guard parts.count == 2, - let hour = Int(parts[0]), - let minute = Int(parts[1]), - (0...23).contains(hour), - (0...59).contains(minute) - else { - return isCurrentlyNight - } - - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(identifier: "Asia/Taipei")! - - let currentComponents = calendar.dateComponents( - [.hour, .minute], - from: entryDate - ) - - guard let currentHour = currentComponents.hour, - let currentMinute = currentComponents.minute - else { - return isCurrentlyNight - } - - let currentMinutes = currentHour * 60 + currentMinute - let forecastMinutes = hour * 60 + minute - - var minutesAhead = forecastMinutes - currentMinutes - - if minutesAhead < 0 { - minutesAhead += 24 * 60 - } - - guard minutesAhead <= 12 * 60 else { + guard let minutesAhead = ForecastWidgetSelection.minutesAhead( + for: pointTime, at: entryDate + ) else { return isCurrentlyNight } @@ -392,7 +361,7 @@ private struct MediumCurrentWeatherView: View { !forecast.points.isEmpty { HourlyForecastSection( forecast: forecast, - compact: true, + family: .systemMedium, entryDate: entry.date, isCurrentlyNight: entry.isNight, nextTransitionTime: snapshot.nextDayNightTransitionTime @@ -513,7 +482,7 @@ private struct LargeCurrentWeatherView: View { !forecast.points.isEmpty { HourlyForecastSection( forecast: forecast, - compact: false, + family: .systemLarge, entryDate: entry.date, isCurrentlyNight: entry.isNight, nextTransitionTime: snapshot.nextDayNightTransitionTime @@ -697,36 +666,36 @@ private struct HourlyForecastPointView: View { private struct HourlyForecastSection: View { let forecast: ForecastWidgetSnapshot - let compact: Bool + let family: WidgetFamily let entryDate: Date let isCurrentlyNight: Bool let nextTransitionTime: Int var body: some View { - VStack( - alignment: .leading, - spacing: compact ? 4 : 12 - ) { - HStack( - alignment: .top, - spacing: compact ? 4 : 8 + let points = ForecastWidgetFamilyPolicy.visiblePoints( + in: forecast, at: entryDate, family: family + ) + let compact = family == .systemMedium + if !points.isEmpty { + VStack( + alignment: .leading, + spacing: compact ? 4 : 12 ) { - ForEach( - Array(forecast.points.enumerated()), - id: \.offset - ) { _, point in - HourlyForecastPointView( - point: point, - compact: compact, - entryDate: entryDate, - isCurrentlyNight: isCurrentlyNight, - nextTransitionTime: nextTransitionTime - ) - .frame( - maxWidth: .infinity, - alignment: .center + HStack( + alignment: .top, + spacing: compact ? 4 : 8 + ) { + ForEach(Array(points.enumerated()), id: \.offset) { _, point in + HourlyForecastPointView( + point: point, + compact: compact, + entryDate: entryDate, + isCurrentlyNight: isCurrentlyNight, + nextTransitionTime: nextTransitionTime ) + .frame(maxWidth: .infinity, alignment: .center) + } } } } diff --git a/ios/DPIPWidgets/ForecastRemoteDTO.swift b/ios/DPIPWidgets/ForecastRemoteDTO.swift index e2194d319..9268e6279 100644 --- a/ios/DPIPWidgets/ForecastRemoteDTO.swift +++ b/ios/DPIPWidgets/ForecastRemoteDTO.swift @@ -1,7 +1,7 @@ import Foundation /// The narrow v5 township forecast response. Point labels are clock labels, -/// never dates; usable points retain their API order. +/// never dates; all usable points retain their API order. struct ForecastRemoteDTO: Decodable, Sendable { /// API publication time, Unix milliseconds. let updateTime: Int64 @@ -34,6 +34,6 @@ struct ForecastRemoteDTO: Decodable, Sendable { [UsablePoint].self, forKey: .forecast ) - points = Array(forecast.compactMap(\.point).prefix(5)) + points = forecast.compactMap(\.point) } } diff --git a/ios/DPIPWidgets/ForecastWidgetSelection.swift b/ios/DPIPWidgets/ForecastWidgetSelection.swift new file mode 100644 index 000000000..7cc8d137d --- /dev/null +++ b/ios/DPIPWidgets/ForecastWidgetSelection.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Selects display hours from date-free API clock labels at each timeline entry. +enum ForecastWidgetSelection { + private static let minutesPerDay = 24 * 60 + private static let maximumFutureMinutes = 12 * 60 + private static let taipei = TimeZone(identifier: "Asia/Taipei")! + + static func minutesAhead(for clockLabel: String, at date: Date) -> Int? { + guard let forecastMinutes = ForecastWidgetPoint.minutesSinceMidnight( + clockLabel + ) else { return nil } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = taipei + let current = calendar.dateComponents([.hour, .minute], from: date) + guard let hour = current.hour, let minute = current.minute else { + return nil + } + + // 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. + let currentMinutes = hour * 60 + minute + let distance = (forecastMinutes - currentMinutes + minutesPerDay) + % minutesPerDay + return distance <= maximumFutureMinutes ? distance : nil + } + + static func upcomingPoints( + _ points: [ForecastWidgetPoint], + at date: Date, + limit: Int + ) -> [ForecastWidgetPoint] { + guard limit > 0 else { return [] } + return Array(points.enumerated().compactMap { index, point + -> (index: Int, distance: Int, point: ForecastWidgetPoint)? in + guard let distance = minutesAhead(for: point.time, at: date) else { + return nil + } + return (index, distance, point) + }.sorted { lhs, rhs in + if lhs.distance != rhs.distance { + return lhs.distance < rhs.distance + } + return lhs.index < rhs.index + }.prefix(limit).map { $0.point }) + } +} diff --git a/ios/DPIPWidgets/ForecastWidgetSnapshot.swift b/ios/DPIPWidgets/ForecastWidgetSnapshot.swift index 3d0807cb2..9fbdec8e7 100644 --- a/ios/DPIPWidgets/ForecastWidgetSnapshot.swift +++ b/ios/DPIPWidgets/ForecastWidgetSnapshot.swift @@ -1,7 +1,7 @@ import Foundation struct ForecastWidgetPoint: Codable, Equatable, Sendable { - /// API-supplied HH:mm label only; no date or hour offset is inferred. + /// API-supplied HH:mm label only; no date or hour offset is persisted. let time: String /// Forecast air temperature in degrees Celsius. let temperature: Double @@ -16,8 +16,8 @@ struct ForecastWidgetPoint: Codable, Equatable, Sendable { init?(time: String, temperature: Double, weather: String, weatherCode: Int, pop: Int?) { - guard Self.isValidClockLabel(time), temperature.isFinite, - !weather.isEmpty else { + guard Self.minutesSinceMidnight(time) != nil, temperature.isFinite, + !weather.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } self.time = time @@ -54,14 +54,15 @@ struct ForecastWidgetPoint: Codable, Equatable, Sendable { self = point } - private static func isValidClockLabel(_ value: String) -> Bool { + static func minutesSinceMidnight(_ value: String) -> Int? { let bytes = Array(value.utf8) guard bytes.count == 5, bytes[2] == 58, [0, 1, 3, 4].allSatisfy({ (48...57).contains(bytes[$0]) }) - else { return false } + else { return nil } let hour = Int(bytes[0] - 48) * 10 + Int(bytes[1] - 48) let minute = Int(bytes[3] - 48) * 10 + Int(bytes[4] - 48) - return hour < 24 && minute < 60 + guard hour < 24 && minute < 60 else { return nil } + return hour * 60 + minute } } @@ -83,7 +84,7 @@ struct ForecastWidgetSnapshot: Codable, Sendable { sourceIdentifier: sourceIdentifier ), WidgetResolvedWeatherLocationValidation.isValidRegionCode(regionCode), updateTime > 0, receivedAt > 0, - (1...5).contains(points.count) else { return nil } + !points.isEmpty else { return nil } if case .saved(let code) = address, code != regionCode { return nil } self.schemaVersion = Self.schemaVersion self.sourceIdentifier = sourceIdentifier diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index ecbbed6a2..3dbb8a834 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -265,6 +265,7 @@ ForecastRemoteDTO.swift, ForecastWidgetExpiry.swift, ForecastWidgetRefreshService.swift, + ForecastWidgetSelection.swift, ForecastWidgetSnapshot.swift, ForecastWidgetSnapshotStore.swift, SavedCurrentWeatherWidgetRefreshService.swift, diff --git a/ios/RunnerTests/ForecastWidgetTests.swift b/ios/RunnerTests/ForecastWidgetTests.swift index eeab8cd27..d9167aa61 100644 --- a/ios/RunnerTests/ForecastWidgetTests.swift +++ b/ios/RunnerTests/ForecastWidgetTests.swift @@ -3,13 +3,13 @@ import WidgetKit import XCTest final class ForecastRemoteDTOTests: XCTestCase { - func testDecodesFirstFiveUsablePointsInAPIOrderAcrossMidnight() throws { + func testDecodesAllUsablePointsInAPIOrderAcrossMidnight() throws { let dto = try forecastDTO( times: ["bad", "23:00", "00:00", "01:00", "02:00", "03:00", "04:00"] ) XCTAssertEqual(dto.updateTime, 1_790_336_400_000) XCTAssertEqual(dto.points.map(\.time), - ["23:00", "00:00", "01:00", "02:00", "03:00"]) + ["23:00", "00:00", "01:00", "02:00", "03:00", "04:00"]) } func testFewerThanFiveUsablePointsRemainUnchanged() throws { @@ -43,6 +43,115 @@ final class ForecastRemoteDTOTests: XCTestCase { let dto = try JSONDecoder().decode(ForecastRemoteDTO.self, from: data) XCTAssertEqual(dto.points.map(\.pop), [nil, nil, nil, 50]) } + + func testIncompleteMiddlePointIsSkippedWithoutLosingLaterHours() throws { + let data = Data(""" + {"updateTime":1790336400000,"forecast":[ + {"time":"09:00","temperature":20,"weather":"晴","weatherCode":100}, + {"time":"10:00","temperature":21,"weather":"晴"}, + {"time":"11:00","temperature":22,"weather":"晴","weatherCode":100}, + {"time":"12:00","temperature":23,"weather":"晴","weatherCode":100}, + {"time":"13:00","temperature":24,"weather":"晴","weatherCode":100}, + {"time":"14:00","temperature":25,"weather":"晴","weatherCode":100} + ]} + """.utf8) + let dto = try JSONDecoder().decode(ForecastRemoteDTO.self, from: data) + let selected = ForecastWidgetSelection.upcomingPoints( + dto.points, at: instant("2026-09-25T00:30:00Z"), limit: 5 + ) + XCTAssertEqual(selected.map(\.time), + ["09:00", "11:00", "12:00", "13:00", "14:00"]) + XCTAssertNil(selected[0].pop) + } + + func testMissingRequiredWeatherAndWhitespaceWeatherAreSkipped() throws { + let data = Data(""" + {"updateTime":1790336400000,"forecast":[ + {"time":"09:00","temperature":20,"weather":" ","weatherCode":100}, + {"time":"10:00","temperature":21,"weatherCode":100}, + {"time":"11:00","weather":"晴","weatherCode":100}, + {"time":"12:00","temperature":22,"weather":"晴","weatherCode":100} + ]} + """.utf8) + let dto = try JSONDecoder().decode(ForecastRemoteDTO.self, from: data) + XCTAssertEqual(dto.points.map(\.time), ["12:00"]) + } +} + +final class ForecastWidgetSelectionTests: XCTestCase { + func testOrdersClockLabelsByTaipeiFutureDistanceAcrossMidnight() { + let points = forecastPoints(times: [ + "01:00", "23:00", "02:00", "00:00", + ]) + let selected = ForecastWidgetSelection.upcomingPoints( + points, at: instant("2026-09-25T14:30:00Z"), limit: 5 + ) + XCTAssertEqual(selected.map(\.time), + ["23:00", "00:00", "01:00", "02:00"]) + } + + func testExpiredHourIsNotTomorrowButMidnightHourIsUpcoming() { + let selected = ForecastWidgetSelection.upcomingPoints( + forecastPoints(times: ["09:00", "11:00", "10:00"]), + at: instant("2026-09-25T02:30:00Z"), limit: 5 + ) + XCTAssertEqual(selected.map(\.time), ["11:00"]) + XCTAssertEqual(ForecastWidgetSelection.minutesAhead( + for: "00:00", at: instant("2026-09-25T15:30:00Z") + ), 30) + XCTAssertNil(ForecastWidgetSelection.minutesAhead( + for: "09:00", at: instant("2026-09-25T02:30:00Z") + )) + XCTAssertEqual(ForecastWidgetSelection.minutesAhead( + for: "22:30", at: instant("2026-09-25T02:30:00Z") + ), 12 * 60) + XCTAssertNil(ForecastWidgetSelection.minutesAhead( + for: "22:31", at: instant("2026-09-25T02:30:00Z") + )) + } + + func testSelectionUsesTaipeiEvenWhenDeviceTimezoneChanges() { + let original = NSTimeZone.default + defer { NSTimeZone.default = original } + let date = instant("2026-09-25T15:30:00Z") + for timezone in ["America/Los_Angeles", "Pacific/Auckland"] { + NSTimeZone.default = TimeZone(identifier: timezone)! + XCTAssertEqual(ForecastWidgetSelection.minutesAhead( + for: "00:00", at: date + ), 30) + } + } + + func testKeepsOnlyUsableUpcomingPointsAndDoesNotCreatePlaceholders() { + let points = forecastPoints(times: ["08:00", "11:00", "12:00"]) + let selected = ForecastWidgetSelection.upcomingPoints( + points, at: instant("2026-09-25T02:30:00Z"), limit: 5 + ) + XCTAssertEqual(selected.map(\.time), ["11:00", "12:00"]) + } + + func testSelectsNearestFiveFromMoreThanFiveValidPoints() { + let points = forecastPoints(times: [ + "16:00", "13:00", "11:00", "15:00", "12:00", "14:00", "10:00", + ]) + let selected = ForecastWidgetSelection.upcomingPoints( + points, at: instant("2026-09-25T01:30:00Z"), limit: 5 + ) + XCTAssertEqual(selected.map(\.time), + ["10:00", "11:00", "12:00", "13:00", "14:00"]) + } + + func testMissingPoPDoesNotRemoveAnUpcomingPoint() { + let point = ForecastWidgetPoint( + time: "11:00", temperature: 22, weather: "晴", + weatherCode: 100, pop: nil + )! + let selected = ForecastWidgetSelection.upcomingPoints( + [point], at: instant("2026-09-25T02:30:00Z"), limit: 5 + ) + XCTAssertEqual(selected.count, 1) + XCTAssertNil(selected[0].pop) + } } final class ForecastClientTests: XCTestCase { @@ -119,7 +228,7 @@ final class ForecastClientTests: XCTestCase { } final class ForecastWidgetSnapshotTests: XCTestCase { - func testAcceptsFivePointsAndRejectsMoreThanFive() { + func testSnapshotRetainsMoreThanFivePointsButRejectsEmpty() { XCTAssertNotNil(forecastSnapshot(times: [ "10:00", "11:00", "12:00", "13:00", "14:00", ])) @@ -129,12 +238,18 @@ final class ForecastWidgetSnapshotTests: XCTestCase { weather: "晴", weatherCode: 100, pop: 20)! } - XCTAssertNil(ForecastWidgetSnapshot( + XCTAssertNotNil(ForecastWidgetSnapshot( sourceIdentifier: "region:407", regionCode: "407", updateTime: 1_790_336_400_000, receivedAt: 1_790_337_060_000, points: sixPoints )) + XCTAssertNil(ForecastWidgetSnapshot( + sourceIdentifier: "region:407", regionCode: "407", + updateTime: 1_790_336_400_000, + receivedAt: 1_790_337_060_000, + points: [] + )) } func testSchemaV1RoundTripRetainsIdentityAndMillisecondTimes() throws { @@ -270,6 +385,22 @@ final class ForecastWidgetProviderTests: XCTestCase { XCTAssertFalse(ForecastWidgetFamilyPolicy.supportsForecast(.systemExtraLarge)) } + func testMediumAndLargeUseTheSameVisibleSelection() { + let forecast = forecastSnapshot(times: [ + "09:00", "13:00", "11:00", "15:00", "12:00", "14:00", + ]) + let date = instant("2026-09-25T02:30:00Z") + let medium = ForecastWidgetFamilyPolicy.visiblePoints( + in: forecast, at: date, family: .systemMedium + ) + let large = ForecastWidgetFamilyPolicy.visiblePoints( + in: forecast, at: date, family: .systemLarge + ) + XCTAssertEqual(medium, large) + XCTAssertEqual(medium.map(\.time), + ["11:00", "12:00", "13:00", "14:00", "15:00"]) + } + func testSmallDoesNotRefreshForecast() async { let target = WidgetLocationTarget.saved(regionCode: "407") let state = ForecastPlannerState(current: currentSnapshot(target: target)) @@ -429,6 +560,30 @@ final class ForecastWidgetProviderTests: XCTestCase { XCTAssertNil(state.forecast) } + func testRefreshStoresAllValidPointsForTimelineSelection() async throws { + let target = WidgetLocationTarget.saved(regionCode: "407") + let state = ForecastPlannerState(current: currentSnapshot(target: target)) + let dto = try forecastDTO(times: [ + "14:00", "16:00", "18:00", "17:00", "19:00", "20:00", "21:00", + ]) + let date = instant("2026-09-25T07:21:00Z") + let service = ForecastWidgetRefreshService( + fetch: { _ in dto }, + loadCurrent: { state.loadCurrent($0) }, + savedIsResolved: { _ in true }, + write: { snapshot, _ in state.forecast = snapshot; return true }, + now: { date } + ) + + await service.refresh(target: target, regionCode: "407") + + let stored = try XCTUnwrap(state.forecast) + XCTAssertEqual(stored.points.count, 7) + XCTAssertEqual(ForecastWidgetFamilyPolicy.visiblePoints( + in: stored, at: date, family: .systemMedium + ).map(\.time), ["16:00", "17:00", "18:00", "19:00", "20:00"]) + } + func testUnresolvedSavedTargetNeverStartsForecastRequest() async { let target = WidgetLocationTarget.saved(regionCode: "407") let state = ForecastPlannerState(current: currentSnapshot(target: target)) @@ -584,6 +739,13 @@ private func forecastSnapshot( )! } +private func forecastPoints(times: [String]) -> [ForecastWidgetPoint] { + times.map { + ForecastWidgetPoint(time: $0, temperature: 22, + weather: "晴", weatherCode: 100, pop: 20)! + } +} + private func currentSnapshot(target: WidgetLocationTarget, regionCode: String = "407") -> CurrentWeatherWidgetSnapshot { From c6cb8616b5f11dd2b3b03edd278dc7b3615fda53 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 28 Sep 2026 14:54:19 +0800 Subject: [PATCH 09/17] fix(weather): use calibrated time for widget forecast hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 裝置時間不準時,iOS 天氣小工具的預報時段與日夜圖示不再跟著偏移 Fix(en-US): the iOS weather widget forecast no longer follows an inaccurate device clock --- .../DPIPWidgetProviderSupport.swift | 7 +++- ios/DPIPWidgets/DPIPWidgets.swift | 39 ++++++++++++------- ios/DPIPWidgets/ForecastWidgetSelection.swift | 18 +++++++-- 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift index babc08e36..c51af1e1c 100644 --- a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -11,14 +11,17 @@ 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. static func visiblePoints( in forecast: ForecastWidgetSnapshot, - at date: Date, + at calibratedDate: Date, family: WidgetFamily ) -> [ForecastWidgetPoint] { guard supportsForecast(family) else { return [] } return ForecastWidgetSelection.upcomingPoints( - forecast.points, at: date, limit: 5 + forecast.points, at: calibratedDate, limit: 5 ) } } diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index 17f589c94..c51d0c45f 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -138,14 +138,18 @@ struct DPIPWidgetEntry: TimelineEntry { } } +/// - 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 +/// rather than the time remaining. private func forecastIsNight( pointTime: String, - entryDate: Date, + calibratedDate: Date, isCurrentlyNight: Bool, nextTransitionTime: Int ) -> Bool { guard let minutesAhead = ForecastWidgetSelection.minutesAhead( - for: pointTime, at: entryDate + for: pointTime, at: calibratedDate ) else { return isCurrentlyNight } @@ -155,7 +159,7 @@ private func forecastIsNight( ) let minutesUntilTransition = - transitionDate.timeIntervalSince(entryDate) / 60 + transitionDate.timeIntervalSince(calibratedDate) / 60 guard minutesUntilTransition >= 0 else { return isCurrentlyNight @@ -285,6 +289,10 @@ private struct MediumCurrentWeatherView: View { timeIntervalSince1970: TimeInterval(snapshot.observationTime) ) + let calibratedDate = CurrentWeatherWidgetTimeCalibration( + snapshot: snapshot + ).calibratedDate(fromDeviceDate: entry.date) + VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline) { HStack(spacing: 4) { @@ -362,7 +370,7 @@ private struct MediumCurrentWeatherView: View { HourlyForecastSection( forecast: forecast, family: .systemMedium, - entryDate: entry.date, + calibratedDate: calibratedDate, isCurrentlyNight: entry.isNight, nextTransitionTime: snapshot.nextDayNightTransitionTime ) @@ -404,6 +412,10 @@ private struct LargeCurrentWeatherView: View { TimeInterval(snapshot.nextDayNightTransitionTime) ) + let calibratedDate = CurrentWeatherWidgetTimeCalibration( + snapshot: snapshot + ).calibratedDate(fromDeviceDate: entry.date) + VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { HStack(spacing: 4) { @@ -483,15 +495,10 @@ private struct LargeCurrentWeatherView: View { HourlyForecastSection( forecast: forecast, family: .systemLarge, - entryDate: entry.date, + calibratedDate: calibratedDate, isCurrentlyNight: entry.isNight, nextTransitionTime: snapshot.nextDayNightTransitionTime ) - - let forecastUpdateDate = Date( - timeIntervalSince1970: - TimeInterval(forecast.updateTime) / 1000 - ) } Spacer() @@ -619,14 +626,14 @@ private struct HourlyForecastPointView: View { let point: ForecastWidgetPoint let compact: Bool - let entryDate: Date + let calibratedDate: Date let isCurrentlyNight: Bool let nextTransitionTime: Int var body: some View { let isNight = forecastIsNight( pointTime: point.time, - entryDate: entryDate, + calibratedDate: calibratedDate, isCurrentlyNight: isCurrentlyNight, nextTransitionTime: nextTransitionTime ) @@ -668,13 +675,15 @@ private struct HourlyForecastSection: View { let forecast: ForecastWidgetSnapshot let family: WidgetFamily - let entryDate: Date + /// Calibrated, not the WidgetKit entry date: API clock labels are Taipei + /// wall-clock readings of calibrated time. + let calibratedDate: Date let isCurrentlyNight: Bool let nextTransitionTime: Int var body: some View { let points = ForecastWidgetFamilyPolicy.visiblePoints( - in: forecast, at: entryDate, family: family + in: forecast, at: calibratedDate, family: family ) let compact = family == .systemMedium if !points.isEmpty { @@ -690,7 +699,7 @@ private struct HourlyForecastSection: View { HourlyForecastPointView( point: point, compact: compact, - entryDate: entryDate, + calibratedDate: calibratedDate, isCurrentlyNight: isCurrentlyNight, nextTransitionTime: nextTransitionTime ) diff --git a/ios/DPIPWidgets/ForecastWidgetSelection.swift b/ios/DPIPWidgets/ForecastWidgetSelection.swift index 7cc8d137d..a4fccc84d 100644 --- a/ios/DPIPWidgets/ForecastWidgetSelection.swift +++ b/ios/DPIPWidgets/ForecastWidgetSelection.swift @@ -6,14 +6,22 @@ enum ForecastWidgetSelection { private static let maximumFutureMinutes = 12 * 60 private static let taipei = TimeZone(identifier: "Asia/Taipei")! - static func minutesAhead(for clockLabel: String, at date: Date) -> Int? { + /// - Parameter calibratedDate: a calibrated instant. A label is a Taipei + /// wall-clock reading of calibrated time, so a device-clock date would + /// shift the selection by the calibration offset. + static func minutesAhead( + for clockLabel: String, + at calibratedDate: Date + ) -> Int? { guard let forecastMinutes = ForecastWidgetPoint.minutesSinceMidnight( clockLabel ) else { return nil } var calendar = Calendar(identifier: .gregorian) calendar.timeZone = taipei - let current = calendar.dateComponents([.hour, .minute], from: date) + let current = calendar.dateComponents( + [.hour, .minute], from: calibratedDate + ) guard let hour = current.hour, let minute = current.minute else { return nil } @@ -29,13 +37,15 @@ enum ForecastWidgetSelection { static func upcomingPoints( _ points: [ForecastWidgetPoint], - at date: Date, + at calibratedDate: Date, limit: Int ) -> [ForecastWidgetPoint] { guard limit > 0 else { return [] } return Array(points.enumerated().compactMap { index, point -> (index: Int, distance: Int, point: ForecastWidgetPoint)? in - guard let distance = minutesAhead(for: point.time, at: date) else { + guard let distance = minutesAhead( + for: point.time, at: calibratedDate + ) else { return nil } return (index, distance, point) From 8706885fcc2b98c947378c6e77c5dad2d6b6a6a7 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 28 Sep 2026 14:56:55 +0800 Subject: [PATCH 10/17] fix(weather): remove every current-weather snapshot on clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 切換地區後,iOS 天氣小工具不再繼續顯示舊地區的天氣 Fix(en-US): the iOS weather widget no longer keeps showing the previous region's weather after switching region --- ios/Runner/WidgetSnapshotPlugin.swift | 30 ++++++++++++++++--- ios/RunnerTests/RunnerTests.swift | 17 ++++++++++- .../CurrentWeatherSnapshotStorage.swift | 26 ++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/ios/Runner/WidgetSnapshotPlugin.swift b/ios/Runner/WidgetSnapshotPlugin.swift index 4300336a0..d5f049898 100644 --- a/ios/Runner/WidgetSnapshotPlugin.swift +++ b/ios/Runner/WidgetSnapshotPlugin.swift @@ -148,18 +148,40 @@ enum WidgetSnapshotFile { } } + /// Removes everything `write` could have produced for this kind. + /// + /// The `switch` mirrors `snapshotURL(kind:sourceIdentifier:in:)` on purpose. + /// Current weather stopped being one file per kind when it became one file + /// per target, and for a while only the write path knew that: this removed + /// the single legacy file, found nothing, reported success, and left every + /// per-target snapshot on disk for the widget to keep drawing. static func clear(_ kind: WidgetSnapshotKind, in container: URL) throws { let directory = container.appendingPathComponent("WidgetSnapshots", isDirectory: true) - let snapshot = directory.appendingPathComponent(kind.filename) do { - try FileManager.default.removeItem(at: snapshot) - } catch let error as CocoaError where error.code == .fileNoSuchFile { - // Clearing an absent snapshot is intentionally idempotent. + switch kind { + case .currentWeather: + try CurrentWeatherSnapshotStorage(containerURL: container).removeAll() + // And the pre-per-target file. WidgetSnapshotStore still reads it as a + // migration fallback, so on a device upgraded across that change it is + // exactly what would stay on screen after everything else was cleared. + try remove(directory.appendingPathComponent(kind.filename)) + + case .weatherForecast, .locationCatalog: + try remove(directory.appendingPathComponent(kind.filename)) + } } catch { throw WidgetSnapshotError.writeFailed } } + private static func remove(_ url: URL) throws { + do { + try FileManager.default.removeItem(at: url) + } catch let error as CocoaError where error.code == .fileNoSuchFile { + // Clearing an absent snapshot is intentionally idempotent. + } + } + static func snapshotURL( kind: WidgetSnapshotKind, sourceIdentifier: String?, diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 76ba46e0e..8a00e4751 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -102,7 +102,7 @@ final class RunnerTests: XCTestCase { XCTAssertEqual(stored["regionCode"] as? String, "110") } - func testSnapshotClearIsIdempotent() throws { + func testSnapshotClearRemovesEveryCurrentWeatherSnapshot() throws { let container = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: container) } let kind = try WidgetSnapshotFile.kind("currentWeather") @@ -135,8 +135,23 @@ final class RunnerTests: XCTestCase { try WidgetSnapshotFile.clear(kind, in: container) XCTAssertFalse(FileManager.default.fileExists(atPath: legacyTarget.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: perLocationTarget.path)) + + // The ordering sidecars went with them. Had one survived, it would claim a + // committed snapshot that is no longer on disk, and every write from here + // on would fail `invalidOrderingState` — a cleared widget that can never + // be republished. + XCTAssertNoThrow( + try WidgetSnapshotFile.replace( + data, + kind: kind, + sourceIdentifier: "region:220", + in: container + ) + ) XCTAssertTrue(FileManager.default.fileExists(atPath: perLocationTarget.path)) + try WidgetSnapshotFile.clear(kind, in: container) XCTAssertNoThrow(try WidgetSnapshotFile.clear(kind, in: container)) } } diff --git a/ios/Shared/CurrentWeatherSnapshotStorage.swift b/ios/Shared/CurrentWeatherSnapshotStorage.swift index 72de70b41..4f51ada97 100644 --- a/ios/Shared/CurrentWeatherSnapshotStorage.swift +++ b/ios/Shared/CurrentWeatherSnapshotStorage.swift @@ -223,6 +223,32 @@ struct CurrentWeatherSnapshotStorage: Sendable { } } + /// Removes every per-target snapshot together with its ordering sidecar. + /// + /// Ordering first, snapshots second, and the order is not cosmetic. A + /// sidecar that outlives its snapshot claims a committed fingerprint that + /// is no longer on disk, and `reconcile` fails closed on that pairing — so + /// a half-finished removal in the other order leaves `beginWrite` throwing + /// `invalidOrderingState` forever, and the widget could never be published + /// again. A snapshot that outlives its sidecar is merely adopted. + /// + /// Uncoordinated, like the removal it replaces: a clear that races a write + /// is ambiguous whichever way it resolves, and both outcomes — a surviving + /// stale snapshot, or a discarded fresh one — are recoverable by the next + /// refresh. + func removeAll() throws { + try removeDirectory(at: orderingDirectoryURL) + try removeDirectory(at: snapshotDirectoryURL) + } + + private func removeDirectory(at url: URL) throws { + do { + try FileManager.default.removeItem(at: url) + } catch let error as CocoaError where error.code == .fileNoSuchFile { + // Nothing written yet, or already cleared. + } + } + private var snapshotDirectoryURL: URL { containerURL .appendingPathComponent( From 2312a14978c14a499c34784cf8746ee2153af744 Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 28 Sep 2026 15:08:46 +0800 Subject: [PATCH 11/17] refactor(weather): coordinate the current-weather snapshot clear Platform: ios --- ...rentWeatherWidgetSnapshotWriterTests.swift | 14 +++++ .../CurrentWeatherSnapshotStorage.swift | 52 ++++++++++++++++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift index b1b11d95a..6633b7c23 100644 --- a/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift +++ b/ios/RunnerTests/CurrentWeatherWidgetSnapshotWriterTests.swift @@ -955,6 +955,13 @@ private struct FailingSnapshotCoordinator: ) throws -> T { throw SnapshotCoordinationTestError.failed } + + func coordinate( + deletingItemAt url: URL, + _ accessor: (URL) throws -> T + ) throws -> T { + throw SnapshotCoordinationTestError.failed + } } private final class BlockingFirstSnapshotCoordinator: @@ -986,6 +993,13 @@ private final class BlockingFirstSnapshotCoordinator: return try accessor(url) } + func coordinate( + deletingItemAt url: URL, + _ accessor: (URL) throws -> T + ) throws -> T { + try accessor(url) + } + func releaseFirstAccess() { firstAccessGate.signal() } diff --git a/ios/Shared/CurrentWeatherSnapshotStorage.swift b/ios/Shared/CurrentWeatherSnapshotStorage.swift index 4f51ada97..b34b7a66b 100644 --- a/ios/Shared/CurrentWeatherSnapshotStorage.swift +++ b/ios/Shared/CurrentWeatherSnapshotStorage.swift @@ -24,6 +24,21 @@ protocol CurrentWeatherSnapshotCoordinating: Sendable { writingItemAt url: URL, _ accessor: (URL) throws -> T ) throws -> T + + /// Coordinates a deletion, which is not the same claim as a replacement. + /// + /// `.forDeleting` is what tells a presenter the item is going away rather + /// than changing, and it is the option `removeAll` needs on a whole + /// directory: a delete claim on a directory conflicts with a write claim + /// on anything inside it, which is what makes clearing atomic against a + /// `beginWrite` or `replace` already in flight for one of its snapshots. + /// Declared here rather than defaulted in an extension on purpose — a + /// default would let a test double inherit real coordination it never + /// performs, and the resulting green test would say nothing. + func coordinate( + deletingItemAt url: URL, + _ accessor: (URL) throws -> T + ) throws -> T } struct CurrentWeatherSnapshotFileCoordinator: @@ -32,6 +47,21 @@ struct CurrentWeatherSnapshotFileCoordinator: func coordinate( writingItemAt url: URL, _ accessor: (URL) throws -> T + ) throws -> T { + try coordinate(url, options: .forReplacing, accessor) + } + + func coordinate( + deletingItemAt url: URL, + _ accessor: (URL) throws -> T + ) throws -> T { + try coordinate(url, options: .forDeleting, accessor) + } + + private func coordinate( + _ url: URL, + options: NSFileCoordinator.WritingOptions, + _ accessor: (URL) throws -> T ) throws -> T { let coordinator = NSFileCoordinator(filePresenter: nil) var coordinationError: NSError? @@ -39,7 +69,7 @@ struct CurrentWeatherSnapshotFileCoordinator: coordinator.coordinate( writingItemAt: url, - options: .forReplacing, + options: options, error: &coordinationError ) { coordinatedURL in accessorResult = Result { @@ -232,13 +262,21 @@ struct CurrentWeatherSnapshotStorage: Sendable { /// `invalidOrderingState` forever, and the widget could never be published /// again. A snapshot that outlives its sidecar is merely adopted. /// - /// Uncoordinated, like the removal it replaces: a clear that races a write - /// is ambiguous whichever way it resolves, and both outcomes — a surviving - /// stale snapshot, or a discarded fresh one — are recoverable by the next - /// refresh. + /// Both removals happen inside one delete claim on the snapshot directory, + /// and that particular item is the point. Every writer coordinates on a + /// snapshot file, which is a descendant, so a claim here conflicts with all + /// of them; the ordering sidecars are written inside those same claims and + /// are never coordinated on their own, so nothing but this containment + /// protects them. Clearing the ordering directory under its own claim would + /// have serialised against no one and could delete a sidecar in the middle + /// of the three-step commit `replace` performs. func removeAll() throws { - try removeDirectory(at: orderingDirectoryURL) - try removeDirectory(at: snapshotDirectoryURL) + try coordinator.coordinate( + deletingItemAt: snapshotDirectoryURL + ) { coordinatedSnapshots in + try removeDirectory(at: orderingDirectoryURL) + try removeDirectory(at: coordinatedSnapshots) + } } private func removeDirectory(at url: URL) throws { From db058ea78a86ee1462678fe117cad67cd4dcd10a Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 28 Sep 2026 15:08:51 +0800 Subject: [PATCH 12/17] fix(weather): clear the widget's cached hourly forecast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 切換地區後,iOS 天氣小工具不再繼續顯示舊地區的逐時預報 Fix(en-US): the iOS weather widget no longer keeps showing the previous region's hourly forecast after switching region --- .../ForecastWidgetSnapshotStore.swift | 5 +- ios/Runner.xcodeproj/project.pbxproj | 3 + ios/Runner/WidgetSnapshotPlugin.swift | 22 ++++--- ios/RunnerTests/RunnerTests.swift | 35 +++++++++++ ios/Shared/ForecastSnapshotLocation.swift | 59 +++++++++++++++++++ 5 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 ios/Shared/ForecastSnapshotLocation.swift diff --git a/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift b/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift index cbe4f011f..5cf8e80fa 100644 --- a/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift +++ b/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift @@ -10,9 +10,8 @@ struct ForecastWidgetSnapshotStore: Sendable { let address = CurrentWeatherSnapshotAddress( sourceIdentifier: source ) else { return nil } - return containerURL - .appendingPathComponent("WidgetSnapshots", isDirectory: true) - .appendingPathComponent("hourly-forecast", isDirectory: true) + return ForecastSnapshotLocation + .directoryURL(in: containerURL) .appendingPathComponent(address.filename) } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3dbb8a834..6eb204839 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -192,6 +192,7 @@ membershipExceptions = ( CurrentWeatherSnapshotAddress.swift, CurrentWeatherSnapshotStorage.swift, + ForecastSnapshotLocation.swift, WidgetLocationCatalog.swift, ); target = 2715FCD430570C1C0014DC8A /* DPIPWidgetsExtension */; @@ -201,6 +202,7 @@ membershipExceptions = ( CurrentWeatherSnapshotAddress.swift, CurrentWeatherSnapshotStorage.swift, + ForecastSnapshotLocation.swift, ); target = 97C146ED1CF9000F007C117D /* Runner */; }; @@ -209,6 +211,7 @@ membershipExceptions = ( CurrentWeatherSnapshotAddress.swift, CurrentWeatherSnapshotStorage.swift, + ForecastSnapshotLocation.swift, WidgetLocationCatalog.swift, ); target = 331C8080294A63A400263BE5 /* RunnerTests */; diff --git a/ios/Runner/WidgetSnapshotPlugin.swift b/ios/Runner/WidgetSnapshotPlugin.swift index d5f049898..38805eecc 100644 --- a/ios/Runner/WidgetSnapshotPlugin.swift +++ b/ios/Runner/WidgetSnapshotPlugin.swift @@ -148,13 +148,15 @@ enum WidgetSnapshotFile { } } - /// Removes everything `write` could have produced for this kind. + /// Removes everything that could be on disk for this kind — which is more + /// than `write` puts there, because the widget extension caches too. /// - /// The `switch` mirrors `snapshotURL(kind:sourceIdentifier:in:)` on purpose. - /// Current weather stopped being one file per kind when it became one file - /// per target, and for a while only the write path knew that: this removed - /// the single legacy file, found nothing, reported success, and left every - /// per-target snapshot on disk for the widget to keep drawing. + /// Current weather and hourly forecast both stopped being one file per kind + /// when they became one file per target, and for a while only the write paths + /// knew it: this removed the single legacy file, found nothing, reported + /// success, and left every per-target snapshot on disk for the widget to keep + /// drawing. Neither per-target layout is named here; each is owned by the + /// type that writes it, and this only asks that type to empty itself. static func clear(_ kind: WidgetSnapshotKind, in container: URL) throws { let directory = container.appendingPathComponent("WidgetSnapshots", isDirectory: true) do { @@ -166,7 +168,13 @@ enum WidgetSnapshotFile { // exactly what would stay on screen after everything else was cleared. try remove(directory.appendingPathComponent(kind.filename)) - case .weatherForecast, .locationCatalog: + case .weatherForecast: + try ForecastSnapshotLocation.removeAll(in: container) + // And the pre-per-target file, for the same reason current weather + // clears its own: the widget still falls back to it. + try remove(directory.appendingPathComponent(kind.filename)) + + case .locationCatalog: try remove(directory.appendingPathComponent(kind.filename)) } } catch { diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 8a00e4751..b887c0d0f 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -154,4 +154,39 @@ final class RunnerTests: XCTestCase { try WidgetSnapshotFile.clear(kind, in: container) XCTAssertNoThrow(try WidgetSnapshotFile.clear(kind, in: container)) } + + func testSnapshotClearRemovesTheWidgetsForecastCache() throws { + let container = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: container) } + let kind = try WidgetSnapshotFile.kind("weatherForecast") + let data = try WidgetSnapshotFile.payload("{\"schemaVersion\":1}") + let directory = container.appendingPathComponent("WidgetSnapshots") + let legacyTarget = directory.appendingPathComponent("weather-forecast.json") + + XCTAssertNoThrow(try WidgetSnapshotFile.clear(kind, in: container)) + + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + try data.write(to: legacyTarget, options: .atomic) + + // Written by ForecastWidgetSnapshotStore, in the extension. The app never + // puts a file here, which is why clearing used to walk past it: a forecast + // survived "clear" and the widget went on drawing it. + let cache = ForecastSnapshotLocation.directoryURL(in: container) + let cached = cache.appendingPathComponent("region-220.json") + try FileManager.default.createDirectory( + at: cache, + withIntermediateDirectories: true + ) + try data.write(to: cached, options: .atomic) + + try WidgetSnapshotFile.clear(kind, in: container) + XCTAssertFalse(FileManager.default.fileExists(atPath: legacyTarget.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: cached.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: cache.path)) + + XCTAssertNoThrow(try WidgetSnapshotFile.clear(kind, in: container)) + } } diff --git a/ios/Shared/ForecastSnapshotLocation.swift b/ios/Shared/ForecastSnapshotLocation.swift new file mode 100644 index 000000000..0d96e1d29 --- /dev/null +++ b/ios/Shared/ForecastSnapshotLocation.swift @@ -0,0 +1,59 @@ +import Foundation + +/// The one place the per-target hourly-forecast cache's location is written. +/// +/// It lives in `Shared` rather than beside `ForecastWidgetSnapshotStore`, which +/// is the only thing that reads and writes files in it, because two targets +/// need the path and only one of them can see that store. The store is built on +/// `WidgetLocationTarget`, `ForecastWidgetSnapshot` and `ForecastWidgetExpiry`, +/// none of which the app links; making it a Runner member to reach one +/// directory name would drag the widget's whole model layer across. +/// +/// The alternative was to spell the path a second time inside the plugin, and a +/// second spelling of a snapshot path is the exact defect this repository has +/// already shipped once: when current weather became one file per target, only +/// the write path was told, and `clear` went on removing a single legacy file, +/// reporting success, and leaving every real snapshot on disk. +enum ForecastSnapshotLocation { + static func directoryURL(in containerURL: URL) -> URL { + containerURL + .appendingPathComponent("WidgetSnapshots", isDirectory: true) + .appendingPathComponent("hourly-forecast", isDirectory: true) + } + + /// Removes the whole cache under one delete claim. + /// + /// `ForecastWidgetSnapshotStore.write` coordinates on the individual file, + /// which is a descendant of this directory, so the claim conflicts with a + /// refresh already writing — without it a forecast published a moment + /// before the clear could recreate the directory and survive it. + static func removeAll(in containerURL: URL) throws { + let directory = directoryURL(in: containerURL) + let coordinator = NSFileCoordinator(filePresenter: nil) + var coordinationError: NSError? + var result: Result? + + coordinator.coordinate( + writingItemAt: directory, + options: .forDeleting, + error: &coordinationError + ) { coordinatedURL in + result = Result { + do { + try FileManager.default.removeItem(at: coordinatedURL) + } catch let error as CocoaError + where error.code == .fileNoSuchFile + { + // Nothing cached yet, or already cleared. + } + } + } + + if let result { + return try result.get() + } + if let coordinationError { + throw coordinationError + } + } +} From c06eb1e4bce81aa179acab4c8ce7c05fdec13dee Mon Sep 17 00:00:00 2001 From: PiscesXD Date: Mon, 28 Sep 2026 14:56:58 +0800 Subject: [PATCH 13/17] ci: build and test the iOS targets on every pull request --- .github/workflows/ios.yml | 22 ++++++++- AGENTS.md | 6 +++ tool/dev/test_ios.sh | 93 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100755 tool/dev/test_ios.sh diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 01fbc4131..1d245f3d7 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -3,9 +3,21 @@ name: Build iOS # Pull requests only. A push to `main` is handled by release.yml, # which builds the signed, uploadable artifact — running this as well would # spend a second runner producing an unsigned one nobody installs. +# +# Every pull request, not only the ones targeting `main`. A stacked branch +# opens its pull request against the layer below it, so `branches: [main]` +# silently excluded exactly the reviews that most needed a compiler: the ones +# where only the top of the stack is ever read against `main`. Four iOS-only +# commits reached review that way without Swift being built once. on: pull_request: - branches: ["main"] + +# One in-flight run per ref; a new push cancels the previous run. This matters +# more now that the trigger is every pull request: restacking a five-deep chain +# pushes five branches at once, and each one wants a macOS runner. +concurrency: + group: ios-${{ github.ref }} + cancel-in-progress: true jobs: changes: @@ -66,6 +78,14 @@ jobs: bash tool/dev/deps.sh bash tool/dev/codegen.sh + # Before the device build, because it is the step that can fail on + # something a reviewer would have had to catch by eye. It builds the + # Runner scheme, which depends on DPIPWidgetsExtension, so the widget + # target and its SwiftUI views are compiled here as well — nothing else + # in this repository compiles them. + - name: Test (ios/RunnerTests) + run: bash tool/dev/test_ios.sh + - name: Build iOS App and create IPA run: | bash tool/dev/build.sh ios --debug diff --git a/AGENTS.md b/AGENTS.md index 9fb1413b7..e633a12ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ tool/dev/analyze.sh |---|---| | Start the app | `tool/run.sh` (see [Running](#running)) | | Run the tests | `tool/dev/test.sh` | +| Run the Swift tests (macOS only) | `tool/dev/test_ios.sh` — `ios/RunnerTests`, which `tool/dev/test.sh` cannot reach | | Test coverage, into `coverage/` | `tool/dev/coverage.sh` (see [Coverage](#coverage)) | | Format + analyze | `tool/dev/analyze.sh` | | Reformat in place | `tool/dev/format.sh` | @@ -223,6 +224,7 @@ tool/check/tooling.sh tool/dev/analyze.sh tool/dev/codegen.sh # then git diff --exit-code tool/dev/test.sh +tool/dev/test_ios.sh # only if you touched ios/ — macOS, and slow ``` The bash gates need only bash and python3, so they fail fast without the @@ -230,6 +232,10 @@ toolchain. `.github/workflows/ci.yml` must stay green; `android.yml` / `ios.yml` build artifacts and `review.yml` adds an automated PR review. +`ios.yml` also runs `ios/RunnerTests`, and it is the only thing that compiles +the widget extension — so a change under `ios/` is unverified until that job +goes green, whatever `ci.yml` says about the Dart side. + Safety-critical seismic maths is pinned by golden tests (`test/features/earthquake/eew_estimator_test.dart`). If you change the EEW estimator, update those goldens deliberately. diff --git a/tool/dev/test_ios.sh b/tool/dev/test_ios.sh new file mode 100755 index 000000000..887734da6 --- /dev/null +++ b/tool/dev/test_ios.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# The Swift test suite — ios/RunnerTests, on a simulator. +# +# tool/dev/test_ios.sh # the whole suite +# tool/dev/test_ios.sh -only-testing:RunnerTests/ForecastWidgetTests +# +# Arguments are passed straight to `xcodebuild test`. +# +# tool/dev/test.sh cannot see any of this. The widget extension, the App Group +# snapshot storage and the WidgetKit timeline are Swift, their tests are +# XCTest, and until this script existed nothing ran them — not the checklist, +# not CI. A Swift test that nobody runs is indistinguishable from one that +# passes, and the suite had a case asserting a defect as if it were the +# contract. +# +# macOS and Xcode only; there is no cross-platform way to run this. +source "$(dirname "${BASH_SOURCE[0]}")/_lib.sh" +cd "$(repo_root)" + +if [[ $OSTYPE != darwin* ]]; then + printf '\n tool/dev/test_ios.sh needs macOS and Xcode.\n\n' >&2 + exit 1 +fi + +if ! xcrun --find xcodebuild >/dev/null 2>&1; then + cat >&2 <<'EOF' + + No xcodebuild. Install Xcode (not just the Command Line Tools) and point + xcode-select at it: + + sudo xcode-select -s /Applications/Xcode.app + +EOF + exit 1 +fi + +# A concrete simulator, because `xcodebuild test` cannot run against a generic +# destination. Resolved rather than named: a hardcoded "iPhone 16" is a device +# that exists on the machine it was written on and on no runner a year later, +# and the failure it produces names the destination rather than the cause. +# +# DPIP_IOS_SIMULATOR overrides with a name or a UDID, for testing an iPad +# layout or a specific OS version. +destination="${DPIP_IOS_SIMULATOR:-}" +if [[ -n $destination ]]; then + destination="platform=iOS Simulator,name=$destination" +else + # ` iPhone 17 Pro (UDID) (Shutdown)` — the parenthesised field is the UDID. + udid="$(xcrun simctl list devices available | + awk -F '[()]' '/^ *iPhone /{ gsub(/ /, "", $2); print $2; exit }')" + if [[ -z $udid ]]; then + cat >&2 <<'EOF' + + No iPhone simulator is available. Xcode installs one with a platform: + + xcodebuild -downloadPlatform iOS + + Or name one yourself: DPIP_IOS_SIMULATOR='iPhone 17' tool/dev/test_ios.sh + +EOF + exit 1 + fi + destination="id=$udid" +fi + +# Generated.xcconfig and the Flutter framework the Runner target links against. +# xcodebuild has no idea how to produce either, and without them it fails on a +# missing include long before it reaches a test. +# +# DPIP_RUN_SH for the same reason tool/run.sh passes it: RunnerTests is hosted +# *in* the Runner app, so every test launches it, and a debug build that was +# not started through a tool script calls exit(1) from bootstrap before Flutter +# is up (lib/bootstrap.dart, _refuseUnlessLaunchedByTool). xcodebuild reports +# that as "the test runner exited with code 1 before establishing connection", +# which names neither the guard nor the app — so without this the whole suite +# fails on something no test wrote. The premise the guard protects holds here: +# this is a tool script and the SDK below is the pinned one. +step "Configuring the iOS project for the simulator" +pinned flutter build ios --simulator --debug --config-only \ + --dart-define=DPIP_RUN_SH=true + +# The shared Runner scheme, which lists RunnerTests as its testable and depends +# on DPIPWidgetsExtension — so this compiles the widget target too, including +# the SwiftUI views that no other check reaches. +step "Running ios/RunnerTests on $destination" +xcodebuild test \ + -workspace ios/Runner.xcworkspace \ + -scheme Runner \ + -destination "$destination" \ + -clonedSourcePackagesDirPath build/ios/SourcePackages \ + CODE_SIGNING_ALLOWED=NO \ + -quiet \ + "$@" From 6d165aef627e374b0b0e2b11f394f66ae6df0908 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 28 Sep 2026 19:21:22 +0800 Subject: [PATCH 14/17] docs(weather): clarify widget weather presentation mapping Platform: ios --- .../CurrentWeatherWidgetSnapshot.swift | 14 ++++++++++++++ lib/core/weather/weather_code.dart | 16 ++++++++++++---- .../domain/current_weather_widget_snapshot.dart | 11 +++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index 6963b8642..eae33c0e4 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -1,6 +1,10 @@ import Foundation import SwiftUI +/// Coarse condition stored in the cross-platform snapshot contract. +/// +/// Keep this semantic enum independent of the finer presentation variants +/// below; a platform-specific symbol choice does not redefine the CWB meaning. enum CurrentWeatherWidgetCondition: String, Decodable, Sendable { case clear case cloudy @@ -69,6 +73,12 @@ enum CurrentWeatherWidgetCondition: String, Decodable, Sendable { } } +/// Widget-only presentation classification, refined beyond the persisted +/// condition so SF Symbols can distinguish hail, sleet, and lightning alone. +/// +/// This is intentionally derived from the source code/text instead of encoded +/// into the snapshot: SwiftUI uses SF Symbols, while Flutter uses its bundled +/// Material Symbols subset, and those icon sets need not share glyph mappings. enum WidgetWeatherCondition: CaseIterable, Equatable, Sendable { case clear case cloudy @@ -83,6 +93,8 @@ enum WidgetWeatherCondition: CaseIterable, Equatable, Sendable { case unknown init(weatherCode: Int, weather: String) { + // The suffix identifies a phenomenon across CWB families; consult the + // hundreds family next, and text only if neither code part classifies it. switch weatherCode % 100 { case 1, 2, 5: self = .fog @@ -122,6 +134,8 @@ enum WidgetWeatherCondition: CaseIterable, Equatable, Sendable { } private static func fallbackCondition(weather: String) -> Self { + // API text is less structured than the numeric code, so use it only + // when neither a recognized suffix nor a recognized family applies. if weather.contains("雷"), weather.contains("雨") { return .thunderstorm } diff --git a/lib/core/weather/weather_code.dart b/lib/core/weather/weather_code.dart index 951882c8a..217c3f16e 100644 --- a/lib/core/weather/weather_code.dart +++ b/lib/core/weather/weather_code.dart @@ -1,7 +1,12 @@ /// Pure semantic classification for CWB weather-condition codes. library; -/// The weather meaning shared by Flutter visuals and platform transports. +/// Coarse weather meaning shared by behavior and platform transports. +/// +/// Presentation can refine a condition using the CWB code suffix, so this +/// intentionally has fewer cases than the available weather glyphs. For +/// example, hail, sleet, and lightning-only codes can share a broad condition +/// while still receiving distinct presentation icons. enum WeatherCondition { clear, cloudy, @@ -13,10 +18,13 @@ enum WeatherCondition { unknown, } -/// Weather-code suffix → weather phenomenon. +/// Weather-code suffix → broad semantic phenomenon. /// -/// The phenomenon wins over the family sky: `106` is rain even though it is -/// in the clear-sky family. This is the authoritative suffix classification. +/// The last two digits (`code % 100`) identify the phenomenon; the hundreds +/// family (`code ~/ 100`) supplies the plain sky only when there is no +/// phenomenon suffix. Thus `106` is rain even though it is in the clear-sky +/// family. These broad meanings intentionally do not enumerate every visual +/// precipitation variant. const Map _phenomenonCondition = { 1: WeatherCondition.fog, // 有霾 2: WeatherCondition.fog, // 有靄 diff --git a/lib/features/weather/domain/current_weather_widget_snapshot.dart b/lib/features/weather/domain/current_weather_widget_snapshot.dart index 1f4f4e7f7..7eb8d7afd 100644 --- a/lib/features/weather/domain/current_weather_widget_snapshot.dart +++ b/lib/features/weather/domain/current_weather_widget_snapshot.dart @@ -13,6 +13,12 @@ enum CurrentWeatherWidgetCondition { unknown, } +/// Cross-platform Widget payload; keep its fields aligned with the Swift DTO. +/// +/// Weather text and code preserve the source observation. [condition] is the +/// coarse, versioned semantic value; native presentation derives a finer +/// glyph from the code (and uses the text only when the code cannot classify +/// the observation), without expanding this persisted enum for every icon. final class CurrentWeatherWidgetSnapshot { const CurrentWeatherWidgetSnapshot({ this.schemaVersion = 7, @@ -46,8 +52,13 @@ final class CurrentWeatherWidgetSnapshot { final String stationName; + /// Original CWB label, retained for display/fallback when a code is unknown. final String weather; + + /// Original numeric CWB code; its suffix supports finer icon selection. final int weatherCode; + + /// Coarse persisted classification shared with the native snapshot schema. final CurrentWeatherWidgetCondition condition; final bool isNight; From c6f38e991c8ec7e09377d248e6c83f3e1761a742 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 28 Sep 2026 21:04:07 +0800 Subject: [PATCH 15/17] docs(weather): clarify widget observation time semantics Platform: ios --- ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift | 2 ++ ios/DPIPWidgets/DPIPWidgets.swift | 2 ++ .../weather/domain/current_weather_widget_snapshot.dart | 1 + 3 files changed, 5 insertions(+) diff --git a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift index eae33c0e4..31480d3fb 100644 --- a/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift +++ b/ios/DPIPWidgets/CurrentWeatherWidgetSnapshot.swift @@ -350,6 +350,8 @@ struct CurrentWeatherWidgetSnapshot: Codable, Sendable { let regionCode: String let regionName: String + /// Source observation time, Unix seconds. Widget views show this value so + /// an old observation cannot appear fresh after a new timeline render. let observationTime: Int let stationName: String diff --git a/ios/DPIPWidgets/DPIPWidgets.swift b/ios/DPIPWidgets/DPIPWidgets.swift index c51d0c45f..dd5106fb3 100644 --- a/ios/DPIPWidgets/DPIPWidgets.swift +++ b/ios/DPIPWidgets/DPIPWidgets.swift @@ -172,6 +172,8 @@ private func forecastIsNight( return !isCurrentlyNight } +// All weather families show the source observation time beside current weather. +// Using the WidgetKit entry or render time would make stale data look fresh. private struct SmallCurrentWeatherView: View { let entry: DPIPWidgetEntry diff --git a/lib/features/weather/domain/current_weather_widget_snapshot.dart b/lib/features/weather/domain/current_weather_widget_snapshot.dart index 7eb8d7afd..c697d3a3a 100644 --- a/lib/features/weather/domain/current_weather_widget_snapshot.dart +++ b/lib/features/weather/domain/current_weather_widget_snapshot.dart @@ -48,6 +48,7 @@ final class CurrentWeatherWidgetSnapshot { final String regionCode; final String regionName; + /// Source weather observation time, Unix seconds; not snapshot publish time. final int observationTime; final String stationName; From 1b0de014b4bad1a66e92ece93bf1db9578b9e8e2 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 28 Sep 2026 21:06:59 +0800 Subject: [PATCH 16/17] fix(weather): calibrate widget forecast expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 修正裝置時間不準時可能造成天氣小工具預報快取期限錯誤 Fix(en-US): fix widget forecast cache expiry when the device clock is inaccurate --- .../DPIPWidgetProviderSupport.swift | 27 +++++-- ios/DPIPWidgets/ForecastWidgetExpiry.swift | 32 ++++++-- .../ForecastWidgetRefreshService.swift | 12 ++- .../ForecastWidgetSnapshotStore.swift | 10 ++- ios/RunnerTests/ForecastWidgetTests.swift | 79 +++++++++++++++++++ 5 files changed, 142 insertions(+), 18 deletions(-) diff --git a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift index c51af1e1c..86dd324d0 100644 --- a/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift +++ b/ios/DPIPWidgets/DPIPWidgetProviderSupport.swift @@ -84,8 +84,13 @@ struct DPIPWidgetTimelinePlan: Sendable { let reloadDate: Date func forecast(at date: Date) -> ForecastWidgetSnapshot? { - guard let forecast, - ForecastWidgetExpiry.isUsable(forecast, at: date) else { + guard let forecast, let snapshot, + ForecastWidgetExpiry.isUsable( + forecast, + at: date, + calibratedTimeOffsetMilliseconds: + snapshot.calibratedTimeOffsetMilliseconds + ) else { return nil } return forecast @@ -219,8 +224,12 @@ struct DPIPWidgetTimelinePlanner: Sendable { } else { forecast = nil } - if let forecast { - let expiry = ForecastWidgetExpiry.date(for: forecast) + if let forecast, let snapshot { + let expiry = ForecastWidgetExpiry.date( + for: forecast, + calibratedTimeOffsetMilliseconds: + snapshot.calibratedTimeOffsetMilliseconds + ) if expiry > deviceNow, expiry < reloadDate, !states.contains(where: { $0.date == expiry }) { states.append(CurrentWeatherWidgetTimeline.state( @@ -330,8 +339,14 @@ enum DPIPWidgetProviderRuntime { } let loadForecast: DPIPWidgetTimelinePlanner.LoadForecast = { target, regionCode, date in - forecastStore?.load( - for: target, regionCode: regionCode, at: date + guard let current = loadSnapshot(target), + current.regionCode == regionCode else { return nil } + return forecastStore?.load( + for: target, + regionCode: regionCode, + at: date, + calibratedTimeOffsetMilliseconds: + current.calibratedTimeOffsetMilliseconds ) } let forecastClient = ForecastClient() diff --git a/ios/DPIPWidgets/ForecastWidgetExpiry.swift b/ios/DPIPWidgets/ForecastWidgetExpiry.swift index 682d15bbd..71ac52606 100644 --- a/ios/DPIPWidgets/ForecastWidgetExpiry.swift +++ b/ios/DPIPWidgets/ForecastWidgetExpiry.swift @@ -1,24 +1,40 @@ import Foundation enum ForecastWidgetExpiry { - /// Cache/display deadline only. Neither input is a forecast-point time. - static func date(for snapshot: ForecastWidgetSnapshot) -> Date { - let published = Date( + /// 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. + 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 received = Date( + let receivedDevice = Date( timeIntervalSince1970: TimeInterval(snapshot.receivedAt) / 1_000 ) var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "Asia/Taipei")! let nextTaipeiHour = calendar.dateInterval( of: .hour, - for: received + for: receivedDevice.addingTimeInterval(offset) )!.end - return min(published.addingTimeInterval(30 * 60), nextTaipeiHour) + return min( + publishedCalibrated.addingTimeInterval(30 * 60), + nextTaipeiHour + ).addingTimeInterval(-offset) } - static func isUsable(_ snapshot: ForecastWidgetSnapshot, at date: Date) -> Bool { - date < self.date(for: snapshot) + static func isUsable( + _ snapshot: ForecastWidgetSnapshot, + at deviceDate: Date, + calibratedTimeOffsetMilliseconds: Int = 0 + ) -> Bool { + deviceDate < self.date( + for: snapshot, + calibratedTimeOffsetMilliseconds: calibratedTimeOffsetMilliseconds + ) } } diff --git a/ios/DPIPWidgets/ForecastWidgetRefreshService.swift b/ios/DPIPWidgets/ForecastWidgetRefreshService.swift index ad2e9af3c..438feb501 100644 --- a/ios/DPIPWidgets/ForecastWidgetRefreshService.swift +++ b/ios/DPIPWidgets/ForecastWidgetRefreshService.swift @@ -41,7 +41,10 @@ struct ForecastWidgetRefreshService: Sendable { // Recheck after await: a Current Location request for A may complete // after the current-weather snapshot has moved to township B. guard matchesCurrent(target: target, regionCode: regionCode), - isResolved(target), !response.points.isEmpty else { return } + isResolved(target), !response.points.isEmpty, + let current = loadCurrent(target), + target.matches(snapshot: current), + current.regionCode == regionCode else { return } let receivedAt = Int64(now().timeIntervalSince1970 * 1_000) guard let snapshot = ForecastWidgetSnapshot( sourceIdentifier: target.sourceIdentifier ?? "", @@ -49,7 +52,12 @@ struct ForecastWidgetRefreshService: Sendable { updateTime: response.updateTime, receivedAt: receivedAt, points: response.points - ), ForecastWidgetExpiry.isUsable(snapshot, at: now()) else { return } + ), ForecastWidgetExpiry.isUsable( + snapshot, + at: now(), + calibratedTimeOffsetMilliseconds: + current.calibratedTimeOffsetMilliseconds + ) else { return } _ = try? write(snapshot, target) } diff --git a/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift b/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift index 5cf8e80fa..1ada72a85 100644 --- a/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift +++ b/ios/DPIPWidgets/ForecastWidgetSnapshotStore.swift @@ -18,7 +18,8 @@ struct ForecastWidgetSnapshotStore: Sendable { func load( for target: WidgetLocationTarget, regionCode: String, - at date: Date + at date: Date, + calibratedTimeOffsetMilliseconds: Int = 0 ) -> ForecastWidgetSnapshot? { guard let url = snapshotURL(for: target), let attributes = try? FileManager.default.attributesOfItem( @@ -34,7 +35,12 @@ struct ForecastWidgetSnapshotStore: Sendable { ), snapshot.sourceIdentifier == target.sourceIdentifier, snapshot.regionCode == regionCode, - ForecastWidgetExpiry.isUsable(snapshot, at: date) + ForecastWidgetExpiry.isUsable( + snapshot, + at: date, + calibratedTimeOffsetMilliseconds: + calibratedTimeOffsetMilliseconds + ) else { return nil } return snapshot } diff --git a/ios/RunnerTests/ForecastWidgetTests.swift b/ios/RunnerTests/ForecastWidgetTests.swift index d9167aa61..da5631174 100644 --- a/ios/RunnerTests/ForecastWidgetTests.swift +++ b/ios/RunnerTests/ForecastWidgetTests.swift @@ -317,6 +317,26 @@ final class ForecastWidgetExpiryTests: XCTestCase { XCTAssertEqual(ForecastWidgetExpiry.date(for: snapshot), expected) XCTAssertFalse(ForecastWidgetExpiry.isUsable(snapshot, at: expected)) } + + func testPublicationAndReceiveDeadlinesUseTheSameClockDomain() { + let deviceAhead = forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:26:00Z") + ) + XCTAssertEqual(ForecastWidgetExpiry.date( + for: deviceAhead, + calibratedTimeOffsetMilliseconds: -300_000 + ), instant("2026-09-25T07:45:00Z")) + + let deviceBehind = forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:40:00Z"), + receivedAt: milliseconds("2026-09-25T07:45:00Z") + ) + XCTAssertEqual(ForecastWidgetExpiry.date( + for: deviceBehind, + calibratedTimeOffsetMilliseconds: 300_000 + ), instant("2026-09-25T07:55:00Z")) + } } final class ForecastWidgetSnapshotStoreTests: XCTestCase { @@ -375,6 +395,30 @@ final class ForecastWidgetSnapshotStoreTests: XCTestCase { XCTAssertNil(store.load(for: target, regionCode: "407", at: instant("2026-09-25T07:21:00Z"))) } + + func testCacheExpiryUsesCalibratedPublicationWithDeviceClockOffset() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ForecastWidgetSnapshotStore(containerURL: directory) + let target = WidgetLocationTarget.saved(regionCode: "407") + let snapshot = forecastSnapshot( + updateTime: milliseconds("2026-09-25T07:10:00Z"), + receivedAt: milliseconds("2026-09-25T07:26:00Z") + ) + try store.write(snapshot, for: target) + + XCTAssertNotNil(store.load( + for: target, regionCode: "407", + at: instant("2026-09-25T07:42:00Z"), + calibratedTimeOffsetMilliseconds: -300_000 + )) + XCTAssertNil(store.load( + for: target, regionCode: "407", + at: instant("2026-09-25T07:45:00Z"), + calibratedTimeOffsetMilliseconds: -300_000 + )) + } } final class ForecastWidgetProviderTests: XCTestCase { @@ -401,6 +445,41 @@ final class ForecastWidgetProviderTests: XCTestCase { ["11:00", "12:00", "13:00", "14:00", "15:00"]) } + func testSelectionUsesCalibratedTaipeiTimeWithDeviceClockOffset() { + let deviceDate = instant("2026-09-25T02:30:00Z") // 10:30 Taipei + let current = CurrentWeatherWidgetSnapshot( + schemaVersion: 7, + sourceIdentifier: "region:407", + regionCode: "407", + regionName: "測試地區", + observationTime: Int(deviceDate.timeIntervalSince1970) - 3_600, + stationName: "station", + weather: "晴", + weatherCode: 100, + condition: .clear, + isNight: false, + nextDayNightTransitionTime: 0, + calibratedTimeOffsetMilliseconds: 3_600_000, + temperature: 22, + humidity: 50, + rain: 0 + ) + let calibratedDate = CurrentWeatherWidgetTimeCalibration( + snapshot: current + ).calibratedDate(fromDeviceDate: deviceDate) // 11:30 Taipei + let forecast = forecastSnapshot( + updateTime: milliseconds("2026-09-24T00:00:00Z"), + times: ["10:00", "11:00", "12:00"] + ) + + for family: WidgetFamily in [.systemMedium, .systemLarge] { + let selected = ForecastWidgetFamilyPolicy.visiblePoints( + in: forecast, at: calibratedDate, family: family + ) + XCTAssertEqual(selected.map(\.time), ["12:00"]) + } + } + func testSmallDoesNotRefreshForecast() async { let target = WidgetLocationTarget.saved(regionCode: "407") let state = ForecastPlannerState(current: currentSnapshot(target: target)) From 675f8ab9edc16e3408ef8bc9e35c63688a0aca79 Mon Sep 17 00:00:00 2001 From: YinCheng0106 Date: Mon, 28 Sep 2026 21:10:41 +0800 Subject: [PATCH 17/17] ci(ios): resolve simulator overrides by device id Platform: ios --- tool/dev/test_ios.sh | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tool/dev/test_ios.sh b/tool/dev/test_ios.sh index 887734da6..23f48f5c7 100755 --- a/tool/dev/test_ios.sh +++ b/tool/dev/test_ios.sh @@ -43,7 +43,30 @@ fi # layout or a specific OS version. destination="${DPIP_IOS_SIMULATOR:-}" if [[ -n $destination ]]; then - destination="platform=iOS Simulator,name=$destination" + if [[ $destination =~ ^[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}$ ]]; then + destination="platform=iOS Simulator,id=$destination" + else + # A name alone makes xcodebuild assume OS=latest, even when that device + # exists only on an older installed runtime. Resolve its actual UDID. + udid="$(xcrun simctl list devices available --json | python3 -c ' +import json +import sys + +devices = [ + device + for runtime in json.load(sys.stdin)["devices"].values() + for device in runtime + if device["name"] == sys.argv[1] +] +booted = next((device for device in devices if device["state"] == "Booted"), None) +print((booted or devices[0])["udid"] if devices else "") +' "$destination")" + if [[ -z $udid ]]; then + printf '\n No available iOS simulator named %s.\n\n' "$destination" >&2 + exit 1 + fi + destination="platform=iOS Simulator,id=$udid" + fi else # ` iPhone 17 Pro (UDID) (Shutdown)` — the parenthesised field is the UDID. udid="$(xcrun simctl list devices available |