Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7fe6ee6
feat(weather): add wind data to widget snapshots
YinCheng0106 Sep 22, 2026
fcd8a8d
feat(weather): add widget apparent temperature data
YinCheng0106 Sep 23, 2026
9a8e9ff
refactor(weather): split widget family layout
YinCheng0106 Sep 23, 2026
5331f00
feat(weather): add medium iOS weather widget
YinCheng0106 Sep 25, 2026
f9becc4
feat(weather): add native iOS widget forecast data
YinCheng0106 Sep 25, 2026
ec38710
feat(weather): add medium and large iOS weather widgets
YinCheng0106 Sep 26, 2026
e3f7408
fix(weather): make large widget wind speed text consistent
YinCheng0106 Sep 26, 2026
000975c
fix(weather): skip unusable widget forecast points
YinCheng0106 Sep 27, 2026
dc28c14
fix(weather): use calibrated time for widget forecast hours
PiscesXD Sep 28, 2026
01672b5
fix(weather): remove every current-weather snapshot on clear
PiscesXD Sep 28, 2026
e9c32c8
refactor(weather): coordinate the current-weather snapshot clear
PiscesXD Sep 28, 2026
94eeb97
fix(weather): clear the widget's cached hourly forecast
PiscesXD Sep 28, 2026
c353438
ci: build and test the iOS targets on every pull request
PiscesXD Sep 28, 2026
c9b20e6
docs(weather): clarify widget weather presentation mapping
YinCheng0106 Sep 28, 2026
9d44d99
docs(weather): clarify widget observation time semantics
YinCheng0106 Sep 28, 2026
473c9ce
fix(weather): calibrate widget forecast expiry
YinCheng0106 Sep 28, 2026
82be539
ci(ios): resolve simulator overrides by device id
YinCheng0106 Sep 28, 2026
5cf5097
fix(time): fall back to HTTP when SNTP is blocked
PiscesXD Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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` |
Expand Down Expand Up @@ -224,13 +225,18 @@ 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
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.
Expand Down
16 changes: 14 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,20 @@ feed that is `stale`/`offline` must never be presented as current.

the `ServerClock` corrects device time via real **SNTP**
(`ntp_time_source.dart` → `flutter_ntp`, UDP/123, `time.exptech.com.tw` primary
/ `time.apple.com` backup — not an HTTP endpoint), anchored to a monotonic
clock so a device-clock or timezone change can't move it (only a resync does).
/ `time.apple.com` backup), anchored to a monotonic clock so a device-clock or
timezone change can't move it (only a resync does).

When both SNTP hosts fail, `fallback_time_source.dart` drops to
`https://api.lb.exptech.dev/ntp` (`http_time_source.dart`; the iOS widget
mirrors it in `WidgetHTTPTimeSource.swift`) — corporate Wi-Fi, hotel and campus
networks block UDP/123, and there the alternative is no calibration at all. The
order is precision, not preference: SNTP resolves "now" to about a millisecond,
one HTTPS round trip only to ±RTT/2. This is the one bare host the repository
requests, because a clock reading has no region to pin; the body is never
trusted on its own but checked against the Cloudflare-stamped `Date` header of
the same response, which is what catches a backend whose own clock is wrong —
see [api.md](api.md) for the 15.7-second measurement that made the check
necessary.
`RealtimeService` resyncs it every 60s (paused in background, resynced on
resume). Read corrected time anywhere via the global `AppTime` facade
(`AppTime.utc` / `AppTime.utc8`, the fixed-offset Taipei wall clock) — never
Expand Down
49 changes: 45 additions & 4 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,39 @@
> 真正的呼叫是一個參數化的 `MeteorSnapshotApi` 加上兩個專用類別。帶著類別名,
> 一次 grep 就能證實或推翻這張表的任何一列。
>
> **對時不是 HTTP 端點。** App 的時鐘使用真正的 **SNTP**
> **對時優先走 SNTP,HTTP `/ntp` 只是退路。** App 的時鐘使用真正的 **SNTP**
> (`flutter_ntp`,UDP/123),對 `time.exptech.com.tw`(主)/
> `time.apple.com`(備),而非 `/ntp` HTTP 呼叫 —— 見
> `core/realtime/ntp_time_source.dart` 與 `app_time.dart`(`AppTime.utc` /
> `AppTime.utc8`)。
> `time.apple.com`(備)—— 見 `core/realtime/ntp_time_source.dart` 與
> `app_time.dart`(`AppTime.utc` / `AppTime.utc8`)。
>
> 兩台都失敗才改打 **`https://api.lb.exptech.dev/ntp`**
> (`core/realtime/http_time_source.dart`,iOS widget 的對應實作是
> `WidgetHTTPTimeSource.swift`),因為企業 Wi-Fi、飯店與校園網路常擋掉
> UDP/123,而那時唯一的替代品是完全不校時。順序是精度而非偏好:SNTP 的
> RFC 5905 交換約到毫秒,單趟 HTTPS 只到 ±RTT/2(實測 ±150 ms)。
>
> **這是全 repo 唯一使用 bare host 的地方。** `ApiTier` 流量刻意不用(見下方
> 「多活備援」與 `api_region.dart`),因為區域選擇與容錯要由 App 掌握;但對時
> 沒有區域可選,沒有東西要 pin、也沒有 per-region 容錯要保留,而 bare name 正
> 是 Cloudflare 用**有效憑證**(`CN=*.lb.exptech.dev`,Google Trust Services)
> 終結的那一個。
>
> **`/ntp` 的 body 不可盲信,要用它自己的 `Date` header 驗證。** body 是後端的
> 時鐘,而後端的時鐘不一定對:這個名字後面的四台 `lb-*` 當中,撰寫時 `lb-2`
> 與 `lb-4` 都慢 15.7 秒,而且兩台數值一致 —— 跨主機取多數也抓不到。
>
> 能抓到的是同一個回應的 `Date` header:這台在 Cloudflare 後面,`Date` 由
> **edge** 蓋,跟哪一台後端回應無關,所以每個回應都能自我檢查 —— body 與自己的
> `Date` 差超過 2 秒就是壞後端(或凍結的快取),直接丟棄,不需要知道它來自哪
> 個節點。(來回超過 1.5 秒也丟棄,因為 offset 估計的誤差隨 RTT 成長。)
>
> 連續 30 次取樣都落在自己 header 的 910 ms 內,所以這個名字目前確實會導到正
> 常的後端 —— 而上面那條檢查的作用,就是讓這件事不必靠 App 去假設。全部被拒
> 就維持舊 anchor:裝上 15 秒誤差比不校時更糟。
>
> (`time.exptech.com.tw`,也就是 SNTP 的主機,同樣回應 `/ntp`,但它提供的憑證
> 是 `CN=tpe-1.lb.exptech.dev` 且已於 2026-03-13 過期,HTTPS 連不上;而且它的
> `Date` 由同一台機器上的 openresty 蓋,無法自我驗證。所以不用它。)

## 多活備援 (multi-active)

Expand Down Expand Up @@ -254,6 +282,19 @@ lightning 沒有測站,也沒有 `trend`)。`{kind}` = `track` \| `potential
`/api/v1/dpip/event/{id}` 存在於 `api-1`,但 App 裡**沒有任何方法呼叫它** ——
先前這裡列的 `getEvent` 並不存在於程式碼中。

### 對時 `/ntp`(SNTP 的退路,**不走 `ApiClient`**)

| 路徑 | 主機 | 回應 |
|---|---|---|
| `/ntp` | `api.lb.exptech.dev`(**HTTPS only**;全 repo 唯一的 bare host) | Unix **毫秒**純文字,帶小數(`1791607233513.399`) |

不是 `ApiTier`,所以沒有區域容錯;也刻意不經過 `ApiClient` —— 它的
absolute-URL 路徑都會過 `EtagInterceptor`,而快取一個對時回應比不對時更糟。
Dart 用裸 `HttpClient`、Swift 用 `URLSession` +
`reloadIgnoringLocalAndRemoteCacheData`,兩邊都送 `Cache-Control: no-store`。

為何用 bare host、以及為何每個回應都要拿 `Date` header 驗證,見上方「對時」段落。

## 外部(第三方,無區域)

走 `ApiClient.getAbsolute` / `postAbsolute`:沒有 tier、沒有區域容錯,也不參與
Expand Down
20 changes: 20 additions & 0 deletions ios/DPIPWidgets/CurrentApparentTemperature.swift
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 35 additions & 0 deletions ios/DPIPWidgets/CurrentWeatherRemoteDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -31,13 +33,15 @@ struct CurrentWeatherRemoteDTO: Decodable, Sendable {
let temperature: Double?
let humidity: Int?
let rain: Double?
let wind: Wind

private enum CodingKeys: String, CodingKey {
case weather
case weatherCode
case temperature
case humidity
case rain
case wind
}

init(from decoder: Decoder) throws {
Expand All @@ -57,6 +61,7 @@ struct CurrentWeatherRemoteDTO: Decodable, Sendable {
from: container,
forKey: .rain
)
wind = try container.decode(Wind.self, forKey: .wind)
}

private static func decodeNullableDouble(
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
}

Expand Down
Loading