diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0d090c186..88027c5ab 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -35,6 +35,51 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config"
android:allowBackup="false">
+
+
+
+
= Build.VERSION_CODES.R) {
context.packageManager
@@ -54,10 +61,10 @@ class DeviceInfoChannel(private val context: Context) :
}
} catch (e: Exception) {
// The package can be queried out from under us (uninstalling while
- // running); an unknown installer is a sideload for our purposes.
+ // running); an unknown installer means no store did it.
null
}
- return if (installer == "com.android.vending") "playStore" else "sideload"
+ return if (installer == "com.android.vending") "playStore" else "github"
}
/** Total physical RAM in MiB — the cheap proxy for the low-end tier. */
diff --git a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt
index aa29937f7..6b4660184 100644
--- a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt
@@ -59,5 +59,8 @@ class MainActivity : FlutterActivity() {
EventChannel(messenger, CompassChannel.NAME)
.setStreamHandler(CompassChannel(applicationContext))
+
+ MethodChannel(messenger, PlainChannelsChannel.NAME)
+ .setMethodCallHandler(PlainChannelsChannel(applicationContext))
}
}
diff --git a/android/app/src/main/kotlin/com/exptech/dpip/PlainChannelsChannel.kt b/android/app/src/main/kotlin/com/exptech/dpip/PlainChannelsChannel.kt
new file mode 100644
index 000000000..1a3adec66
--- /dev/null
+++ b/android/app/src/main/kotlin/com/exptech/dpip/PlainChannelsChannel.kt
@@ -0,0 +1,98 @@
+package com.exptech.dpip
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.media.AudioAttributes
+import android.net.Uri
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+
+/**
+ * Mirrors the notification catalogue under **plain, un-hashed channel IDs**.
+ *
+ * awesome_notifications derives each Android channel's ID from a hash of its
+ * model, so the ID a locally-rendered notification targets is not stable
+ * across builds. FCM's system-tray path cannot follow that dance at all: a
+ * push carrying an FCM `notification` block is rendered by the SDK itself,
+ * which looks up `android_channel_id` — the plain key from the backend —
+ * verbatim, and falls back to the system default channel (system sound) the
+ * moment it misses.
+ *
+ * [ensure] creates any missing plain-key channel straight through
+ * NotificationManager. An existing channel is never touched — user tuning
+ * survives ordinary launches, and the Dart-side catalogue-version gate is
+ * what drives delete + re-create when the sound files themselves change.
+ */
+class PlainChannelsChannel(private val context: Context) :
+ MethodChannel.MethodCallHandler {
+
+ companion object {
+ const val NAME = "com.exptech.dpip/plain_notification_channels"
+ }
+
+ override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+ when (call.method) {
+ "ensure" -> {
+ val channels =
+ call.argument>>("channels") ?: emptyList()
+ result.success(ensure(channels))
+ }
+
+ else -> result.notImplemented()
+ }
+ }
+
+ private fun ensure(channels: List>): Int {
+ val manager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ var created = 0
+ for (entry in channels) {
+ val id = entry["id"] as? String ?: continue
+ // Create-if-missing only: a channel that already exists may carry
+ // settings the user tuned in the OS UI, and rewriting it would be
+ // ignored for behaviour anyway — Android freezes created channels.
+ if (manager.getNotificationChannel(id) != null) continue
+
+ val name = entry["name"] as? String ?: continue
+ val importance = (entry["importance"] as? Number)?.toInt()
+ ?: NotificationManager.IMPORTANCE_DEFAULT
+
+ val channel = NotificationChannel(id, name, importance)
+ (entry["description"] as? String)?.let { channel.description = it }
+ (entry["group"] as? String)?.let { channel.group = it }
+
+ // Read from the current APK resources, so the URI is correct by
+ // construction — no stale numeric resource ids, ever.
+ val sound = entry["sound"] as? String
+ if (sound != null) {
+ val resId =
+ context.resources.getIdentifier(sound, "raw", context.packageName)
+ if (resId > 0) {
+ val attributes = AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_ALARM)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .build()
+ channel.setSound(
+ Uri.parse("android.resource://${context.packageName}/$resId"),
+ attributes,
+ )
+ }
+ }
+
+ (entry["vibrationPattern"] as? List<*>)?.let { pattern ->
+ val longs = pattern.mapNotNull { (it as? Number)?.toLong() }
+ .toLongArray()
+ if (longs.isNotEmpty()) channel.vibrationPattern = longs
+ }
+ (entry["ledColor"] as? Number)?.let { color ->
+ channel.enableLights(true)
+ channel.lightColor = color.toInt()
+ }
+
+ manager.createNotificationChannel(channel)
+ created++
+ }
+ return created
+ }
+}
diff --git a/api.md b/api.md
index c87742b02..050370909 100644
--- a/api.md
+++ b/api.md
@@ -10,11 +10,16 @@
`coreExclusiveApi` = 僅 `api.core-tnn1`、`coreStaticExclusive` =
僅 `static.core-tnn1`、`legacyApi` = 舊 server `api-1`(逐步淘汰中)。
-> 這是**端點目錄**,不是程式碼對照表。沒有 `lib/api/` 巨石檔:每個端點在其所屬
-> feature 的 `data/`(基礎設施則在 `core/`)裡,各自建成一個輕薄的 datasource,
-> 並帶著自己的 `ApiTier`(`core/network/api_region.dart`);路徑字串集中於
+> 沒有 `lib/api/` 巨石檔:每個端點在其所屬 feature 的 `data/`(基礎設施則在
+> `core/`)裡,各自建成一個輕薄的 datasource,並帶著自己的 `ApiTier`
+> (`core/network/api_region.dart`);路徑字串集中於
> `core/network/api_paths.dart`(與 `EtagInterceptor` 共用,不會漂移)。
>
+> **第一欄一律寫成 `類別.方法`。** 只寫方法名的版本曾經整段對不上程式碼 ——
+> `getWeatherStations`、`getRainLatest`、`getTyphoonTrack` 這些名字從來不存在,
+> 真正的呼叫是一個參數化的 `MeteorSnapshotApi` 加上兩個專用類別。帶著類別名,
+> 一次 grep 就能證實或推翻這張表的任何一列。
+>
> **對時不是 HTTP 端點。** App 的時鐘使用真正的 **SNTP**
> (`flutter_ntp`,UDP/123),對 `time.exptech.com.tw`(主)/
> `time.apple.com`(備),而非 `/ntp` HTTP 呼叫 —— 見
@@ -23,15 +28,15 @@
## 多活備援 (multi-active)
-| 方法 | 路徑 | 層級 | 主機(容錯順序 = 選定區域優先) |
+| 類別.方法 | 路徑 | 層級 | 主機(容錯順序 = 選定區域優先) |
|---|---|---|---|
-| `openEewSse` | `/api/v2/eq/eew?sse=1&compress=1` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` |
-| `openRtsSse` | `/api/v2/trem/rts?sse=1&compress=1` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` |
-| `getRtsRealtime` | `/api/v2/trem/rts` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` |
-| `getEewRealtime` | `/api/v2/eq/eew` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` |
-| `getEewAt` | `/api/v2/eq/eew/{sec}` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` |
-| `getReportList` | `/api/v2/eq/report` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` |
-| `getReport` | `/api/v2/eq/report/{id}` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` |
+| `EarthquakeApi.openEewSse` | `/api/v2/eq/eew?sse=1&compress=1` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` |
+| `EarthquakeApi.openRtsSse` | `/api/v2/trem/rts?sse=1&compress=1` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` |
+| `EarthquakeApi.getRtsRealtime` | `/api/v2/trem/rts` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` |
+| `EarthquakeApi.getEewRealtime` | `/api/v2/eq/eew` | `lbApi` | `api.lb-{tpe1,khh1}.exptech.dev` |
+| `EarthquakeApi.getEewAt` | `/api/v2/eq/eew/{sec}` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` |
+| `EarthquakeApi.getReportList` | `/api/v2/eq/report` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` |
+| `EarthquakeApi.getReport` | `/api/v2/eq/report/{id}` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` |
> **地震報告 list(v2)query:** `limit`/`page`、`sort`/`order`
> (`time`\|`intensity`\|`magnitude`\|`depth` × `asc`\|`desc`)、震度/規模/深度
@@ -78,10 +83,15 @@ Basemap、OSM 詳細街道建築與 terrain 都由 MapLibre 直接抓(app 的
tile 是 WebP,放在 **static** 主機(由 MapLibre 直接抓取,`Cache-Control:
max-age=300`)。`{sec}` 就是解出清單後的 10 位數秒,直接使用。
-| 方法 | 路徑 | 層級 | 主機 |
+**有效觀測範圍不是宣告網格。** 宣告的是 115–126.5°E、18–29°N(921 × 881 格,
+0.0125°),但實際只有四座雷達測距圓的聯集裁到該網格內才有觀測;其餘是空的。
+空白代表「未觀測」而非「無降水」,所以地圖的「顯示掃描範圍」外框畫的是那個聯集,
+幾何與推導在 `radar_scan_range.dart`。
+
+| 類別.方法 | 路徑 | 層級 | 主機 |
|---|---|---|---|
-| `getFrames` | `/api/v2/tiles/radar/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` |
-| `tileUrl` | `/api/v2/tiles/radar/{sec}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
+| `FrameTileApi.getFrames` | `/api/v2/tiles/radar/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` |
+| `FrameTileApi.tileUrl` | `/api/v2/tiles/radar/{sec}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
### 衛星雲圖(v2)—— `core-tnn1`
@@ -89,16 +99,22 @@ Himawari-9 AHI 的 XYZ WebP,預設是 Band-13 IR。時間清單是差量編碼
Unix 秒(`[baseSec, Δ, …]`),在 API 主機上帶 ETag/304;tile 在 **static**
主機。`{sec}` 就是解出清單後的 10 分鐘秒,直接使用。
-`?channel=` 選取渲染的頻道或產品 —— 單一頻道用數字(`13`)、命名產品用名稱
-(`btd_wvirw`、`cloudtop`…,即 `satellite-tiles-go/docs.md` 的產品目錄)。
-帶 channel 時時間清單為該 channel 的交集(產品需要的頻道缺一就不可渲染,
-`list` 只列齊全的時刻)。App 的圖層選擇器為每個 channel 註冊一個獨立圖層
-(`satellite` 保留給 B13,其餘為 `satellite-`)。
+**`{channel}` 與 `{style}` 是路徑段,不是 query。** `{channel}` 選頻道或產品
+—— 單一頻道用數字(`13`,也是省略時的預設)、命名產品用名稱(`btd_wvirw`、
+`cloudtop`…,即 `satellite-tiles-go/docs.md` 的產品目錄)。清單為該 channel 的
+交集(產品需要的頻道缺一就不可渲染,`list` 只列齊全的時刻)。
+
+`{style}` 只出現在 tile 路徑:數字頻道可選 `normal` / `jma` / `bd`,命名產品一律
+`normal`(調色盤是產品本身的一部分)。`gray` 會摺成 `normal`。推導在
+`FrameTileApi._satelliteStyle`。
-| 方法 | 路徑 | 層級 | 主機 |
+App 的圖層選擇器為每個 channel 註冊一個獨立圖層(`satellite` 保留給 B13,其餘為
+`satellite-`)。
+
+| 類別.方法 | 路徑 | 層級 | 主機 |
|---|---|---|---|
-| `getFrames` | `/api/v2/tiles/satellite/list[?channel=…]` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` |
-| `tileUrl` | `/api/v2/tiles/satellite/{sec}/{z}/{x}/{y}.webp[?channel=…]` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
+| `FrameTileApi.getFrames` | `/api/v2/tiles/satellite/{channel}/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` |
+| `FrameTileApi.tileUrl` | `/api/v2/tiles/satellite/{channel}/{style}/{sec}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
### 未來1小時降水預報 QPESUMS(v2)—— `core-tnn1`
@@ -106,10 +122,18 @@ QPESUMS 定量降水預報 XYZ WebP。時間清單是差量編碼的 Unix **毫
(`[baseMs, Δ, …]`);tile 在 **static** 主機。`{ms}` 就是解出清單後的 13 位數
毫秒,直接使用(時間軸解析已同時支援秒與毫秒)。
-| 方法 | 路徑 | 層級 | 主機 |
+**覆蓋範圍是方形,整塊都有資料**:441 × 561 格,每格 0.0125°
+(與雷達同解析度);118.0–123.5125°E、20.0–27.0125°N 是整塊格網的外緣,
+不是格心座標。
+
+這**不是**雷達的有效範圍。預報發布在自己的網格上,雷達的是測距圓聯集,兩者在
+方形四角(預報有、圓弧無)與圓弧外凸處(圓弧有、方形無)都不一致。「顯示掃描
+範圍」外框因此各畫各的幾何,QPESUMS 的在 `qpesums_scan_range.dart`。
+
+| 類別.方法 | 路徑 | 層級 | 主機 |
|---|---|---|---|
-| `getFrames` | `/api/v2/tiles/qpesums/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` |
-| `tileUrl` | `/api/v2/tiles/qpesums/{ms}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
+| `FrameTileApi.getFrames` | `/api/v2/tiles/qpesums/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` |
+| `FrameTileApi.tileUrl` | `/api/v2/tiles/qpesums/{ms}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
### 防災地圖 DPM(v2)—— `core-tnn1`
@@ -120,24 +144,29 @@ tile 由 MapLibre 直接抓,詳情經 `ApiClient`。Source-layer 名 = `{layer
(AED 為 `aed`)。單點有 `id`(內部 PK,打詳情用,非 `aed_id`);低 zoom 的
cluster 帶 `point_count`。
-| 方法 | 路徑 | 層級 | 主機 |
+| 類別.方法 | 路徑 | 層級 | 主機 |
|---|---|---|---|
-| `tileUrl` | `/api/v2/tiles/dpm/{layer}/{z}/{x}/{y}.mvt` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
-| `getAedDetail` | `/api/v2/tiles/dpm/aed/{id}` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
-| `getRestroomDetail` | `/api/v2/tiles/dpm/restroom/{id}` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
-| `getShelterDetail` | `/api/v2/tiles/dpm/shelter/{id}` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
+| `DisasterMapApi.tileUrl` | `/api/v2/tiles/dpm/{layer}/{z}/{x}/{y}.mvt` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
+| `DisasterMapApi.getAedDetail` | `/api/v2/tiles/dpm/aed/{id}` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
+| `DisasterMapApi.getRestroomDetail` | `/api/v2/tiles/dpm/restroom/{id}` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
+| `DisasterMapApi.getShelterDetail` | `/api/v2/tiles/dpm/shelter/{id}` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
### 風場 Wind(v2 / v1)—— `core-tnn1`
風場 overlay:XYZ WebP 圖層 + 低 zoom 的 **`.bin` 向量風場**(`WND1` 格式,
-`fetchWindBin`)。時間清單/圖層與其他 tiles 家族同形狀;`.bin` 用 `{model}`
-(`gfs` / `ecmwf`)與 `{frame}` 定址。圖層選擇器把 wind 註冊為獨立圖層。
+`fetchWindBin`)。圖層選擇器把 wind 註冊為獨立圖層。
-| 方法 | 路徑 | 層級 | 主機 |
+**`{model}` 是路徑段,不是 query,而且一個 frame 定址需要兩個時間。** 預報是
+「哪一次模式跑」加「預報到哪個時刻」,所以 tile 與 `.bin` 都以
+`{cycle}`(模式執行時刻)+ `{validTime}`(預報有效時刻)定址;`getFrames` 回傳的
+不透明 frame id 由 `FrameTileApi.windFrameParts` 拆成這兩段。`{model}` 是
+`gfs` / `ecmwf`。
+
+| 類別.方法 | 路徑 | 層級 | 主機 |
|---|---|---|---|
-| `getFrames` | `/api/v2/tiles/wind/list[?model=…]` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` |
-| `tileUrl` | `/api/v2/tiles/wind/{ts}/{z}/{x}/{y}.webp[?model=…]` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
-| `fetchWindBin` | `/api/v1/wind/{model}/{frame}.bin` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
+| `FrameTileApi.getFrames` | `/api/v2/tiles/wind/{model}/list` | `coreExclusiveApi` | `api.core-tnn1.exptech.dev` |
+| `FrameTileApi.tileUrl` | `/api/v2/tiles/wind/{model}/{cycle}/{validTime}/{z}/{x}/{y}.webp` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
+| `FrameTileApi.fetchWindBin` | `/api/v1/wind/{model}/{cycle}/{validTime}.bin` | `coreStaticExclusive` | `static.core-tnn1.exptech.dev` |
### 氣象家族(**v5**)—— `core-tnn1`
@@ -149,30 +178,30 @@ typhoon)共用同一組形狀:`/api/v5/meteor/{family}` 是最新快照、`/
時間軸與數值皆為**差量/哨符編碼**,由 `core/network/meteor_decode.dart` 還原:
`ts` 是 `[baseSec, Δ, …]`,數值序列中的 `-99` 代表 null(缺值),不是讀數。
-| 方法 | 路徑 | 層級 |
+`weather` / `rain` / `lightning` 三家共用同一個參數化的 `MeteorSnapshotApi`
+(建構時傳入 `_base`),所以**方法名只有五個,不是每家一組**。颱風的形狀不同,
+自成 `MeteorTyphoonApi`。
+
+| 類別.方法 | 路徑 | 層級 |
|---|---|---|
-| `getWeatherStations` | `/api/v5/meteor/weather/station` | `coreExclusiveApi` |
-| `getWeatherLatest` | `/api/v5/meteor/weather` | `coreExclusiveApi` |
-| `getWeatherList` | `/api/v5/meteor/weather/list` | `coreExclusiveApi` |
-| `getWeatherAt` | `/api/v5/meteor/weather/{sec}` | `coreStaticExclusive` |
-| `getWeatherTrend` | `/api/v5/meteor/weather/trend/{id}?range=24h\|7d` | `coreExclusiveApi` |
-| `getWeatherRealtime` | `/api/v5/meteor/weather/realtime/{lat},{lng}` | `coreExclusiveApi` |
-| `getWeatherForecast` | `/api/v5/meteor/weather/forecast/{code}` | `coreExclusiveApi` |
-| `getRainStations` | `/api/v5/meteor/rain/station` | `coreExclusiveApi` |
-| `getRainLatest` | `/api/v5/meteor/rain` | `coreExclusiveApi` |
-| `getRainList` | `/api/v5/meteor/rain/list` | `coreExclusiveApi` |
-| `getRainAt` | `/api/v5/meteor/rain/{sec}` | `coreStaticExclusive` |
-| `getRainTrend` | `/api/v5/meteor/rain/trend/{id}?range=24h\|7d` | `coreExclusiveApi` |
-| `getLightningLatest` | `/api/v5/meteor/lightning` | `coreExclusiveApi` |
-| `getLightningList` | `/api/v5/meteor/lightning/list` | `coreExclusiveApi` |
-| `getLightningAt` | `/api/v5/meteor/lightning/{sec}` | `coreStaticExclusive` |
-| `getTyphoonLatest` | `/api/v5/meteor/typhoon` | `coreExclusiveApi` |
-| `getTyphoonTrack` | `/api/v5/meteor/typhoon/track` | `coreExclusiveApi` |
-| `getTyphoonPotential` | `/api/v5/meteor/typhoon/potential` | `coreExclusiveApi` |
-| `getTyphoonProbability` | `/api/v5/meteor/typhoon/probability` | `coreExclusiveApi` |
-| `getTyphoonWarning` | `/api/v5/meteor/typhoon/warning` | `coreExclusiveApi` |
-| `getTyphoonKindList` | `/api/v5/meteor/typhoon/{kind}/list` | `coreExclusiveApi` |
-| `getTyphoonKindAt` | `/api/v5/meteor/typhoon/{kind}/{sec}` | `coreStaticExclusive` |
+| `MeteorSnapshotApi.getStation` | `/api/v5/meteor/{family}/station` | `coreExclusiveApi` |
+| `MeteorSnapshotApi.getLatest` | `/api/v5/meteor/{family}` | `coreExclusiveApi` |
+| `MeteorSnapshotApi.getList` | `/api/v5/meteor/{family}/list` | `coreExclusiveApi` |
+| `MeteorSnapshotApi.getAt` | `/api/v5/meteor/{family}/{sec}` | `coreStaticExclusive` |
+| `MeteorSnapshotApi.getTrend` | `/api/v5/meteor/{family}/trend/{id}?range=24h\|7d` | `coreExclusiveApi` |
+| `MeteorWeatherApi.getRealtime` | `/api/v5/meteor/weather/realtime/{lat},{lng}` | `coreExclusiveApi` |
+| `MeteorWeatherApi.getForecast` | `/api/v5/meteor/weather/forecast/{code}` | `coreExclusiveApi` |
+| `MeteorTyphoonApi.getCyclones` | `/api/v5/meteor/typhoon` | `coreExclusiveApi` |
+| `MeteorTyphoonApi.getTrack` | `/api/v5/meteor/typhoon/track` | `coreExclusiveApi` |
+| `MeteorTyphoonApi.getPotential` | `/api/v5/meteor/typhoon/potential` | `coreExclusiveApi` |
+| `MeteorTyphoonApi.getProbability` | `/api/v5/meteor/typhoon/probability` | `coreExclusiveApi` |
+| `MeteorTyphoonApi.getWarning` | `/api/v5/meteor/typhoon/warning` | `coreExclusiveApi` |
+| `MeteorTyphoonApi.getList` | `/api/v5/meteor/typhoon/{kind}/list` | `coreExclusiveApi` |
+| `MeteorTyphoonApi.getAt` | `/api/v5/meteor/typhoon/{kind}/{sec}` | `coreStaticExclusive` |
+
+`{family}` = `weather` \| `rain` \| `lightning`(`station` 只有前兩家有;
+lightning 沒有測站,也沒有 `trend`)。`{kind}` = `track` \| `potential` \|
+`probability` \| `warning`(`TyphoonKind.path`,與 enum 名同字)。
> **颱風多颱**:`/`、`/track`、`/potential`、`/probability`、`/warning` 一律
> `{ updated, cyclones: [...] }`;唯一識別是 **`tdNo`**(CWA `CwaTdNo`,未命名
@@ -186,38 +215,45 @@ typhoon)共用同一組形狀:`/api/v5/meteor/{family}` 是最新快照、`/
### 裝置與通知 —— `core-tnn1`
-| 方法 | 路徑 | 層級 |
+| 類別.方法 | 路徑 | 層級 |
|---|---|---|
-| `updateDeviceLocation` | `/api/v2/location/{platform}/{token}/{version}/{lat},{lng}` | `coreExclusiveApi` |
-| `getNotify` | `/api/v2/notify/{token}` | `coreExclusiveApi` |
-| `setNotify` | `/api/v2/notify/{token}/{channel}/{status}` | `coreExclusiveApi` |
+| `LocationApi.updateDeviceLocation` | `/api/v2/location/{platform}/{token}/{version}/{lat},{lng}` | `coreExclusiveApi` |
+| `NotifyApi.getNotify` | `/api/v2/notify/{token}` | `coreExclusiveApi` |
+| `NotifyApi.setNotify` | `/api/v2/notify/{token}/{channel}/{status}` | `coreExclusiveApi` |
### 舊 server `api-1`(逐步淘汰中)
後端會把端點陸續搬到 `core-tnn1`,這裡會隨之縮減。以下**仍只在 `api-1` 上**,
且都已在 App 中實際使用:
-| 方法 | 路徑 | 層級 | 使用處 |
+| 類別.方法 | 路徑 | 層級 | 使用處 |
|---|---|---|---|
-| `getStations` | `/api/v1/trem/station` | `legacyApi` | 強震監視器測站 |
-| `getHistoryList` | `/api/v1/dpip/history/list` | `legacyApi` | 事件頁(全國) |
-| `getHistoryRegion` | `/api/v1/dpip/history/{region}` | `legacyApi` | 事件頁(鄉鎮) |
-| `getRealtimeList` | `/api/v1/dpip/realtime/list` | `legacyApi` | 首頁拖盤收起(全國生效中) |
-| `getRealtimeRegion` | `/api/v1/dpip/realtime/{region}` | `legacyApi` | 首頁拖盤收起(鄉鎮生效中) |
-| `getRtsAt` | `/api/v2/trem/rts/{sec}` | `legacyApi` | 強震波形回放(時間軸) |
+| `TremStationRepositoryImpl.stations` | `/api/v1/trem/station` | `legacyApi` | 強震監視器測站 |
+| `EventApi.getHistoryList` | `/api/v1/dpip/history/list` | `legacyApi` | 事件頁(全國) |
+| `EventApi.getHistoryRegion` | `/api/v1/dpip/history/{region}` | `legacyApi` | 事件頁(鄉鎮) |
+| `EventApi.getRealtimeList` | `/api/v1/dpip/realtime/list` | `legacyApi` | 首頁拖盤收起(全國生效中) |
+| `EventApi.getRealtimeRegion` | `/api/v1/dpip/realtime/{region}` | `legacyApi` | 首頁拖盤收起(鄉鎮生效中) |
+| `EarthquakeApi.getRtsAt` | `/api/v2/trem/rts/{sec}` | `legacyApi` | 強震波形回放(時間軸) |
-尚未接上、但端點存在於 `api-1`:
-
-| 方法 | 路徑 | 層級 |
-|---|---|---|
-| `getEvent` | `/api/v1/dpip/event/{id}` | `legacyApi` |
+`/api/v1/dpip/event/{id}` 存在於 `api-1`,但 App 裡**沒有任何方法呼叫它** ——
+先前這裡列的 `getEvent` 並不存在於程式碼中。
## 外部(第三方,無區域)
-| 方法 | URL |
+走 `ApiClient.getAbsolute` / `postAbsolute`:沒有 tier、沒有區域容錯,也不參與
+ETag 重新驗證。
+
+| 類別.方法 | URL |
|---|---|
-| `getReleases` | `https://api.github.com/repos/ExpTechTW/DPIP/releases`(ETag;`per_page=30`) |
-| `getRainHourForecast` | `https://exptech.dingbot.tw/api/weather/rainforecast/{code}`(`{code}` = 鄉鎮 3 碼;回應為單 series 信封 `{"<系列名>": [{"start": 秒, "rain": [60 × mm]}]}`;空 series `[]` = 該小時無雨,卡片隱藏) |
+| `ChangelogApi.getReleases` | `https://api.github.com/repos/ExpTechTW/DPIP/releases`(ETag;`per_page=30`) |
+| `ChangelogApi.getAvatarBytes` | `https://avatars.githubusercontent.com/…`(貢獻者頭像,內容定址故長快取) |
+| `RainHourTrendApi.getForecast` | `https://exptech.dingbot.tw/api/weather/rainforecast/{code}`(`{code}` = 鄉鎮 3 碼;回應為單 series 信封 `{"<系列名>": [{"start": 秒, "rain": [60 × mm]}]}`;空 series `[]` = 該小時無雨,卡片隱藏) |
+| `ServerStatusApi.getStatus` | `https://status.exptech.dev/api/ds/query`(**POST**,Grafana datasource query;伺服器狀態頁) |
+| `CloudflareStatusApi.getComponents` | `https://www.cloudflarestatus.com/api/v2/components.json`(Cloudflare 元件狀態) |
+| `HasteApi.upload` | `https://haste.exptech.dev/api/pastes`(**POST**,上傳 App 日誌;回應的 `key` 組成 `https://haste.exptech.dev/`) |
+
+> **衛星 TLE 目前不打網路。** `TleSource` 有一條遠端更新路徑(`TleFetcher`),
+> 但正式碼沒有接線(`fetch` 為 null),實際只讀打包在 `assets/astro/` 的元素集。
## curl 可用性(2026-08-02 實測,HTTP 狀態碼)
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index aea27e133..71fd913c9 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -14,6 +14,7 @@
17BD5D769AA990E7EE681203 /* eew_alert.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 4D00D2962CF4598B61D0C722 /* eew_alert.aiff */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ DP1PF1REBASE0001PL1ST010 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */; };
522508B9301F863A006148C2 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 522508B7301F863A006148C2 /* InfoPlist.strings */; };
72E4CBC23930C168D057AC64 /* warn.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 3AE87ED82FDB896B2B5C5F1B /* warn.aiff */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
@@ -26,6 +27,7 @@
A8D382D04B4ACD327E29F46B /* eq.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 682D0165E2FF3895C5B252C5 /* eq.aiff */; };
AA0000000000000000000C02 /* CompassPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000C01 /* CompassPlugin.swift */; };
AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000E01 /* ScreenWakePlugin.swift */; };
+ AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000F01 /* ApnsTokenPlugin.swift */; };
AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000D01 /* DeviceInfoPlugin.swift */; };
AE92AD9862B7A721B0924557 /* eew.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 7A6E88CB92902C0CACB07792 /* eew.aiff */; };
CAC4EF00000000000000B001 /* MapCachePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000B002 /* MapCachePlugin.swift */; };
@@ -88,9 +90,11 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
A382CD9DEA741E45DBF741D7 /* rain.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/rain.aiff; sourceTree = ""; };
AA0000000000000000000C01 /* CompassPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassPlugin.swift; sourceTree = ""; };
AA0000000000000000000E01 /* ScreenWakePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenWakePlugin.swift; sourceTree = ""; };
+ AA0000000000000000000F01 /* ApnsTokenPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApnsTokenPlugin.swift; sourceTree = ""; };
AA0000000000000000000D01 /* DeviceInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlugin.swift; sourceTree = ""; };
B916667D1B2356583B174E80 /* normal.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/normal.aiff; sourceTree = ""; };
CAC4EF00000000000000B002 /* MapCachePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MapCachePlugin.swift; sourceTree = ""; };
@@ -167,12 +171,14 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
+ DP1PF1REBASE0002PL1ST020 /* GoogleService-Info.plist */,
522508B7301F863A006148C2 /* InfoPlist.strings */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
AA0000000000000000000C01 /* CompassPlugin.swift */,
AA0000000000000000000E01 /* ScreenWakePlugin.swift */,
+ AA0000000000000000000F01 /* ApnsTokenPlugin.swift */,
AA0000000000000000000D01 /* DeviceInfoPlugin.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
@@ -298,6 +304,7 @@
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ DP1PF1REBASE0001PL1ST010 /* GoogleService-Info.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
522508B9301F863A006148C2 /* InfoPlist.strings in Resources */,
@@ -366,6 +373,7 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
AA0000000000000000000C02 /* CompassPlugin.swift in Sources */,
AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */,
+ AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */,
AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
@@ -671,6 +679,7 @@
);
PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip;
PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 11c5949ca..2f2801e7f 100644
--- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -99,6 +99,15 @@
"version" : "0.12.1"
}
},
+ {
+ "identity" : "iosawnfcmcore",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/rafaelsetragni/IosAwnFcmCore.git",
+ "state" : {
+ "revision" : "4c914884b3c0213284df7bcd8237c469ed58f4d2",
+ "version" : "0.12.0"
+ }
+ },
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 11c5949ca..2f2801e7f 100644
--- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -99,6 +99,15 @@
"version" : "0.12.1"
}
},
+ {
+ "identity" : "iosawnfcmcore",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/rafaelsetragni/IosAwnFcmCore.git",
+ "state" : {
+ "revision" : "4c914884b3c0213284df7bcd8237c469ed58f4d2",
+ "version" : "0.12.0"
+ }
+ },
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
diff --git a/ios/Runner/ApnsTokenPlugin.swift b/ios/Runner/ApnsTokenPlugin.swift
new file mode 100644
index 000000000..9fc363abb
--- /dev/null
+++ b/ios/Runner/ApnsTokenPlugin.swift
@@ -0,0 +1,65 @@
+import Flutter
+import UIKit
+
+/// The device's APNs token, taken from the place iOS actually hands it over.
+///
+/// The backend keys every iOS registration on the raw APNs device token — not
+/// Firebase's FCM registration token, which is a different string it does not
+/// recognise (measured: the write 202s and every later lookup 401s, so the
+/// mistake is silent). That token has exactly one source,
+/// `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`; every
+/// SDK that offers to "get" it is reading back what it captured from the same
+/// callback. Reading it here removes the middleman, and with it the whole
+/// firebase_messaging dependency that existed for this one value.
+///
+/// Registration itself is not ours to trigger: `awesome_notifications` calls
+/// `registerForRemoteNotifications()` once permission is granted. This only
+/// listens.
+public class ApnsTokenPlugin: NSObject, FlutterPlugin {
+ /// Set on the main thread by the delegate callback, read by the channel.
+ ///
+ /// Static because the token arrives whenever iOS decides — often before Dart
+ /// asks, sometimes long after — and the instance answering the channel may
+ /// not be the one that was registered when it landed.
+ private static var token: String?
+
+ public static func register(with registrar: FlutterPluginRegistrar) {
+ let channel = FlutterMethodChannel(
+ name: "com.exptech.dpip/apns_token",
+ binaryMessenger: registrar.messenger())
+ let plugin = ApnsTokenPlugin()
+ registrar.addMethodCallDelegate(plugin, channel: channel)
+ registrar.addApplicationDelegate(plugin)
+ }
+
+ public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "token":
+ // Null until iOS has registered. Dart treats that as "not yet", not as
+ // failure: on a cold launch the callback usually lands a second or two
+ // after the engine starts.
+ result(ApnsTokenPlugin.token)
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ public func application(
+ _ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
+ ) {
+ // Lowercase hex, no separators — the form APNs itself uses and the one the
+ // backend already has on file for existing devices.
+ ApnsTokenPlugin.token = deviceToken.map { String(format: "%02x", $0) }.joined()
+ }
+
+ public func application(
+ _ application: UIApplication,
+ didFailToRegisterForRemoteNotificationsWithError error: Error
+ ) {
+ // Left visible rather than swallowed: without a token this device can be
+ // registered by the backend but never addressed, and nothing else in the
+ // app would say so.
+ NSLog("APNs registration failed: \(error.localizedDescription)")
+ }
+}
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index b5fd33cbc..c315634bb 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -1,5 +1,6 @@
import Flutter
import UIKit
+import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@@ -7,9 +8,17 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
+
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+ override func applicationDidBecomeActive(_ application: UIApplication) {
+ super.applicationDidBecomeActive(application)
+ // Re-assert: firebase_messaging's proxy can claim the delegate again from
+ // its own launch observer, and whoever is last wins.
+ NotificationDelegateProxy.shared.install()
+ }
+
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
let registry = engineBridge.pluginRegistry
// Firebase and other pub plugins.
@@ -24,9 +33,132 @@ import UIKit
MapCachePlugin.register(with: registry.registrar(forPlugin: "MapCachePlugin")!)
StorageScanPlugin.register(with: registry.registrar(forPlugin: "StorageScanPlugin")!)
ScreenWakePlugin.register(with: registry.registrar(forPlugin: "ScreenWakePlugin")!)
+ ApnsTokenPlugin.register(with: registry.registrar(forPlugin: "ApnsTokenPlugin")!)
BackgroundLocationPlugin.register(
with: registry.registrar(forPlugin: "BackgroundLocationPlugin")!)
BackgroundExecutionPlugin.register(
with: registry.registrar(forPlugin: "BackgroundExecutionPlugin")!)
+
+ // Re-post the launch notification the plugins just missed.
+ //
+ // This callback is *deferred* registration — Flutter's own header calls it
+ // that. Under the UISceneDelegate lifecycle the implicit engine is built
+ // lazily, so plugins are registered here, well after UIKit has already
+ // posted `UIApplication.didFinishLaunchingNotification`. A plugin that
+ // waits for that notification instead of implementing
+ // `application:didFinishLaunchingWithOptions:` therefore never hears it.
+ //
+ // awesome_notifications is one: it observes the notification
+ // (AwesomeNotifications.swift:156) and only inside the handler does it set
+ // `UNUserNotificationCenter.current().delegate = self` (:508). Miss it and
+ // the app runs with **no notification-centre delegate at all** — which iOS
+ // reads as "never present a notification while the app is in the
+ // foreground". Background delivery is unaffected because it needs no
+ // delegate, which is exactly the shape of the bug: pushes arrived normally
+ // with the app closed and vanished with it open.
+ //
+ // Posting it again is narrow by construction: the only observers that can
+ // be here are ones registered moments ago in this very method, and they
+ // have not seen it once.
+ NotificationDelegateProxy.shared.install()
+ }
+}
+
+/// Answers iOS for pushes that no plugin will answer for.
+///
+/// The app's pushes are published straight to APNs by AWS SNS — no FCM, no
+/// `mutable-content`, no Notification Service Extension. awesome_notifications
+/// cannot render such a push: its own README requires all three. That is why
+/// they arrive correctly in the background — awesome is bypassed entirely and
+/// iOS presents `aps` itself — and vanish in the foreground, where Apple hands
+/// the decision to whatever holds the notification-centre delegate.
+///
+/// awesome holds it, and for these pushes it answers nothing at all: its
+/// `willPresent` calls `showNotificationOnStatusBar`, which throws when the
+/// channel is not in its native registry, and the surrounding `catch`
+/// (AwesomeNotifications.swift:666) never calls the completion handler.
+/// `StatusBarManager.swift:118` has the same shape — a bare `return` past the
+/// handler. Apple's contract has no timeout for that: "if the handler is not
+/// called in a timely manner then the notification will not be presented".
+/// No banner, no sound, no log.
+///
+/// This proxy sits in front and restores the documented default for exactly
+/// those pushes — the same presentation the background already gets — while
+/// forwarding everything else, taps included, to awesome untouched.
+final class NotificationDelegateProxy: NSObject, UNUserNotificationCenterDelegate {
+ static let shared = NotificationDelegateProxy()
+
+ /// The delegate awesome installed, kept so its own notifications still work.
+ ///
+ /// Strongly held on purpose: `UNUserNotificationCenter.delegate` is `weak`,
+ /// so a proxy nobody retains would be released the moment `install()`
+ /// returns — leaving the delegate nil and the bug apparently "fixed" for the
+ /// wrong reason.
+ private var wrapped: UNUserNotificationCenterDelegate?
+
+ /// Takes the delegate, remembering whoever had it.
+ ///
+ /// Idempotent, and safe to call repeatedly: installing over ourselves would
+ /// otherwise make `wrapped` point at this proxy and every forward recurse.
+ func install() {
+ let center = UNUserNotificationCenter.current()
+ if center.delegate === self { return }
+ wrapped = center.delegate
+ center.delegate = self
+ }
+
+ /// Whether this notification is one the server sent straight to APNs.
+ ///
+ /// FCM stamps every message it delivers with `gcm.message_id`; ours has none
+ /// and carries the `content` object the backend sends instead. Anything else
+ /// — including notifications awesome created locally — is not ours to answer.
+ private func isServerPush(_ notification: UNNotification) -> Bool {
+ let info = notification.request.content.userInfo
+ return info["gcm.message_id"] == nil && info["content"] != nil
+ }
+
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+ ) {
+ if isServerPush(notification) {
+ #if DEBUG
+ // stderr, not NSLog: `flutter run` on a device relays only the former.
+ fputs("DPIP-NOTIF [proxy] presenting server push\n", stderr)
+ fflush(stderr)
+ #endif
+ completionHandler([.banner, .list, .badge, .sound])
+ return
+ }
+ guard
+ let wrapped,
+ wrapped.responds(
+ to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:)))
+ else {
+ completionHandler([.banner, .list, .badge, .sound])
+ return
+ }
+ wrapped.userNotificationCenter?(
+ center, willPresent: notification, withCompletionHandler: completionHandler)
+ }
+
+ /// Taps are never ours. awesome owns action routing and `getInitialAction`,
+ /// and intercepting here would break deep links from a notification.
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse,
+ withCompletionHandler completionHandler: @escaping () -> Void
+ ) {
+ guard
+ let wrapped,
+ wrapped.responds(
+ to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:)))
+ else {
+ completionHandler()
+ return
+ }
+ wrapped.userNotificationCenter?(
+ center, didReceive: response, withCompletionHandler: completionHandler)
}
}
diff --git a/ios/Runner/DeviceInfoPlugin.swift b/ios/Runner/DeviceInfoPlugin.swift
index 4f03a7587..017f5705a 100644
--- a/ios/Runner/DeviceInfoPlugin.swift
+++ b/ios/Runner/DeviceInfoPlugin.swift
@@ -37,17 +37,31 @@ public class DeviceInfoPlugin: NSObject, FlutterPlugin {
/// Where this build came from — which decides where an update prompt sends
/// the user.
///
- /// The App Store receipt's filename is the marker: TestFlight (and a debug
- /// build run from Xcode) gets a `sandboxReceipt`, an App Store install gets
- /// `receipt`. A DEBUG build is never a store install, so it is reported as a
- /// sideload rather than as TestFlight, which would otherwise put every
+ /// The App Store receipt's filename is the first marker: TestFlight (and a
+ /// debug build run from Xcode) gets a `sandboxReceipt`, an App Store install
+ /// gets `receipt`. A DEBUG build is never a store install, so it is reported
+ /// as a sideload rather than as TestFlight, which would otherwise put every
/// developer on the beta channel.
+ ///
+ /// On its own `sandboxReceipt` cannot separate TestFlight from a locally
+ /// signed install — the GitHub release IPA re-signed by AltStore or
+ /// Sideloadly carries one too, and that user must land on the GitHub release
+ /// page, not a TestFlight app that holds no DPIP update for them. The
+ /// discriminator is the embedded provisioning profile: App Store Connect
+ /// strips it from TestFlight builds, while any profile-signed bundle keeps
+ /// it.
private static func installSource() -> String {
#if DEBUG
- return "sideload"
+ return "development"
#else
- guard let receipt = Bundle.main.appStoreReceiptURL else { return "sideload" }
- return receipt.lastPathComponent == "sandboxReceipt" ? "testFlight" : "appStore"
+ guard let receipt = Bundle.main.appStoreReceiptURL else { return "github" }
+ // A simulator has no App Store; its receipt lives under CoreSimulator
+ // and would otherwise read as an App Store install.
+ if receipt.path.contains("CoreSimulator") { return "development" }
+ guard receipt.lastPathComponent == "sandboxReceipt" else { return "appStore" }
+ return Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") == nil
+ ? "testFlight"
+ : "sideload"
#endif
}
diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart
index 10e35ef0b..a514ad904 100644
--- a/lib/app/router/app_router.dart
+++ b/lib/app/router/app_router.dart
@@ -38,9 +38,22 @@ import 'package:dpip/features/status/presentation/pages/server_status_page.dart'
import 'package:dpip/features/weather/presentation/pages/weather_ranking_page.dart';
import 'package:dpip/shared/navigation/app_routes.dart';
import 'package:dpip/shared/navigation/refresh_on_appear.dart';
+import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
+/// Fires when a redirect *input* changes outside any navigation — today only
+/// the background durable-database recovery adopting settings rows this
+/// session launched without seeing ([bootstrap] calls [fire]). Without it,
+/// GoRouter re-runs [redirect] on navigation events alone, so a returning
+/// user held on the welcome page by a failed launch would stay there all
+/// session even after the completion flag came back.
+final OnboardingRefreshSignal onboardingRefresh = OnboardingRefreshSignal();
+
+final class OnboardingRefreshSignal extends ChangeNotifier {
+ void fire() => notifyListeners();
+}
+
/// The application's route table.
///
/// A [StatefulShellRoute] hosts the five bottom-navigation branches, in the
@@ -49,6 +62,7 @@ import 'package:provider/provider.dart';
/// page widgets to navigate.
final GoRouter appRouter = GoRouter(
initialLocation: AppRoutes.homePath,
+ refreshListenable: onboardingRefresh,
// Lets the shell notice that one of the full-screen routes below has covered
// it, so the tabs underneath can idle instead of animating at nobody.
observers: [shellRouteObserver],
diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart
index dc1da99d4..86378df21 100644
--- a/lib/bootstrap.dart
+++ b/lib/bootstrap.dart
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart' show kDebugMode, kReleaseMode;
import 'package:dpip/app/app.dart';
+import 'package:dpip/app/router/app_router.dart' show onboardingRefresh;
import 'package:dpip/core/di/core_providers.dart';
import 'package:dpip/core/di/shared_deps.dart';
import 'package:dpip/core/geo/device_location_reporter.dart';
@@ -13,6 +14,7 @@ import 'package:dpip/core/version/app_build.dart';
import 'package:dpip/core/logging/log_store.dart';
import 'package:dpip/core/network/api_client.dart';
import 'package:dpip/core/platform/background_location.dart';
+import 'package:dpip/core/platform/install_source.dart';
import 'package:dpip/core/network/dio_client.dart';
import 'package:dpip/core/network/endpoint_health.dart';
import 'package:dpip/core/network/etag_cache_store.dart';
@@ -43,6 +45,7 @@ import 'package:dpip/core/geo/town_directory.dart';
import 'package:dpip/core/settings/experimental_settings.dart';
import 'package:dpip/core/settings/locale_controller.dart';
import 'package:dpip/core/settings/map_layer_order_controller.dart';
+import 'package:dpip/core/settings/map_layer_visibility_controller.dart';
import 'package:dpip/core/settings/onboarding_store.dart';
import 'package:dpip/core/astro/tle_store.dart';
import 'package:dpip/core/settings/setting_keys.dart';
@@ -72,7 +75,7 @@ import 'package:flutter/foundation.dart'
show LicenseEntry, LicenseEntryWithLineBreaks, LicenseRegistry;
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
-import 'package:sqflite/sqflite.dart';
+import 'package:sqlite_async/sqlite_async.dart';
/// Initializes platform services and launches the app.
///
@@ -161,6 +164,18 @@ Future bootstrap() async {
Log.info('DPIP starting up');
_refuseUnlessLaunchedByTool();
+ // Which distributor this build came from (App Store, Play Store, TestFlight,
+ // or a non-store install — a GitHub release APK included).
+ // `InstallSourceService`
+ // memoizes and logs on its own first call, so firing it here just moves that
+ // line to the top of every session's log instead of leaving it to arrive
+ // whenever `UpdatePrompt` gets around to its own post-first-frame check —
+ // a bug report's most basic question ("did this even come from a store?")
+ // otherwise depended on that check having run and logged before the report
+ // was pulled. Unawaited: a platform channel round trip must never delay
+ // launch, and nothing here consumes the result.
+ unawaited(InstallSourceService.load());
+
// The bundled weather glyphs are Material Symbols (Apache-2.0). Registering
// the licence puts it in the app's own 開放原始碼授權 page (More → licences),
// which is where a bundled third-party asset has to be declared — Flutter
@@ -188,6 +203,12 @@ Future bootstrap() async {
'settings ready durable=${durable != null} keys=${settings.keys.length} '
'onboarding=${settings.getBool(SettingKeys.onboardingComplete)}',
);
+ final onboarding = OnboardingStore(settings);
+ // A launch that could not open the database must not spend the whole session
+ // pretending to be a first run: keep trying, and when the file opens, hand
+ // it to the store so this session's writes persist — then re-announce
+ // onboarding so a redirect held on the welcome page re-runs.
+ if (durable == null) unawaited(_recoverDurable(settings, onboarding));
// Persist the log as early as the database allows: everything after this
// point survives a crash or a background kill, which is exactly the window
// the in-memory history used to lose.
@@ -195,7 +216,6 @@ Future bootstrap() async {
if (logStore != null) Log.persistTo(logStore);
final regions = RegionSelection(settings);
final experimental = ExperimentalSettings(settings);
- final onboarding = OnboardingStore(settings);
final locale = LocaleController(settings);
final theme = ThemeController(settings);
// Constructed before the first frame: it installs the saved setting into
@@ -204,6 +224,7 @@ Future bootstrap() async {
final display = DisplaySettings(settings);
final defaultMapLayer = DefaultMapLayerController(settings);
final mapLayerOrder = MapLayerOrderController(settings);
+ final mapLayerVisibility = MapLayerVisibilityController(settings);
final cache = await cacheFuture;
final dio = createDio(etagCache: cache?.etag, usage: cache?.usage);
final endpointHealth = EndpointHealthMonitor();
@@ -355,6 +376,7 @@ Future bootstrap() async {
display: display,
defaultMapLayer: defaultMapLayer,
mapLayerOrder: mapLayerOrder,
+ mapLayerVisibility: mapLayerVisibility,
meshtastic: meshtastic,
meshLink: meshLink,
meshAlerts: meshAlerts,
@@ -411,22 +433,18 @@ Future bootstrap() async {
/// database can't be opened the app runs without HTTP caching / accounting rather
/// than failing to launch. The usage tables are created with `IF NOT EXISTS` on
/// every open, so they're added to a pre-existing cache DB without a version bump.
-Future<({EtagCacheStore etag, NetworkUsageStore usage, Database db})?>
+///
+/// The v1→v2 migration rides a column probe instead of sqflite's
+/// `version`/`onUpgrade` hooks (sqlite_async has none):
+/// [EtagCacheStore.createSchema] drops the re-fetchable v1 envelope table when
+/// its columns do not match v2, then creates a clean columnar cache.
+Future<({EtagCacheStore etag, NetworkUsageStore usage, SqliteDatabase db})?>
_openCache() async {
try {
final base = await getApplicationCacheDirectory();
- final db = await openDatabase(
- '${base.path}/http_etag_cache.db',
- version: 2,
- onCreate: (db, _) => EtagCacheStore.createSchema(db),
- onUpgrade: (db, oldVersion, newVersion) async {
- // v1 was a gzip+json+base64 envelope — drop and rebuild for the fast
- // columnar schema (one-time cold miss on upgrade).
- if (oldVersion < 2) await EtagCacheStore.migrateToV2(db);
- },
- );
+ final db = EtagCacheStore.open(path: '${base.path}/http_etag_cache.db');
await NetworkUsageStore.createSchema(db);
- await EtagCacheStore.configureConnection(db);
+ await EtagCacheStore.createSchema(db);
final usage = NetworkUsageStore(db);
return (etag: EtagCacheStore(db, usage: usage), usage: usage, db: db);
} catch (error, stackTrace) {
@@ -442,24 +460,32 @@ _openCache() async {
/// cache is a separate file for exactly that reason, which is also what makes
/// "clear cache" unable to reach any of this. See `core/storage/app_database.dart`.
///
-/// Best-effort like the cache: transient launch races get three bounded retries;
-/// a persistent failure means settings live only for this session rather than
-/// the app refusing to launch. Every schema statement is `IF NOT EXISTS` and
-/// runs on every open, so a database created by an older build picks up tables
-/// added later without a version bump.
-Future _openDurable() async {
+/// Best-effort like the cache. The old failure mode this used to retry around —
+/// a notification-tap cold start losing a race for the file against the
+/// background engine awesome spins up, and degrading the session to "never been
+/// configured" — is gone at the source with sqlite_async: opens run on a
+/// background isolate with WAL and a built-in lock timeout (30 s default), so
+/// contention waits instead of failing. What remains here is the honest
+/// fallback for an open that still fails (missing parent directory, full disk,
+/// first-unlock encryption state): bounded retries, then [_recoverDurable]
+/// keeps trying off the launch path. Every schema statement is `IF NOT EXISTS`
+/// and runs on every open, so a database created by an older build picks up
+/// tables added later without a version bump.
+Future _openDurable() async {
const attempts = 3;
Object? lastError;
StackTrace? lastStackTrace;
for (var attempt = 1; attempt <= attempts; attempt++) {
- Database? db;
try {
final base = await getApplicationSupportDirectory();
- db = await openDatabase(
- '${base.path}/dpip.db',
- version: appDatabaseVersion,
- onConfigure: (db) => _configureJournal(db, durable: true),
- onCreate: (db, _) => _createDurableSchema(db),
+ final db = SqliteDatabase(
+ path: '${base.path}/dpip.db',
+ options: const SqliteOptions(
+ // WAL + busy_timeout are package defaults; FULL fsyncs every commit,
+ // which is what settings, the mesh conversation and the log want —
+ // none of it can be fetched again (the cache file relaxes to NORMAL).
+ synchronous: SqliteSynchronous.full,
+ ),
);
await _createDurableSchema(db);
if (attempt > 1) {
@@ -469,19 +495,11 @@ Future _openDurable() async {
} catch (error, stackTrace) {
lastError = error;
lastStackTrace = stackTrace;
- if (db != null) {
- try {
- await db.close();
- } on Object {
- // The open/schema error is the useful failure; closing a partial
- // handle must not replace it or prevent the next recovery attempt.
- }
- }
if (attempt < attempts) {
Log.warning(
- 'durable database attempt $attempt/$attempts failed; retrying',
+ 'durable database attempt $attempt/$attempts failed: $error',
);
- await Future.delayed(Duration(milliseconds: 100 * attempt));
+ await Future.delayed(const Duration(milliseconds: 150));
}
}
}
@@ -490,49 +508,60 @@ Future _openDurable() async {
return null;
}
-/// Puts a database into **WAL**, so a commit is an append rather than a
-/// journal dance.
+/// Keeps trying to open the durable database after a launch that could not —
+/// a degraded session is recoverable, not terminal.
///
-/// With the default rollback journal every transaction — a buffered log flush,
-/// an LRU touch, a tile batch — creates a journal file, fsyncs it, fsyncs the
-/// directory, writes the pages back, then deletes the journal and fsyncs the
-/// directory again: several barriers and a double write of every changed page,
-/// for a handful of rows. WAL appends the new pages to one long-lived `-wal`
-/// file and fsyncs that; the write-back into the database file is deferred to a
-/// checkpoint that amortizes over many commits. Same durability, a fraction of
-/// the IO and the flash wear.
-///
-/// This has to run in `onConfigure`: it is the only sqflite callback invoked
-/// outside a transaction, and `journal_mode` cannot be changed inside one.
-/// The mode itself is persisted in the file header (so it only has to take
-/// once), while `synchronous` is per connection and must be set on every open.
-///
-/// [durable] keeps `synchronous = FULL` — the SQLite default — for `dpip.db`:
-/// settings, the mesh conversation and the log cannot be fetched again, so a
-/// commit there still fsyncs before it counts. The cache file relaxes to
-/// `NORMAL`, where a WAL commit costs no fsync at all, because every byte in it
-/// is re-downloadable by definition and lives in a directory the OS may empty
-/// anyway. Both modes survive an app crash; only a power cut can cost the cache
-/// its last commits.
-///
-/// Best-effort, like the opens themselves: a database that will not take WAL
-/// keeps working on the rollback journal.
-Future _configureJournal(Database db, {required bool durable}) async {
- try {
- // `PRAGMA journal_mode` returns a row, which Android's `execute` rejects
- // and Darwin reports as "not an error" — sqflite's helper handles both.
- await db.setJournalMode('WAL');
- if (!durable) {
- // A pragma still goes through [rawQuery] here for the same reason
- // [EtagCacheStore.configureConnection] does.
- await db.rawQuery('PRAGMA synchronous = NORMAL');
+/// A slow first unlock or a genuinely wedged file can still cost the open at
+/// launch. Rather than showing a returning user onboarding for the rest of the
+/// session, poll until the file opens (or the budget runs out), then hand it
+/// to [SettingsStore.attachDatabase]: memory stays authoritative, disk catches
+/// up, and everything written in between survives. The onboarding store then
+/// re-announces, so a router redirect held on the welcome page re-runs and
+/// releases the user the moment the flag is back.
+Future _recoverDurable(
+ SettingsStore settings,
+ OnboardingStore onboarding,
+) async {
+ const attempts = 10;
+ const interval = Duration(seconds: 3);
+ for (var attempt = 1; attempt <= attempts; attempt++) {
+ await Future.delayed(interval);
+ SqliteDatabase? db;
+ try {
+ final base = await getApplicationSupportDirectory();
+ db = SqliteDatabase(
+ path: '${base.path}/dpip.db',
+ options: const SqliteOptions(synchronous: SqliteSynchronous.full),
+ );
+ await _createDurableSchema(db);
+ final moved = await settings.attachDatabase(db);
+ Log.info(
+ 'durable database attached in the background'
+ '${moved ? '; reconciled session-only writes' : ''}',
+ );
+ // Rows adopted from disk (onboarding.complete among them) only matter
+ // once listeners re-read them: OnboardingStore's listeners re-read the
+ // store, and the router's redirect re-runs on the refresh signal.
+ onboarding.reload();
+ onboardingRefresh.fire();
+ return;
+ } catch (error) {
+ try {
+ await db?.close();
+ } on Object {
+ // The open/attach error is the useful failure. Closing a partial pool
+ // must not replace it or prevent the next recovery attempt.
+ }
+ Log.warning('durable recovery attempt $attempt/$attempts failed: $error');
}
- } catch (error, stackTrace) {
- Log.handle(error, stackTrace, 'WAL unavailable (rollback journal)');
}
+ Log.error(
+ 'durable database never opened this session; '
+ 'settings will not persist until the next launch',
+ );
}
-Future _createDurableSchema(Database db) async {
+Future _createDurableSchema(SqliteDatabase db) async {
await SettingsStore.createSchema(db);
await LogStore.createSchema(db);
await TleStore.createSchema(db);
diff --git a/lib/core/astro/tle_store.dart b/lib/core/astro/tle_store.dart
index 171f00774..93d321e21 100644
--- a/lib/core/astro/tle_store.dart
+++ b/lib/core/astro/tle_store.dart
@@ -11,7 +11,7 @@
library;
import 'package:dpip/core/logging/log.dart';
-import 'package:sqflite/sqflite.dart';
+import 'package:sqlite_async/sqlite_async.dart';
/// The table this store owns.
const String tleTable = 'tle';
@@ -29,14 +29,14 @@ class TleStore {
/// Null when the database would not open — the caller then falls back to the
/// bundled snapshot, which is a complete answer on its own.
- final Database? _db;
+ final SqliteDatabase? _db;
/// Creates the table. Safe on every open.
///
/// `CHECK (id = 0)` is the single-row constraint: there is only ever one
/// current element set, and making that a schema rule beats remembering to
/// delete the old one.
- static Future createSchema(Database db) => db.execute(
+ static Future createSchema(SqliteDatabase db) => db.execute(
'CREATE TABLE IF NOT EXISTS $tleTable ('
'id INTEGER PRIMARY KEY CHECK (id = 0), '
'text TEXT NOT NULL, '
@@ -47,8 +47,9 @@ class TleStore {
final db = _db;
if (db == null) return null;
try {
- final rows = await db.query(tleTable, limit: 1);
- final row = rows.firstOrNull;
+ final row = await db.getOptional(
+ 'SELECT text, fetched_at FROM $tleTable LIMIT 1',
+ );
if (row == null) return null;
return StoredElements(
text: row['text']! as String,
@@ -70,16 +71,16 @@ class TleStore {
if (db == null) return;
try {
if (text == null) {
- await db.update(tleTable, {
- 'fetched_at': fetchedAt.toUtc().millisecondsSinceEpoch,
- }, where: 'id = 0');
+ await db.execute('UPDATE $tleTable SET fetched_at = ? WHERE id = 0', [
+ fetchedAt.toUtc().millisecondsSinceEpoch,
+ ]);
return;
}
- await db.insert(tleTable, {
- 'id': 0,
- 'text': text,
- 'fetched_at': fetchedAt.toUtc().millisecondsSinceEpoch,
- }, conflictAlgorithm: ConflictAlgorithm.replace);
+ await db.execute(
+ 'INSERT OR REPLACE INTO $tleTable (id, text, fetched_at) '
+ 'VALUES (0, ?, ?)',
+ [text, fetchedAt.toUtc().millisecondsSinceEpoch],
+ );
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'writing elements');
}
diff --git a/lib/core/di/core_providers.dart b/lib/core/di/core_providers.dart
index 2957707d4..1c57522a0 100644
--- a/lib/core/di/core_providers.dart
+++ b/lib/core/di/core_providers.dart
@@ -28,6 +28,7 @@ import 'package:dpip/core/settings/default_map_layer_controller.dart';
import 'package:dpip/core/settings/experimental_settings.dart';
import 'package:dpip/core/settings/locale_controller.dart';
import 'package:dpip/core/settings/map_layer_order_controller.dart';
+import 'package:dpip/core/settings/map_layer_visibility_controller.dart';
import 'package:dpip/core/settings/onboarding_store.dart';
import 'package:dpip/core/settings/region_store.dart';
import 'package:dpip/core/settings/color_vision_controller.dart';
@@ -56,6 +57,9 @@ List coreProviders(SharedDeps deps) => [
ChangeNotifierProvider.value(
value: deps.mapLayerOrder,
),
+ ChangeNotifierProvider.value(
+ value: deps.mapLayerVisibility,
+ ),
Provider.value(value: deps.settings),
Provider.value(value: deps.database),
Provider.value(value: deps.tleStore),
diff --git a/lib/core/di/shared_deps.dart b/lib/core/di/shared_deps.dart
index b0671febb..b27fb2e3b 100644
--- a/lib/core/di/shared_deps.dart
+++ b/lib/core/di/shared_deps.dart
@@ -26,6 +26,7 @@ import 'package:dpip/core/settings/default_map_layer_controller.dart';
import 'package:dpip/core/settings/experimental_settings.dart';
import 'package:dpip/core/settings/locale_controller.dart';
import 'package:dpip/core/settings/map_layer_order_controller.dart';
+import 'package:dpip/core/settings/map_layer_visibility_controller.dart';
import 'package:dpip/core/settings/onboarding_store.dart';
import 'package:dpip/core/settings/settings_store.dart';
import 'package:dpip/core/settings/region_store.dart';
@@ -68,6 +69,7 @@ class SharedDeps {
required this.display,
required this.defaultMapLayer,
required this.mapLayerOrder,
+ required this.mapLayerVisibility,
required this.meshtastic,
required this.meshLink,
required this.meshAlerts,
@@ -153,6 +155,9 @@ class SharedDeps {
/// User-customised map layer-picker order (also provided).
final MapLayerOrderController mapLayerOrder;
+ /// The map layers the user hid (also provided).
+ final MapLayerVisibilityController mapLayerVisibility;
+
/// LoRa mesh (Meshtastic) over BLE — off-grid emergency messaging.
final MeshtasticService meshtastic;
diff --git a/lib/core/diagnostics/diagnostics_report.dart b/lib/core/diagnostics/diagnostics_report.dart
index f18e71918..babad1739 100644
--- a/lib/core/diagnostics/diagnostics_report.dart
+++ b/lib/core/diagnostics/diagnostics_report.dart
@@ -15,7 +15,6 @@ library;
import 'dart:io';
import 'package:dpip/core/build_info.g.dart';
-import 'package:dpip/core/logging/log.dart';
import 'package:dpip/core/network/etag_cache_store.dart';
import 'package:dpip/core/network/network_usage_store.dart';
import 'package:dpip/core/notifications/notification_service.dart';
@@ -26,7 +25,6 @@ import 'package:dpip/core/platform/unused_app_restrictions.dart';
import 'package:dpip/core/storage/app_database.dart';
import 'package:dpip/core/storage/app_storage_scan.dart';
import 'package:dpip/core/version/app_build.dart';
-import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -252,24 +250,6 @@ String _keptActive(UnusedAppRestrictions status) => switch (status) {
UnusedAppRestrictions.unavailable => 'n/a',
};
-Future _fcmToken() async {
- try {
- return await FirebaseMessaging.instance.getToken();
- } catch (error, stackTrace) {
- Log.handle(error, stackTrace, 'dev: FCM token');
- return null;
- }
-}
-
-Future _apnsToken() async {
- try {
- return await FirebaseMessaging.instance.getAPNSToken();
- } catch (error, stackTrace) {
- Log.handle(error, stackTrace, 'dev: APNs token');
- return null;
- }
-}
-
/// Reads every subsystem that can explain a support question.
///
/// Takes its services rather than reaching for a locator, so a test can hand it
@@ -319,10 +299,13 @@ class DiagnosticsCollector {
// back to the platform build number outside a repo.
final buildRef = kGitCommit == 'unknown' ? info.buildNumber : kGitCommit;
// Show the platform's own push token: FCM on Android, APNs on iOS.
- final fcmToken = Platform.isAndroid
- ? (notifications.token ?? await _fcmToken())
- : null;
- final apnsToken = Platform.isIOS ? await _apnsToken() : null;
+ //
+ // Read back from settings rather than asked for again. It is the same value
+ // the backend was registered with, which is the one worth seeing in a
+ // report — a freshly queried token that differs from the stored one would
+ // look reassuring and be exactly the bug.
+ final fcmToken = Platform.isAndroid ? notifications.token : null;
+ final apnsToken = Platform.isIOS ? notifications.token : null;
final sections = [
(
diff --git a/lib/core/logging/log_store.dart b/lib/core/logging/log_store.dart
index f5523863d..a1f3a5900 100644
--- a/lib/core/logging/log_store.dart
+++ b/lib/core/logging/log_store.dart
@@ -21,7 +21,7 @@ library;
import 'dart:async';
-import 'package:sqflite/sqflite.dart';
+import 'package:sqlite_async/sqlite_async.dart';
/// The table this store owns.
const String logTable = 'logs';
@@ -78,7 +78,7 @@ class LogStore {
_flushInterval = flushInterval,
_flushAt = flushAt;
- final Database _db;
+ final SqliteDatabase _db;
final DateTime Function() _now;
final Duration _flushInterval;
@@ -106,25 +106,21 @@ class LogStore {
}
/// Creates the table. Safe on every open.
- static Future createSchema(Database db) async {
- // One batch: this re-runs on every launch (the IF NOT EXISTS is the
- // migration mechanism), so every statement here is a launch-window
- // platform round trip.
- final batch = db.batch()
- ..execute(
- 'CREATE TABLE IF NOT EXISTS $logTable ('
- 'id INTEGER PRIMARY KEY AUTOINCREMENT, '
- 'time INTEGER NOT NULL, '
- 'level TEXT NOT NULL, '
- 'message TEXT NOT NULL, '
- 'error TEXT, '
- 'stack TEXT)',
- )
+ static Future createSchema(SqliteDatabase db) async {
+ // One call: this re-runs on every launch (the IF NOT EXISTS is the
+ // migration mechanism), so both statements ride one background-isolate
+ // round trip instead of two serial awaits.
+ await db.executeMultiple(
+ 'CREATE TABLE IF NOT EXISTS $logTable ('
+ 'id INTEGER PRIMARY KEY AUTOINCREMENT, '
+ 'time INTEGER NOT NULL, '
+ 'level TEXT NOT NULL, '
+ 'message TEXT NOT NULL, '
+ 'error TEXT, '
+ 'stack TEXT);'
// Both the retention delete and every read are ordered by time.
- ..execute(
- 'CREATE INDEX IF NOT EXISTS ${logTable}_time ON $logTable(time)',
- );
- await batch.commit(noResult: true);
+ 'CREATE INDEX IF NOT EXISTS ${logTable}_time ON $logTable(time)',
+ );
}
/// Queues a line. Returns immediately — never touches the database.
@@ -148,19 +144,17 @@ class LogStore {
Future prune() async {
await _enqueueDatabase(() async {
try {
- await _db.delete(
- logTable,
- where: 'time < ?',
- whereArgs: [
+ await _db.writeTransaction((tx) async {
+ await tx.execute('DELETE FROM $logTable WHERE time < ?', [
_now().toUtc().subtract(logRetention).millisecondsSinceEpoch,
- ],
- );
- // See [logMaxRows]: the newest lines survive whatever the clock says.
- await _db.rawDelete(
- 'DELETE FROM $logTable WHERE id NOT IN ('
- 'SELECT id FROM $logTable ORDER BY id DESC LIMIT ?)',
- [logMaxRows],
- );
+ ]);
+ // See [logMaxRows]: the newest lines survive whatever the clock says.
+ await tx.execute(
+ 'DELETE FROM $logTable WHERE id NOT IN ('
+ 'SELECT id FROM $logTable ORDER BY id DESC LIMIT ?)',
+ [logMaxRows],
+ );
+ });
} on Object {
// Reporting a logging failure through the logger is how a write loop
// starts.
@@ -180,31 +174,29 @@ class LogStore {
_pending.clear();
await _enqueueDatabase(() async {
try {
- await _db.transaction((txn) async {
- final insert = txn.batch();
+ await _db.writeTransaction((tx) async {
for (final entry in batch) {
- insert.insert(logTable, {
- 'time': entry.time.toUtc().millisecondsSinceEpoch,
- 'level': entry.level,
- 'message': entry.message,
- 'error': entry.error,
- 'stack': entry.stackTrace,
- });
+ await tx.execute(
+ 'INSERT INTO $logTable (time, level, message, error, stack) '
+ 'VALUES (?, ?, ?, ?, ?)',
+ [
+ entry.time.toUtc().millisecondsSinceEpoch,
+ entry.level,
+ entry.message,
+ entry.error,
+ entry.stackTrace,
+ ],
+ );
}
- await insert.commit(noResult: true);
- await txn.delete(
- logTable,
- where: 'time < ?',
- whereArgs: [
- _now().toUtc().subtract(logRetention).millisecondsSinceEpoch,
- ],
- );
+ await tx.execute('DELETE FROM $logTable WHERE time < ?', [
+ _now().toUtc().subtract(logRetention).millisecondsSinceEpoch,
+ ]);
// The count ceiling in the same transaction as the insert, so a
// burst cannot outrun it. `id` rather than `time` because it is the
// primary key and monotonic: a clock that steps backwards would
// otherwise make the newest rows look like the oldest and delete
// them.
- await txn.rawDelete(
+ await tx.execute(
'DELETE FROM $logTable WHERE id NOT IN ('
'SELECT id FROM $logTable ORDER BY id DESC LIMIT ?)',
[logMaxRows],
@@ -221,13 +213,17 @@ class LogStore {
Future> recent({int limit = 500, String? level}) async {
await _databaseTail;
try {
- final rows = await _db.query(
- logTable,
- where: level == null ? null : 'level = ?',
- whereArgs: level == null ? null : [level],
- orderBy: 'time DESC, id DESC',
- limit: limit,
- );
+ final rows = level == null
+ ? await _db.getAll(
+ 'SELECT time, level, message, error, stack FROM $logTable '
+ 'ORDER BY time DESC, id DESC LIMIT ?',
+ [limit],
+ )
+ : await _db.getAll(
+ 'SELECT time, level, message, error, stack FROM $logTable '
+ 'WHERE level = ? ORDER BY time DESC, id DESC LIMIT ?',
+ [level, limit],
+ );
return [
for (final row in rows)
StoredLog(
@@ -251,8 +247,8 @@ class LogStore {
Future count() async {
await _databaseTail;
try {
- final rows = await _db.rawQuery('SELECT COUNT(*) AS n FROM $logTable');
- return (rows.firstOrNull?['n'] as int?) ?? 0;
+ final row = await _db.get('SELECT COUNT(*) AS n FROM $logTable');
+ return (row['n'] as num).toInt();
} on Object {
return 0;
}
@@ -263,7 +259,7 @@ class LogStore {
_pending.clear();
await _enqueueDatabase(() async {
try {
- await _db.delete(logTable);
+ await _db.execute('DELETE FROM $logTable');
} on Object {
// Nothing useful to say, and nowhere safe to say it.
}
diff --git a/lib/core/meshtastic/data/mesh_store.dart b/lib/core/meshtastic/data/mesh_store.dart
index 538ad0b7b..9671da8a8 100644
--- a/lib/core/meshtastic/data/mesh_store.dart
+++ b/lib/core/meshtastic/data/mesh_store.dart
@@ -17,7 +17,7 @@ library;
import 'package:dpip/core/logging/log.dart';
import 'package:dpip/core/realtime/app_time.dart';
-import 'package:sqflite/sqflite.dart';
+import 'package:sqlite_async/sqlite_async.dart';
/// One line of the conversation log.
class MeshStoredMessage {
@@ -159,145 +159,114 @@ class MeshStore {
/// How long utilization samples are kept — what the chart plots.
static const Duration metricRetention = Duration(hours: 24);
- final Database _db;
+ final SqliteDatabase _db;
final DateTime Function() _now;
- static Future createSchema(Database db) async {
+ static Future createSchema(SqliteDatabase db) async {
// This re-runs on every launch (the IF NOT EXISTS is the migration
- // mechanism), so every statement is a launch-window platform round trip —
- // one probe for the ALTER-added columns, then everything else as a single
- // batch commit, instead of ten serial awaits.
+ // mechanism), so every statement rides one background-isolate round trip:
+ // one probe per table with ALTER-added columns, then everything else in a
+ // single executeMultiple, instead of ten serial awaits.
// One probe per table that has gained columns since it shipped. An empty
// set means the table does not exist yet, so the CREATE below makes it
// without them and every ALTER is needed.
final existing = >{};
for (final table in _alterColumns.keys) {
existing[table] = {
- for (final row in await db.rawQuery('PRAGMA table_info($table)'))
+ for (final row in await db.getAll('PRAGMA table_info($table)'))
row['name'] as String,
};
}
- final batch = db.batch()
- // The node table the radio hands over on every connect, kept for the
- // times there is no radio. A row per node, not a JSON blob in a settings
- // key: 250 nodes re-serialised on every telemetry packet is exactly what
- // the key-value store was bad at.
- ..execute(
- 'CREATE TABLE IF NOT EXISTS $_nodes ('
- 'num INTEGER PRIMARY KEY NOT NULL, '
- 'name TEXT NOT NULL, '
- 'battery INTEGER, '
- 'last_heard INTEGER, '
- 'latitude REAL, '
- 'longitude REAL, '
- 'snr REAL NOT NULL DEFAULT 0, '
- 'via_mqtt INTEGER NOT NULL DEFAULT 0)',
- )
- ..execute(
- 'CREATE INDEX IF NOT EXISTS ${_nodes}_heard ON $_nodes(last_heard DESC)',
- )
- // The channel table, for the times there is no radio to ask.
- //
- // A channel's *name* is only known while connected — it arrives in the
- // config download and lives nowhere else. Without this table the chat
- // screen fell back to the slot number the moment the radio went away, so
- // a conversation the user knows as "DPIP" was labelled "CH2" whenever
- // they opened the page before the radio finished configuring. The stored
- // log outlives the connection; its labels have to as well.
- ..execute(
- 'CREATE TABLE IF NOT EXISTS $_channels ('
- 'idx INTEGER PRIMARY KEY NOT NULL, '
- 'name TEXT NOT NULL)',
- )
- ..execute('''
+ await db.executeMultiple('''
+ CREATE TABLE IF NOT EXISTS $_nodes (
+ num INTEGER PRIMARY KEY NOT NULL,
+ name TEXT NOT NULL,
+ battery INTEGER,
+ last_heard INTEGER,
+ latitude REAL,
+ longitude REAL,
+ snr REAL NOT NULL DEFAULT 0,
+ via_mqtt INTEGER NOT NULL DEFAULT 0);
+ CREATE INDEX IF NOT EXISTS ${_nodes}_heard ON $_nodes(last_heard DESC);
+ -- The channel table, for the times there is no radio to ask.
+ --
+ -- A channel's *name* is only known while connected — it arrives in the
+ -- config download and lives nowhere else. Without this table the chat
+ -- screen fell back to the slot number the moment the radio went away, so
+ -- a conversation the user knows as "DPIP" was labelled "CH2" whenever
+ -- they opened the page before the radio finished configuring. The stored
+ -- log outlives the connection; its labels have to as well.
+ CREATE TABLE IF NOT EXISTS $_channels (
+ idx INTEGER PRIMARY KEY NOT NULL,
+ name TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS $_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
node INTEGER NOT NULL,
channel INTEGER NOT NULL,
text TEXT NOT NULL,
- outgoing INTEGER NOT NULL DEFAULT 0
- )
- ''')
- // Duplicate suppression as a constraint, not a scan: a reconnect replays
- // packets the log may already hold, and `INSERT OR IGNORE` drops them at
- // the storage layer.
- ..execute(
- 'CREATE UNIQUE INDEX IF NOT EXISTS ${_messages}_identity '
- 'ON $_messages (node, channel, ts, text)',
- )
- // The read is always "this channel, newest first".
- ..execute(
- 'CREATE INDEX IF NOT EXISTS ${_messages}_channel_ts '
- 'ON $_messages (channel, ts DESC)',
- )
- ..execute('''
+ outgoing INTEGER NOT NULL DEFAULT 0);
+ -- Duplicate suppression as a constraint, not a scan: a reconnect replays
+ -- packets the log may already hold, and `INSERT OR IGNORE` drops them at
+ -- the storage layer.
+ CREATE UNIQUE INDEX IF NOT EXISTS ${_messages}_identity
+ ON $_messages (node, channel, ts, text);
+ -- The read is always "this channel, newest first".
+ CREATE INDEX IF NOT EXISTS ${_messages}_channel_ts
+ ON $_messages (channel, ts DESC);
CREATE TABLE IF NOT EXISTS $_metrics (
ts INTEGER PRIMARY KEY,
channel_util REAL,
air_util REAL,
- battery INTEGER
- )
- ''')
- // What the rest of the mesh looked like, one row per node per reading.
- //
- // The in-memory ring [MeshNodeStore] keeps is bounded by count, so on a
- // busy mesh it holds minutes; this holds a day, which is the window in
- // which "when did that node start failing" is a question anyone asks.
- // The composite key makes a re-emitted reading an overwrite rather than
- // a duplicate — the radio repeats a node's telemetry until it changes.
- ..execute('''
+ battery INTEGER);
+ -- What the rest of the mesh looked like, one row per node per reading.
+ --
+ -- The in-memory ring [MeshNodeStore] keeps is bounded by count, so on a
+ -- busy mesh it holds minutes; this holds a day, which is the window in
+ -- which "when did that node start failing" is a question anyone asks.
+ -- The composite key makes a re-emitted reading an overwrite rather than
+ -- a duplicate — the radio repeats a node's telemetry until it changes.
CREATE TABLE IF NOT EXISTS $_nodeMetrics (
ts INTEGER NOT NULL,
node INTEGER NOT NULL,
battery INTEGER,
voltage REAL,
snr REAL,
- PRIMARY KEY (ts, node)
- )
- ''')
- // Both reads are "this node, over time" and "everything since T".
- ..execute(
- 'CREATE INDEX IF NOT EXISTS ${_nodeMetrics}_node_ts '
- 'ON $_nodeMetrics (node, ts)',
- )
- // How far into each conversation the user has read — what the unread
- // dots are computed against. Its own table rather than a column on
- // [_channels]: that one is replaced wholesale from the radio's table
- // and only holds named channels, either of which would silently reset
- // read positions.
- ..execute(
- 'CREATE TABLE IF NOT EXISTS $_reads ('
- 'channel INTEGER PRIMARY KEY NOT NULL, '
- 'last_read INTEGER NOT NULL)',
- );
+ PRIMARY KEY (ts, node));
+ -- Both reads are "this node, over time" and "everything since T".
+ CREATE INDEX IF NOT EXISTS ${_nodeMetrics}_node_ts
+ ON $_nodeMetrics (node, ts);
+ -- How far into each conversation the user has read — what the unread
+ -- dots are computed against. Its own table rather than a column on
+ -- [$_channels]: that one is replaced wholesale from the radio's table
+ -- and only holds named channels, either of which would silently reset
+ -- read positions.
+ CREATE TABLE IF NOT EXISTS $_reads (
+ channel INTEGER PRIMARY KEY NOT NULL,
+ last_read INTEGER NOT NULL)
+ ''');
// Columns added after a table shipped arrive by ALTER — IF NOT EXISTS does
// nothing for a table that already exists. On an installed one the probe
// names what is present; on a fresh one the set is empty, so the ALTERs run
- // after the CREATE below.
+ // after the CREATE above.
for (final entry in _alterColumns.entries) {
final present = existing[entry.key]!;
if (present.isEmpty) continue;
for (final (column, type) in entry.value) {
if (present.contains(column)) continue;
- batch.execute('ALTER TABLE ${entry.key} ADD COLUMN $column $type');
+ await db.execute('ALTER TABLE ${entry.key} ADD COLUMN $column $type');
}
}
- await batch.commit(noResult: true);
// A fresh install: the CREATEs above carry none of the ALTER columns, so
// add them now that the tables exist.
- final fresh = db.batch();
- var any = false;
for (final entry in _alterColumns.entries) {
if (existing[entry.key]!.isNotEmpty) continue;
for (final (column, type) in entry.value) {
- fresh.execute('ALTER TABLE ${entry.key} ADD COLUMN $column $type');
- any = true;
+ await db.execute('ALTER TABLE ${entry.key} ADD COLUMN $column $type');
}
}
- if (any) await fresh.commit(noResult: true);
}
/// Columns added to a table after it shipped — one list per table so the
@@ -349,18 +318,27 @@ class MeshStore {
/// Appends [message], ignoring one the log already holds. Returns whether it
/// was new — the caller uses that to decide whether to notify or re-render.
+ ///
+ /// `RETURNING` answers "did this insert land" the way sqflite's
+ /// insert-with-ignore rowid once did: the unique identity index suppresses
+ /// reconnect replays, and a suppressed insert contributes no returning row.
Future addMessage(MeshStoredMessage message) async {
try {
- final id = await _db.insert(_messages, {
- 'ts': message.timestamp.millisecondsSinceEpoch,
- 'received_at': _now().millisecondsSinceEpoch,
- 'node': message.from,
- 'channel': message.channel,
- 'text': message.text,
- 'outgoing': message.outgoing ? 1 : 0,
- 'binary': message.binary ? 1 : 0,
- }, conflictAlgorithm: ConflictAlgorithm.ignore);
- return id != 0;
+ final result = await _db.execute(
+ 'INSERT OR IGNORE INTO $_messages '
+ '(ts, received_at, node, channel, text, outgoing, binary) '
+ 'VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id',
+ [
+ message.timestamp.millisecondsSinceEpoch,
+ _now().millisecondsSinceEpoch,
+ message.from,
+ message.channel,
+ message.text,
+ message.outgoing ? 1 : 0,
+ message.binary ? 1 : 0,
+ ],
+ );
+ return result.isNotEmpty;
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'mesh store addMessage');
return false;
@@ -374,18 +352,24 @@ class MeshStore {
int limit = 200,
}) async {
try {
- final rows = await _db.query(
- _messages,
- where: channel == null ? null : 'channel = ?',
- whereArgs: channel == null ? null : [channel],
- // Arrival order, falling back to the radio's stamp for rows written
- // before the column existed. Ranking incoming (radio clock) and
- // outgoing (our clock) rows together by `ts` put a reply above the
- // message it answered — visible only after a restart, because live
- // inserts land in arrival order anyway.
- orderBy: 'COALESCE(received_at, ts) DESC, id DESC',
- limit: limit,
- );
+ final rows = channel == null
+ ? await _db.getAll(
+ 'SELECT id, ts, received_at, node, channel, text, outgoing, binary '
+ 'FROM $_messages '
+ // Arrival order, falling back to the radio's stamp for rows
+ // written before the column existed. Ranking incoming (radio
+ // clock) and outgoing (our clock) rows together by `ts` put a
+ // reply above the message it answered — visible only after a
+ // restart, because live inserts land in arrival order anyway.
+ 'ORDER BY COALESCE(received_at, ts) DESC, id DESC LIMIT ?',
+ [limit],
+ )
+ : await _db.getAll(
+ 'SELECT id, ts, received_at, node, channel, text, outgoing, binary '
+ 'FROM $_messages WHERE channel = ? '
+ 'ORDER BY COALESCE(received_at, ts) DESC, id DESC LIMIT ?',
+ [channel, limit],
+ );
return [for (final row in rows) _readMessage(row)];
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'mesh store messages');
@@ -397,7 +381,7 @@ class MeshStore {
/// Channel → when the user last read it (ms). Missing = never read.
Future> readLastReads() async {
try {
- final rows = await _db.query(_reads);
+ final rows = await _db.getAll('SELECT channel, last_read FROM $_reads');
return {
for (final row in rows)
row['channel']! as int: row['last_read']! as int,
@@ -411,10 +395,10 @@ class MeshStore {
/// Marks [channel] read up to [ts] (ms).
Future writeLastRead(int channel, int ts) async {
try {
- await _db.insert(_reads, {
- 'channel': channel,
- 'last_read': ts,
- }, conflictAlgorithm: ConflictAlgorithm.replace);
+ await _db.execute(
+ 'INSERT OR REPLACE INTO $_reads (channel, last_read) VALUES (?, ?)',
+ [channel, ts],
+ );
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'mesh store writeLastRead');
}
@@ -428,7 +412,7 @@ class MeshStore {
/// compare timestamps would be a page-open cost for two integers a channel.
Future> unreadCounts() async {
try {
- final rows = await _db.rawQuery(
+ final rows = await _db.getAll(
'SELECT m.channel AS channel, COUNT(*) AS n '
'FROM $_messages m '
'LEFT JOIN $_reads r ON r.channel = m.channel '
@@ -446,7 +430,7 @@ class MeshStore {
/// advances to when a conversation is opened.
Future> newestIncomingTsByChannel() async {
try {
- final rows = await _db.rawQuery(
+ final rows = await _db.getAll(
'SELECT channel, MAX(ts) AS ts FROM $_messages '
'WHERE outgoing = 0 GROUP BY channel',
);
@@ -461,7 +445,7 @@ class MeshStore {
Future> messageCountsByChannel() async {
try {
- final rows = await _db.rawQuery(
+ final rows = await _db.getAll(
'SELECT channel, COUNT(*) AS n FROM $_messages GROUP BY channel',
);
return {
@@ -479,9 +463,9 @@ class MeshStore {
// behind, they point past a log that no longer exists — so the first
// message to arrive after a clear lands *below* a cursor that outlived
// its conversation and is counted as already read.
- await _db.transaction((txn) async {
- await txn.delete(_messages);
- await txn.delete(_reads);
+ await _db.writeTransaction((tx) async {
+ await tx.execute('DELETE FROM $_messages');
+ await tx.execute('DELETE FROM $_reads');
});
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'mesh store clearMessages');
@@ -492,24 +476,31 @@ class MeshStore {
/// so the same telemetry can't be stored twice.
Future addMetric(MeshMetricSample sample) async {
try {
- await _db.insert(_metrics, {
- 'ts': sample.at.millisecondsSinceEpoch,
- 'channel_util': sample.channelUtilization,
- 'air_util': sample.airUtilTx,
- 'battery': sample.batteryPercent,
- 'voltage': sample.voltage,
- 'nodes_total': sample.nodesTotal,
- 'nodes_online': sample.nodesOnline,
- 'rx_packets': sample.rxPackets,
- 'tx_packets': sample.txPackets,
- 'ls_rx': sample.lsRx,
- 'ls_rx_bad': sample.lsRxBad,
- 'ls_tx': sample.lsTx,
- 'ls_rx_dupe': sample.lsRxDupe,
- 'ls_tx_relay': sample.lsTxRelay,
- 'ls_tx_relay_cancel': sample.lsTxRelayCancel,
- 'heap_free': sample.heapFree,
- }, conflictAlgorithm: ConflictAlgorithm.replace);
+ await _db.execute(
+ 'INSERT OR REPLACE INTO $_metrics '
+ '(ts, channel_util, air_util, battery, voltage, nodes_total, '
+ 'nodes_online, rx_packets, tx_packets, ls_rx, ls_rx_bad, ls_tx, '
+ 'ls_rx_dupe, ls_tx_relay, ls_tx_relay_cancel, heap_free) '
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ [
+ sample.at.millisecondsSinceEpoch,
+ sample.channelUtilization,
+ sample.airUtilTx,
+ sample.batteryPercent,
+ sample.voltage,
+ sample.nodesTotal,
+ sample.nodesOnline,
+ sample.rxPackets,
+ sample.txPackets,
+ sample.lsRx,
+ sample.lsRxBad,
+ sample.lsTx,
+ sample.lsRxDupe,
+ sample.lsTxRelay,
+ sample.lsTxRelayCancel,
+ sample.heapFree,
+ ],
+ );
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'mesh store addMetric');
}
@@ -521,8 +512,8 @@ class MeshStore {
Future _windowStart(String table, Duration window, DateTime now) async {
final nowMs = now.millisecondsSinceEpoch;
try {
- final rows = await _db.rawQuery('SELECT MAX(ts) AS newest FROM $table');
- final newest = (rows.first['newest'] as num?)?.toInt();
+ final row = await _db.get('SELECT MAX(ts) AS newest FROM $table');
+ final newest = (row['newest'] as num?)?.toInt();
final anchor = newest != null && newest < nowMs ? newest : nowMs;
return anchor - window.inMilliseconds;
} catch (error, stackTrace) {
@@ -535,11 +526,12 @@ class MeshStore {
Future> metrics() async {
try {
final since = await _windowStart(_metrics, metricRetention, _now());
- final rows = await _db.query(
- _metrics,
- where: 'ts >= ?',
- whereArgs: [since],
- orderBy: 'ts ASC',
+ final rows = await _db.getAll(
+ 'SELECT ts, channel_util, air_util, battery, voltage, nodes_total, '
+ 'nodes_online, rx_packets, tx_packets, ls_rx, ls_rx_bad, ls_tx, '
+ 'ls_rx_dupe, ls_tx_relay, ls_tx_relay_cancel, heap_free '
+ 'FROM $_metrics WHERE ts >= ? ORDER BY ts ASC',
+ [since],
);
return [
for (final row in rows)
@@ -575,18 +567,20 @@ class MeshStore {
Future addNodeMetrics(List samples) async {
if (samples.isEmpty) return;
try {
- await _db.transaction((txn) async {
- final batch = txn.batch();
+ await _db.writeTransaction((tx) async {
for (final sample in samples) {
- batch.insert(_nodeMetrics, {
- 'ts': sample.at.millisecondsSinceEpoch,
- 'node': sample.node,
- 'battery': sample.battery,
- 'voltage': sample.voltage,
- 'snr': sample.snr,
- }, conflictAlgorithm: ConflictAlgorithm.replace);
+ await tx.execute(
+ 'INSERT OR REPLACE INTO $_nodeMetrics '
+ '(ts, node, battery, voltage, snr) VALUES (?, ?, ?, ?, ?)',
+ [
+ sample.at.millisecondsSinceEpoch,
+ sample.node,
+ sample.battery,
+ sample.voltage,
+ sample.snr,
+ ],
+ );
}
- await batch.commit(noResult: true);
});
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'mesh store addNodeMetrics');
@@ -598,12 +592,17 @@ class MeshStore {
Future> nodeMetrics({int? node}) async {
try {
final since = await _windowStart(_nodeMetrics, metricRetention, _now());
- final rows = await _db.query(
- _nodeMetrics,
- where: node == null ? 'ts >= ?' : 'ts >= ? AND node = ?',
- whereArgs: node == null ? [since] : [since, node],
- orderBy: 'ts ASC',
- );
+ final rows = node == null
+ ? await _db.getAll(
+ 'SELECT ts, node, battery, voltage, snr FROM $_nodeMetrics '
+ 'WHERE ts >= ? ORDER BY ts ASC',
+ [since],
+ )
+ : await _db.getAll(
+ 'SELECT ts, node, battery, voltage, snr FROM $_nodeMetrics '
+ 'WHERE ts >= ? AND node = ? ORDER BY ts ASC',
+ [since, node],
+ );
return [
for (final row in rows)
MeshNodeMetricSample(
@@ -628,19 +627,21 @@ class MeshStore {
// On `received_at`, never on `ts` — see [_alterColumns]. A row with no
// arrival time survives: it predates the column, and its true age is
// unknowable.
- await _db.delete(
- _messages,
- where: 'received_at IS NOT NULL AND received_at < ?',
- whereArgs: [now.subtract(messageRetention).millisecondsSinceEpoch],
+ await _db.execute(
+ 'DELETE FROM $_messages '
+ 'WHERE received_at IS NOT NULL AND received_at < ?',
+ [now.subtract(messageRetention).millisecondsSinceEpoch],
);
// Rows written before the channel-hash guard existed can carry a hash
// (242, 92, …) where an index belongs; they synthesise phantom "CH242"
// conversations in the picker. The guard stops new ones — this clears
// the legacy ones. Slot indices are 0–7, fixed by the firmware.
- await _db.delete(_messages, where: 'channel > 7 OR channel < 0');
+ await _db.execute(
+ 'DELETE FROM $_messages WHERE channel > 7 OR channel < 0',
+ );
// The same shape guard on the read cursors, which are keyed by the same
// channel number and had no prune path at all.
- await _db.delete(_reads, where: 'channel > 7 OR channel < 0');
+ await _db.execute('DELETE FROM $_reads WHERE channel > 7 OR channel < 0');
// Measured from the newest row when that is *older* than now, not from
// now alone.
//
@@ -657,12 +658,10 @@ class MeshStore {
// accumulate — and every chart windows from *now* regardless, so a stale
// day is never drawn as current.
final metricCutoff = await _windowStart(_metrics, metricRetention, now);
- await _db.delete(_metrics, where: 'ts < ?', whereArgs: [metricCutoff]);
- await _db.delete(
- _nodeMetrics,
- where: 'ts < ?',
- whereArgs: [await _windowStart(_nodeMetrics, metricRetention, now)],
- );
+ await _db.execute('DELETE FROM $_metrics WHERE ts < ?', [metricCutoff]);
+ await _db.execute('DELETE FROM $_nodeMetrics WHERE ts < ?', [
+ await _windowStart(_nodeMetrics, metricRetention, now),
+ ]);
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'mesh store prune');
}
@@ -687,7 +686,7 @@ class MeshStore {
/// arrives later.
Future> readChannels() async {
try {
- final rows = await _db.query(_channels);
+ final rows = await _db.getAll('SELECT idx, name FROM $_channels');
return {
for (final row in rows) row['idx']! as int: row['name']! as String,
};
@@ -703,14 +702,15 @@ class MeshStore {
/// merge would keep the name of a channel the user has since deleted.
Future writeChannels(Map names) async {
try {
- await _db.transaction((txn) async {
- await txn.delete(_channels);
- final batch = txn.batch();
+ await _db.writeTransaction((tx) async {
+ await tx.execute('DELETE FROM $_channels');
for (final entry in names.entries) {
if (entry.value.isEmpty) continue;
- batch.insert(_channels, {'idx': entry.key, 'name': entry.value});
+ await tx.execute('INSERT INTO $_channels (idx, name) VALUES (?, ?)', [
+ entry.key,
+ entry.value,
+ ]);
}
- await batch.commit(noResult: true);
});
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'writing mesh channels');
@@ -723,7 +723,12 @@ class MeshStore {
/// passes its own.
Future>> readNodes({int limit = 5000}) async {
try {
- return await _db.query(_nodes, orderBy: 'last_heard DESC', limit: limit);
+ final rows = await _db.getAll(
+ 'SELECT num, name, battery, last_heard, latitude, longitude, snr, '
+ 'via_mqtt, hops_away FROM $_nodes ORDER BY last_heard DESC LIMIT ?',
+ [limit],
+ );
+ return [for (final row in rows) Map.of(row)];
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'reading mesh nodes');
return const [];
@@ -737,13 +742,26 @@ class MeshStore {
/// one transaction is cheaper than reconciling deletions.
Future writeNodes(List> rows) async {
try {
- await _db.transaction((txn) async {
- await txn.delete(_nodes);
- final batch = txn.batch();
+ await _db.writeTransaction((tx) async {
+ await tx.execute('DELETE FROM $_nodes');
for (final row in rows) {
- batch.insert(_nodes, row);
+ await tx.execute(
+ 'INSERT INTO $_nodes (num, name, battery, last_heard, latitude, '
+ 'longitude, snr, via_mqtt, hops_away) '
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ [
+ row['num'],
+ row['name'],
+ row['battery'],
+ row['last_heard'],
+ row['latitude'],
+ row['longitude'],
+ row['snr'],
+ row['via_mqtt'],
+ row['hops_away'],
+ ],
+ );
}
- await batch.commit(noResult: true);
});
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'writing mesh nodes');
diff --git a/lib/core/network/etag_cache_store.dart b/lib/core/network/etag_cache_store.dart
index f56e95b78..727359898 100644
--- a/lib/core/network/etag_cache_store.dart
+++ b/lib/core/network/etag_cache_store.dart
@@ -37,7 +37,8 @@ import 'dart:isolate';
import 'dart:typed_data';
import 'package:dpip/core/network/network_usage_store.dart';
-import 'package:sqflite/sqflite.dart';
+import 'package:sqlite_async/sqlite_async.dart';
+import 'package:sqlite_async/native.dart';
/// A cached HTTP response: the server [etag] to revalidate with, the response
/// [body] (the JSON-encoded payload), its [contentType], and [size] — the wire
@@ -117,16 +118,16 @@ class EtagCacheStore {
// first write re-establishes it.
unawaited(
_db
- .rawQuery('SELECT MAX(time) AS newest FROM $_table')
- .then((rows) {
- final newest = (rows.first['newest'] as num?)?.toInt() ?? 0;
+ .get('SELECT MAX(time) AS newest FROM $_table')
+ .then((row) {
+ final newest = (row['newest'] as num?)?.toInt() ?? 0;
if (newest > _lastStamp) _lastStamp = newest;
})
.catchError((Object _) {}),
);
}
- final Database _db;
+ final SqliteDatabase _db;
/// Optional traffic accounting for binary [readBytes] hits. Callers must not
/// also [NetworkUsageStore.record] those serves — misses / JSON `304`s stay
@@ -190,9 +191,35 @@ class EtagCacheStore {
int? _trackedBytes;
int _writesSinceSweep = 0;
- /// Creates the v2 cache table (idempotent) — call from `onCreate` / migrate.
- static Future createSchema(Database db) async {
- await db.execute(
+ static const _v2Columns = {
+ 'key',
+ 'etag',
+ 'content_type',
+ 'kind',
+ 'body',
+ 'size',
+ 'time',
+ };
+
+ /// Creates the v2 cache table, dropping an incompatible legacy cache first.
+ ///
+ /// v1 stored one `value` envelope instead of v2's columnar body. There is no
+ /// data migration on purpose: every row is re-fetchable, so a clean drop is
+ /// both safer and cheaper than decoding and rewriting hundreds of megabytes.
+ static Future createSchema(SqliteDatabase db) async {
+ final columns = {
+ for (final row in await db.getAll('PRAGMA table_info($_table)'))
+ if (row['name'] case final String name) name,
+ };
+ if (columns.isNotEmpty && !columns.containsAll(_v2Columns)) {
+ await migrateToV2(db);
+ return;
+ }
+ await _createV2Schema(db);
+ }
+
+ static Future _createV2Schema(SqliteDatabase db) async {
+ await db.executeMultiple(
'CREATE TABLE IF NOT EXISTS $_table ('
'key TEXT PRIMARY KEY, '
'etag TEXT NOT NULL, '
@@ -200,33 +227,35 @@ class EtagCacheStore {
'kind INTEGER NOT NULL, '
'body BLOB NOT NULL, '
'size INTEGER NOT NULL, '
- 'time INTEGER NOT NULL)',
- );
- await db.execute(
+ 'time INTEGER NOT NULL);'
'CREATE INDEX IF NOT EXISTS ${_table}_time ON $_table(time)',
);
}
- /// Connection-level SQLite knobs for hot tile reads (page cache + mmap).
- /// Call once after [openDatabase].
- static Future configureConnection(
- Database db, {
+ /// Connection-level SQLite knobs for hot tile reads.
+ ///
+ /// sqlite_async opens each pooled connection in its own background isolate,
+ /// so per-connection PRAGMAs ride a [NativeSqliteOpenFactory] subclass whose
+ /// [NativeSqliteOpenFactory.pragmaStatements] runs inside every opened
+ /// connection — not once per database. Call instead of the plain
+ /// [SqliteDatabase] constructor when opening this file.
+ static SqliteDatabase open({
+ required String path,
int pageCacheKiB = defaultPageCacheKiB,
- }) async {
- // PRAGMAs that return a row must use [rawQuery] — on Darwin, [execute]
- // treats the result as an error ("not an error") and would abort bootstrap
- // into "ETag cache unavailable".
- // Negative cache_size = kibibytes reserved for the pager (~25 MiB default).
- await db.rawQuery('PRAGMA cache_size = -$pageCacheKiB');
- await db.rawQuery('PRAGMA mmap_size = ${64 * 1024 * 1024}');
- }
+ }) => SqliteDatabase.withFactory(
+ _CacheOpenFactory(
+ path: path,
+ sqliteOptions: const SqliteOptions(synchronous: SqliteSynchronous.normal),
+ pageCacheKiB: pageCacheKiB,
+ ),
+ );
/// Migrates v1 (single `value` blob envelope) → v2 columnar schema.
/// Drops the old table (one-time cold miss) — simpler and safer than parsing
/// every legacy row on the UI isolate.
- static Future migrateToV2(Database db) async {
+ static Future migrateToV2(SqliteDatabase db) async {
await db.execute('DROP TABLE IF EXISTS $_table');
- await createSchema(db);
+ await _createV2Schema(db);
}
/// Returns the cached **JSON** entry for [url], or null on a miss.
@@ -340,12 +369,11 @@ class EtagCacheStore {
final chunk = end < urls.length
? urls.sublist(i, end)
: urls.sublist(i);
- final placeholders = List.filled(chunk.length, '?').join(',');
rows.addAll(
- await _db.query(
- _table,
- where: 'key IN ($placeholders)',
- whereArgs: chunk,
+ await _db.getAll(
+ 'SELECT key, etag, content_type, kind, body, size FROM $_table '
+ 'WHERE key IN (${List.filled(chunk.length, '?').join(',')})',
+ chunk,
),
);
}
@@ -401,16 +429,13 @@ class EtagCacheStore {
/// Returns just the cached etag for [url], or null on a miss.
Future readEtag(String url) async {
try {
- final rows = await _db.query(
- _table,
- columns: ['etag'],
- where: 'key = ?',
- whereArgs: [url],
- limit: 1,
+ final row = await _db.getOptional(
+ 'SELECT etag FROM $_table WHERE key = ? LIMIT 1',
+ [url],
);
- if (rows.isEmpty) return null;
+ if (row == null) return null;
_scheduleTouch(url);
- return rows.first['etag'] as String?;
+ return row['etag'] as String?;
} catch (_) {
return null;
}
@@ -473,17 +498,22 @@ class EtagCacheStore {
try {
final encoded = await _encodeBinaryAll(writes);
final now = _lruStamp();
- await _db.transaction((txn) async {
+ await _db.writeTransaction((tx) async {
for (final row in encoded) {
- await txn.insert(_table, {
- 'key': row.url,
- 'etag': row.etag,
- 'content_type': row.contentType,
- 'kind': row.kind,
- 'body': row.body,
- 'size': row.size,
- 'time': now,
- }, conflictAlgorithm: ConflictAlgorithm.replace);
+ await tx.execute(
+ 'INSERT OR REPLACE INTO $_table '
+ '(key, etag, content_type, kind, body, size, time) '
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [
+ row.url,
+ row.etag,
+ row.contentType,
+ row.kind,
+ row.body,
+ row.size,
+ now,
+ ],
+ );
}
});
var added = 0;
@@ -519,15 +549,12 @@ class EtagCacheStore {
required Uint8List body,
required int size,
}) async {
- await _db.insert(_table, {
- 'key': url,
- 'etag': etag,
- 'content_type': contentType,
- 'kind': kind,
- 'body': body,
- 'size': size,
- 'time': _lruStamp(),
- }, conflictAlgorithm: ConflictAlgorithm.replace);
+ await _db.execute(
+ 'INSERT OR REPLACE INTO $_table '
+ '(key, etag, content_type, kind, body, size, time) '
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [url, etag, contentType, kind, body, size, _lruStamp()],
+ );
await _noteWrite(body.length, 1);
}
@@ -545,7 +572,7 @@ class EtagCacheStore {
if (tracked != null) _trackedBytes = tracked + addedBytes;
if (maxBytes <= 0) {
- await _db.delete(_table);
+ await _db.execute('DELETE FROM $_table');
_trackedBytes = 0;
_writesSinceSweep = 0;
return;
@@ -583,7 +610,7 @@ class EtagCacheStore {
// BY` is unchanged, so the `time` index still serves it without a sort.
final victims = {};
for (var offset = 0; total > maxBytes; offset += _trimScanChunk) {
- final rows = await _db.rawQuery(
+ final rows = await _db.getAll(
'SELECT key, LENGTH(body) AS b FROM $_table '
'ORDER BY time ASC LIMIT ? OFFSET ?',
[_trimScanChunk, offset],
@@ -605,20 +632,19 @@ class EtagCacheStore {
for (var i = 0; i < keys.length; i += _readInChunk) {
final end = i + _readInChunk;
final chunk = end < keys.length ? keys.sublist(i, end) : keys.sublist(i);
- await _db.delete(
- _table,
- where: 'key IN (${List.filled(chunk.length, '?').join(',')})',
- whereArgs: chunk,
+ await _db.execute(
+ 'DELETE FROM $_table WHERE key IN (${List.filled(chunk.length, '?').join(',')})',
+ chunk,
);
}
_trackedBytes = total;
}
Future _measureBytes() async {
- final rows = await _db.rawQuery(
+ final row = await _db.get(
'SELECT COALESCE(SUM(LENGTH(body)), 0) AS b FROM $_table',
);
- return (rows.first['b'] as num).toInt();
+ return (row['b'] as num).toInt();
}
/// Brings the store back inside its byte budget.
@@ -643,7 +669,7 @@ class EtagCacheStore {
/// Deletes every cached entry.
Future clear() async {
try {
- await _db.delete(_table);
+ await _db.execute('DELETE FROM $_table');
_trackedBytes = 0;
_writesSinceSweep = 0;
} catch (_) {}
@@ -661,10 +687,9 @@ class EtagCacheStore {
/// Row count and total stored body bytes — for the Debug page.
Future stats() async {
try {
- final rows = await _db.rawQuery(
+ final row = await _db.get(
'SELECT COUNT(*) AS c, COALESCE(SUM(LENGTH(body)), 0) AS b FROM $_table',
);
- final row = rows.first;
return (
rows: (row['c'] as num).toInt(),
bytes: (row['b'] as num).toInt(),
@@ -675,14 +700,13 @@ class EtagCacheStore {
}
Future?> _queryRow(String url) async {
- final rows = await _db.query(
- _table,
- where: 'key = ?',
- whereArgs: [url],
- limit: 1,
+ final row = await _db.getOptional(
+ 'SELECT key, etag, content_type, kind, body, size FROM $_table '
+ 'WHERE key = ? LIMIT 1',
+ [url],
);
- if (rows.isEmpty) return null;
- return rows.first;
+ if (row == null) return null;
+ return Map.of(row);
}
/// JSON bodies are stored gzip-1; inflate off the UI isolate when large.
@@ -903,15 +927,14 @@ class EtagCacheStore {
_pendingTouch.clear();
final now = _lruStamp();
try {
- await _db.transaction((txn) async {
+ await _db.writeTransaction((tx) async {
for (var i = 0; i < urls.length; i += _touchInChunk) {
final end = i + _touchInChunk;
final chunk = end < urls.length
? urls.sublist(i, end)
: urls.sublist(i);
- final placeholders = List.filled(chunk.length, '?').join(',');
- await txn.rawUpdate(
- 'UPDATE $_table SET time = ? WHERE key IN ($placeholders)',
+ await tx.execute(
+ 'UPDATE $_table SET time = ? WHERE key IN (${List.filled(chunk.length, '?').join(',')})',
[now, ...chunk],
);
}
@@ -919,3 +942,26 @@ class EtagCacheStore {
} catch (_) {}
}
}
+
+/// Pool factory for the cache file — adds the hot-read PRAGMAs to **every**
+/// connection sqlite_async opens (one writer plus up to [SqliteOptions.maxReaders]
+/// readers), which is where they belong: `cache_size` and `mmap_size` are
+/// per-connection settings, and a tile burst reads through whichever pooled
+/// reader picks it up.
+base class _CacheOpenFactory extends NativeSqliteOpenFactory {
+ _CacheOpenFactory({
+ required super.path,
+ required super.sqliteOptions,
+ required this.pageCacheKiB,
+ });
+
+ final int pageCacheKiB;
+
+ @override
+ List pragmaStatements(covariant SqliteOpenOptions options) => [
+ ...super.pragmaStatements(options),
+ // Negative cache_size = kibibytes reserved for the pager (~25 MiB).
+ 'PRAGMA cache_size = -$pageCacheKiB',
+ 'PRAGMA mmap_size = ${64 * 1024 * 1024}',
+ ];
+}
diff --git a/lib/core/network/network_usage_store.dart b/lib/core/network/network_usage_store.dart
index e550168bf..aae89e9d3 100644
--- a/lib/core/network/network_usage_store.dart
+++ b/lib/core/network/network_usage_store.dart
@@ -1,6 +1,6 @@
import 'dart:async';
-import 'package:sqflite/sqflite.dart';
+import 'package:sqlite_async/sqlite_async.dart';
/// A snapshot of network usage for the Debug page.
///
@@ -101,7 +101,7 @@ class NetworkUsageStore {
this.flushEvery = 64,
}) : _now = now ?? DateTime.now;
- final Database _db;
+ final SqliteDatabase _db;
/// Injectable clock — the wall time used to bucket and window usage.
final DateTime Function() _now;
@@ -129,7 +129,7 @@ class NetworkUsageStore {
/// Creates the usage table (idempotent) — call on database open. Uses
/// `IF NOT EXISTS` so it also adds the table to a pre-existing cache database
/// without a version bump, then migrates an older shape in place.
- static Future createSchema(Database db) async {
+ static Future createSchema(SqliteDatabase db) async {
final defs = _columns
.map((c) => '$c INTEGER NOT NULL DEFAULT 0')
.join(', ');
@@ -143,10 +143,10 @@ class NetworkUsageStore {
///
/// [createSchema] runs on every open with `IF NOT EXISTS`, so an installed
/// database never picks up new columns on its own.
- static Future _migrate(Database db) async {
+ static Future _migrate(SqliteDatabase db) async {
try {
final existing = {
- for (final row in await db.rawQuery('PRAGMA table_info($_buckets)'))
+ for (final row in await db.getAll('PRAGMA table_info($_buckets)'))
row['name'] as String,
};
for (final column in _columns) {
@@ -225,15 +225,13 @@ class NetworkUsageStore {
try {
final hour = _now().millisecondsSinceEpoch ~/ _hourMs;
- await _db.transaction((txn) async {
+ await _db.writeTransaction((tx) async {
for (final entry in pending.entries) {
- await _addToBucket(txn, entry.key, entry.value);
+ await _addToBucket(tx, entry.key, entry.value);
}
- await txn.delete(
- _buckets,
- where: 'hour < ?',
- whereArgs: [hour - _windowHours],
- );
+ await tx.execute('DELETE FROM $_buckets WHERE hour < ?', [
+ hour - _windowHours,
+ ]);
});
} catch (_) {
// Accounting is diagnostic-only; never surface a failure.
@@ -250,11 +248,9 @@ class NetworkUsageStore {
Future prune() async {
try {
final hour = _now().millisecondsSinceEpoch ~/ _hourMs;
- await _db.delete(
- _buckets,
- where: 'hour < ?',
- whereArgs: [hour - _windowHours],
- );
+ await _db.execute('DELETE FROM $_buckets WHERE hour < ?', [
+ hour - _windowHours,
+ ]);
} catch (_) {
// Accounting is diagnostic-only; never surface a failure.
}
@@ -271,7 +267,7 @@ class NetworkUsageStore {
_pendingByHour.clear();
_pendingEvents = 0;
try {
- await _db.delete(_buckets);
+ await _db.execute('DELETE FROM $_buckets');
} catch (_) {
// Accounting is diagnostic-only; never surface a failure.
}
@@ -315,7 +311,7 @@ class NetworkUsageStore {
final hour = _now().millisecondsSinceEpoch ~/ _hourMs;
final bucket = hour ~/ bucketHours;
final count = hours ~/ bucketHours;
- final rows = await _db.rawQuery(
+ final rows = await _db.getAll(
'SELECT hour / ? AS bucket, '
'COALESCE(SUM(down), 0) AS down, '
'COALESCE(SUM(saved), 0) AS saved, '
@@ -326,7 +322,8 @@ class NetworkUsageStore {
[bucketHours, (bucket - count + 1) * bucketHours],
);
final byBucket = >{
- for (final row in rows) row['bucket'] as int: row,
+ for (final row in rows)
+ row['bucket'] as int: Map.of(row),
};
return [
for (var b = bucket - count + 1; b <= bucket; b++)
@@ -346,29 +343,32 @@ class NetworkUsageStore {
static int _counter(Object? value) => (value as num?)?.toInt() ?? 0;
// Update-then-insert instead of UPSERT, so it works on any bundled SQLite.
- Future _addToBucket(DatabaseExecutor db, int hour, _Pending add) async {
+ Future _addToBucket(
+ SqliteWriteContext tx,
+ int hour,
+ _Pending add,
+ ) async {
final sets = _columns.map((c) => '$c = $c + ?').join(', ');
final values = [add.down, add.saved, add.hits, add.misses];
- final updated = await db.rawUpdate(
+ final result = await tx.execute(
'UPDATE $_buckets SET $sets WHERE hour = ?',
[...values, hour],
);
- if (updated == 0) {
- await db.insert(_buckets, {
- 'hour': hour,
- for (var i = 0; i < _columns.length; i++) _columns[i]: values[i],
- });
+ if (result.isEmpty) {
+ await tx.execute(
+ 'INSERT INTO $_buckets (hour, ${_columns.join(', ')}) '
+ 'VALUES (?, ?, ?, ?, ?)',
+ [hour, ...values],
+ );
}
}
/// Sums every counter over one trailing window in a single query.
Future<_Pending> _sumSince(int sinceHour) async {
final sums = _columns.map((c) => 'COALESCE(SUM($c), 0) AS $c').join(', ');
- final rows = await _db.rawQuery(
- 'SELECT $sums FROM $_buckets WHERE hour >= ?',
- [sinceHour],
- );
- final row = rows.first;
+ final row = await _db.get('SELECT $sums FROM $_buckets WHERE hour >= ?', [
+ sinceHour,
+ ]);
return _Pending()
..down = (row['down'] as num).toInt()
..saved = (row['saved'] as num).toInt()
diff --git a/lib/core/notifications/notification_channels.dart b/lib/core/notifications/notification_channels.dart
index 459a0e714..4a075e0ab 100644
--- a/lib/core/notifications/notification_channels.dart
+++ b/lib/core/notifications/notification_channels.dart
@@ -14,7 +14,17 @@ abstract final class NotificationChannels {
const NotificationChannels._();
/// Bump when any channel definition below changes, to force a re-create.
- static const int version = 2;
+ ///
+ /// **The bump is not optional paperwork.** Android resolves
+ /// `resource://raw/` to a numeric resource ID when a channel is first
+ /// created and caches that number system-side; the docs forbid changing a
+ /// created channel's behaviour, so the only repair is delete + re-create.
+ /// Resource IDs are re-assigned whenever the resource set changes — and when
+ /// the sound files were re-encoded in place (#525) with no bump, upgraded
+ /// installs kept channels pointing at numbers from an older APK: crossed or
+ /// silent sounds on every device that had seen the previous build. Bumping
+ /// this counter is what makes [NotificationService] force-update them.
+ static const int version = 4;
/// Default status-bar icon (Android) — a monochrome drawable.
static const String icon = 'resource://drawable/ic_stat_name';
diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart
index 3994d6e3a..efd3ff8e2 100644
--- a/lib/core/notifications/notification_service.dart
+++ b/lib/core/notifications/notification_service.dart
@@ -1,17 +1,19 @@
import 'dart:async';
+import 'dart:convert';
import 'dart:io';
import 'package:awesome_notifications/awesome_notifications.dart';
+import 'package:awesome_notifications_fcm/awesome_notifications_fcm.dart';
import 'package:dpip/core/logging/log.dart';
import 'package:dpip/core/permissions/permission_outcome.dart';
import 'package:dpip/core/permissions/system_settings.dart';
import 'package:dpip/core/notifications/notification_channels.dart';
-import 'package:dpip/core/notifications/notification_tap.dart';
import 'package:dpip/core/notifications/notification_taps.dart';
+import 'package:dpip/core/notifications/plain_channels.dart';
import 'package:dpip/core/settings/setting_keys.dart';
import 'package:dpip/core/settings/settings_store.dart';
-import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
/// Fallback channel for a message with no/unknown `channel` — must be a
/// registered channel or the OS rejects the notification.
@@ -72,11 +74,47 @@ class NotificationService {
/// Initializes channels, the tap listener, and the FCM/APNs transport. Call
/// once at start-up; safe to await best-effort (a failure just means no push).
Future init() async {
- await _initChannels();
+ final channels = await _initChannels();
await AwesomeNotifications().setListeners(
onActionReceivedMethod: NotificationTaps.onActionReceived,
+ // Kept for operational visibility, not for one bug. `created` fires when
+ // awesome accepts a notification and `displayed` when it reaches the
+ // status bar, so the log answers "did the alert actually surface?" — the
+ // question that matters most in an app whose reason to exist is alerts,
+ // and the one that took five rebuilds to answer the last time it came up
+ // because nothing recorded it.
+ onNotificationCreatedMethod: onNotificationCreated,
+ onNotificationDisplayedMethod: onNotificationDisplayed,
+ );
+ await _initMessaging(channels);
+ }
+
+ /// One line per launch saying whether push actually came up.
+ ///
+ /// Push is the app's reason to exist and every one of its failures is silent:
+ /// a channel the OS rejected, a permission never granted, a token that never
+ /// arrived — the app looks identical in all of them and simply never rings.
+ /// Printed after the token settles rather than at the end of [init], because
+ /// the token is fetched in the background and a line without it would say
+ /// "ready" before the one thing that can still be missing is known.
+ void _logStartup({required int channels, required int rejected}) {
+ final stored = token;
+ final kind = Platform.isIOS ? 'APNs' : 'FCM';
+ final tokenText = stored == null
+ ? 'MISSING — this device cannot be reached'
+ : kDebugMode
+ ? '$kind $stored'
+ // Enough to tell two devices apart and to match against the backend,
+ // without putting the whole addressable identifier in a shared log.
+ : '$kind …${stored.substring(stored.length - 8)} (${stored.length})';
+ final channelText = rejected == 0
+ ? '$channels ok'
+ : '${channels - rejected}/$channels ok, $rejected REJECTED';
+ Log.info(
+ 'push: ${stored == null ? 'DEGRADED' : 'ready'} · '
+ '${Platform.isIOS ? 'iOS' : 'Android'} · '
+ 'channels $channelText · token $tokenText',
);
- await _initMessaging();
}
/// Requests ordinary notification permission. Call from a screen (e.g.
@@ -213,7 +251,7 @@ class NotificationService {
return openNotificationSettingsPage();
}
- Future _initChannels() async {
+ Future<({int total, int rejected})> _initChannels() async {
final channels = NotificationChannels.channels;
// The normal path is one batch call — the same one this always made.
@@ -267,25 +305,52 @@ class NotificationService {
'notifications: no channel could be registered — every one was '
'rejected. Alerts will not be delivered.',
);
- return;
+ return (total: channels.length, rejected: channels.length);
}
}
- // Android caches a channel's settings after first creation, so a changed
- // sound or importance only takes effect with `forceUpdate` — which is what
- // the catalogue version is for.
+ // Android freezes a created channel's behaviour — sound included — so a
+ // changed definition cannot simply be pushed. awesome's own update path
+ // makes it worse: a forced update deletes the channel under its plain key
+ // and recreates it under `_`, an id FCM's system-tray renders
+ // can never find. The backend's pushes carry an FCM `notification` block,
+ // so background delivery is exactly that path — and every upgraded install
+ // fell back to the system default sound.
+ //
+ // The catalogue version therefore means **remove everything and register
+ // again**: `removeChannel` deletes both the plain and the hashed variant
+ // plus the registry entry, so each re-registration takes the "created"
+ // branch — plain key, current sound files, no hash suffix ever.
final stored = _settings.getInt(SettingKeys.channelVersion) ?? 0;
- final force = stored < NotificationChannels.version;
+ final outdated = stored < NotificationChannels.version;
// On the happy path this only runs when the catalogue changed. On the
// degraded path it always runs, because it is what registers the channels
// the seed did not.
- if (force || !registered) {
+ if (outdated || !registered) {
+ if (outdated && registered) {
+ var purged = true;
+ for (final channel in channels) {
+ if (rejected.contains(channel.channelKey)) continue;
+ try {
+ await AwesomeNotifications().removeChannel(channel.channelKey!);
+ } catch (error, stackTrace) {
+ purged = false;
+ Log.handle(error, stackTrace, 'purging ${channel.channelKey}');
+ }
+ }
+ if (!purged) {
+ Log.error(
+ 'notifications: channel purge incomplete — stale sounds may '
+ 'persist on upgraded installs until the next launch',
+ );
+ }
+ }
for (final channel in channels) {
if (identical(channel, seed)) continue;
if (rejected.contains(channel.channelKey)) continue;
try {
- await AwesomeNotifications().setChannel(channel, forceUpdate: force);
+ await AwesomeNotifications().setChannel(channel);
} catch (error, stackTrace) {
rejected.add(channel.channelKey ?? '?');
Log.handle(error, stackTrace, 'channel ${channel.channelKey}');
@@ -299,46 +364,49 @@ class NotificationService {
'rejected — ${rejected.join(', ')}. The rest are registered.',
);
}
- if (force) {
+ if (outdated) {
await _settings.setInt(
SettingKeys.channelVersion,
NotificationChannels.version,
);
}
- }
- Future _initMessaging() async {
- final messaging = FirebaseMessaging.instance;
+ // FCM renders background pushes itself against the PLAIN channel id, a
+ // lookup that must not depend on how awesome hashed or re-hashed its own
+ // channels this launch. Mirror the catalogue under plain keys last, after
+ // every purge and re-registration above has settled.
+ await PlainChannels.ensure(channels);
+ return (total: channels.length, rejected: rejected.length);
+ }
- FirebaseMessaging.onBackgroundMessage(onBackgroundMessage);
- FirebaseMessaging.onMessage.listen((message) {
- final content = contentFromMessage(message);
- if (content != null) {
- AwesomeNotifications().createNotification(content: content);
- }
- });
- FirebaseMessaging.onMessageOpenedApp.listen((m) => _routeTap(m.data));
-
- final initial = await messaging.getInitialMessage();
- if (initial != null) _routeTap(initial.data);
-
- messaging.onTokenRefresh.listen((token) async {
- Log.debug('Push token refreshed');
- if (defaultTargetPlatform == TargetPlatform.iOS) {
- // This stream only carries the FCM registration token (see
- // [_fetchToken] for why that's not what iOS registration needs);
- // re-read the APNs token directly rather than persist [token] as-is.
- final apns = await messaging.getAPNSToken();
- if (apns != null) {
- await _settings.setString(SettingKeys.pushToken, apns);
- }
- return;
- }
- await _settings.setString(SettingKeys.pushToken, token);
- });
- // Fire-and-forget: this can wait seconds for the iOS APNs token, and
- // `init()` is awaited at launch, so it must not block start-up.
- unawaited(_fetchToken());
+ Future _initMessaging(({int total, int rejected}) channels) async {
+ // Push belongs to awesome_notifications_fcm, on both platforms.
+ //
+ // `awesome_notifications` handles local notifications only — its own source
+ // says so: "we do not chain to a previously-installed delegate … FCM is
+ // handled by awesome_notifications_fcm". Without that companion the remote
+ // path has no owner: on iOS a server push reached awesome's willPresent,
+ // was claimed as its own (the payload's `content` key is awesome's model
+ // format) and then had nothing to display it with, so the foreground went
+ // silent and blank.
+ //
+ // `firebase_messaging` is gone: upstream says the two must not coexist, and
+ // everything it was still doing here has an equivalent above — the two
+ // token handlers replace its refresh stream and its launch-time fetch, and
+ // the foreground presentation it configured is now decided by this app's
+ // own notification-centre delegate (see AppDelegate).
+ await AwesomeNotificationsFcm().initialize(
+ onFcmTokenHandle: (token) => _storeToken(token, isApns: false),
+ onNativeTokenHandle: (token) => _storeToken(token, isApns: true),
+ onFcmSilentDataHandle: onFcmSilentData,
+ debug: kDebugMode,
+ );
+ unawaited(
+ _fetchToken().whenComplete(
+ () =>
+ _logStartup(channels: channels.total, rejected: channels.rejected),
+ ),
+ );
}
/// Fetches the push token and persists it as [SettingKeys.pushToken] —
@@ -364,74 +432,201 @@ class NotificationService {
/// the APNs auth key uploaded to the Firebase console. Best-effort: a failure
/// just leaves the token unset until [requestPermission] or
/// `onTokenRefresh` tries again.
+ /// The one place [SettingKeys.pushToken] is written.
+ ///
+ /// Tokens arrive from three directions — awesome_notifications_fcm's two
+ /// handlers, firebase's refresh stream, and the launch-time fetch — and the
+ /// two kinds are **not interchangeable**: the backend keys on the raw APNs
+ /// token on iOS and the FCM registration token on Android. Registering the
+ /// wrong one is not a loud failure; it was measured to 202 on the write and
+ /// then 401 on every later lookup, which reads as "push is broken" with no
+ /// clue why.
+ ///
+ /// So every writer states which kind it holds and this decides, instead of
+ /// each caller repeating a platform test — the version that did not repeat
+ /// it let iOS overwrite a good APNs token with an FCM one seconds later.
+ Future _storeToken(String token, {required bool isApns}) async {
+ if (token.isEmpty) return;
+ if (isApns != Platform.isIOS) return;
+ await _settings.setString(SettingKeys.pushToken, token);
+ Log.debug('Push token stored (${isApns ? 'APNs' : 'FCM'})');
+ }
+
+ /// The APNs device token, read from the iOS side that receives it.
+ static const _apns = MethodChannel('com.exptech.dpip/apns_token');
+
Future _fetchToken() async {
- final messaging = FirebaseMessaging.instance;
try {
- String? apnsToken;
- if (defaultTargetPlatform == TargetPlatform.iOS) {
+ if (Platform.isIOS) {
+ // iOS hands the token to the app delegate whenever it finishes
+ // registering, which is usually a moment after launch. `onNativeTokenHandle`
+ // delivers it too, but only if it fires — and a token that never
+ // arrives is silent: the backend keeps the device on file and simply
+ // stops being able to reach it. Polling the native side closes that
+ // hole without another SDK in between.
for (var attempt = 0; attempt < 5; attempt++) {
- apnsToken = await messaging.getAPNSToken();
- if (apnsToken != null) break;
+ final token = await _apns.invokeMethod('token');
+ if (token != null) {
+ await _storeToken(token, isApns: true);
+ return;
+ }
await Future.delayed(const Duration(seconds: 1));
}
+ Log.warning(
+ 'APNs token still unavailable — push cannot reach this device',
+ );
+ return;
}
- final fcmToken = await messaging.getToken();
- final pushToken = defaultTargetPlatform == TargetPlatform.iOS
- ? apnsToken
- : fcmToken;
- if (pushToken != null) {
- await _settings.setString(SettingKeys.pushToken, pushToken);
- }
+ await _storeToken(
+ await AwesomeNotificationsFcm().requestFirebaseAppToken(),
+ isApns: false,
+ );
} catch (error, stackTrace) {
- // The failure mode is platform-specific: on iOS an unready APNs token is
- // the usual cause, on Android getToken() fails at FCM registration (e.g.
- // the app's signing SHA-1 not registered in the Firebase console).
- final title = defaultTargetPlatform == TargetPlatform.iOS
- ? 'getToken (APNs may not be ready)'
- : 'getToken (FCM registration failed)';
- Log.handle(error, stackTrace, title);
+ // On Android this is FCM registration failing, usually the app's signing
+ // SHA-1 not being registered in the Firebase console.
+ Log.handle(error, stackTrace, 'push token');
}
}
-
- void _routeTap(Map data) =>
- NotificationTaps.route(NotificationTap.fromData(data));
}
/// Builds notification content from a message's `data` (preferred, legacy
/// format) falling back to its `notification` block, or null when there's
/// nothing to show.
-NotificationContent? contentFromMessage(RemoteMessage message) {
- final data = message.data;
- final notification = message.notification;
- final title = (data['title'] as String?) ?? notification?.title;
- final body = (data['body'] as String?) ?? notification?.body;
+///
+/// Two payload shapes arrive here:
+///
+/// - **Flat** — `data['channel']` / `data['title']` / `data['body']` /
+/// `data['id']`, the contract [ARCHITECTURE.md] describes.
+/// - **Nested** — everything packed into `data['content']` as one JSON string
+/// (`{channelKey, id, body, …}`), which is what the push producer still
+/// sends: the FCM `notification` block carries the visible text while the
+/// structured fields ride inside that string. Without reading it back,
+/// every such message loses its channel and collapses onto the announcement
+/// fallback — wrong sound, wrong tap routing.
+///
+/// Flat keys win where both exist. Nested JSON is parsed leniently: malformed
+/// or non-object content is treated as absent, never thrown on.
+/// [fallbackTitle] / [fallbackBody] stand in for an FCM `notification` block.
+/// A silent-data push carries none, so its text has to come from the payload.
+NotificationContent? contentFromData(
+ Map data, {
+ String? fallbackTitle,
+ String? fallbackBody,
+}) {
+ final nested = _nestedContent(data);
+ String? nestedField(String name) => switch (nested?[name]) {
+ final String value => value,
+ final int value => value.toString(),
+ _ => null,
+ };
+ final title =
+ (data['title'] as String?) ?? fallbackTitle ?? nestedField('title');
+ final body = (data['body'] as String?) ?? fallbackBody ?? nestedField('body');
if (title == null && body == null) return null;
- final channelKey = (data['channel'] as String?) ?? _fallbackChannelKey;
+ final channelKey =
+ (data['channel'] as String?) ??
+ nestedField('channelKey') ??
+ _fallbackChannelKey;
+ final id =
+ _asNotificationId(data['id']) ?? _asNotificationId(nested?['id']) ?? 0;
+ final idText = (data['id'] as String?) ?? nestedField('id');
return NotificationContent(
- id: int.tryParse((data['id'] as String?) ?? '') ?? 0,
+ id: id,
channelKey: channelKey,
title: title,
body: body,
// Carry channel + id on the payload so an awesome-displayed tap deep-links
// symmetrically with the FCM-delivered path.
- payload: {'channel': channelKey, 'id': data['id'] as String?},
+ payload: {'channel': channelKey, 'id': idText},
wakeUpScreen: true,
- category: NotificationCategory.Alarm,
+ // Deliberately NO `category: Alarm` here: awesome turns that into
+ // FLAG_INSISTENT | FLAG_NO_CLEAR, which repeats the channel sound until
+ // the notification is opened — reported as "the alert loops forever".
+ // Insistence is a per-channel policy decision, not something every push
+ // should inherit from a hardcoded default.
);
}
-/// Displays a background/terminated **data-only** message via awesome (a
-/// `notification`-payload message is shown by the OS itself). Runs on a
-/// background isolate, so awesome must be initialized here before use.
+/// The structured fields of a message whose producer nested them inside
+/// `data['content']` as one JSON string — see [contentFromData]'s doc.
+Map? _nestedContent(Map data) {
+ final raw = data['content'];
+ if (raw is! String || raw.isEmpty) return null;
+ try {
+ final decoded = jsonDecode(raw);
+ return decoded is Map ? decoded : null;
+ } on FormatException {
+ return null;
+ }
+}
+
+/// Coerces a payload id to the form awesome accepts.
+///
+/// awesome validates ids against the **signed 32-bit** range and throws —
+/// killing the whole notification — on anything wider. The producer computes
+/// its id as a 40-bit hex slice (`parseInt(md5slice, 16)`), so oversized ids
+/// are the common case, not the edge: they are treated as absent and the
+/// notification renders with id 0, replacing whatever came before it.
+int? _asNotificationId(Object? value) {
+ final parsed = switch (value) {
+ final int v => v,
+ final String s => int.tryParse(s),
+ _ => null,
+ };
+ if (parsed == null || parsed < -0x80000000 || parsed > 0x7FFFFFFF) {
+ return null;
+ }
+ return parsed;
+}
+
+/// Fires when awesome accepts a notification, before it is shown.
@pragma('vm:entry-point')
-Future onBackgroundMessage(RemoteMessage message) async {
- if (message.notification != null) return;
- final content = contentFromMessage(message);
- if (content == null) return;
+Future onNotificationCreated(ReceivedNotification notification) async {
+ Log.debug(
+ 'notif created: id=${notification.id} channel=${notification.channelKey} '
+ 'lifecycle=${notification.createdLifeCycle}',
+ );
+}
+
+/// Fires when a notification actually reaches the status bar.
+@pragma('vm:entry-point')
+Future onNotificationDisplayed(ReceivedNotification notification) async {
+ Log.debug(
+ 'notif displayed: id=${notification.id} channel=${notification.channelKey} '
+ 'lifecycle=${notification.displayedLifeCycle}',
+ );
+}
+
+/// Draws a push that arrived through awesome_notifications_fcm.
+///
+/// Runs on a background isolate when the app is not in the foreground, so
+/// awesome has to be initialized here before it can be used — the isolate does
+/// not inherit the one `init()` set up.
+///
+/// The terminated case goes through `createNotificationFromJsonData` rather
+/// than a hand-built [NotificationContent]: at that point there is no engine
+/// state to rely on, and the payload is already in awesome's own wire format
+/// (the server sends a `content` object with `channelKey`), so handing it over
+/// whole is both shorter and closer to what the sender meant.
+@pragma('vm:entry-point')
+Future onFcmSilentData(FcmSilentData silentData) async {
+ final data = silentData.data;
+ if (data == null || data.isEmpty) return;
+
await AwesomeNotifications().initialize(
NotificationChannels.icon,
NotificationChannels.channels,
channelGroups: NotificationChannels.groups,
);
+
+ if (silentData.createdLifeCycle == NotificationLifeCycle.Terminated) {
+ await AwesomeNotifications().createNotificationFromJsonData(
+ data.cast(),
+ );
+ return;
+ }
+
+ final content = contentFromData(data.cast());
+ if (content == null) return;
await AwesomeNotifications().createNotification(content: content);
}
diff --git a/lib/core/notifications/notification_taps.dart b/lib/core/notifications/notification_taps.dart
index a4c9b699e..40ebf8103 100644
--- a/lib/core/notifications/notification_taps.dart
+++ b/lib/core/notifications/notification_taps.dart
@@ -20,8 +20,10 @@ abstract final class NotificationTaps {
/// Routes [tap] now via [onTap], or stashes it for [drainPending] if the app /
/// router isn't ready yet (cold start). Shared by awesome-displayed taps
- /// ([onActionReceived]) and FCM-delivered taps (firebase's
- /// `onMessageOpenedApp` / `getInitialMessage`).
+ /// ([onActionReceived]). Firebase's `onMessageOpenedApp` /
+ /// `getInitialMessage` no longer feed this: push is owned by
+ /// awesome_notifications_fcm, so every notification the user can tap was
+ /// displayed by awesome and arrives through [onActionReceived].
static void route(NotificationTap tap) {
final handler = onTap;
if (handler != null) {
diff --git a/lib/core/notifications/plain_channels.dart b/lib/core/notifications/plain_channels.dart
new file mode 100644
index 000000000..d4b5153e0
--- /dev/null
+++ b/lib/core/notifications/plain_channels.dart
@@ -0,0 +1,59 @@
+/// Mirrors the notification catalogue to Android under plain channel keys.
+///
+/// FCM renders background pushes **itself** whenever the payload carries an
+/// FCM `notification` block, and it resolves `android_channel_id` against
+/// plain, un-hashed channel IDs. awesome's own channels are keyed by a hash
+/// of their model — invisible to that lookup. Without this mirror, every
+/// background push falls back to the system default channel: the system
+/// sound, regardless of what the catalogue says.
+///
+/// Best-effort by design: a failure leaves background pushes on the fallback
+/// channel rather than breaking the app, and the call is a no-op off Android.
+library;
+
+import 'dart:io';
+
+import 'package:awesome_notifications/awesome_notifications.dart';
+import 'package:dpip/core/logging/log.dart';
+import 'package:flutter/services.dart';
+
+abstract final class PlainChannels {
+ static const _channel = MethodChannel(
+ 'com.exptech.dpip/plain_notification_channels',
+ );
+
+ /// Creates any plain-key channel from [channels] that does not exist yet.
+ ///
+ /// Existing channels are never rewritten: Android freezes created channels'
+ /// behaviour, and the user may have tuned them in the OS UI. Sound refreshes
+ /// ride the catalogue-version gate instead, which deletes and re-registers.
+ static Future ensure(List channels) async {
+ if (!Platform.isAndroid) return;
+ try {
+ await _channel.invokeMethod('ensure', {
+ 'channels': [for (final channel in channels) _payload(channel)],
+ });
+ } on PlatformException catch (error, stackTrace) {
+ Log.handle(error, stackTrace, 'mirroring plain notification channels');
+ } on MissingPluginException {
+ // An engine that never registered the channel (tests, hot restarts
+ // into a fresh messenger) simply skips the mirror.
+ }
+ }
+
+ static Map _payload(NotificationChannel channel) => {
+ 'id': channel.channelKey,
+ 'name': channel.channelName,
+ if (channel.channelDescription != null)
+ 'description': channel.channelDescription,
+ // awesome's importance enum is declared in Android's IMPORTANCE_* order,
+ // so the index is the constant the native side expects.
+ 'importance': channel.importance?.index ?? 3,
+ if (channel.channelGroupKey != null) 'group': channel.channelGroupKey,
+ if (channel.soundSource != null)
+ 'sound': channel.soundSource!.replaceAll('resource://raw/', ''),
+ if (channel.vibrationPattern != null)
+ 'vibrationPattern': channel.vibrationPattern,
+ if (channel.ledColor != null) 'ledColor': channel.ledColor!.toARGB32(),
+ };
+}
diff --git a/lib/core/platform/install_source.dart b/lib/core/platform/install_source.dart
index 0adb28ead..b8d5bad27 100644
--- a/lib/core/platform/install_source.dart
+++ b/lib/core/platform/install_source.dart
@@ -7,8 +7,10 @@
/// all. So the destination follows the installer, not `Platform.isIOS`.
///
/// Native detection is cheap and definitive on both platforms: iOS reads the
-/// App Store receipt's filename (`sandboxReceipt` **is** the TestFlight
-/// marker), Android reads the installing package name.
+/// App Store receipt's filename (`sandboxReceipt` **is** the pre-release
+/// marker, narrowed to TestFlight by the absence of an embedded provisioning
+/// profile — see `DeviceInfoPlugin.installSource`), Android reads the
+/// installing package name.
library;
import 'package:dpip/core/logging/log.dart';
@@ -25,11 +27,22 @@ enum InstallSource {
/// Android, installed by the Play Store.
playStore,
- /// Installed by something else: a sideloaded APK, an Xcode/`flutter run`
- /// build, another Android store. There is no store page to send it to.
- sideload,
+ /// A local development build: `flutter run`, Xcode, an adb-installed debug
+ /// APK. Detected by the DEBUG compilation condition on iOS and
+ /// `FLAG_DEBUGGABLE` on Android — both are build facts, not installer
+ /// records, so they work even though adb leaves no installer behind.
+ development,
- /// Detection failed or has not run. Treated as [sideload] for destinations,
+ /// Installed outside any store: a GitHub release APK/IPA, a re-signed IPA,
+ /// another Android store. There is no store page to send it to.
+ ///
+ /// Named for the channel rather than the act: neither platform reveals
+ /// *where* a manually installed package was downloaded from, only that no
+ /// store did it — but every such install takes its updates from the same
+ /// place, the GitHub release page, so that is what the name says.
+ github,
+
+ /// Detection failed or has not run. Treated as [github] for destinations,
/// and as the stable channel for update checks.
unknown;
@@ -70,7 +83,8 @@ abstract final class InstallSourceService {
'appStore' => InstallSource.appStore,
'testFlight' => InstallSource.testFlight,
'playStore' => InstallSource.playStore,
- 'sideload' => InstallSource.sideload,
+ 'development' => InstallSource.development,
+ 'sideload' || 'github' => InstallSource.github,
_ => InstallSource.unknown,
};
}
diff --git a/lib/core/settings/map_layer_visibility_controller.dart b/lib/core/settings/map_layer_visibility_controller.dart
new file mode 100644
index 000000000..d0550c7ce
--- /dev/null
+++ b/lib/core/settings/map_layer_visibility_controller.dart
@@ -0,0 +1,72 @@
+/// Persisted hidden map layers — the per-layer display switch.
+library;
+
+import 'package:dpip/core/settings/setting_keys.dart';
+import 'package:dpip/core/settings/settings_store.dart';
+import 'package:flutter/foundation.dart';
+
+/// Holds the ids of the map layers the user hid, persisted across launches.
+///
+/// A hidden layer disappears from every map surface's picker and never
+/// renders; the default is an empty set — every layer a surface offers is
+/// shown. Toggling lives in the layer-order editor (the tune icon in the
+/// picker), which is also where a layer can be shown again. Surfaces resolve
+/// the saved ids against their own layer set, so an id saved on one surface
+/// but not offered by another is simply ignored there.
+///
+/// "Hidden" is the complement of "shown", not a third state: the map shows
+/// exactly one overlay at a time, so hiding means "never offer it to me
+/// again", not "keep it loaded but invisible".
+class MapLayerVisibilityController extends ChangeNotifier {
+ MapLayerVisibilityController(this._settings)
+ : _hidden =
+ (_settings.getStringList(SettingKeys.mapLayerHiddenIds) ?? const [])
+ .toSet();
+
+ final SettingsStore _settings;
+
+ Set _hidden;
+
+ /// The hidden layer ids. Unmodifiable view — mutate through [setHidden].
+ Set get hiddenIds => Set.unmodifiable(_hidden);
+
+ /// Whether the layer [id] is currently hidden.
+ bool isHidden(String id) => _hidden.contains(id);
+
+ /// Persists [hidden]'s new state for [id] and notifies watchers (the picker
+ /// and every open map surface rebuild). No-op — no write, no notification —
+ /// when the state already matches.
+ Future setHidden(String id, {required bool hidden}) async {
+ if (hidden == _hidden.contains(id)) return;
+ final next = Set.of(_hidden);
+ hidden ? next.add(id) : next.remove(id);
+ _hidden = next;
+ await _settings.setStringList(
+ SettingKeys.mapLayerHiddenIds,
+ _hidden.toList(),
+ );
+ notifyListeners();
+ }
+
+ /// Persists [hidden]'s new state for every id in [ids] as one write and one
+ /// notification — the order editor's per-category "show all" / "hide all"
+ /// buttons use this so toggling several layers at once doesn't re-persist
+ /// and rebuild once per layer. No-op when none of them change state.
+ Future setManyHidden(
+ Iterable ids, {
+ required bool hidden,
+ }) async {
+ final next = Set.of(_hidden);
+ var changed = false;
+ for (final id in ids) {
+ changed |= hidden ? next.add(id) : next.remove(id);
+ }
+ if (!changed) return;
+ _hidden = next;
+ await _settings.setStringList(
+ SettingKeys.mapLayerHiddenIds,
+ _hidden.toList(),
+ );
+ notifyListeners();
+ }
+}
diff --git a/lib/core/settings/onboarding_store.dart b/lib/core/settings/onboarding_store.dart
index 83ef220aa..00de86e3e 100644
--- a/lib/core/settings/onboarding_store.dart
+++ b/lib/core/settings/onboarding_store.dart
@@ -24,4 +24,11 @@ class OnboardingStore extends ChangeNotifier {
await _settings.setBool(SettingKeys.onboardingComplete, true);
notifyListeners();
}
+
+ /// Re-announces state after an external writer changed it underneath this
+ /// store — the background durable-database recovery adopting rows this
+ /// session never saw. Listeners (the services host, and anything gating on
+ /// [isComplete]) re-read; the router's redirect re-runs on the refresh that
+ /// follows, releasing a returning user held on the welcome page.
+ void reload() => notifyListeners();
}
diff --git a/lib/core/settings/setting_keys.dart b/lib/core/settings/setting_keys.dart
index 695965744..376c9f30d 100644
--- a/lib/core/settings/setting_keys.dart
+++ b/lib/core/settings/setting_keys.dart
@@ -93,6 +93,11 @@ abstract final class SettingKeys {
static const SettingKey> mapLayerCategoryOrder =
SettingKey>._('map.layerCategoryOrder');
+ /// Map layer ids the user hid from the picker (empty = every layer shown).
+ /// See `MapLayerVisibilityController`.
+ static const SettingKey> mapLayerHiddenIds =
+ SettingKey>._('map.layerHiddenIds');
+
/// Saved Home township codes (ordered list). See `RegionStore`.
static const SettingKey> savedRegionCodes =
SettingKey>._('home.savedRegionCodes');
diff --git a/lib/core/settings/settings_store.dart b/lib/core/settings/settings_store.dart
index 2c4337661..1c7cdf80d 100644
--- a/lib/core/settings/settings_store.dart
+++ b/lib/core/settings/settings_store.dart
@@ -15,6 +15,13 @@
/// memory immediately and reach the database in the background. The trade is
/// deliberate and stated here rather than hidden inside a plugin.
///
+/// A launch where the database would not open degrades to a **session-only**
+/// store: reads answer what memory holds (nothing) and writes stay in memory.
+/// Every such write is recorded in [_pendingWrites] and warned once, so a
+/// degraded session is visible in the log and reversible — [attachDatabase]
+/// replays those writes once a database is opened later in the same session
+/// (see `_recoverDurable` in `bootstrap.dart`).
+///
/// A write that fails is logged, not thrown: a setting that did not persist is
/// worth a log line, never a crash in a settings screen.
library;
@@ -23,7 +30,7 @@ import 'dart:convert';
import 'package:dpip/core/logging/log.dart';
import 'package:dpip/core/settings/setting_keys.dart';
-import 'package:sqflite/sqflite.dart';
+import 'package:sqlite_async/sqlite_async.dart';
/// The table this store owns. Named here so `app_database.dart` can document
/// the layout and the storage gate can check nothing else writes it.
@@ -36,20 +43,25 @@ final class SettingsStore {
/// The database, or null when it could not be opened — the app then runs
/// with settings that live only for this session rather than not at all.
- final Database? _db;
+ SqliteDatabase? _db;
/// The whole table, in memory.
final Map _values;
+ /// Writes made while [_db] was null — the degraded-session backlog that
+ /// [attachDatabase] replays. A removal is remembered as a removal (null
+ /// value), so replaying cannot resurrect a deleted key.
+ final Map _pendingWrites = {};
+
/// Creates the table. Safe to call on every open.
- static Future createSchema(Database db) => db.execute(
+ static Future createSchema(SqliteDatabase db) => db.execute(
'CREATE TABLE IF NOT EXISTS $settingsTable ('
'key TEXT PRIMARY KEY NOT NULL, '
'value TEXT NOT NULL)',
);
/// Loads every row into memory.
- static Future open(Database? db) async {
+ static Future open(SqliteDatabase? db) async {
if (db == null) return SettingsStore._(null, {});
Object? lastError;
@@ -57,7 +69,9 @@ final class SettingsStore {
for (var attempt = 1; attempt <= _loadAttempts; attempt++) {
try {
final values = {};
- for (final row in await db.query(settingsTable)) {
+ for (final row in await db.getAll(
+ 'SELECT key, value FROM $settingsTable',
+ )) {
final key = row['key'] as String?;
final value = row['value'] as String?;
if (key == null || value == null) continue;
@@ -123,9 +137,12 @@ final class SettingsStore {
Future remove(SettingKey key) async {
_values.remove(key.name);
final db = _db;
- if (db == null) return;
+ if (db == null) {
+ _pendingWrites[key.name] = null;
+ return;
+ }
try {
- await db.delete(settingsTable, where: 'key = ?', whereArgs: [key.name]);
+ await db.execute('DELETE FROM $settingsTable WHERE key = ?', [key.name]);
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'removing setting ${key.name}');
}
@@ -135,15 +152,114 @@ final class SettingsStore {
/// finished, and by the debug page.
Iterable get keys => _values.keys;
+ /// Whether this session is running without a database — reads still work,
+ /// but nothing written here survives the process.
+ bool get isDegraded => _db == null;
+
+ /// Binds a database to a store that launched without one, and reconciles
+ /// the two directions:
+ ///
+ /// 1. **Session → disk**: writes made while degraded are replayed, removals
+ /// included, so what the user did this session survives.
+ /// 2. **Disk → session**: rows the session never saw (the whole table, for
+ /// a launch whose open failed) are adopted into memory — *only* keys
+ /// memory has no opinion on, never overwriting what the session read or
+ /// wrote. This is what un-degrades the launch: `onboarding.complete`,
+ /// saved regions and the push token come back instead of the session
+ /// spending its whole life looking like a first run.
+ ///
+ /// Returns whether anything moved in either direction. Attaching to an
+ /// already-attached store is a no-op that answers false. A reconciliation
+ /// failure leaves the store degraded and throws, so the caller can close the
+ /// failed handle and retry without losing the backlog.
+ Future attachDatabase(SqliteDatabase db) async {
+ if (_db != null) return false;
+ var moved = false;
+ // Adopt disk first, without overwriting anything this session has already
+ // read or written. A write racing this await updates [_values] immediately,
+ // so the containsKey check still gives the session the final say.
+ for (final row in await db.getAll(
+ 'SELECT key, value FROM $settingsTable',
+ )) {
+ final name = row['key'] as String?;
+ final raw = row['value'] as String?;
+ if (name == null ||
+ raw == null ||
+ _values.containsKey(name) ||
+ _pendingWrites.containsKey(name)) {
+ continue;
+ }
+ try {
+ _values[name] = jsonDecode(raw);
+ moved = true;
+ } catch (_) {
+ // A row unreadable at attach time is no better than one unreadable at
+ // load time — skip it rather than poison the session.
+ }
+ }
+
+ // Drain until empty. Writes keep using [_pendingWrites] while [_db] is
+ // null; checking empty and publishing [_db] contain no await between them,
+ // so no write can land in an orphaned queue at the hand-off boundary.
+ while (true) {
+ final pending = Map.of(_pendingWrites);
+ if (pending.isEmpty) {
+ _db = db;
+ return moved;
+ }
+ _pendingWrites.clear();
+ try {
+ await db.writeTransaction((tx) async {
+ for (final MapEntry(key: name, :value) in pending.entries) {
+ if (value == null) {
+ await tx.execute('DELETE FROM $settingsTable WHERE key = ?', [
+ name,
+ ]);
+ } else {
+ await tx.execute(
+ 'INSERT OR REPLACE INTO $settingsTable (key, value) '
+ 'VALUES (?, ?)',
+ [name, jsonEncode(value)],
+ );
+ }
+ }
+ });
+ moved = true;
+ } catch (error, stackTrace) {
+ // A newer racing write for the same key wins; otherwise restore the
+ // failed batch intact for the next recovery attempt.
+ for (final entry in pending.entries) {
+ if (!_pendingWrites.containsKey(entry.key)) {
+ _pendingWrites[entry.key] = entry.value;
+ }
+ }
+ Log.handle(error, stackTrace, 'attaching durable settings database');
+ rethrow;
+ }
+ }
+ }
+
Future _put(SettingKey key, Object value) async {
_values[key.name] = value;
final db = _db;
- if (db == null) return;
+ if (db == null) {
+ // A degraded session must not look healthy: without this line a launch
+ // whose database never opened runs, accepts every setting, and drops
+ // all of them silently — the "configured install looks like first run"
+ // bug wearing a different hat.
+ if (_pendingWrites.isEmpty) {
+ Log.warning(
+ 'settings are session-only: the durable database is not open',
+ );
+ }
+ _pendingWrites[key.name] = value;
+ return;
+ }
try {
- await db.insert(settingsTable, {
- 'key': key.name,
- 'value': jsonEncode(value),
- }, conflictAlgorithm: ConflictAlgorithm.replace);
+ await db.execute(
+ 'INSERT OR REPLACE INTO $settingsTable (key, value) VALUES (?, ?)',
+ [key.name, jsonEncode(value)],
+ );
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'writing setting ${key.name}');
}
diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart
index ea1c2b30c..3487108d9 100644
--- a/lib/core/storage/app_database.dart
+++ b/lib/core/storage/app_database.dart
@@ -33,7 +33,7 @@
library;
import 'package:dpip/core/logging/log.dart';
-import 'package:sqflite/sqflite.dart';
+import 'package:sqlite_async/sqlite_async.dart';
/// Schema version of the durable database.
const int appDatabaseVersion = 1;
@@ -48,26 +48,37 @@ class AppDatabase {
const AppDatabase({required this.durable, required this.cache});
/// Settings, orbital elements and mesh history. Survives a cache purge.
- final Database? durable;
+ final SqliteDatabase? durable;
/// Re-fetchable bytes only.
- final Database? cache;
+ final SqliteDatabase? cache;
/// Empties every cache table, and nothing else.
///
/// It takes the cache handle and no other, so there is no path from here to
/// the settings or the mesh log even by accident. Returns the number of rows
/// dropped, which is what a settings screen wants to show.
+ ///
+ /// One transaction: the per-table counts come from SQLite's `changes()`
+ /// read on the same write connection as the delete — outside one,
+ /// sqlite_async's pooled readers would answer from a different connection
+ /// where nothing had changed.
Future clearCache() async {
final database = cache;
if (database == null) return 0;
var removed = 0;
- for (final table in cacheTables) {
- try {
- removed += await database.delete(table);
- } catch (error, stackTrace) {
- Log.handle(error, stackTrace, 'clearing $table');
- }
+ try {
+ removed = await database.writeTransaction((tx) async {
+ var dropped = 0;
+ for (final table in cacheTables) {
+ await tx.execute('DELETE FROM $table');
+ final row = await tx.get('SELECT changes() AS n');
+ dropped += ((row['n'] as num?) ?? 0).toInt();
+ }
+ return dropped;
+ });
+ } catch (error, stackTrace) {
+ Log.handle(error, stackTrace, 'clearing cache tables');
}
// Reclaim the file space rather than leaving it as free pages: the point
// of clearing a 350 MB cache is to get the storage back.
@@ -83,11 +94,11 @@ class AppDatabase {
Future cacheBytes() async {
final database = cache;
if (database == null) return 0;
- final rows = await database.rawQuery(
+ final row = await database.get(
'SELECT page_count * page_size AS bytes '
'FROM pragma_page_count(), pragma_page_size()',
);
- return (rows.firstOrNull?['bytes'] as int?) ?? 0;
+ return ((row['bytes'] as num?) ?? 0).toInt();
}
/// Row count and size of every table in both files, biggest first.
@@ -100,10 +111,13 @@ class AppDatabase {
...await _statsFor(cache, 'http_etag_cache.db'),
]..sort((a, b) => b.bytes.compareTo(a.bytes));
- static Future> _statsFor(Database? db, String file) async {
+ static Future> _statsFor(
+ SqliteDatabase? db,
+ String file,
+ ) async {
if (db == null) return const [];
try {
- final names = await db.rawQuery(
+ final names = await db.getAll(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name NOT LIKE 'sqlite_%' ORDER BY name",
);
@@ -130,13 +144,13 @@ class AppDatabase {
}
/// On-disk bytes per table from `dbstat`, or null where it is unavailable.
- static Future?> _pageSizes(Database db) async {
+ static Future?> _pageSizes(SqliteDatabase db) async {
try {
// Joined to `sqlite_master` so an index's pages land on the table it
// belongs to. Grouping by `dbstat.name` alone lists indexes as if they
// were tables and leaves every table looking smaller than it is — on a
// message log with two indexes, most of the cost would be invisible.
- final rows = await db.rawQuery(
+ final rows = await db.getAll(
'SELECT COALESCE(m.tbl_name, d.name) AS tbl, SUM(d.pgsize) AS bytes '
'FROM dbstat d LEFT JOIN sqlite_master m ON m.name = d.name '
'GROUP BY tbl',
@@ -150,9 +164,9 @@ class AppDatabase {
}
}
- static Future _countRows(Database db, String table) async {
- final rows = await db.rawQuery('SELECT COUNT(*) AS n FROM "$table"');
- return (rows.firstOrNull?['n'] as num?)?.toInt() ?? 0;
+ static Future _countRows(SqliteDatabase db, String table) async {
+ final row = await db.get('SELECT COUNT(*) AS n FROM "$table"');
+ return ((row['n'] as num?) ?? 0).toInt();
}
/// Stored payload of a table: the length of every value in every row.
@@ -160,18 +174,18 @@ class AppDatabase {
/// The fallback when `dbstat` is missing. It undercounts — page overhead,
/// free space and indexes are invisible to it — which is why [TableStat]
/// carries [TableStat.onDisk] rather than letting the two be confused.
- static Future _payloadBytes(Database db, String table) async {
- final columns = await db.rawQuery('PRAGMA table_info("$table")');
+ static Future _payloadBytes(SqliteDatabase db, String table) async {
+ final columns = await db.getAll('PRAGMA table_info("$table")');
final names = [
for (final column in columns)
if (column['name'] case final String name) name,
];
if (names.isEmpty) return 0;
final sum = names.map((name) => 'COALESCE(LENGTH("$name"), 0)').join(' + ');
- final rows = await db.rawQuery(
+ final row = await db.get(
'SELECT COALESCE(SUM($sum), 0) AS bytes FROM "$table"',
);
- return (rows.firstOrNull?['bytes'] as num?)?.toInt() ?? 0;
+ return ((row['bytes'] as num?) ?? 0).toInt();
}
}
diff --git a/lib/features/changelog/domain/update_destination.dart b/lib/features/changelog/domain/update_destination.dart
index 80d7fdbba..d0217bdd0 100644
--- a/lib/features/changelog/domain/update_destination.dart
+++ b/lib/features/changelog/domain/update_destination.dart
@@ -54,7 +54,8 @@ UpdateDestination updateDestinationFor(
scheme: 'market://details?id=$_androidPackage',
web: 'https://play.google.com/store/apps/details?id=$_androidPackage',
);
- case InstallSource.sideload:
+ case InstallSource.development:
+ case InstallSource.github:
case InstallSource.unknown:
final url = releaseUrl.isEmpty
? 'https://github.com/ExpTechTW/DPIP/releases'
diff --git a/lib/features/changelog/presentation/widgets/update_prompt.dart b/lib/features/changelog/presentation/widgets/update_prompt.dart
index 275708466..8e027be12 100644
--- a/lib/features/changelog/presentation/widgets/update_prompt.dart
+++ b/lib/features/changelog/presentation/widgets/update_prompt.dart
@@ -137,7 +137,9 @@ class _UpdatePromptState extends State {
InstallSource.appStore => l10n.updateOpenAppStore,
InstallSource.testFlight => l10n.updateOpenTestFlight,
InstallSource.playStore => l10n.updateOpenPlayStore,
- InstallSource.sideload || InstallSource.unknown => l10n.updateDownload,
+ InstallSource.development ||
+ InstallSource.github ||
+ InstallSource.unknown => l10n.updateDownload,
};
Future _openStore(InstallSource source, String releaseUrl) async {
diff --git a/lib/features/earthquake/data/earthquake_api.dart b/lib/features/earthquake/data/earthquake_api.dart
index 917305c0b..3c3240924 100644
--- a/lib/features/earthquake/data/earthquake_api.dart
+++ b/lib/features/earthquake/data/earthquake_api.dart
@@ -58,6 +58,11 @@ class EarthquakeApi {
// doesn't auto-decode it — the caller gets a raw JSON string instead of
// a Map. Decode it here so this method's return shape matches the rest
// of [EarthquakeApi] regardless of the host's content-type quirk.
+ //
+ // Inline on purpose, not an isolate: the snapshot is ~5 KB (~111
+ // stations, re-measured 2026-08-24 against api-1), so the decode is tens
+ // of microseconds even at replay's 1 Hz — an isolate spawn would cost
+ // more than it saves.
return data is String ? jsonDecode(data) : data;
}
diff --git a/lib/features/earthquake/data/rts_box_grid_source.dart b/lib/features/earthquake/data/rts_box_grid_source.dart
index 24a70d770..e758e90df 100644
--- a/lib/features/earthquake/data/rts_box_grid_source.dart
+++ b/lib/features/earthquake/data/rts_box_grid_source.dart
@@ -10,6 +10,12 @@ import 'package:flutter/services.dart' show rootBundle;
/// `Polygon` features, each carrying an integer `ID` property matched against
/// `Rts.box`'s keys. Kept out of the pure domain (which only consumes the
/// parsed grid) so the domain stays Flutter-free.
+///
+/// Deliberately **not** decoded in an isolate, unlike its sibling travel-time
+/// table: this asset is 773 bytes compressed / 7 KB inflated (43 polygons,
+/// 215 points), so the whole decode lands well under a millisecond, while an
+/// isolate spawn costs more than that before any work runs. Measured, not
+/// assumed — re-audit if the asset ever grows.
class RtsBoxGridSource {
const RtsBoxGridSource();
diff --git a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart
index 6631ecb21..a8eb9f71b 100644
--- a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart
+++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart
@@ -425,6 +425,14 @@ class _RainOnCardState extends State
// skipped the whole simulation and left the edge dry.
if (dt <= 0 || _size.width <= 0) return;
+ // A ticker goes on firing between `deactivate()` and `dispose()`, and that
+ // window is exactly where this card sits when the list scrolls it away or a
+ // tab teardown removes the page. [_syncPositionGate] reads
+ // `context.findRenderObject()`, which throws on an inactive element, and
+ // then calls `setState`, which throws on an unmounted one. Neither is worth
+ // a frame of physics nobody can see.
+ if (!mounted) return;
+
_syncPositionGate();
// Gate closed means the card is leaving the top of the sheet — cut the
diff --git a/lib/features/location/presentation/pages/region_city_page.dart b/lib/features/location/presentation/pages/region_city_page.dart
index 2cd2af983..b4bda67e8 100644
--- a/lib/features/location/presentation/pages/region_city_page.dart
+++ b/lib/features/location/presentation/pages/region_city_page.dart
@@ -2,22 +2,25 @@
/// toggleable as a saved Home region (up to [RegionStore.maxSaved]).
library;
+import 'package:dpip/app/theme/app_spacing.dart';
import 'package:dpip/core/geo/town.dart';
import 'package:dpip/core/geo/town_directory.dart';
import 'package:dpip/core/settings/region_store.dart';
import 'package:dpip/l10n/gen/app_localizations.dart';
import 'package:dpip/shared/navigation/app_routes.dart';
+import 'package:dpip/shared/widgets/empty_view.dart';
import 'package:dpip/shared/widgets/section_header.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
-/// Lists the townships of [city]. Tapping a row toggles it as a saved region:
-/// a saved row shows a filled star and removes on tap; an unsaved row adds
-/// (when under the cap) or, once full, leaves the selection unchanged and
-/// explains the limit. The selection is stored by **code** in the [RegionStore];
-/// names and coordinates shown here are derived from the directory.
-class RegionCityPage extends StatelessWidget {
+/// Lists the townships of [city], filterable from a search field on top.
+/// Tapping a row toggles it as a saved region: a saved row shows a filled star
+/// and removes on tap; an unsaved row adds (when under the cap) or, once full,
+/// leaves the selection unchanged and explains the limit. The selection is
+/// stored by **code** in the [RegionStore]; names and coordinates shown here
+/// are derived from the directory.
+class RegionCityPage extends StatefulWidget {
const RegionCityPage({
super.key,
required this.city,
@@ -34,39 +37,94 @@ class RegionCityPage extends StatelessWidget {
/// 是否成功選擇後返回頁面
final bool? returnToMore;
+ @override
+ State createState() => _RegionCityPageState();
+}
+
+class _RegionCityPageState extends State {
+ final _searchController = TextEditingController();
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final directory = context.read();
final store = context.watch();
- final towns = directory.townsInCity(city);
+ final towns = directory.townsInCity(widget.city);
final query = GoRouterState.of(context).uri.queryParameters;
- final effectiveReplaceCode = replaceCode ?? query['replace'];
+ final effectiveReplaceCode = widget.replaceCode ?? query['replace'];
+
+ final needle = _searchController.text.trim().toLowerCase();
+ final shown = [
+ for (final town in towns)
+ if (needle.isEmpty || town.townName.toLowerCase().contains(needle))
+ town,
+ ];
return Scaffold(
- appBar: AppBar(title: Text(city)),
+ appBar: AppBar(title: Text(widget.city)),
body: ListView(
children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(
+ AppSpacing.md,
+ AppSpacing.sm,
+ AppSpacing.md,
+ AppSpacing.sm,
+ ),
+ child: TextField(
+ controller: _searchController,
+ textInputAction: TextInputAction.search,
+ decoration: InputDecoration(
+ hintText: l10n.regionSearchTownHint,
+ prefixIcon: const Icon(Icons.search),
+ suffixIcon: needle.isEmpty
+ ? null
+ : IconButton(
+ icon: const Icon(Icons.clear),
+ tooltip: l10n.commonClose,
+ onPressed: () {
+ _searchController.clear();
+ setState(() {});
+ },
+ ),
+ isDense: true,
+ border: const OutlineInputBorder(),
+ ),
+ onChanged: (_) => setState(() {}),
+ ),
+ ),
SectionHeader(
l10n.regionSelectCount(
store.savedCodes.length,
RegionStore.maxSaved,
),
),
- for (final town in towns)
- _TownTile(
- town: town,
- saved: store.savedCodes.contains(town.code),
- enabled:
- effectiveReplaceCode == null ||
- town.code == effectiveReplaceCode ||
- !store.savedCodes.contains(town.code),
- canAdd:
- effectiveReplaceCode != null ||
- store.canSave(town.code) ||
- store.savedCodes.contains(town.code),
- onToggle: () => _toggle(context, store, town),
- ),
+ if (shown.isEmpty)
+ EmptyView(
+ icon: Icons.search_off,
+ message: l10n.regionSearchTownEmpty,
+ )
+ else
+ for (final town in shown)
+ _TownTile(
+ town: town,
+ saved: store.savedCodes.contains(town.code),
+ enabled:
+ effectiveReplaceCode == null ||
+ town.code == effectiveReplaceCode ||
+ !store.savedCodes.contains(town.code),
+ canAdd:
+ effectiveReplaceCode != null ||
+ store.canSave(town.code) ||
+ store.savedCodes.contains(town.code),
+ onToggle: () => _toggle(context, store, town),
+ ),
],
),
);
@@ -74,9 +132,9 @@ class RegionCityPage extends StatelessWidget {
void _toggle(BuildContext context, RegionStore store, Town town) {
final query = GoRouterState.of(context).uri.queryParameters;
- final replace = replaceCode ?? query['replace'];
+ final replace = widget.replaceCode ?? query['replace'];
final shouldReturnToMore =
- returnToMore == true || query['returnToMore'] == '1';
+ widget.returnToMore == true || query['returnToMore'] == '1';
var changed = false;
if (replace != null) {
if (store.savedCodes.contains(town.code) && town.code != replace) return;
diff --git a/lib/features/location/presentation/pages/region_select_page.dart b/lib/features/location/presentation/pages/region_select_page.dart
index 223aca885..aa5991a0b 100644
--- a/lib/features/location/presentation/pages/region_select_page.dart
+++ b/lib/features/location/presentation/pages/region_select_page.dart
@@ -1,20 +1,25 @@
-/// The first level of the region picker: a list of cities to drill into.
+/// The first level of the region picker: a search field over the counties and
+/// cities, then the full city list to drill into.
library;
+import 'package:dpip/app/theme/app_spacing.dart';
import 'package:dpip/core/geo/town_directory.dart';
import 'package:dpip/core/settings/region_store.dart';
import 'package:dpip/l10n/gen/app_localizations.dart';
import 'package:dpip/shared/navigation/app_routes.dart';
+import 'package:dpip/shared/widgets/empty_view.dart';
import 'package:dpip/shared/widgets/section_header.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
-/// Lists every city (`縣市`); tapping one opens its township list where regions
-/// are toggled on/off. A city that already holds a saved township is marked with
-/// a star, and the header shows how many of the [RegionStore.maxSaved] slots are
-/// used — so the whole selection is legible from the top level.
-class RegionSelectPage extends StatelessWidget {
+/// The city level of the region picker, with a filter on top.
+///
+/// The query narrows the city list in place — it never leaves this page: a
+/// matching city keeps its star marker and drill-down, a non-matching one
+/// disappears, and no match at all shows an empty view. Townships are reached
+/// by drilling in, as before.
+class RegionSelectPage extends StatefulWidget {
const RegionSelectPage({super.key, this.replaceCode, this.returnToMore});
/// 選擇一個區域會替換掉之前的
@@ -23,6 +28,19 @@ class RegionSelectPage extends StatelessWidget {
/// 是否成功選擇後返回頁面
final bool? returnToMore;
+ @override
+ State createState() => _RegionSelectPageState();
+}
+
+class _RegionSelectPageState extends State {
+ final _searchController = TextEditingController();
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@@ -30,49 +48,84 @@ class RegionSelectPage extends StatelessWidget {
final directory = context.read();
final store = context.watch();
final query = GoRouterState.of(context).uri.queryParameters;
- final effectiveReplaceCode = replaceCode ?? query['replace'];
+ final effectiveReplaceCode = widget.replaceCode ?? query['replace'];
final effectiveReturnToMore =
- returnToMore == true || query['returnToMore'] == '1';
+ widget.returnToMore == true || query['returnToMore'] == '1';
- final cities = directory.cities;
- // Cities that contain at least one saved township, for the star marker.
final savedCities = {
for (final code in store.savedCodes) directory.byCode(code)?.cityName,
};
+ final needle = _searchController.text.trim().toLowerCase();
+ final cities = [
+ for (final city in directory.cities)
+ if (needle.isEmpty || city.toLowerCase().contains(needle)) city,
+ ];
return Scaffold(
appBar: AppBar(title: Text(l10n.regionSelectTitle)),
body: ListView(
children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(
+ AppSpacing.md,
+ AppSpacing.sm,
+ AppSpacing.md,
+ AppSpacing.sm,
+ ),
+ child: TextField(
+ controller: _searchController,
+ textInputAction: TextInputAction.search,
+ decoration: InputDecoration(
+ hintText: l10n.regionSearchHint,
+ prefixIcon: const Icon(Icons.search),
+ suffixIcon: needle.isEmpty
+ ? null
+ : IconButton(
+ icon: const Icon(Icons.clear),
+ tooltip: l10n.commonClose,
+ onPressed: () {
+ _searchController.clear();
+ setState(() {});
+ },
+ ),
+ isDense: true,
+ border: const OutlineInputBorder(),
+ ),
+ onChanged: (_) => setState(() {}),
+ ),
+ ),
SectionHeader(
l10n.regionSelectCount(
store.savedCodes.length,
RegionStore.maxSaved,
),
),
- for (final city in cities)
- ListTile(
- leading: const Icon(Icons.location_city_outlined),
- title: Text(city),
- trailing: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- if (savedCities.contains(city))
- Icon(Icons.star, size: 18, color: colors.primary),
- const Icon(Icons.chevron_right),
- ],
- ),
- onTap: () => context.pushNamed(
- AppRoutes.regionSelectCity,
- pathParameters: {'city': city},
- queryParameters: {
- ...?(effectiveReplaceCode == null
- ? null
- : {'replace': effectiveReplaceCode}),
- if (effectiveReturnToMore) 'returnToMore': '1',
- },
+ if (cities.isEmpty)
+ EmptyView(icon: Icons.search_off, message: l10n.regionSearchEmpty)
+ else
+ for (final city in cities)
+ ListTile(
+ leading: const Icon(Icons.location_city_outlined),
+ title: Text(city),
+ trailing: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (savedCities.contains(city))
+ Icon(Icons.star, size: 18, color: colors.primary),
+ const Icon(Icons.chevron_right),
+ ],
+ ),
+ onTap: () => context.pushNamed(
+ AppRoutes.regionSelectCity,
+ pathParameters: {'city': city},
+ queryParameters: {
+ ...?(effectiveReplaceCode == null
+ ? null
+ : {'replace': effectiveReplaceCode}),
+ if (effectiveReturnToMore) 'returnToMore': '1',
+ },
+ ),
),
- ),
],
),
);
diff --git a/lib/features/map/presentation/layers/qpesums_layer.dart b/lib/features/map/presentation/layers/qpesums_layer.dart
index 05fd7dd55..631dbc34b 100644
--- a/lib/features/map/presentation/layers/qpesums_layer.dart
+++ b/lib/features/map/presentation/layers/qpesums_layer.dart
@@ -1,5 +1,6 @@
import 'package:dpip/core/a11y/color_vision.dart';
import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart';
+import 'package:dpip/features/map/presentation/layers/qpesums_scan_range.dart';
import 'package:dpip/features/map/presentation/layers/scan_range_overlay_chrome.dart';
import 'package:dpip/features/map/presentation/widgets/scan_range_overlay_menu.dart';
import 'package:dpip/features/weather/domain/qpesums_repository.dart';
@@ -17,10 +18,11 @@ import 'package:flutter/material.dart';
/// only the layer's identity, its opacity, and the QPESUMS hourly-rate colour
/// key. Frame ids are Unix milliseconds, which [parseFrameTime] already reads.
///
-/// The forecast covers the same grid the radar composite observes, so it shares
-/// the radar's scan-range geometry — and, like radar, it redraws its own
-/// scan-range outline plus county/town borders **over** the raster
-/// ([ScanRangeOverlayChrome]), switchable from its options chip.
+/// Like radar, it redraws its own coverage outline plus county/town borders
+/// **over** the raster ([ScanRangeOverlayChrome]), switchable from its options
+/// chip — but not with radar's geometry: the forecast is published over a plain
+/// rectangle ([QpesumsScanRange]), while the composite the radars observe is a
+/// union of range circles.
class QpesumsMapLayer extends RasterTimelineLayer
with AdminOutlineChrome, ScanRangeOverlayChrome {
QpesumsMapLayer(QpesumsRepository super.repository);
@@ -28,10 +30,14 @@ class QpesumsMapLayer extends RasterTimelineLayer
/// Distinct from radar's ids: both layers can be on the map at once, and each
/// draws its own outline instead of clashing over one source/layer pair.
@override
- String get scanRangeSourceId => 'qpesums-scan-range';
+ String get scanRangeSourceId => QpesumsScanRange.sourceId;
@override
- String get scanRangeLayerId => 'qpesums-scan-range-outline';
+ String get scanRangeLayerId => QpesumsScanRange.outlineLayerId;
+
+ /// The forecast grid's own rectangle, not the radar composite's circles.
+ @override
+ Map get scanRangeGeoJson => QpesumsScanRange.geoJson();
@override
String get scanRangeColor => '#78909C'.vision;
diff --git a/lib/features/map/presentation/layers/qpesums_scan_range.dart b/lib/features/map/presentation/layers/qpesums_scan_range.dart
new file mode 100644
index 000000000..65fa61f19
--- /dev/null
+++ b/lib/features/map/presentation/layers/qpesums_scan_range.dart
@@ -0,0 +1,68 @@
+import 'package:dpip/features/map/presentation/layers/radar_scan_range.dart';
+
+/// The area the QPESUMS next-1-hour precipitation forecast covers.
+///
+/// A plain rectangle, and **not** [RadarScanRange]'s geometry. The composite
+/// the radars observe is a union of four range circles clipped to a wider grid;
+/// the forecast is computed on its own grid and published over all of it, so
+/// outlining it with the radar circles claimed coverage on the corners the
+/// forecast does have and denied it along the edges of the circles.
+///
+/// 441 × 561 cells at the same 0.0125° step the composite uses. 118.0°E,
+/// 20.0°N is the south-west edge of the first cell; the opposite **outer**
+/// edges are `118.0 + 441 × 0.0125 = 123.5125` and
+/// `20.0 + 561 × 0.0125 = 27.0125`. The bounds are written out rather than
+/// derived so the numbers in the file are the numbers on the wire.
+abstract final class QpesumsScanRange {
+ QpesumsScanRange._();
+
+ /// Grid step, in degrees — the same resolution as the radar composite.
+ static const double gridResolution = 0.0125;
+
+ static const double west = 118.0;
+ static const double east = 123.5125;
+ static const double south = 20.0;
+ static const double north = 27.0125;
+
+ /// Source and layer ids, kept distinct from the radar raster's own so a map
+ /// showing both draws two outlines instead of clashing over one.
+ static const String sourceId = 'qpesums-scan-range';
+ static const String outlineLayerId = 'qpesums-scan-range-outline';
+
+ /// The rectangle as a closed, counter-clockwise `[lon, lat]` ring — right
+ /// edge up, top edge across, left edge down, and back.
+ ///
+ /// Four corners is exact here: this is Web Mercator, where a constant
+ /// latitude projects to a horizontal straight line and a constant longitude
+ /// to a vertical one, so densifying the edges would add vertices that all
+ /// land on the segment already being drawn.
+ static const List> ring = [
+ [east, south],
+ [east, north],
+ [west, north],
+ [west, south],
+ [east, south],
+ ];
+
+ /// The coverage outline as a GeoJSON polygon.
+ ///
+ /// A **map**, never an encoded string: `addSource` hands this straight to
+ /// `NSJSONSerialization.dataWithJSONObject` on iOS, which throws — crashing
+ /// the app, not returning an error — on a top-level string.
+ static Map geoJson() => {
+ 'type': 'FeatureCollection',
+ 'features': [
+ {
+ 'type': 'Feature',
+ 'properties': {
+ 'name': 'effective_extent',
+ 'note': 'QPESUMS forecast grid, 441×561 cells at 0.0125°',
+ },
+ 'geometry': {
+ 'type': 'Polygon',
+ 'coordinates': [ring],
+ },
+ },
+ ],
+ };
+}
diff --git a/lib/features/map/presentation/layers/radar_scan_range.dart b/lib/features/map/presentation/layers/radar_scan_range.dart
index 62639145f..3fd8fb950 100644
--- a/lib/features/map/presentation/layers/radar_scan_range.dart
+++ b/lib/features/map/presentation/layers/radar_scan_range.dart
@@ -185,19 +185,21 @@ abstract final class RadarScanRange {
/// Outline only — no fill. The covered area is where the echo itself is, and
/// a wash over it would tint every dBZ colour on the map.
///
- /// [sourceId]/[layerId] let a second raster (QPESUMS, whose coverage is the
- /// same composite) draw its own outline under distinct ids — the defaults are
- /// the radar ids, so existing callers pass nothing.
+ /// [sourceId]/[layerId] let a second raster draw its own outline under
+ /// distinct ids, and [data] lets it outline its own shape — the QPESUMS
+ /// forecast publishes a plain rectangle, not this union of range circles. All
+ /// three default to the radar's, so existing callers pass nothing.
static Future add(
MapLibreMapController controller, {
required String outlineColor,
String? belowLayerId,
String sourceId = RadarScanRange.sourceId,
String layerId = RadarScanRange.outlineLayerId,
+ Map? data,
}) async {
await controller.addSource(
sourceId,
- GeojsonSourceProperties(data: geoJson()),
+ GeojsonSourceProperties(data: data ?? geoJson()),
);
await controller.addLineLayer(
sourceId,
diff --git a/lib/features/map/presentation/layers/rain_color_scale.dart b/lib/features/map/presentation/layers/rain_color_scale.dart
new file mode 100644
index 000000000..c621cacdc
--- /dev/null
+++ b/lib/features/map/presentation/layers/rain_color_scale.dart
@@ -0,0 +1,84 @@
+/// Rainfall accumulation colour scales (CWA banded ramp).
+library;
+
+import 'package:dpip/features/weather/domain/rain_interval.dart';
+
+/// Which set of thresholds the rainfall ramp is read against.
+///
+/// The colours are identical in both; only the mm boundaries move. One hour of
+/// rain and three days of rain differ by two orders of magnitude, so a single
+/// table either flattens every short window to grey or saturates every long one
+/// to pink. Two tables keep the same 17 bands legible at both ends.
+enum RainColorScale {
+ /// 1–300 mm. Short windows: a typhoon hour tops out near 100 mm.
+ fine,
+
+ /// 10–1500 mm. Multi-day totals: Morakot's 2009 maximum was ~2900 mm/3 d.
+ coarse;
+
+ /// The scale that suits [interval] when the user has not chosen one.
+ ///
+ /// The split is at 6 h: 3 h of rain reaching 300 mm is already a records-level
+ /// event, while 6 h routinely passes it in a typhoon.
+ static RainColorScale defaultFor(RainInterval interval) => switch (interval) {
+ RainInterval.now ||
+ RainInterval.min10 ||
+ RainInterval.hour1 ||
+ RainInterval.hour3 => RainColorScale.fine,
+ RainInterval.hour6 ||
+ RainInterval.hour12 ||
+ RainInterval.hour24 ||
+ RainInterval.day2 ||
+ RainInterval.day3 => RainColorScale.coarse,
+ };
+
+ /// Ascending `(mm, hex)` band floors, lowest first.
+ ///
+ /// Read as **steps, not a gradient**: a value takes the colour of the last
+ /// floor it is at or above, so 99 mm is the same red as 90 mm. That is what
+ /// the CWA scale means — a band is a category, and interpolating across it
+ /// invents readings the observation never made.
+ ///
+ /// The first entry is the below-threshold band (dry / trace), which is why
+ /// there are 17 entries for 16 printed boundaries.
+ List<(double, String)> get stops => switch (this) {
+ RainColorScale.fine => const [
+ (0, '#c2c2c2'),
+ (1, '#a0fffa'),
+ (2, '#00cdff'),
+ (6, '#0096ff'),
+ (10, '#0069ff'),
+ (15, '#329600'),
+ (20, '#32ff00'),
+ (30, '#ffff00'),
+ (40, '#ffc800'),
+ (50, '#ff9600'),
+ (70, '#ff0000'),
+ (90, '#c80000'),
+ (110, '#a00000'),
+ (130, '#96009b'),
+ (150, '#c800d2'),
+ (200, '#ff00f0'),
+ (300, '#ffc8ff'),
+ ],
+ RainColorScale.coarse => const [
+ (0, '#c2c2c2'),
+ (10, '#a0fffa'),
+ (20, '#00cdff'),
+ (60, '#0096ff'),
+ (100, '#0069ff'),
+ (150, '#329600'),
+ (200, '#32ff00'),
+ (300, '#ffff00'),
+ (400, '#ffc800'),
+ (500, '#ff9600'),
+ (600, '#ff0000'),
+ (700, '#c80000'),
+ (800, '#a00000'),
+ (900, '#96009b'),
+ (1000, '#c800d2'),
+ (1200, '#ff00f0'),
+ (1500, '#ffc8ff'),
+ ],
+ };
+}
diff --git a/lib/features/map/presentation/layers/rain_layer.dart b/lib/features/map/presentation/layers/rain_layer.dart
index 2b6321cd3..8db87a124 100644
--- a/lib/features/map/presentation/layers/rain_layer.dart
+++ b/lib/features/map/presentation/layers/rain_layer.dart
@@ -4,6 +4,7 @@ library;
import 'package:dpip/core/a11y/color_vision.dart';
import 'package:dpip/features/map/presentation/layers/weather_station_layer.dart';
+import 'package:dpip/features/map/presentation/layers/rain_color_scale.dart';
import 'package:dpip/features/weather/domain/rain_interval.dart';
import 'package:dpip/features/weather/domain/rain_snapshot.dart';
import 'package:dpip/features/weather/domain/rain_trend.dart';
@@ -29,14 +30,38 @@ extension RainIntervalL10n on RainInterval {
};
}
+/// Localised labels for [RainColorScale].
+extension RainColorScaleL10n on RainColorScale {
+ String label(AppLocalizations l10n) => switch (this) {
+ RainColorScale.fine => l10n.rainScaleFine,
+ RainColorScale.coarse => l10n.rainScaleCoarse,
+ };
+}
+
/// Shares [WeatherStationLayer]'s dots/sheet/trend machinery; only the value
/// source (the accumulation window) and its chrome differ.
class RainMapLayer
extends WeatherStationLayer {
RainMapLayer(super.repository);
- /// Selected accumulation window — default matches legacy (`now` = 今日).
- final ValueNotifier interval = ValueNotifier(RainInterval.now);
+ /// Selected accumulation window.
+ ///
+ /// One hour, not `now`: the day-so-far total answers "has it rained", which
+ /// the forecast already says, while the last hour answers "is it raining
+ /// hard right now" — the question a rainfall map is opened for.
+ final ValueNotifier interval = ValueNotifier(
+ RainInterval.hour1,
+ );
+
+ /// Threshold table the ramp is read against.
+ ///
+ /// Changing the window re-suggests the scale that suits it, and an explicit
+ /// choice holds only until the next window change. Sticking to a manual
+ /// choice forever would silently flatten a 3-day total to one grey blob for
+ /// anyone who once picked the fine scale to inspect an hour.
+ final ValueNotifier colorScale = ValueNotifier(
+ RainColorScale.defaultFor(RainInterval.hour1),
+ );
@override
String get id => 'rain';
@@ -60,21 +85,16 @@ class RainMapLayer
@override
bool get chartBars => true;
- /// Legacy precipitation colour ramp (mm).
+ /// CWA banded precipitation scale (mm) at the selected [colorScale].
@override
List<(double, String)> get colorStops => [
- (0, '#c2c2c2'.vision),
- (10, '#9cfcff'.vision),
- (30, '#059bff'.vision),
- (50, '#39ff03'.vision),
- (100, '#fffb03'.vision),
- (200, '#ff9500'.vision),
- (300, '#ff0000'.vision),
- (500, '#fb00ff'.vision),
- (1000, '#960099'.vision),
- (2000, '#000000'.vision),
+ for (final (at, hex) in colorScale.value.stops) (at, hex.vision),
];
+ /// The published scale is a table of categories, not a gradient.
+ @override
+ bool get bandedColors => true;
+
@override
double? valueOf(RainObservation observation) =>
interval.value.valueOf(observation);
@@ -106,7 +126,7 @@ class RainMapLayer
) => value > 0 || zoom > 8;
@override
- Listenable get chromeListenable => interval;
+ Listenable get chromeListenable => Listenable.merge([interval, colorScale]);
@override
Widget? legendHeader(BuildContext context) => Text(
@@ -121,16 +141,36 @@ class RainMapLayer
}
/// Switches the accumulation window and refreshes dots + labels in place.
+ ///
+ /// The scale follows: a window change is the moment an explicit scale choice
+ /// stops being informed, because it was made about a different range.
Future setInterval(RainInterval next) async {
if (interval.value == next) return;
interval.value = next;
+ colorScale.value = RainColorScale.defaultFor(next);
+ await _repaint();
+ }
+
+ /// Switches the threshold table, keeping the window.
+ Future setColorScale(RainColorScale next) async {
+ if (colorScale.value == next) return;
+ colorScale.value = next;
+ await _repaint();
+ }
+
+ /// Re-pushes the source and the value ramp after a window/scale change.
+ ///
+ /// The dots carry their value in the GeoJSON but take their colour from the
+ /// layer's paint expression, so a scale change has to re-assert the ramp too
+ /// — the feature data alone is unchanged and would repaint identically.
+ Future _repaint() async {
final map = controller;
- if (map != null) {
- try {
- await map.setGeoJsonSource(sourceId, geoJson);
- } catch (_) {
- // Source gone (layer torn down) — next [render] rebuilds it.
- }
+ if (map == null) return;
+ try {
+ await map.setGeoJsonSource(sourceId, geoJson);
+ await applyColorRamp(map);
+ } catch (_) {
+ // Source gone (layer torn down) — next [render] rebuilds it.
}
}
@@ -146,18 +186,28 @@ class RainMapLayer
final l10n = AppLocalizations.of(context);
final colors = Theme.of(context).colorScheme;
return ListenableBuilder(
- listenable: Listenable.merge([interval, showTownLabels, showTerrain]),
+ listenable: Listenable.merge([
+ interval,
+ colorScale,
+ showTownLabels,
+ showTerrain,
+ ]),
builder: (context, _) {
final current = interval.value;
+ final scale = colorScale.value;
return MenuAnchor(
alignmentOffset: const Offset(0, 4),
style: MapChipButton.menuStyle(context),
builder: (context, controller, _) => MapChipButton(
icon: Icons.timelapse_outlined,
+ // The window changes what every dot on the map means, so it is read
+ // far more often than it is set — a bare icon made the answer cost
+ // a menu open. Same chip affordance and height as every other
+ // layer's menu, so the compass (parked under the chip band) lines
+ // up across layers.
+ label: current.label(l10n),
tooltip: l10n.rainIntervalMenu,
- // Same chip affordance and height as every other layer's menu, so
- // the compass (parked under the chip band) lines up across layers.
- active: current != RainInterval.now,
+ active: current != RainInterval.hour1,
onTap: () =>
controller.isOpen ? controller.close() : controller.open(),
),
@@ -180,6 +230,16 @@ class RainMapLayer
: null,
child: Text(option.label(l10n)),
),
+ const MapMenuDivider(),
+ SectionHeader(l10n.rainScaleSection),
+ for (final option in RainColorScale.values)
+ MenuItemButton(
+ onPressed: () => setColorScale(option),
+ trailingIcon: option == scale
+ ? Icon(Icons.check, size: 18, color: colors.primary)
+ : null,
+ child: Text(option.label(l10n)),
+ ),
],
),
],
diff --git a/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart
index 821782561..3a8023fbe 100644
--- a/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart
+++ b/lib/features/map/presentation/layers/scan_range_overlay_chrome.dart
@@ -25,12 +25,14 @@ import 'package:maplibre_gl/maplibre_gl.dart';
/// rain*, and an unidentified county is one you cannot act on.
///
/// The county / township half of that chrome is [AdminOutlineChrome], which
-/// the wind forecast layer shares; this adds the radar scan-range outline on
-/// top of it. The geometry is the radar composite's ([RadarScanRange]) for
-/// every consumer: QPESUMS forecasts the same grid the radars observe, so its
-/// coverage is the same union of range circles. Only the ids differ, so a map
-/// showing radar and QPESUMS at once draws two outlines instead of clashing
-/// over one.
+/// the wind forecast layer shares; this adds the coverage outline on top of it.
+/// The geometry defaults to the radar composite's ([RadarScanRange]) and is
+/// overridable, because coverage is a fact about the source and not about this
+/// chrome: the QPESUMS forecast is published over a plain rectangle, so drawing
+/// it with the composite's range circles both claimed coverage it does not have
+/// on the corners and denied coverage it does have along the arcs. The ids are
+/// per-layer too, so a map showing radar and QPESUMS at once draws two outlines
+/// instead of clashing over one.
mixin ScanRangeOverlayChrome on AdminOutlineChrome {
/// Whether the observed area is outlined. On by default — see the class doc.
final ValueNotifier showScanRange = ValueNotifier(true);
@@ -48,6 +50,12 @@ mixin ScanRangeOverlayChrome on AdminOutlineChrome {
/// outline is never mistaken for precipitation.
String get scanRangeColor;
+ /// The shape this layer outlines as its observed area.
+ ///
+ /// Defaults to the radar composite's union of range circles; a source whose
+ /// data covers something else overrides it.
+ Map get scanRangeGeoJson => RadarScanRange.geoJson();
+
/// All chrome listenables, for a legend that follows the toggles.
Listenable get chromeListenable =>
Listenable.merge([showScanRange, adminChromeListenable]);
@@ -106,6 +114,7 @@ mixin ScanRangeOverlayChrome on AdminOutlineChrome {
belowLayerId: chromeBelowLayerId,
sourceId: scanRangeSourceId,
layerId: scanRangeLayerId,
+ data: scanRangeGeoJson,
);
} else {
await RadarScanRange.remove(
diff --git a/lib/features/map/presentation/layers/weather_station_layer.dart b/lib/features/map/presentation/layers/weather_station_layer.dart
index 4702e07e0..c16d18a7c 100644
--- a/lib/features/map/presentation/layers/weather_station_layer.dart
+++ b/lib/features/map/presentation/layers/weather_station_layer.dart
@@ -62,6 +62,16 @@ abstract class WeatherStationLayer<
/// value colour and the legend, so a second pass would compound on all three.
List<(double, String)> get colorStops;
+ /// Whether [colorStops] are **band floors** rather than gradient anchors.
+ ///
+ /// Continuous fields (temperature, pressure, humidity) read as a gradient: a
+ /// value between two stops genuinely lies between two colours. Accumulations
+ /// do not — the published rainfall scale is a table of categories, and a dot
+ /// blended halfway between the 70 mm and 90 mm bands claims a precision the
+ /// band structure denies. Banded layers get a MapLibre `step`, [stepColor]
+ /// in the sheet, and a hard-edged legend, so all three agree.
+ bool get bandedColors => false;
+
/// Whether to draw the value-coloured dot. A subclass may replace it with its
/// own symbology (e.g. wind arrows) by returning false.
@protected
@@ -151,13 +161,7 @@ abstract class WeatherStationLayer<
await controller.addCircleLayer(
_sourceId,
_circleId,
- CircleLayerProperties(
- circleColor: _colorExpression(),
- circleRadius: 6,
- circleStrokeColor: _strokeColor,
- circleStrokeWidth: 1,
- circleOpacity: 0.9,
- ),
+ _circleProperties(),
// Non-interactive: we do our own nearest-station math in onMapTap and
// want EVERY tap via map#onMapClick — an interactive layer would eat an
// on-dot tap as feature#onTap (unhandled) so the station never selects.
@@ -224,7 +228,11 @@ abstract class WeatherStationLayer<
@override
Widget buildLegend(BuildContext context) {
final header = legendHeader(context);
- final scale = ColorScaleLegend(stops: colorStops, unit: unit);
+ final scale = ColorScaleLegend(
+ stops: colorStops,
+ unit: unit,
+ banded: bandedColors,
+ );
final child = header == null
? scale
: Column(
@@ -282,7 +290,10 @@ abstract class WeatherStationLayer<
Color? valueColor(String id) {
final observation = observationOf(id);
final value = observation == null ? null : valueOf(observation);
- return value == null ? null : rampColor(colorStops, value);
+ if (value == null) return null;
+ return bandedColors
+ ? stepColor(colorStops, value)
+ : rampColor(colorStops, value);
}
@override
@@ -371,12 +382,53 @@ abstract class WeatherStationLayer<
return {'type': 'FeatureCollection', 'features': features};
}
- List _colorExpression() => [
- 'interpolate',
- ['linear'],
- ['get', 'value'],
- for (final (at, color) in colorStops) ...[at, color],
- ];
+ /// The dot's complete look. One definition, used by both the initial mount
+ /// and every later re-assert.
+ ///
+ /// It has to be complete: `setLayerProperties` defaults to `skipNulls: false`
+ /// and then assigns *every* field of the layer type, so a partial update
+ /// silently resets the ones it omits — a colour-only re-assert shrank these
+ /// dots to MapLibre's default radius and erased their white outline.
+ CircleLayerProperties _circleProperties() => CircleLayerProperties(
+ circleColor: _colorExpression(),
+ circleRadius: 6,
+ circleStrokeColor: _strokeColor,
+ circleStrokeWidth: 1,
+ circleOpacity: 0.9,
+ );
+
+ /// Re-asserts the value ramp on the already-mounted dot layer.
+ ///
+ /// A subclass whose [colorStops] depend on runtime state (the rainfall scale)
+ /// has to push the new expression itself: the GeoJSON carries values, not
+ /// colours, so re-setting the source alone repaints the identical picture.
+ /// Silent when the layer is not mounted or does not draw dots — the next
+ /// [render] builds it with the current ramp either way.
+ @protected
+ Future applyColorRamp(MapLibreMapController controller) async {
+ if (!drawCircle) return;
+ await controller.setLayerProperties(_circleId, _circleProperties());
+ }
+
+ List _colorExpression() {
+ if (!bandedColors) {
+ return [
+ 'interpolate',
+ ['linear'],
+ ['get', 'value'],
+ for (final (at, color) in colorStops) ...[at, color],
+ ];
+ }
+ // `step` takes the below-first-stop colour as its default argument, so the
+ // first stop supplies the fallback and only the rest are boundaries.
+ final stops = colorStops;
+ return [
+ 'step',
+ ['get', 'value'],
+ stops.first.$2,
+ for (final (at, color) in stops.skip(1)) ...[at, color],
+ ];
+ }
Future _removeFromMap(MapLibreMapController controller) async {
// Layers must go before their source; tolerate any that aren't on the map.
diff --git a/lib/features/map/presentation/layers/wind_forecast_layer.dart b/lib/features/map/presentation/layers/wind_forecast_layer.dart
index 923a27dee..8fba9e27f 100644
--- a/lib/features/map/presentation/layers/wind_forecast_layer.dart
+++ b/lib/features/map/presentation/layers/wind_forecast_layer.dart
@@ -9,6 +9,7 @@ import 'package:dpip/core/logging/log.dart';
import 'package:dpip/features/map/presentation/layers/admin_outline_chrome.dart';
import 'package:dpip/features/map/presentation/widgets/forecast_overlay_menu.dart';
import 'package:dpip/features/map/presentation/widgets/wind_particle_overlay.dart';
+import 'package:dpip/features/map/presentation/layers/wind_particle_native.dart';
import 'package:dpip/features/weather/domain/wind_field.dart';
import 'package:dpip/features/weather/domain/wind_forecast_model.dart';
import 'package:dpip/features/weather/domain/wind_forecast_repository.dart';
@@ -26,8 +27,9 @@ import 'package:maplibre_gl/maplibre_gl.dart';
/// Everything about scrubbing lives in [RasterTimelineLayer]; this supplies the
/// model's identity, its opacity, the shared wind-speed colour key, the two
/// admin-border overlays its options chip toggles ([AdminOutlineChrome]), and
-/// the particle animation ([WindParticleOverlay]) that rides the loaded
-/// [field].
+/// the particle animation — native on Android and iOS
+/// ([WindParticleNative]), [WindParticleOverlay] elsewhere — driven by the
+/// loaded [field].
///
/// The tiles are a semi-transparent speed wash, so the layer draws its own
/// county / township borders **over** the field the same way radar does over
@@ -47,6 +49,21 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome {
/// field arrives; the overlay starts its animation only once this is set.
final ValueNotifier field = ValueNotifier(null);
+ /// The GPU renderer that draws the particles inside the map.
+ ///
+ /// Where it is available it replaces [WindParticleOverlay] entirely. On
+ /// Android that is not a preference but the leak escape: a Flutter overlay
+ /// repainting above a platform view leaks a full-screen graphics buffer per
+ /// frame under HCPP, and the particles were the only thing in the app
+ /// repainting every frame. iOS has no leak, but the same native path is where
+ /// map content belongs and keeps one wire for both.
+ late final WindParticleNative particles = WindParticleNative(
+ field: field,
+ interacting: interacting,
+ );
+
+ bool _particlesAttached = false;
+
/// Whether a finger is currently on the map.
///
/// The particle field is torn down for the whole gesture and reseeded when it
@@ -72,12 +89,24 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome {
/// the camera from it every frame.
MapLibreMapController? get mapController => controller;
+ /// Attaches the native renderer the first time a controller is in hand.
+ ///
+ /// Lazily rather than in a constructor: the layer outlives any one platform
+ /// view, and a controller that has been replaced must not keep a layer bound
+ /// to the old one.
+ void _ensureParticles(MapLibreMapController controller) {
+ if (_particlesAttached || !particles.isSupported) return;
+ _particlesAttached = true;
+ particles.attach(controller);
+ }
+
@override
Future show(
MapLibreMapController controller,
MapFrame frame, {
bool scrubbing = false,
}) async {
+ _ensureParticles(controller);
if (scrubbing) {
// A megabyte-scale WND1 grid per crossed frame cannot keep up with a
// finger. More importantly, displaying the previous grid under a new
@@ -147,11 +176,26 @@ class WindForecastMapLayer extends RasterTimelineLayer with AdminOutlineChrome {
// The overlay is gone with the layer; stop advertising a field so a
// re-attach starts clean rather than animating yesterday's grid.
_invalidateField();
+ _particlesAttached = false;
+ await particles.detach();
}
@override
- Widget buildMapOverlay(BuildContext context) =>
- WindParticleOverlay(layer: this);
+ void onSurfaceVisibility(bool visible) {
+ super.onSurfaceVisibility(visible);
+ particles.setSurfaceVisible(visible);
+ }
+
+ /// The Flutter overlay, only where the map cannot draw the particles itself.
+ ///
+ /// Where the native layer carries them (Android, iOS) this is deliberately
+ /// empty — returning the overlay anyway would put a per-frame Flutter
+ /// presentation back on platforms that no longer need one. It is still the
+ /// real implementation everywhere else.
+ @override
+ Widget buildMapOverlay(BuildContext context) => particles.isActive
+ ? const SizedBox.shrink()
+ : WindParticleOverlay(layer: this);
/// The particle overlay reads the live camera on every tick, so it never
/// needs a rebuild to reproject — and it must not get one: re-keying it on
diff --git a/lib/features/map/presentation/layers/wind_particle_native.dart b/lib/features/map/presentation/layers/wind_particle_native.dart
new file mode 100644
index 000000000..e6f464212
--- /dev/null
+++ b/lib/features/map/presentation/layers/wind_particle_native.dart
@@ -0,0 +1,242 @@
+/// Drives the GPU wind-particle layer that lives inside the map.
+library;
+
+import 'dart:async';
+import 'dart:ui' as ui;
+
+import 'package:dpip/core/logging/log.dart';
+import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart';
+import 'package:dpip/features/weather/domain/wind_field.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:maplibre_gl/maplibre_gl.dart';
+
+/// Binds [WindForecastMapLayer]'s state to the native particle renderer.
+///
+/// The particles used to be a Flutter widget painted over the map. On Android
+/// that is a leak with a stopwatch on it: HCPP allocates a full-screen graphics
+/// buffer for every Flutter frame presented above a platform view and never
+/// returns it, so a ticker-driven overlay took the process from 394 MB to
+/// 8 GB of GPU memory in sixteen seconds. Drawn inside the map instead, the
+/// particles produce no Flutter frame at all.
+///
+/// iOS draws inside the map too, through `MLNCustomStyleLayer`'s Metal encoder
+/// (see `WindParticleEngine.swift` in the fork) — there was never a leak there,
+/// but the same pass is where map content belongs, and one wire feeds both.
+///
+/// This class owns only the *conversation* with that renderer — when to add it,
+/// what to upload, when to let it run. The simulation itself is gone from Dart;
+/// [WindParticleSim] survives as the numeric oracle the native shaders are
+/// checked against, not as something that runs in production.
+class WindParticleNative {
+ WindParticleNative({
+ required this.field,
+ required this.interacting,
+ TargetPlatform? platform,
+ }) : _platform = platform ?? defaultTargetPlatform;
+
+ /// The grid to animate; null while the timeline moves or before it arrives.
+ final ValueListenable field;
+
+ /// Whether a finger is on the map.
+ final ValueListenable interacting;
+
+ final TargetPlatform _platform;
+
+ MapLibreMapController? _controller;
+ WindField? _uploaded;
+ bool _added = false;
+ bool _playing = false;
+ bool _visible = true;
+ bool _unavailable = false;
+
+ /// Serialises every native call.
+ ///
+ /// Ordering is the whole contract here: an `add` that lands after its own
+ /// `setField` uploads into a layer that does not exist yet, and a `remove`
+ /// that overtakes a `setPlaying` leaves the map stuck in continuous
+ /// rendering. Awaiting each call in turn is cheap — there are a handful per
+ /// minute, none of them per frame.
+ Future _queue = Future.value();
+
+ /// Whether the native renderer is carrying the particles.
+ ///
+ /// False on platforms without it, and false after the device refused the
+ /// layer — the caller falls back to its Flutter overlay rather than showing
+ /// nothing.
+ bool get isActive => _added && !_unavailable;
+
+ /// Whether this platform should even try.
+ ///
+ /// Android and iOS. The Android path is the reason this class exists: the
+ /// HCPP overlay leak lives in that platform's SurfaceControl, and the map's
+ /// GL surface is where drawing costs nothing (see the native layer's class
+ /// comment). iOS draws through `MLNCustomStyleLayer`'s Metal encoder in the
+ /// map's own pass — same reasoning, no leak to escape — and arrived later;
+ /// see `WindParticleEngine.swift` in the fork for how its passes are split.
+ bool get isSupported =>
+ _platform == TargetPlatform.android || _platform == TargetPlatform.iOS;
+
+ void attach(MapLibreMapController controller) {
+ if (!isSupported || _unavailable) return;
+ _controller = controller;
+ field.addListener(_onFieldChanged);
+ interacting.addListener(_onInteractingChanged);
+ _enqueue(() async {
+ await controller.addWindParticleLayer();
+ _added = true;
+ await controller.setWindParticleTuning(
+ windParticleTuning(pixelRatio: _devicePixelRatio()),
+ );
+ await _pushField(controller);
+ await _pushPlaying(controller);
+ });
+ }
+
+ /// Releases the native layer. Safe to call when nothing was ever added.
+ Future detach() async {
+ field.removeListener(_onFieldChanged);
+ interacting.removeListener(_onInteractingChanged);
+ final controller = _controller;
+ _controller = null;
+ if (!_added || controller == null) {
+ _added = false;
+ _playing = false;
+ _uploaded = null;
+ return;
+ }
+ _added = false;
+ _playing = false;
+ _uploaded = null;
+ _enqueue(() => controller.removeWindParticleLayer());
+ await _queue;
+ }
+
+ /// The hosting surface was hidden or revealed.
+ void setSurfaceVisible(bool visible) {
+ if (_visible == visible) return;
+ _visible = visible;
+ _sync();
+ }
+
+ void _onFieldChanged() {
+ final controller = _controller;
+ if (controller == null) return;
+ _enqueue(() async {
+ await _pushField(controller);
+ await _pushPlaying(controller);
+ });
+ }
+
+ void _onInteractingChanged() => _sync();
+
+ void _sync() {
+ final controller = _controller;
+ if (controller == null) return;
+ _enqueue(() => _pushPlaying(controller));
+ }
+
+ Future _pushField(MapLibreMapController controller) async {
+ final current = field.value;
+ if (identical(current, _uploaded)) return;
+ _uploaded = current;
+ final payload = current == null ? null : windFieldPayload(current);
+ if (payload == null) return;
+ await controller.setWindParticleField(payload);
+ }
+
+ /// The animation runs only when there is something to animate and someone to
+ /// see it. A gesture no longer stops it: the particle count is fixed on the
+ /// GPU, so a pinch changes how many are drawn rather than reseeding the
+ /// population, and there is nothing left for hiding them to protect.
+ Future _pushPlaying(MapLibreMapController controller) async {
+ final want = _visible && _uploaded?.source != null;
+ if (want == _playing) return;
+ _playing = want;
+ await controller.setWindParticlePlaying(want);
+ }
+
+ void _enqueue(Future Function() action) {
+ _queue = _queue.then((_) async {
+ if (_unavailable) return;
+ try {
+ await action();
+ } on PlatformException catch (error) {
+ if (error.code == 'WIND_LAYER_UNAVAILABLE') {
+ // The device cannot host the layer — texture mode is on, so the map
+ // has no SurfaceView to draw into. Stop trying and let the caller
+ // fall back; this is a configuration difference, not a failure.
+ _unavailable = true;
+ _added = false;
+ Log.warning('Wind particle layer unavailable: ${error.message}');
+ return;
+ }
+ Log.warning('Wind particle layer call failed: ${error.message}');
+ } catch (error) {
+ Log.warning('Wind particle layer call failed: $error');
+ }
+ });
+ }
+}
+
+/// The display scale the point size is expressed in.
+///
+/// `pointSize` is tuned in logical pixels, but `gl_PointSize` is in physical
+/// ones — on a 3x phone an unconverted value draws the field a third of its
+/// intended weight, which reads as "the wind is faint today" rather than as a
+/// bug. Read from the dispatcher rather than a `BuildContext` because the layer
+/// attaches from a controller callback, where there is no element to look up.
+double _devicePixelRatio() {
+ final views = ui.PlatformDispatcher.instance.views;
+ return views.isEmpty ? 1 : views.first.devicePixelRatio;
+}
+
+/// The zoom curves as their endpoints, in the wire's shape.
+///
+/// Sent whole so the numbers keep one home. Evaluating them per zoom in Dart
+/// would put a platform call on the camera path — the exact per-frame traffic
+/// this design exists to remove — and would leave two copies of the curve to
+/// drift apart. `wind_particle_sim.dart` stays their definition; this is a
+/// projection of it.
+Map windParticleTuning({double pixelRatio = 1}) => {
+ 'zoomLo': kWindZoomLo,
+ 'zoomHi': kWindZoomHi,
+ 'particlesLo': kWindParticles.$1,
+ 'particlesHi': kWindParticles.$2,
+ 'pointSizeLo': kWindPointSize.$1,
+ 'pointSizeHi': kWindPointSize.$2,
+ 'speedFactorLo': kWindSpeedFactor.$1,
+ 'speedFactorHi': kWindSpeedFactor.$2,
+ 'fadeOpacityLo': kWindFadeOpacity.$1,
+ 'fadeOpacityHi': kWindFadeOpacity.$2,
+ 'dropRate': kWindDropRate,
+ 'densityCalm': kWindDensityCalm,
+ 'densityStrong': kWindDensityStrong,
+ 'speedScale': kWindSpeedScale,
+ 'pixelRatio': pixelRatio,
+};
+
+/// One wind field in the wire's shape, or null when it carries no payload.
+///
+/// The raw WND1 body goes over untouched, with the header Dart already parsed
+/// alongside it. Native does not re-parse: one parser, one place, one set of
+/// tests. A field assembled in code rather than decoded has no body and is
+/// skipped rather than faked.
+Map? windFieldPayload(WindField field) {
+ final source = field.source;
+ if (source == null) return null;
+ return {
+ 'bytes': source,
+ 'planeOffset': field.planeOffset,
+ 'width': field.width,
+ 'height': field.height,
+ 'lat0': field.lat0,
+ 'lon0': field.lon0,
+ 'dLat': field.dLat,
+ 'dLon': field.dLon,
+ 'uMin': field.uMin,
+ 'uMax': field.uMax,
+ 'vMin': field.vMin,
+ 'vMax': field.vMax,
+ };
+}
diff --git a/lib/features/map/presentation/layers/wind_particle_sim.dart b/lib/features/map/presentation/layers/wind_particle_sim.dart
index 2b3710291..d701e8d07 100644
--- a/lib/features/map/presentation/layers/wind_particle_sim.dart
+++ b/lib/features/map/presentation/layers/wind_particle_sim.dart
@@ -17,24 +17,31 @@ import 'package:dpip/features/weather/domain/wind_field.dart';
/// Wind speed (m/s) that saturates the visual ramp — fixed across every frame
/// and both models so a streak means the same thing everywhere
/// (`web/wind.js`, `SPEED_SCALE`).
+/// The tuning curves below are **public because they are now a wire contract**.
+///
+/// The simulation they were written for no longer runs in production — the
+/// particles are advected on the GPU inside the map. These constants are sent
+/// to that renderer as endpoints and interpolated there with the same rules,
+/// so they keep exactly one definition. This file stays as the numeric oracle
+/// the GLSL is checked against; see `wind_particle_native.dart`.
const double kWindSpeedScale = 32;
/// The zooms the tuned values are pinned at (`web/index.html`, `ZOOM_STOPS`),
/// which are also this layer's own limits.
-const double _kZoomLo = 3;
-const double _kZoomHi = 7;
+const double kWindZoomLo = 3;
+const double kWindZoomHi = 7;
// Each pair is (value at z3, value at z7). Which ones interpolate
// geometrically and which linearly is not a free choice either — it is what
// `TUNE` declares, and a count that steps 6400 → 4096 → 2601 is a visibly
// different field from one that steps 6400 → 5056 → 3712.
-const (double, double) _kParticles = (6400, 1024); // log
-const (double, double) _kPointSize = (1.5, 1.8); // lin, logical px
-const (double, double) _kSpeedFactor = (0.2, 0.0151); // log
-const (double, double) _kFadeOpacity = (0.95, 0.945); // lin
+const (double, double) kWindParticles = (6400, 1024); // log
+const (double, double) kWindPointSize = (1.5, 1.8); // lin, logical px
+const (double, double) kWindSpeedFactor = (0.2, 0.0151); // log
+const (double, double) kWindFadeOpacity = (0.95, 0.945); // lin
/// Chance per frame that a particle in good standing is recycled anyway.
-const double _kDropRate = 0.011;
+const double kWindDropRate = 0.011;
/// Relative density of particles in still air and in strong wind.
///
@@ -42,15 +49,15 @@ const double _kDropRate = 0.011;
/// to say. See the note in `web/index.html`: once the colour underneath
/// carries the speed, thinning the streaks where the weather is spends the one
/// channel still describing direction.
-const double _kDensityCalm = 0.5;
-const double _kDensityStrong = 5.5;
+const double kWindDensityCalm = 0.5;
+const double kWindDensityStrong = 5.5;
/// Where a zoom sits between the two tuned stops.
///
/// Clamped rather than extrapolated, matching the web's `effective()`: outside
/// the stops there is no judgement behind the number, only arithmetic.
double _stopFraction(double zoom) =>
- ((zoom - _kZoomLo) / (_kZoomHi - _kZoomLo)).clamp(0.0, 1.0);
+ ((zoom - kWindZoomLo) / (kWindZoomHi - kWindZoomLo)).clamp(0.0, 1.0);
double _lerpStops((double, double) v, double zoom) {
final f = _stopFraction(zoom);
@@ -68,14 +75,17 @@ double _logLerpStops((double, double) v, double zoom) {
/// particle state and rounds to one; matching the count matters more than the
/// squareness, but rounding the same way keeps the two exactly equal.
int particleCountFor(double zoom) {
- final edge = math.max(1, math.sqrt(_logLerpStops(_kParticles, zoom)).round());
+ final edge = math.max(
+ 1,
+ math.sqrt(_logLerpStops(kWindParticles, zoom)).round(),
+ );
return edge * edge;
}
/// Diameter of a particle in logical pixels. The web sets `gl_PointSize` in
/// device pixels and multiplies by the device pixel ratio to get there, so the
/// tuned number is already the logical one.
-double pointSizeFor(double zoom) => _lerpStops(_kPointSize, zoom);
+double pointSizeFor(double zoom) => _lerpStops(kWindPointSize, zoom);
/// What fraction of the trail buffer survives each frame.
///
@@ -87,21 +97,22 @@ double pointSizeFor(double zoom) => _lerpStops(_kPointSize, zoom);
///
/// Never 1: a fade that does not fade accumulates for ever and the screen
/// saturates to white.
-double fadeOpacityFor(double zoom) => _lerpStops(_kFadeOpacity, zoom);
+double fadeOpacityFor(double zoom) => _lerpStops(kWindFadeOpacity, zoom);
/// Field-space distance a particle rides per (m/s · frame) at [zoom].
///
/// It has to fall with zoom: a field-space step is a fraction of the *world*,
/// so the pixels it covers double with every zoom level in. Held constant at
/// the value that suits z3, particles at z7 move 23× too fast.
-double fieldStepFor(double zoom) => 0.0001 * _logLerpStops(_kSpeedFactor, zoom);
+double fieldStepFor(double zoom) =>
+ 0.0001 * _logLerpStops(kWindSpeedFactor, zoom);
/// The web's `densityWeight`: how many particles a place should hold relative
/// to spreading them evenly, by how hard the wind is blowing there.
double densityWeight(double speed) {
final t = (speed / (0.6 * kWindSpeedScale)).clamp(0.0, 1.0);
final s = t * t * (3 - 2 * t); // smoothstep
- return _kDensityCalm + (_kDensityStrong - _kDensityCalm) * s;
+ return kWindDensityCalm + (kWindDensityStrong - kWindDensityCalm) * s;
}
/// The camera a wind overlay is drawn under — enough to project a lat/lng to
@@ -375,7 +386,7 @@ class WindParticleSim {
// Recycle a particle that has left, and occasionally a healthy one — the
// field would otherwise empty out of wherever the density weighting is
// not putting anything back.
- if (!inView || _random.nextDouble() < _kDropRate) {
+ if (!inView || _random.nextDouble() < kWindDropRate) {
_respawn(p, fieldSpace);
}
}
@@ -518,7 +529,7 @@ class WindParticleSim {
final (u, v) = _sampleUV(x, y);
final weight = densityWeight(math.sqrt(u * u + v * v));
if (_random.nextDouble() <
- weight / math.max(_kDensityCalm, _kDensityStrong)) {
+ weight / math.max(kWindDensityCalm, kWindDensityStrong)) {
p.x = x;
p.y = y;
}
diff --git a/lib/features/map/presentation/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart
index 0f623caf4..73868904c 100644
--- a/lib/features/map/presentation/pages/map_page.dart
+++ b/lib/features/map/presentation/pages/map_page.dart
@@ -6,6 +6,7 @@ import 'package:dpip/core/geo/town_directory.dart';
import 'package:dpip/core/realtime/realtime_notifier.dart';
import 'package:dpip/core/settings/default_map_layer.dart';
import 'package:dpip/core/settings/default_map_layer_controller.dart';
+import 'package:dpip/core/settings/map_layer_visibility_controller.dart';
import 'package:dpip/features/disaster_map/domain/disaster_map_repository.dart';
import 'package:dpip/features/earthquake/domain/eew.dart';
import 'package:dpip/features/earthquake/domain/rts.dart';
@@ -51,6 +52,14 @@ import 'package:provider/provider.dart';
///
/// The initial overlay comes from [DefaultMapLayerController]; a [ValueKey] on
/// the scaffold remounts when that preference changes so the new default wins.
+/// The key is keyed on the *preference*, not on visibility — hiding the
+/// currently-open layer must not remount the whole scaffold (that would close
+/// any sheet open above it, such as the layer-order editor the hide was just
+/// tapped from). A hidden layer drops out of the picker's list entirely — the
+/// order editor's eye toggle is the only way to offer it again. A hide that
+/// removes the on-screen layer mid-session is handled by [MapScaffold] itself,
+/// which watches [MapLayerVisibilityController] directly and falls back in
+/// place.
class MapPage extends StatefulWidget {
const MapPage({super.key});
@@ -110,15 +119,25 @@ class _MapPageState extends State {
@override
Widget build(BuildContext context) {
+ final visibility = context.watch();
// In demo mode the monitor is what there is to see — open straight on it.
- final initial = kMonitorDemoEnabled
+ final preferred = kMonitorDemoEnabled
? DefaultMapLayer.monitor
: context.watch().layer;
+ // Open on the preferred layer unless it (and only it) is hidden; hidden
+ // layers are otherwise offered like any other.
+ final initial = _layers.firstWhere(
+ (layer) => layer.id == preferred.id && !visibility.isHidden(layer.id),
+ orElse: () => _layers.firstWhere(
+ (layer) => !visibility.isHidden(layer.id),
+ orElse: () => _layers.first,
+ ),
+ );
return MapScaffold(
- key: ValueKey(initial.id),
+ key: ValueKey(preferred.id),
layers: _layers,
initialLayerId: initial.id,
- initialOsmEnabled: initial == DefaultMapLayer.dpm,
+ initialOsmEnabled: initial.id == DefaultMapLayer.dpm.id,
tabIndex: MapPage.tabIndex,
);
}
diff --git a/lib/features/map/presentation/widgets/wind_particle_overlay.dart b/lib/features/map/presentation/widgets/wind_particle_overlay.dart
index 104d8f602..2774a4ca3 100644
--- a/lib/features/map/presentation/widgets/wind_particle_overlay.dart
+++ b/lib/features/map/presentation/widgets/wind_particle_overlay.dart
@@ -15,6 +15,7 @@ import 'package:dpip/features/map/presentation/layers/wind_forecast_layer.dart';
import 'package:dpip/features/map/presentation/layers/wind_particle_sim.dart';
import 'package:dpip/features/map/presentation/pages/map_page.dart';
import 'package:dpip/shared/navigation/refresh_on_appear.dart';
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@@ -38,6 +39,28 @@ import 'package:flutter/scheduler.dart';
class WindParticleOverlay extends StatefulWidget {
const WindParticleOverlay({super.key, required this.layer});
+ /// Whether this platform may run the ticker at all. **Temporary containment,
+ /// not a preference** — remove it with the Flutter overlay itself.
+ ///
+ /// Android's HCPP platform-view mode leaks one full-screen HardwareBuffer
+ /// (10.47 MB) for every Flutter frame presented above the map, and a
+ /// ticker-driven overlay presents one every frame. Measured on a Pixel 9:
+ /// 394 -> 8042 MB of GPU memory in 16 s, then lmkd killed the process — which
+ /// takes the earthquake and radar monitoring down with it. A missing
+ /// animation is the cheaper failure. iOS is unaffected; the leak is in the
+ /// Android SurfaceControl/AHB swapchain path.
+ ///
+ /// Delete this once the particles live in a MapLibre layer, or once the
+ /// engine bounds `AHBTexturePoolVK` again — still unbounded at 3.47.1, the
+ /// 3.48 beta and master. See `android/app/src/main/AndroidManifest.xml`.
+ ///
+ /// Tests set this true: the simulation, the trail buffer and the ticker
+ /// lifecycle are all still live code that the MapLibre port has to match, so
+ /// their coverage must not lapse while the containment is in place.
+ @visibleForTesting
+ static bool animateOnThisPlatform =
+ defaultTargetPlatform != TargetPlatform.android;
+
final WindForecastMapLayer layer;
@override
@@ -155,7 +178,11 @@ class _WindParticleOverlayState extends State
/// Whether the animation should be running at all: there is a field, the map
/// tab is on screen, and no gesture is in progress.
bool get _shouldAnimate =>
- _sim != null && _visible && _appForeground && !_interacting;
+ WindParticleOverlay.animateOnThisPlatform &&
+ _sim != null &&
+ _visible &&
+ _appForeground &&
+ !_interacting;
/// Runs the ticker only while [_shouldAnimate].
void _updateTicker() {
diff --git a/lib/features/weather/data/frame_tile_repository.dart b/lib/features/weather/data/frame_tile_repository.dart
index fc32c11fb..ca4a89759 100644
--- a/lib/features/weather/data/frame_tile_repository.dart
+++ b/lib/features/weather/data/frame_tile_repository.dart
@@ -253,12 +253,21 @@ final class FrameTileRepositoryImpl extends FrameTileRepository
final FrameTileApi _api;
- /// Highest zoom this overlay publishes tiles for. Radar / satellite /
- /// QPESUMS reach 11; the 0.25° wind forecast grids stop at 7 (any deeper is
- /// upsampled to nothing new).
+ /// Highest zoom this overlay publishes tiles for — measured from the live
+ /// endpoints, not guessed: radar and QPESUMS serve real bytes for z3–12,
+ /// satellite z0–11, wind z0–11, and everything outside those ranges comes
+ /// back as the empty 35-byte GIF placeholder. The caps sit **below** the
+ /// publish range on purpose: tile sizes shrink monotonically past each
+ /// product's resolution peak (radar / QPESUMS peak at z7, Himawari band 13
+ /// is ~2 km/px), so deeper levels are the server resampling — a viewport of
+ /// round trips per zoom crossing for no new detail. Wind was already 7;
+ /// the others carried the publish ceiling 11 and now match their data.
@override
final int maxZoom;
+ @override
+ int get sourceMaxZoom => maxZoom;
+
@override
String get tilePathPrefix => '${ApiPaths.tiles}/${_api.path}/';
diff --git a/lib/features/weather/domain/wind_field.dart b/lib/features/weather/domain/wind_field.dart
index edd9a4380..bdf6bd68a 100644
--- a/lib/features/weather/domain/wind_field.dart
+++ b/lib/features/weather/domain/wind_field.dart
@@ -40,6 +40,8 @@ class WindField {
required this.model,
required this.u,
required this.v,
+ this.source,
+ this.planeOffset = 0,
});
/// Cells across — the field spans `dLon × width` degrees of longitude.
@@ -80,6 +82,22 @@ class WindField {
/// Quantised northward component, `width × height`, raster order.
final Uint8List v;
+ /// The undecoded WND1 body [u] and [v] are views into.
+ ///
+ /// Kept so the GPU renderer can be handed the payload untouched instead of a
+ /// re-serialised copy: the planes are already in the raster order a texture
+ /// upload wants, and re-packing 2 MB per forecast frame to send the same
+ /// bytes back out would be pure loss. It costs nothing to retain — the views
+ /// pin the buffer regardless.
+ ///
+ /// Null for a field assembled in code rather than decoded from the wire —
+ /// there is no payload to upload, and the GPU renderer skips it rather than
+ /// inventing one.
+ final Uint8List? source;
+
+ /// Byte offset of the u plane within [source]; the v plane follows it.
+ final int planeOffset;
+
/// Parses a WND1 payload. Throws [FormatException] on any structural
/// mismatch (bad magic, unsupported version, truncation) — the data layer
/// wraps that into a [DecodeFailure] before anything else sees it.
@@ -118,6 +136,8 @@ class WindField {
model: String.fromCharCodes(bytes.sublist(67, planeOffset)),
u: Uint8List.sublistView(bytes, planeOffset, planeOffset + n),
v: Uint8List.sublistView(bytes, planeOffset + n, planeOffset + n * 2),
+ source: bytes,
+ planeOffset: planeOffset,
);
}
diff --git a/lib/features/weather/weather_providers.dart b/lib/features/weather/weather_providers.dart
index daed6367e..19e7cdcdb 100644
--- a/lib/features/weather/weather_providers.dart
+++ b/lib/features/weather/weather_providers.dart
@@ -33,18 +33,26 @@ List weatherProviders(SharedDeps deps) {
value: FrameTileRepositoryImpl(
FrameTileApi(deps.apiClient, 'radar'),
deps.mapTileWarmer(),
+ // Publishes z3–12, but the composite's resolution peaks at z7; past
+ // z8 the server resamples. 8 keeps the picture and stops a pinch
+ // across z9–11 from costing three viewports of requests per frame.
+ maxZoom: 8,
),
),
Provider.value(
value: FrameTileRepositoryImpl(
FrameTileApi(deps.apiClient, 'qpesums'),
deps.mapTileWarmer(),
+ // Same shape as radar: publishes z3–12, information ends ~z7.
+ maxZoom: 8,
),
),
Provider.value(
value: FrameTileRepositoryImpl(
FrameTileApi(deps.apiClient, 'satellite'),
deps.mapTileWarmer(),
+ // Publishes z0–11; band 13 is ~2 km/px so z8 already oversamples.
+ maxZoom: 8,
),
),
// One repository per channel the satellite layer picker offers — each needs
@@ -56,12 +64,15 @@ List weatherProviders(SharedDeps deps) {
channel: FrameTileRepositoryImpl(
FrameTileApi(deps.apiClient, 'satellite', channel: channel.key),
deps.mapTileWarmer(),
+ maxZoom: 8,
),
},
),
// One repository per wind forecast model — each needs its own model path on
// both the frame list and every tile URL, and its own warmer. The 0.25°
- // grids stop publishing at z7.
+ // grids stop publishing at z7 — and since that is also where the data ends,
+ // the source cap now matches instead of letting native fetch z8–11 the
+ // warm path never covered.
Provider>.value(
value: {
for (final model in WindForecastModel.values)
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index f37be1347..bc5e184f9 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -1027,6 +1027,10 @@
"description": "Notify channel title"
},
"mapLayerOrderTitle": "Reorder layers",
+ "mapLayerShow": "Show layer",
+ "mapLayerHide": "Hide layer",
+ "mapLayerShowAll": "Show all",
+ "mapLayerHideAll": "Hide all",
"@onboardingPermBackgroundDesc": {
"description": "Permission row description: background location"
},
@@ -2526,6 +2530,10 @@
},
"mapLayerSatelliteWatervapor": "Himawari Water Vapour",
"regionAddButton": "Add a region",
+ "regionSearchHint": "Search counties and cities",
+ "regionSearchEmpty": "No matching counties or cities",
+ "regionSearchTownHint": "Search townships",
+ "regionSearchTownEmpty": "No matching townships",
"displaySettings": "Display",
"restroomGradePoor": "Below standard",
"@moreSectionNotify": {
@@ -3854,5 +3862,17 @@
"description": "Shown when a debug dump could not be uploaded"
},
"statusLegendUnprobed": "Not yet probed",
- "statusLegendUnsupported": "Not offered"
+ "statusLegendUnsupported": "Not offered",
+ "rainScaleSection": "Colour scale",
+ "rainScaleFine": "Fine",
+ "rainScaleCoarse": "Coarse",
+ "@rainScaleSection": {
+ "description": "Menu section header for the rainfall colour-scale interval choice"
+ },
+ "@rainScaleFine": {
+ "description": "Rainfall colour scale option: close-spaced thresholds (1-300 mm), for short accumulation windows"
+ },
+ "@rainScaleCoarse": {
+ "description": "Rainfall colour scale option: wide-spaced thresholds (10-1500 mm), for multi-day totals"
+ }
}
diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb
index 1674f8ad3..6f21e5c4c 100644
--- a/lib/l10n/app_fil.arb
+++ b/lib/l10n/app_fil.arb
@@ -359,6 +359,10 @@
"description": "Send message button"
},
"mapLayerOrderTitle": "Ayusin ang ayos ng layer",
+ "mapLayerShow": "Ipakita ang layer",
+ "mapLayerHide": "Itago ang layer",
+ "mapLayerShowAll": "Ipakita lahat",
+ "mapLayerHideAll": "Itago lahat",
"@skyTimeNoon": {
"description": "Label for the skyTimeNoon option in the experimental backdrop settings."
},
@@ -921,6 +925,10 @@
"typhoonPickerTd": "Tropical depression TD {no}",
"mapLayerSatelliteWatervapor": "Himawari Water Vapour",
"regionAddButton": "Magdagdag ng rehiyon",
+ "regionSearchHint": "Maghanap ng mga lalawigan at lungsod",
+ "regionSearchEmpty": "Walang tumugmang lalawigan o lungsod",
+ "regionSearchTownHint": "Maghanap ng mga bayan",
+ "regionSearchTownEmpty": "Walang tumugmang bayan",
"displaySettings": "Pagpapakita",
"restroomGradePoor": "Mas mababa sa pamantayan",
"restroomCategoryTourist": "Lugar para sa turista",
@@ -1950,5 +1958,8 @@
"dumpCopyAgain": "Kopyahin ulit",
"dumpUploadFailed": "Nabigong mag-upload",
"statusLegendUnprobed": "Hindi pa nasuri",
- "statusLegendUnsupported": "Hindi suportado"
+ "statusLegendUnsupported": "Hindi suportado",
+ "rainScaleSection": "Antas ng kulay",
+ "rainScaleFine": "Pino",
+ "rainScaleCoarse": "Magaspang"
}
diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb
index cbfffcf4a..845190876 100644
--- a/lib/l10n/app_id.arb
+++ b/lib/l10n/app_id.arb
@@ -359,6 +359,10 @@
"description": "Send message button"
},
"mapLayerOrderTitle": "Urutkan lapisan",
+ "mapLayerShow": "Tampilkan lapisan",
+ "mapLayerHide": "Sembunyikan lapisan",
+ "mapLayerShowAll": "Tampilkan semua",
+ "mapLayerHideAll": "Sembunyikan semua",
"@skyTimeNoon": {
"description": "Label for the skyTimeNoon option in the experimental backdrop settings."
},
@@ -921,6 +925,10 @@
"typhoonPickerTd": "Depresi tropis TD {no}",
"mapLayerSatelliteWatervapor": "Himawari Water Vapour",
"regionAddButton": "Tambah wilayah",
+ "regionSearchHint": "Cari kabupaten dan kota",
+ "regionSearchEmpty": "Tidak ada kabupaten/kota yang cocok",
+ "regionSearchTownHint": "Cari kecamatan",
+ "regionSearchTownEmpty": "Tidak ada kecamatan yang cocok",
"displaySettings": "Tampilan",
"restroomGradePoor": "Di bawah standar",
"restroomCategoryTourist": "Kawasan wisata",
@@ -1950,5 +1958,8 @@
"dumpCopyAgain": "Salin lagi",
"dumpUploadFailed": "Gagal mengunggah",
"statusLegendUnprobed": "Belum diperiksa",
- "statusLegendUnsupported": "Tidak tersedia"
+ "statusLegendUnsupported": "Tidak tersedia",
+ "rainScaleSection": "Skala warna",
+ "rainScaleFine": "Halus",
+ "rainScaleCoarse": "Kasar"
}
diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb
index 7101eaaaf..81fbc3ba8 100644
--- a/lib/l10n/app_ja.arb
+++ b/lib/l10n/app_ja.arb
@@ -359,6 +359,10 @@
"description": "Send message button"
},
"mapLayerOrderTitle": "レイヤーの順番",
+ "mapLayerShow": "レイヤーを表示",
+ "mapLayerHide": "レイヤーを非表示",
+ "mapLayerShowAll": "すべて表示",
+ "mapLayerHideAll": "すべて非表示",
"@skyTimeNoon": {
"description": "Label for the skyTimeNoon option in the experimental backdrop settings."
},
@@ -921,6 +925,10 @@
"typhoonPickerTd": "熱帯低気圧 TD {no}",
"mapLayerSatelliteWatervapor": "ひまわり 水蒸気",
"regionAddButton": "地域を追加",
+ "regionSearchHint": "都道府県・市区を検索",
+ "regionSearchEmpty": "一致する地域がありません",
+ "regionSearchTownHint": "町村を検索",
+ "regionSearchTownEmpty": "該当する町村がありません",
"displaySettings": "表示",
"restroomGradePoor": "不合格",
"restroomCategoryTourist": "観光地・景勝地",
@@ -1950,5 +1958,8 @@
"dumpCopyAgain": "もう一度コピー",
"dumpUploadFailed": "アップロードに失敗しました",
"statusLegendUnprobed": "未探知",
- "statusLegendUnsupported": "非対応"
+ "statusLegendUnsupported": "非対応",
+ "rainScaleSection": "色階の間隔",
+ "rainScaleFine": "細かい",
+ "rainScaleCoarse": "粗い"
}
diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb
index b46e9f804..1510a0b4a 100644
--- a/lib/l10n/app_ko.arb
+++ b/lib/l10n/app_ko.arb
@@ -359,6 +359,10 @@
"description": "Send message button"
},
"mapLayerOrderTitle": "레이어 순서",
+ "mapLayerShow": "레이어 표시",
+ "mapLayerHide": "레이어 숨기기",
+ "mapLayerShowAll": "전체 표시",
+ "mapLayerHideAll": "전체 숨기기",
"@skyTimeNoon": {
"description": "Label for the skyTimeNoon option in the experimental backdrop settings."
},
@@ -921,6 +925,10 @@
"typhoonPickerTd": "열대 저기압 TD {no}",
"mapLayerSatelliteWatervapor": "히마와리 수증기",
"regionAddButton": "지역 추가",
+ "regionSearchHint": "시·도 검색",
+ "regionSearchEmpty": "일치하는 시·도가 없습니다",
+ "regionSearchTownHint": "읍·면·동 검색",
+ "regionSearchTownEmpty": "일치하는 읍·면·동이 없습니다",
"displaySettings": "화면",
"restroomGradePoor": "불합격",
"restroomCategoryTourist": "관광 지역·경치 구역",
@@ -1950,5 +1958,8 @@
"dumpCopyAgain": "다시 복사",
"dumpUploadFailed": "업로드하지 못했습니다",
"statusLegendUnprobed": "탐지 안 됨",
- "statusLegendUnsupported": "미지원"
+ "statusLegendUnsupported": "미지원",
+ "rainScaleSection": "색상 간격",
+ "rainScaleFine": "좁게",
+ "rainScaleCoarse": "넓게"
}
diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb
index 8f90516ea..d9f8632ca 100644
--- a/lib/l10n/app_th.arb
+++ b/lib/l10n/app_th.arb
@@ -359,6 +359,10 @@
"description": "Send message button"
},
"mapLayerOrderTitle": "จัดเรียงเลเยอร์",
+ "mapLayerShow": "แสดงเลเยอร์",
+ "mapLayerHide": "ซ่อนเลเยอร์",
+ "mapLayerShowAll": "แสดงทั้งหมด",
+ "mapLayerHideAll": "ซ่อนทั้งหมด",
"@skyTimeNoon": {
"description": "Label for the skyTimeNoon option in the experimental backdrop settings."
},
@@ -921,6 +925,10 @@
"typhoonPickerTd": "ดีเปรสชันเขตร้อน TD {no}",
"mapLayerSatelliteWatervapor": "Himawari Water Vapour",
"regionAddButton": "เพิ่มพื้นที่",
+ "regionSearchHint": "ค้นหาจังหวัดและเมือง",
+ "regionSearchEmpty": "ไม่พบจังหวัดหรือเมืองที่ตรงกัน",
+ "regionSearchTownHint": "ค้นหาตำบล",
+ "regionSearchTownEmpty": "ไม่พบตำบลที่ตรงกัน",
"displaySettings": "การแสดงผล",
"restroomGradePoor": "ต่ำกว่ามาตรฐาน",
"restroomCategoryTourist": "แหล่งท่องเที่ยว",
@@ -1950,5 +1958,8 @@
"dumpCopyAgain": "คัดลอกอีกครั้ง",
"dumpUploadFailed": "อัปโหลดไม่สำเร็จ",
"statusLegendUnprobed": "ยังไม่ตรวจ",
- "statusLegendUnsupported": "ไม่รองรับ"
+ "statusLegendUnsupported": "ไม่รองรับ",
+ "rainScaleSection": "ช่วงระดับสี",
+ "rainScaleFine": "ละเอียด",
+ "rainScaleCoarse": "หยาบ"
}
diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb
index fdf68f43d..a918c954e 100644
--- a/lib/l10n/app_vi.arb
+++ b/lib/l10n/app_vi.arb
@@ -359,6 +359,10 @@
"description": "Send message button"
},
"mapLayerOrderTitle": "Sắp xếp thứ tự lớp",
+ "mapLayerShow": "Hiện lớp bản đồ",
+ "mapLayerHide": "Ẩn lớp bản đồ",
+ "mapLayerShowAll": "Hiện tất cả",
+ "mapLayerHideAll": "Ẩn tất cả",
"@skyTimeNoon": {
"description": "Label for the skyTimeNoon option in the experimental backdrop settings."
},
@@ -921,6 +925,10 @@
"typhoonPickerTd": "Áp thấp nhiệt đới TD {no}",
"mapLayerSatelliteWatervapor": "Himawari Water Vapour",
"regionAddButton": "Thêm khu vực",
+ "regionSearchHint": "Tìm kiếm tỉnh và thành phố",
+ "regionSearchEmpty": "Không tìm thấy tỉnh/thành phố phù hợp",
+ "regionSearchTownHint": "Tìm kiếm xã",
+ "regionSearchTownEmpty": "Không tìm thấy xã phù hợp",
"displaySettings": "Hiển thị",
"restroomGradePoor": "Dưới chuẩn",
"restroomCategoryTourist": "Khu du lịch thắng cảnh",
@@ -1950,5 +1958,8 @@
"dumpCopyAgain": "Sao chép lại",
"dumpUploadFailed": "Tải lên thất bại",
"statusLegendUnprobed": "Chưa dò",
- "statusLegendUnsupported": "Không có"
+ "statusLegendUnsupported": "Không có",
+ "rainScaleSection": "Thang màu",
+ "rainScaleFine": "Mịn",
+ "rainScaleCoarse": "Thô"
}
diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb
index 8dcf071fc..cea976906 100644
--- a/lib/l10n/app_yue.arb
+++ b/lib/l10n/app_yue.arb
@@ -361,6 +361,10 @@
"description": "Send message button"
},
"mapLayerOrderTitle": "調整圖層順序",
+ "mapLayerShow": "顯示圖層",
+ "mapLayerHide": "隱藏圖層",
+ "mapLayerShowAll": "全部顯示",
+ "mapLayerHideAll": "全部隱藏",
"@skyTimeNoon": {
"description": "Label for the skyTimeNoon option in the experimental backdrop settings."
},
@@ -921,6 +925,10 @@
"typhoonPickerTd": "熱帶性低氣壓 TD {no}",
"mapLayerSatelliteWatervapor": "ひまわり 水氣",
"regionAddButton": "新增地區",
+ "regionSearchHint": "搜尋縣市",
+ "regionSearchEmpty": "搵唔到符合嘅縣市",
+ "regionSearchTownHint": "搜尋鄉鎮",
+ "regionSearchTownEmpty": "搵唔到符合嘅鄉鎮",
"displaySettings": "顯示設定",
"restroomGradePoor": "唔合格",
"restroomCategoryTourist": "觀光地區及風景區",
@@ -1950,5 +1958,8 @@
"dumpCopyAgain": "再複製一次",
"dumpUploadFailed": "上載失敗,請稍後再試",
"statusLegendUnprobed": "未探測",
- "statusLegendUnsupported": "唔支援"
+ "statusLegendUnsupported": "唔支援",
+ "rainScaleSection": "色階間距",
+ "rainScaleFine": "小間距",
+ "rainScaleCoarse": "大間距"
}
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index b2ec96a06..a2914c446 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -359,6 +359,10 @@
"description": "Send message button"
},
"mapLayerOrderTitle": "調整圖層順序",
+ "mapLayerShow": "顯示圖層",
+ "mapLayerHide": "隱藏圖層",
+ "mapLayerShowAll": "全部顯示",
+ "mapLayerHideAll": "全部隱藏",
"@skyTimeNoon": {
"description": "Label for the skyTimeNoon option in the experimental backdrop settings."
},
@@ -921,6 +925,10 @@
"typhoonPickerTd": "熱帶性低氣壓 TD {no}",
"mapLayerSatelliteWatervapor": "ひまわり 水氣",
"regionAddButton": "新增地區",
+ "regionSearchHint": "搜尋縣市",
+ "regionSearchEmpty": "找不到符合的縣市",
+ "regionSearchTownHint": "搜尋鄉鎮市區",
+ "regionSearchTownEmpty": "找不到符合的鄉鎮市區",
"displaySettings": "顯示設定",
"restroomGradePoor": "不合格",
"restroomCategoryTourist": "觀光地區及風景區",
@@ -1942,5 +1950,8 @@
"dumpCopyAgain": "再複製一次",
"dumpUploadFailed": "上傳失敗,請稍後再試",
"statusLegendUnprobed": "未探測",
- "statusLegendUnsupported": "不支援"
+ "statusLegendUnsupported": "不支援",
+ "rainScaleSection": "色階間距",
+ "rainScaleFine": "小間距",
+ "rainScaleCoarse": "大間距"
}
diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb
index 3c5c51073..0adafe4a4 100644
--- a/lib/l10n/app_zh_Hans.arb
+++ b/lib/l10n/app_zh_Hans.arb
@@ -359,6 +359,10 @@
"description": "Send message button"
},
"mapLayerOrderTitle": "调整图层顺序",
+ "mapLayerShow": "显示图层",
+ "mapLayerHide": "隐藏图层",
+ "mapLayerShowAll": "全部显示",
+ "mapLayerHideAll": "全部隐藏",
"@skyTimeNoon": {
"description": "Label for the skyTimeNoon option in the experimental backdrop settings."
},
@@ -921,6 +925,10 @@
"typhoonPickerTd": "热带性低气压 TD {no}",
"mapLayerSatelliteWatervapor": "ひまわり 水气",
"regionAddButton": "添加地区",
+ "regionSearchHint": "搜索县市",
+ "regionSearchEmpty": "找不到符合的县市",
+ "regionSearchTownHint": "搜索乡镇市区",
+ "regionSearchTownEmpty": "找不到符合的乡镇市区",
"displaySettings": "显示设置",
"restroomGradePoor": "不合格",
"restroomCategoryTourist": "观光地区及风景区",
@@ -1950,5 +1958,8 @@
"dumpCopyAgain": "再复制一次",
"dumpUploadFailed": "上传失败,请稍后再试",
"statusLegendUnprobed": "未探测",
- "statusLegendUnsupported": "不支持"
+ "statusLegendUnsupported": "不支持",
+ "rainScaleSection": "色阶间距",
+ "rainScaleFine": "小间距",
+ "rainScaleCoarse": "大间距"
}
diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb
index 3fe7caf2d..147925713 100644
--- a/lib/l10n/app_zh_Hant_HK.arb
+++ b/lib/l10n/app_zh_Hant_HK.arb
@@ -359,6 +359,10 @@
"description": "Send message button"
},
"mapLayerOrderTitle": "調整圖層順序",
+ "mapLayerShow": "顯示圖層",
+ "mapLayerHide": "隱藏圖層",
+ "mapLayerShowAll": "全部顯示",
+ "mapLayerHideAll": "全部隱藏",
"@skyTimeNoon": {
"description": "Label for the skyTimeNoon option in the experimental backdrop settings."
},
@@ -921,6 +925,10 @@
"typhoonPickerTd": "熱帶性低氣壓 TD {no}",
"mapLayerSatelliteWatervapor": "ひまわり 水氣",
"regionAddButton": "新增地區",
+ "regionSearchHint": "搜尋縣市",
+ "regionSearchEmpty": "搵唔到符合嘅縣市",
+ "regionSearchTownHint": "搜尋鄉鎮",
+ "regionSearchTownEmpty": "搵唔到符合嘅鄉鎮",
"displaySettings": "顯示設定",
"restroomGradePoor": "不合格",
"restroomCategoryTourist": "觀光地區及風景區",
@@ -1950,5 +1958,8 @@
"dumpCopyAgain": "再複製一次",
"dumpUploadFailed": "上載失敗,請稍後再試",
"statusLegendUnprobed": "未探測",
- "statusLegendUnsupported": "不支援"
+ "statusLegendUnsupported": "不支援",
+ "rainScaleSection": "色階間距",
+ "rainScaleFine": "小間距",
+ "rainScaleCoarse": "大間距"
}
diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb
index 6572933c2..5fc562bd7 100644
--- a/lib/l10n/app_zh_TW.arb
+++ b/lib/l10n/app_zh_TW.arb
@@ -359,6 +359,10 @@
"description": "Send message button"
},
"mapLayerOrderTitle": "調整圖層順序",
+ "mapLayerShow": "顯示圖層",
+ "mapLayerHide": "隱藏圖層",
+ "mapLayerShowAll": "全部顯示",
+ "mapLayerHideAll": "全部隱藏",
"@skyTimeNoon": {
"description": "Label for the skyTimeNoon option in the experimental backdrop settings."
},
@@ -921,6 +925,10 @@
"typhoonPickerTd": "熱帶性低氣壓 TD {no}",
"mapLayerSatelliteWatervapor": "ひまわり 水氣",
"regionAddButton": "新增地區",
+ "regionSearchHint": "搜尋縣市",
+ "regionSearchEmpty": "找不到符合的縣市",
+ "regionSearchTownHint": "搜尋鄉鎮市區",
+ "regionSearchTownEmpty": "找不到符合的鄉鎮市區",
"displaySettings": "顯示設定",
"restroomGradePoor": "不合格",
"restroomCategoryTourist": "觀光地區及風景區",
@@ -1950,5 +1958,8 @@
"dumpCopyAgain": "再複製一次",
"dumpUploadFailed": "上傳失敗,請稍後再試",
"statusLegendUnprobed": "未探測",
- "statusLegendUnsupported": "不支援"
+ "statusLegendUnsupported": "不支援",
+ "rainScaleSection": "色階間距",
+ "rainScaleFine": "小間距",
+ "rainScaleCoarse": "大間距"
}
diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart
index dcad028fd..9088c8bfa 100644
--- a/lib/l10n/gen/app_localizations.dart
+++ b/lib/l10n/gen/app_localizations.dart
@@ -1547,6 +1547,30 @@ abstract class AppLocalizations {
/// **'Reorder layers'**
String get mapLayerOrderTitle;
+ /// No description provided for @mapLayerShow.
+ ///
+ /// In en, this message translates to:
+ /// **'Show layer'**
+ String get mapLayerShow;
+
+ /// No description provided for @mapLayerHide.
+ ///
+ /// In en, this message translates to:
+ /// **'Hide layer'**
+ String get mapLayerHide;
+
+ /// No description provided for @mapLayerShowAll.
+ ///
+ /// In en, this message translates to:
+ /// **'Show all'**
+ String get mapLayerShowAll;
+
+ /// No description provided for @mapLayerHideAll.
+ ///
+ /// In en, this message translates to:
+ /// **'Hide all'**
+ String get mapLayerHideAll;
+
/// Affirmative value in the disaster-map detail sheet
///
/// In en, this message translates to:
@@ -3557,6 +3581,30 @@ abstract class AppLocalizations {
/// **'Add a region'**
String get regionAddButton;
+ /// No description provided for @regionSearchHint.
+ ///
+ /// In en, this message translates to:
+ /// **'Search counties and cities'**
+ String get regionSearchHint;
+
+ /// No description provided for @regionSearchEmpty.
+ ///
+ /// In en, this message translates to:
+ /// **'No matching counties or cities'**
+ String get regionSearchEmpty;
+
+ /// No description provided for @regionSearchTownHint.
+ ///
+ /// In en, this message translates to:
+ /// **'Search townships'**
+ String get regionSearchTownHint;
+
+ /// No description provided for @regionSearchTownEmpty.
+ ///
+ /// In en, this message translates to:
+ /// **'No matching townships'**
+ String get regionSearchTownEmpty;
+
/// Display-settings menu entry and page title (theme mode)
///
/// In en, this message translates to:
@@ -6082,6 +6130,24 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Not offered'**
String get statusLegendUnsupported;
+
+ /// Menu section header for the rainfall colour-scale interval choice
+ ///
+ /// In en, this message translates to:
+ /// **'Colour scale'**
+ String get rainScaleSection;
+
+ /// Rainfall colour scale option: close-spaced thresholds (1-300 mm), for short accumulation windows
+ ///
+ /// In en, this message translates to:
+ /// **'Fine'**
+ String get rainScaleFine;
+
+ /// Rainfall colour scale option: wide-spaced thresholds (10-1500 mm), for multi-day totals
+ ///
+ /// In en, this message translates to:
+ /// **'Coarse'**
+ String get rainScaleCoarse;
}
class _AppLocalizationsDelegate
diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart
index f076ce13a..b361b12b1 100644
--- a/lib/l10n/gen/app_localizations_en.dart
+++ b/lib/l10n/gen/app_localizations_en.dart
@@ -782,6 +782,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get mapLayerOrderTitle => 'Reorder layers';
+ @override
+ String get mapLayerShow => 'Show layer';
+
+ @override
+ String get mapLayerHide => 'Hide layer';
+
+ @override
+ String get mapLayerShowAll => 'Show all';
+
+ @override
+ String get mapLayerHideAll => 'Hide all';
+
@override
String get dpmYes => 'Yes';
@@ -1863,6 +1875,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get regionAddButton => 'Add a region';
+ @override
+ String get regionSearchHint => 'Search counties and cities';
+
+ @override
+ String get regionSearchEmpty => 'No matching counties or cities';
+
+ @override
+ String get regionSearchTownHint => 'Search townships';
+
+ @override
+ String get regionSearchTownEmpty => 'No matching townships';
+
@override
String get displaySettings => 'Display';
@@ -3196,4 +3220,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get statusLegendUnsupported => 'Not offered';
+
+ @override
+ String get rainScaleSection => 'Colour scale';
+
+ @override
+ String get rainScaleFine => 'Fine';
+
+ @override
+ String get rainScaleCoarse => 'Coarse';
}
diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart
index 5c66846f1..1c66146e0 100644
--- a/lib/l10n/gen/app_localizations_fil.dart
+++ b/lib/l10n/gen/app_localizations_fil.dart
@@ -787,6 +787,18 @@ class AppLocalizationsFil extends AppLocalizations {
@override
String get mapLayerOrderTitle => 'Ayusin ang ayos ng layer';
+ @override
+ String get mapLayerShow => 'Ipakita ang layer';
+
+ @override
+ String get mapLayerHide => 'Itago ang layer';
+
+ @override
+ String get mapLayerShowAll => 'Ipakita lahat';
+
+ @override
+ String get mapLayerHideAll => 'Itago lahat';
+
@override
String get dpmYes => 'Oo';
@@ -1873,6 +1885,18 @@ class AppLocalizationsFil extends AppLocalizations {
@override
String get regionAddButton => 'Magdagdag ng rehiyon';
+ @override
+ String get regionSearchHint => 'Maghanap ng mga lalawigan at lungsod';
+
+ @override
+ String get regionSearchEmpty => 'Walang tumugmang lalawigan o lungsod';
+
+ @override
+ String get regionSearchTownHint => 'Maghanap ng mga bayan';
+
+ @override
+ String get regionSearchTownEmpty => 'Walang tumugmang bayan';
+
@override
String get displaySettings => 'Pagpapakita';
@@ -3212,4 +3236,13 @@ class AppLocalizationsFil extends AppLocalizations {
@override
String get statusLegendUnsupported => 'Hindi suportado';
+
+ @override
+ String get rainScaleSection => 'Antas ng kulay';
+
+ @override
+ String get rainScaleFine => 'Pino';
+
+ @override
+ String get rainScaleCoarse => 'Magaspang';
}
diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart
index 0ae6f08ce..15a38f4b6 100644
--- a/lib/l10n/gen/app_localizations_id.dart
+++ b/lib/l10n/gen/app_localizations_id.dart
@@ -784,6 +784,18 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get mapLayerOrderTitle => 'Urutkan lapisan';
+ @override
+ String get mapLayerShow => 'Tampilkan lapisan';
+
+ @override
+ String get mapLayerHide => 'Sembunyikan lapisan';
+
+ @override
+ String get mapLayerShowAll => 'Tampilkan semua';
+
+ @override
+ String get mapLayerHideAll => 'Sembunyikan semua';
+
@override
String get dpmYes => 'Ya';
@@ -1867,6 +1879,18 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get regionAddButton => 'Tambah wilayah';
+ @override
+ String get regionSearchHint => 'Cari kabupaten dan kota';
+
+ @override
+ String get regionSearchEmpty => 'Tidak ada kabupaten/kota yang cocok';
+
+ @override
+ String get regionSearchTownHint => 'Cari kecamatan';
+
+ @override
+ String get regionSearchTownEmpty => 'Tidak ada kecamatan yang cocok';
+
@override
String get displaySettings => 'Tampilan';
@@ -3206,4 +3230,13 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get statusLegendUnsupported => 'Tidak tersedia';
+
+ @override
+ String get rainScaleSection => 'Skala warna';
+
+ @override
+ String get rainScaleFine => 'Halus';
+
+ @override
+ String get rainScaleCoarse => 'Kasar';
}
diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart
index 6eddc7366..19544a6a9 100644
--- a/lib/l10n/gen/app_localizations_ja.dart
+++ b/lib/l10n/gen/app_localizations_ja.dart
@@ -769,6 +769,18 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get mapLayerOrderTitle => 'レイヤーの順番';
+ @override
+ String get mapLayerShow => 'レイヤーを表示';
+
+ @override
+ String get mapLayerHide => 'レイヤーを非表示';
+
+ @override
+ String get mapLayerShowAll => 'すべて表示';
+
+ @override
+ String get mapLayerHideAll => 'すべて非表示';
+
@override
String get dpmYes => 'はい';
@@ -1831,6 +1843,18 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get regionAddButton => '地域を追加';
+ @override
+ String get regionSearchHint => '都道府県・市区を検索';
+
+ @override
+ String get regionSearchEmpty => '一致する地域がありません';
+
+ @override
+ String get regionSearchTownHint => '町村を検索';
+
+ @override
+ String get regionSearchTownEmpty => '該当する町村がありません';
+
@override
String get displaySettings => '表示';
@@ -3141,4 +3165,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get statusLegendUnsupported => '非対応';
+
+ @override
+ String get rainScaleSection => '色階の間隔';
+
+ @override
+ String get rainScaleFine => '細かい';
+
+ @override
+ String get rainScaleCoarse => '粗い';
}
diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart
index cbe9175f4..55621f74c 100644
--- a/lib/l10n/gen/app_localizations_ko.dart
+++ b/lib/l10n/gen/app_localizations_ko.dart
@@ -768,6 +768,18 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get mapLayerOrderTitle => '레이어 순서';
+ @override
+ String get mapLayerShow => '레이어 표시';
+
+ @override
+ String get mapLayerHide => '레이어 숨기기';
+
+ @override
+ String get mapLayerShowAll => '전체 표시';
+
+ @override
+ String get mapLayerHideAll => '전체 숨기기';
+
@override
String get dpmYes => '예';
@@ -1831,6 +1843,18 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get regionAddButton => '지역 추가';
+ @override
+ String get regionSearchHint => '시·도 검색';
+
+ @override
+ String get regionSearchEmpty => '일치하는 시·도가 없습니다';
+
+ @override
+ String get regionSearchTownHint => '읍·면·동 검색';
+
+ @override
+ String get regionSearchTownEmpty => '일치하는 읍·면·동이 없습니다';
+
@override
String get displaySettings => '화면';
@@ -3141,4 +3165,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get statusLegendUnsupported => '미지원';
+
+ @override
+ String get rainScaleSection => '색상 간격';
+
+ @override
+ String get rainScaleFine => '좁게';
+
+ @override
+ String get rainScaleCoarse => '넓게';
}
diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart
index b6afa23cc..9c148e0dc 100644
--- a/lib/l10n/gen/app_localizations_th.dart
+++ b/lib/l10n/gen/app_localizations_th.dart
@@ -780,6 +780,18 @@ class AppLocalizationsTh extends AppLocalizations {
@override
String get mapLayerOrderTitle => 'จัดเรียงเลเยอร์';
+ @override
+ String get mapLayerShow => 'แสดงเลเยอร์';
+
+ @override
+ String get mapLayerHide => 'ซ่อนเลเยอร์';
+
+ @override
+ String get mapLayerShowAll => 'แสดงทั้งหมด';
+
+ @override
+ String get mapLayerHideAll => 'ซ่อนทั้งหมด';
+
@override
String get dpmYes => 'ใช่';
@@ -1859,6 +1871,18 @@ class AppLocalizationsTh extends AppLocalizations {
@override
String get regionAddButton => 'เพิ่มพื้นที่';
+ @override
+ String get regionSearchHint => 'ค้นหาจังหวัดและเมือง';
+
+ @override
+ String get regionSearchEmpty => 'ไม่พบจังหวัดหรือเมืองที่ตรงกัน';
+
+ @override
+ String get regionSearchTownHint => 'ค้นหาตำบล';
+
+ @override
+ String get regionSearchTownEmpty => 'ไม่พบตำบลที่ตรงกัน';
+
@override
String get displaySettings => 'การแสดงผล';
@@ -3189,4 +3213,13 @@ class AppLocalizationsTh extends AppLocalizations {
@override
String get statusLegendUnsupported => 'ไม่รองรับ';
+
+ @override
+ String get rainScaleSection => 'ช่วงระดับสี';
+
+ @override
+ String get rainScaleFine => 'ละเอียด';
+
+ @override
+ String get rainScaleCoarse => 'หยาบ';
}
diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart
index 5ee378d05..057a81b02 100644
--- a/lib/l10n/gen/app_localizations_vi.dart
+++ b/lib/l10n/gen/app_localizations_vi.dart
@@ -781,6 +781,18 @@ class AppLocalizationsVi extends AppLocalizations {
@override
String get mapLayerOrderTitle => 'Sắp xếp thứ tự lớp';
+ @override
+ String get mapLayerShow => 'Hiện lớp bản đồ';
+
+ @override
+ String get mapLayerHide => 'Ẩn lớp bản đồ';
+
+ @override
+ String get mapLayerShowAll => 'Hiện tất cả';
+
+ @override
+ String get mapLayerHideAll => 'Ẩn tất cả';
+
@override
String get dpmYes => 'Có';
@@ -1863,6 +1875,18 @@ class AppLocalizationsVi extends AppLocalizations {
@override
String get regionAddButton => 'Thêm khu vực';
+ @override
+ String get regionSearchHint => 'Tìm kiếm tỉnh và thành phố';
+
+ @override
+ String get regionSearchEmpty => 'Không tìm thấy tỉnh/thành phố phù hợp';
+
+ @override
+ String get regionSearchTownHint => 'Tìm kiếm xã';
+
+ @override
+ String get regionSearchTownEmpty => 'Không tìm thấy xã phù hợp';
+
@override
String get displaySettings => 'Hiển thị';
@@ -3196,4 +3220,13 @@ class AppLocalizationsVi extends AppLocalizations {
@override
String get statusLegendUnsupported => 'Không có';
+
+ @override
+ String get rainScaleSection => 'Thang màu';
+
+ @override
+ String get rainScaleFine => 'Mịn';
+
+ @override
+ String get rainScaleCoarse => 'Thô';
}
diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart
index acc8230e8..e158ee75b 100644
--- a/lib/l10n/gen/app_localizations_yue.dart
+++ b/lib/l10n/gen/app_localizations_yue.dart
@@ -764,6 +764,18 @@ class AppLocalizationsYue extends AppLocalizations {
@override
String get mapLayerOrderTitle => '調整圖層順序';
+ @override
+ String get mapLayerShow => '顯示圖層';
+
+ @override
+ String get mapLayerHide => '隱藏圖層';
+
+ @override
+ String get mapLayerShowAll => '全部顯示';
+
+ @override
+ String get mapLayerHideAll => '全部隱藏';
+
@override
String get dpmYes => '係';
@@ -1820,6 +1832,18 @@ class AppLocalizationsYue extends AppLocalizations {
@override
String get regionAddButton => '新增地區';
+ @override
+ String get regionSearchHint => '搜尋縣市';
+
+ @override
+ String get regionSearchEmpty => '搵唔到符合嘅縣市';
+
+ @override
+ String get regionSearchTownHint => '搜尋鄉鎮';
+
+ @override
+ String get regionSearchTownEmpty => '搵唔到符合嘅鄉鎮';
+
@override
String get displaySettings => '顯示設定';
@@ -3126,4 +3150,13 @@ class AppLocalizationsYue extends AppLocalizations {
@override
String get statusLegendUnsupported => '唔支援';
+
+ @override
+ String get rainScaleSection => '色階間距';
+
+ @override
+ String get rainScaleFine => '小間距';
+
+ @override
+ String get rainScaleCoarse => '大間距';
}
diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart
index 223efb74e..b1ea4ef81 100644
--- a/lib/l10n/gen/app_localizations_zh.dart
+++ b/lib/l10n/gen/app_localizations_zh.dart
@@ -764,6 +764,18 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get mapLayerOrderTitle => '調整圖層順序';
+ @override
+ String get mapLayerShow => '顯示圖層';
+
+ @override
+ String get mapLayerHide => '隱藏圖層';
+
+ @override
+ String get mapLayerShowAll => '全部顯示';
+
+ @override
+ String get mapLayerHideAll => '全部隱藏';
+
@override
String get dpmYes => '是';
@@ -1820,6 +1832,18 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get regionAddButton => '新增地區';
+ @override
+ String get regionSearchHint => '搜尋縣市';
+
+ @override
+ String get regionSearchEmpty => '找不到符合的縣市';
+
+ @override
+ String get regionSearchTownHint => '搜尋鄉鎮市區';
+
+ @override
+ String get regionSearchTownEmpty => '找不到符合的鄉鎮市區';
+
@override
String get displaySettings => '顯示設定';
@@ -3126,6 +3150,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get statusLegendUnsupported => '不支援';
+
+ @override
+ String get rainScaleSection => '色階間距';
+
+ @override
+ String get rainScaleFine => '小間距';
+
+ @override
+ String get rainScaleCoarse => '大間距';
}
/// The translations for Chinese, using the Han script (`zh_Hans`).
@@ -3887,6 +3920,18 @@ class AppLocalizationsZhHans extends AppLocalizationsZh {
@override
String get mapLayerOrderTitle => '调整图层顺序';
+ @override
+ String get mapLayerShow => '显示图层';
+
+ @override
+ String get mapLayerHide => '隐藏图层';
+
+ @override
+ String get mapLayerShowAll => '全部显示';
+
+ @override
+ String get mapLayerHideAll => '全部隐藏';
+
@override
String get dpmYes => '是';
@@ -4943,6 +4988,18 @@ class AppLocalizationsZhHans extends AppLocalizationsZh {
@override
String get regionAddButton => '添加地区';
+ @override
+ String get regionSearchHint => '搜索县市';
+
+ @override
+ String get regionSearchEmpty => '找不到符合的县市';
+
+ @override
+ String get regionSearchTownHint => '搜索乡镇市区';
+
+ @override
+ String get regionSearchTownEmpty => '找不到符合的乡镇市区';
+
@override
String get displaySettings => '显示设置';
@@ -6249,6 +6306,15 @@ class AppLocalizationsZhHans extends AppLocalizationsZh {
@override
String get statusLegendUnsupported => '不支持';
+
+ @override
+ String get rainScaleSection => '色阶间距';
+
+ @override
+ String get rainScaleFine => '小间距';
+
+ @override
+ String get rainScaleCoarse => '大间距';
}
/// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`).
@@ -7010,6 +7076,18 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh {
@override
String get mapLayerOrderTitle => '調整圖層順序';
+ @override
+ String get mapLayerShow => '顯示圖層';
+
+ @override
+ String get mapLayerHide => '隱藏圖層';
+
+ @override
+ String get mapLayerShowAll => '全部顯示';
+
+ @override
+ String get mapLayerHideAll => '全部隱藏';
+
@override
String get dpmYes => '是';
@@ -8066,6 +8144,18 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh {
@override
String get regionAddButton => '新增地區';
+ @override
+ String get regionSearchHint => '搜尋縣市';
+
+ @override
+ String get regionSearchEmpty => '搵唔到符合嘅縣市';
+
+ @override
+ String get regionSearchTownHint => '搜尋鄉鎮';
+
+ @override
+ String get regionSearchTownEmpty => '搵唔到符合嘅鄉鎮';
+
@override
String get displaySettings => '顯示設定';
@@ -9372,6 +9462,15 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh {
@override
String get statusLegendUnsupported => '不支援';
+
+ @override
+ String get rainScaleSection => '色階間距';
+
+ @override
+ String get rainScaleFine => '小間距';
+
+ @override
+ String get rainScaleCoarse => '大間距';
}
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
@@ -10133,6 +10232,18 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get mapLayerOrderTitle => '調整圖層順序';
+ @override
+ String get mapLayerShow => '顯示圖層';
+
+ @override
+ String get mapLayerHide => '隱藏圖層';
+
+ @override
+ String get mapLayerShowAll => '全部顯示';
+
+ @override
+ String get mapLayerHideAll => '全部隱藏';
+
@override
String get dpmYes => '是';
@@ -11189,6 +11300,18 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get regionAddButton => '新增地區';
+ @override
+ String get regionSearchHint => '搜尋縣市';
+
+ @override
+ String get regionSearchEmpty => '找不到符合的縣市';
+
+ @override
+ String get regionSearchTownHint => '搜尋鄉鎮市區';
+
+ @override
+ String get regionSearchTownEmpty => '找不到符合的鄉鎮市區';
+
@override
String get displaySettings => '顯示設定';
@@ -12495,4 +12618,13 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get statusLegendUnsupported => '不支援';
+
+ @override
+ String get rainScaleSection => '色階間距';
+
+ @override
+ String get rainScaleFine => '小間距';
+
+ @override
+ String get rainScaleCoarse => '大間距';
}
diff --git a/lib/shared/color_hex.dart b/lib/shared/color_hex.dart
index 97d4d31b0..bf4252c5f 100644
--- a/lib/shared/color_hex.dart
+++ b/lib/shared/color_hex.dart
@@ -19,6 +19,23 @@ Color? colorFromHexRgb(String hex) {
return value == null ? null : Color(0xFF000000 | value);
}
+/// [value] read against a **banded** `(at, hexColour)` ramp — the Dart twin of
+/// the `step` expression handed to MapLibre.
+///
+/// A value takes the colour of the last stop it is at or above; below the first
+/// stop it takes the first stop's colour. Unlike [rampColor] no colour is ever
+/// invented between two stops, which is the point: on a categorical scale (the
+/// CWA rainfall bands) a blend would render a reading that no band defines.
+Color? stepColor(List<(double, String)> stops, double value) {
+ if (stops.isEmpty) return null;
+ var chosen = stops.first.$2;
+ for (final (at, hex) in stops) {
+ if (value < at) break;
+ chosen = hex;
+ }
+ return colorFromHexRgb(chosen);
+}
+
/// [value] interpolated on a `(at, hexColour)` ramp — the Dart twin of the
/// `interpolate` expression handed to MapLibre, so a value-coloured dot on the
/// map and its reading in the sheet agree by construction rather than by two
diff --git a/lib/shared/map/map_layer.dart b/lib/shared/map/map_layer.dart
index 85af41bfa..d9d9aa550 100644
--- a/lib/shared/map/map_layer.dart
+++ b/lib/shared/map/map_layer.dart
@@ -212,6 +212,18 @@ abstract interface class MapLayer {
/// nobody can see) and push one catch-up on the visible edge.
void onSurfaceVisibility(bool visible);
+ /// Android/iOS asked every process to give memory back.
+ ///
+ /// This is not a hint. `TRIM_MEMORY_RUNNING_CRITICAL` is the last notice
+ /// before lmkd picks a victim, and a process that returns nothing is the
+ /// process it picks — DPIP was OOM-killed at ~1 GB resident with 341 MB of
+ /// it in mounted raster textures that no code path was willing to drop.
+ ///
+ /// Give back caches only. A layer must still be able to draw what the user
+ /// is looking at when this returns: never release the displayed frame, and
+ /// never let a release make a stale feed look current.
+ Future onMemoryPressure(MapLibreMapController controller);
+
/// This layer's frames in **chronological order** (oldest first); the last is
/// "now". `Ok()` when the layer currently has nothing to show.
Future>> frames();
@@ -324,6 +336,9 @@ mixin MapLayerDefaults implements MapLayer {
@override
void onSurfaceVisibility(bool visible) {}
+ @override
+ Future onMemoryPressure(MapLibreMapController controller) async {}
+
@override
double get mapMinZoom => BaseMap.defaultMinZoom;
diff --git a/lib/shared/map/map_layer_switcher.dart b/lib/shared/map/map_layer_switcher.dart
index 0ab0f9fd0..896629a6d 100644
--- a/lib/shared/map/map_layer_switcher.dart
+++ b/lib/shared/map/map_layer_switcher.dart
@@ -6,6 +6,7 @@ import 'dart:async';
import 'package:dpip/app/theme/app_radius.dart';
import 'package:dpip/app/theme/app_spacing.dart';
import 'package:dpip/core/settings/map_layer_order_controller.dart';
+import 'package:dpip/core/settings/map_layer_visibility_controller.dart';
import 'package:dpip/l10n/gen/app_localizations.dart';
import 'package:dpip/shared/map/map_layer.dart';
import 'package:dpip/shared/map/map_layer_category.dart';
@@ -93,6 +94,7 @@ class MapLayerSwitcher extends StatelessWidget {
Future _pick(BuildContext context) async {
final orderController = context.read();
+ final visibility = context.read();
// Owns restoring the sheet's own height when a remembered scroll offset
// needs one — see `_RememberedOffsetList`'s doc for why.
final sheetController = DraggableScrollableController();
@@ -127,24 +129,34 @@ class MapLayerSwitcher extends StatelessWidget {
tooltip: l10n.mapLayerOrderTitle,
visualDensity: VisualDensity.compact,
onPressed: () =>
- _editOrder(sheetContext, orderController),
+ _editOrder(sheetContext, orderController, visibility),
),
),
Expanded(
// Live-updates when the order editor above changes it, so
- // the picker reflects a reorder the moment the editor
- // closes back on top of it.
+ // the picker reflects a reorder — or a hide — the moment
+ // the editor closes back on top of it. A hidden layer is
+ // dropped from this list entirely; the eye toggle in the
+ // order editor is the only way back.
child: ListenableBuilder(
- listenable: orderController,
+ listenable: Listenable.merge([
+ orderController,
+ visibility,
+ ]),
builder: (context, _) {
- final ordered = orderedLayers(
- layers,
- orderController.order,
- );
+ final ordered =
+ orderedLayers(layers, orderController.order)
+ .where(
+ (layer) => !visibility.isHidden(layer.id),
+ )
+ .toList();
+ final visibleCategories = {
+ for (final layer in ordered) categoryOf(layer.id),
+ };
final categories = orderedCategories(
MapLayerCategory.values,
orderController.categoryOrder,
- );
+ ).where(visibleCategories.contains);
return _RememberedOffsetList(
// Remembers scroll offset across separate openings
// of this sheet — picking a layer pops the sheet
@@ -189,22 +201,30 @@ class MapLayerSwitcher extends StatelessWidget {
},
);
sheetController.dispose();
- if (selected != null && selected.id != active.id) onSelected(selected);
+ if (selected == null) return;
+ // Same-id picks are not skipped: the scaffold's own handler knows its
+ // *current* layer (post-fallback) and ignores true no-ops itself.
+ onSelected(selected);
}
/// Opens the layer-order editor over the picker. Reordering persists to
- /// [orderController] on every drop, so closing the editor (or the picker)
+ /// [orderController] on every drop, and the eye toggles persist to
+ /// [visibility] immediately, so closing the editor (or the picker)
/// never discards a change.
Future _editOrder(
BuildContext sheetContext,
MapLayerOrderController orderController,
+ MapLayerVisibilityController visibility,
) async {
await showModalBottomSheet(
context: sheetContext,
isScrollControlled: true,
backgroundColor: Colors.transparent,
- builder: (_) =>
- _LayerOrderSheet(layers: layers, controller: orderController),
+ builder: (_) => _LayerOrderSheet(
+ layers: layers,
+ controller: orderController,
+ visibility: visibility,
+ ),
);
}
}
@@ -418,10 +438,15 @@ class _LayerTile extends StatelessWidget {
/// button clears both saved orders so the list falls back to the declared
/// order.
class _LayerOrderSheet extends StatefulWidget {
- const _LayerOrderSheet({required this.layers, required this.controller});
+ const _LayerOrderSheet({
+ required this.layers,
+ required this.controller,
+ required this.visibility,
+ });
final List layers;
final MapLayerOrderController controller;
+ final MapLayerVisibilityController visibility;
@override
State<_LayerOrderSheet> createState() => _LayerOrderSheetState();
@@ -438,6 +463,10 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> {
/// The category whose layers are being edited; null shows the category list.
MapLayerCategory? _editing;
+ /// Navigation direction of the last level switch — drill-in slides the new
+ /// list in from the right, going back mirrors it from the left.
+ bool _drillingIn = true;
+
/// Layer ids in current block order — what gets persisted.
List get _ids => [
for (final block in _blocks)
@@ -487,14 +516,45 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> {
icon: const Icon(Icons.arrow_back),
tooltip: MaterialLocalizations.of(context)
.backButtonTooltip,
- onPressed: () => setState(() => _editing = null),
+ onPressed: () => setState(() {
+ _drillingIn = false;
+ _editing = null;
+ }),
),
right: closeButton,
),
Flexible(
- child: editing == null
- ? _categoryList(context)
- : _layerList(context, editingBlock!),
+ child: AnimatedSwitcher(
+ duration: const Duration(milliseconds: 250),
+ switchInCurve: Curves.easeOutCubic,
+ switchOutCurve: Curves.easeInCubic,
+ transitionBuilder: (child, animation) {
+ // The page matching the current editing state is the one
+ // entering; it slides in from the right on a drill-in and
+ // from the left on the way back. The outgoing page runs
+ // the same tween reversed, so it exits toward the side
+ // the user came from.
+ final currentKey = ValueKey(
+ _editing == null
+ ? 'categories'
+ : 'layers-${_editing!.name}',
+ );
+ final incoming = child.key == currentKey;
+ final sign = _drillingIn ? 1.0 : -1.0;
+ return ClipRect(
+ child: SlideTransition(
+ position: Tween(
+ begin: Offset(incoming ? sign : -sign, 0),
+ end: Offset.zero,
+ ).animate(animation),
+ child: child,
+ ),
+ );
+ },
+ child: editing == null
+ ? _categoryList(context)
+ : _layerList(context, editingBlock!),
+ ),
),
],
),
@@ -505,6 +565,7 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> {
Widget _categoryList(BuildContext context) {
return ReorderableListView.builder(
+ key: const ValueKey('categories'),
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
@@ -516,37 +577,127 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> {
onReorderItem: _reorderCategory,
itemBuilder: (context, index) {
final block = _blocks[index];
- final canOpen = block.ids.length > 1;
+ // Every category opens: the eye toggles live on level 2, so a
+ // single-layer category (radar, typhoon, rts) must be drill-in-able
+ // even though its reorder list holds exactly one row.
return _CategoryOrderTile(
key: ValueKey('category-${block.category.name}'),
category: block.category,
index: index,
- canOpen: canOpen,
- onTap: canOpen
- ? () => setState(() => _editing = block.category)
- : null,
+ onTap: () => setState(() {
+ _drillingIn = true;
+ _editing = block.category;
+ }),
);
},
);
}
Widget _layerList(BuildContext context, _Block block) {
- return ReorderableListView.builder(
- buildDefaultDragHandles: false,
- padding: const EdgeInsets.fromLTRB(
- AppSpacing.md,
- 0,
- AppSpacing.md,
- AppSpacing.md,
- ),
- itemCount: block.ids.length,
- onReorderItem: (oldIndex, newIndex) =>
- _reorderLayer(block, oldIndex, newIndex),
- itemBuilder: (context, index) {
- final id = block.ids[index];
- final layer = widget.layers.firstWhere((layer) => layer.id == id);
- return _ReorderTile(key: ValueKey(id), layer: layer, index: index);
- },
+ final l10n = AppLocalizations.of(context);
+ final hideIds = _idsToHideAllIn(block);
+ final showAllDisabled = block.ids.every(
+ (id) => !widget.visibility.isHidden(id),
+ );
+ final hideAllDisabled = hideIds.every(widget.visibility.isHidden);
+ return Column(
+ key: ValueKey('layers-${block.category.name}'),
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(
+ AppSpacing.md,
+ AppSpacing.xs,
+ AppSpacing.md,
+ AppSpacing.sm,
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: OutlinedButton.icon(
+ onPressed: showAllDisabled
+ ? null
+ : () => _showAllInCategory(block),
+ icon: const Icon(Icons.visibility, size: 18),
+ label: Text(l10n.mapLayerShowAll),
+ ),
+ ),
+ const SizedBox(width: AppSpacing.sm),
+ Expanded(
+ child: OutlinedButton.icon(
+ onPressed: hideAllDisabled
+ ? null
+ : () => _hideAllInCategory(block),
+ icon: const Icon(Icons.visibility_off, size: 18),
+ label: Text(l10n.mapLayerHideAll),
+ ),
+ ),
+ ],
+ ),
+ ),
+ Flexible(
+ child: ReorderableListView.builder(
+ key: ValueKey('layers-list-${block.category.name}'),
+ buildDefaultDragHandles: false,
+ padding: const EdgeInsets.fromLTRB(
+ AppSpacing.md,
+ 0,
+ AppSpacing.md,
+ AppSpacing.md,
+ ),
+ itemCount: block.ids.length,
+ onReorderItem: (oldIndex, newIndex) =>
+ _reorderLayer(block, oldIndex, newIndex),
+ itemBuilder: (context, index) {
+ final id = block.ids[index];
+ final layer = widget.layers.firstWhere((layer) => layer.id == id);
+ return _ReorderTile(
+ key: ValueKey(id),
+ layer: layer,
+ index: index,
+ hidden: widget.visibility.isHidden(id),
+ // Hiding must never leave the surface with nothing to show,
+ // so the last visible layer's eye is disabled until another
+ // one is shown again.
+ canHide:
+ widget.visibility.isHidden(id) ||
+ widget.layers
+ .where((l) => !widget.visibility.isHidden(l.id))
+ .length >
+ 1,
+ onToggleVisibility: () => _toggleVisibility(layer),
+ );
+ },
+ ),
+ ),
+ ],
+ );
+ }
+
+ /// The ids in [block] that "hide all" would actually hide — every id in it,
+ /// unless nothing outside this category is visible, in which case the
+ /// first id stays exempt so the surface always has something to show.
+ List _idsToHideAllIn(_Block block) {
+ final elsewhereVisible = widget.layers.any(
+ (layer) =>
+ categoryOf(layer.id) != block.category &&
+ !widget.visibility.isHidden(layer.id),
+ );
+ return elsewhereVisible ? block.ids : block.ids.skip(1).toList();
+ }
+
+ /// Shows every layer in [block] as one write — never blocked, since
+ /// showing more layers can't violate the "always something visible"
+ /// invariant.
+ void _showAllInCategory(_Block block) {
+ setState(() {});
+ unawaited(widget.visibility.setManyHidden(block.ids, hidden: false));
+ }
+
+ void _hideAllInCategory(_Block block) {
+ setState(() {});
+ unawaited(
+ widget.visibility.setManyHidden(_idsToHideAllIn(block), hidden: true),
);
}
@@ -569,6 +720,20 @@ class _LayerOrderSheetState extends State<_LayerOrderSheet> {
unawaited(widget.controller.setOrder(_ids));
}
+ /// Flips a layer's hidden state. The sheet rebuilds from its own [setState]
+ /// — it does not listen to the controller — while the write itself is
+ /// fire-and-forget: every drop/tap supersedes the previous one and the
+ /// picker underneath reads the controller when it rebuilds.
+ void _toggleVisibility(MapLayer layer) {
+ setState(() {});
+ unawaited(
+ widget.visibility.setHidden(
+ layer.id,
+ hidden: !widget.visibility.isHidden(layer.id),
+ ),
+ );
+ }
+
void _reset() {
setState(() {
_blocks = _buildBlocks(widget.layers, const [], const []);
@@ -626,20 +791,18 @@ List<_Block> _buildBlocks(
}
/// One row of the level-1 category list. Dragging reorders the categories;
-/// tapping a category with more than one layer opens its level-2 layer list.
+/// tapping opens the level-2 layer list (order + visibility).
class _CategoryOrderTile extends StatelessWidget {
const _CategoryOrderTile({
super.key,
required this.category,
required this.index,
- required this.canOpen,
required this.onTap,
});
final MapLayerCategory category;
final int index;
- final bool canOpen;
- final VoidCallback? onTap;
+ final VoidCallback onTap;
@override
Widget build(BuildContext context) {
@@ -668,8 +831,7 @@ class _CategoryOrderTile extends StatelessWidget {
),
),
),
- if (canOpen)
- Icon(Icons.chevron_right, color: colors.onSurfaceVariant),
+ Icon(Icons.chevron_right, color: colors.onSurfaceVariant),
ReorderableDragStartListener(
index: index,
child: const Padding(
@@ -734,18 +896,33 @@ class _CenteredHeader extends StatelessWidget {
}
}
-/// One row of the reorder editor: layer identity on the left, a drag handle on
-/// the right.
+/// One row of the reorder editor: layer identity on the left, an eye toggle
+/// (shown/hidden) and a drag handle on the right.
class _ReorderTile extends StatelessWidget {
- const _ReorderTile({super.key, required this.layer, required this.index});
+ const _ReorderTile({
+ super.key,
+ required this.layer,
+ required this.index,
+ required this.hidden,
+ required this.canHide,
+ required this.onToggleVisibility,
+ });
final MapLayer layer;
final int index;
+ /// Whether this layer is currently hidden from the picker.
+ final bool hidden;
+
+ /// Whether hiding is allowed right now — false for the last visible layer.
+ final bool canHide;
+ final VoidCallback onToggleVisibility;
+
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
+ final l10n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: Material(
@@ -756,6 +933,9 @@ class _ReorderTile extends StatelessWidget {
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
+ // The row looks identical whether the layer is hidden or not — no
+ // dimming, no cross-fade — so pressing the eye never makes anything
+ // appear to vanish. The eye itself is the only state indicator.
child: Row(
children: [
Icon(layer.icon, color: colors.onSurfaceVariant),
@@ -768,6 +948,23 @@ class _ReorderTile extends StatelessWidget {
),
),
),
+ IconButton(
+ visualDensity: VisualDensity.compact,
+ tooltip: hidden ? l10n.mapLayerShow : l10n.mapLayerHide,
+ // Same color in both states — `outlineVariant` (meant for
+ // faint dividers) made the icon nearly invisible against the
+ // tile the moment `hidden` flipped true, so tapping it looked
+ // like the icon itself vanished. The row already says the
+ // glyph swap alone should carry the state.
+ color: colors.onSurfaceVariant,
+ // No press overlay: this button's only feedback is the icon
+ // itself swapping between the two glyphs, so a translucent
+ // state layer on top would just look like a second, competing
+ // signal for the same tap.
+ style: IconButton.styleFrom(overlayColor: Colors.transparent),
+ onPressed: canHide ? onToggleVisibility : null,
+ icon: Icon(hidden ? Icons.visibility_off : Icons.visibility),
+ ),
ReorderableDragStartListener(
index: index,
child: const Padding(
diff --git a/lib/shared/map/map_scaffold.dart b/lib/shared/map/map_scaffold.dart
index 4344e9ce0..f3ee47659 100644
--- a/lib/shared/map/map_scaffold.dart
+++ b/lib/shared/map/map_scaffold.dart
@@ -5,6 +5,7 @@ import 'package:dpip/app/theme/app_spacing.dart';
import 'package:dpip/core/error/failure.dart';
import 'package:dpip/core/logging/log.dart';
import 'package:dpip/core/realtime/app_time.dart';
+import 'package:dpip/core/settings/map_layer_visibility_controller.dart';
import 'package:dpip/l10n/gen/app_localizations.dart';
import 'package:dpip/shared/map/base_map.dart';
import 'package:dpip/shared/map/camera_fit.dart';
@@ -130,6 +131,13 @@ class _MapScaffoldState extends State with WidgetsBindingObserver {
/// Ranking → map: switch layer, frame station, open sheet.
MapStationHandoff? _stationHandoff;
+ /// Hidden-layer set. Watched directly (not via the parent remounting on a
+ /// [ValueKey] change) so hiding the on-screen layer falls back through
+ /// [_onLayerSelected] in place — a remount would tear down this State and
+ /// close any sheet open above it, such as the layer-order editor the hide
+ /// itself was just tapped from.
+ MapLayerVisibilityController? _visibility;
+
late MapLayer _active = _resolveInitial(widget);
static MapLayer _resolveInitial(MapScaffold widget) {
@@ -257,6 +265,11 @@ class _MapScaffoldState extends State with WidgetsBindingObserver {
_stationHandoff?.removeListener(_onStationHandoff);
_stationHandoff = station..addListener(_onStationHandoff);
}
+ final visibility = context.read();
+ if (visibility != _visibility) {
+ _visibility?.removeListener(_onVisibilityChanged);
+ _visibility = visibility..addListener(_onVisibilityChanged);
+ }
final visibleTab = VisibleTabScope.of(context);
if (identical(visibleTab, _visibleTab)) return;
_visibleTab?.removeListener(_onTabChanged);
@@ -349,6 +362,7 @@ class _MapScaffoldState extends State with WidgetsBindingObserver {
_basemapWarmer?.cancel();
_handoff?.removeListener(_onHandoff);
_stationHandoff?.removeListener(_onStationHandoff);
+ _visibility?.removeListener(_onVisibilityChanged);
super.dispose();
}
@@ -415,6 +429,34 @@ class _MapScaffoldState extends State with WidgetsBindingObserver {
// a render resume nor a timeline refetch.
}
+ /// The OS is short on memory and asked for caches back.
+ ///
+ /// Android delivers this from `onTrimMemory` at `TRIM_MEMORY_RUNNING_LOW`
+ /// and above; `RUNNING_CRITICAL` is the last notice before lmkd chooses a
+ /// victim. Returning nothing is how a process becomes that victim — DPIP was
+ /// OOM-killed at ~1 GB resident, 341 MB of it decoded raster textures held
+ /// by mounted timeline sources, with no code path willing to drop any of it.
+ ///
+ /// Only the active layer is asked: an inactive one holds no mounted sources
+ /// (`_onLayerSelected` clears it on the way out). The image cache is Flutter's
+ /// own and is rebuilt on demand.
+ @override
+ void didHaveMemoryPressure() {
+ final controller = _controller;
+ _trace(
+ () =>
+ 'memory-pressure active=${_active.id} '
+ 'controller=${controller != null}',
+ );
+ PaintingBinding.instance.imageCache.clear();
+ PaintingBinding.instance.imageCache.clearLiveImages();
+ if (controller == null) return;
+ _queue(
+ () => _active.onMemoryPressure(controller),
+ label: '${_active.id}.memory-pressure',
+ );
+ }
+
/// A framing request arrived (map re-opened from Home / the nav bar) — apply it
/// once the style is up. Leaves it pending if not, for [_onStyleLoaded].
void _onHandoff() {
@@ -1268,6 +1310,20 @@ class _MapScaffoldState extends State with WidgetsBindingObserver {
});
}
+ /// Hiding the on-screen layer from the picker's eye toggle must take it off
+ /// screen — nothing else would. Route the exit through [_onLayerSelected] so
+ /// the outgoing overlay is cleared exactly as a manual switch would. The
+ /// picker keeps listing hidden layers, so the user can always come back.
+ void _onVisibilityChanged() {
+ final visibility = _visibility;
+ if (visibility == null || !visibility.isHidden(_active.id)) return;
+ final candidate = widget.layers.firstWhere(
+ (layer) => !visibility.isHidden(layer.id),
+ orElse: () => widget.layers.first,
+ );
+ if (candidate.id != _active.id) _onLayerSelected(candidate);
+ }
+
@override
Widget build(BuildContext context) {
return Scaffold(
diff --git a/lib/shared/map/map_tile_cache.dart b/lib/shared/map/map_tile_cache.dart
index 29dea8bea..fe34be79f 100644
--- a/lib/shared/map/map_tile_cache.dart
+++ b/lib/shared/map/map_tile_cache.dart
@@ -284,6 +284,14 @@ class MapTileCache {
if (_isTile(url)) url,
}.toList(growable: false);
if (wanted.isEmpty) return (injected: 0, resident: {});
+ if (shouldContinue?.call() == false) {
+ // Checked before the probe, not only inside it. A superseded schedule
+ // used to spend its full probe first — a device trace caught one paying
+ // 548 ms over 5,632 URLs and then reporting `cancelled before-l2`,
+ // having done nothing but delay the fill that replaced it.
+ trace(() => 'warm cancelled before-probe wanted=${wanted.length}');
+ return (injected: 0, resident: {});
+ }
final traceId = ++_traceSequence;
final elapsed = Stopwatch()..start();
trace(
@@ -429,6 +437,12 @@ class MapTileCache {
var messages = 0;
for (var i = 0; i < wanted.length; i += _probeChunk) {
final end = math.min(i + _probeChunk, wanted.length);
+ if (shouldContinue?.call() == false) {
+ // Before the message, so a cancellation that lands mid-sweep costs the
+ // chunk in flight rather than the chunk after it as well.
+ missing.addAll(wanted.sublist(i));
+ break;
+ }
missing.addAll(await mapLibreTilesMissing(wanted.sublist(i, end)));
if (shouldContinue?.call() == false) {
// Unprobed URLs are conservatively unknown/missing. That keeps the
diff --git a/lib/shared/map/map_tile_warmer.dart b/lib/shared/map/map_tile_warmer.dart
index 015df4837..b1e66a452 100644
--- a/lib/shared/map/map_tile_warmer.dart
+++ b/lib/shared/map/map_tile_warmer.dart
@@ -273,14 +273,15 @@ class MapTileWarmer {
final direct = {};
final framesByFamily = >{};
- final flushFamilies = {};
for (final url in stale) {
final prefix = _framePrefix(url);
final family = _frameFamilyPrefix(url);
if (prefix == null || family == null) {
direct.add(url);
} else if (wantedFrames.contains(prefix)) {
- flushFamilies.add(family);
+ // Stale coordinates inside a frame the new set still wants: the camera
+ // moved, not the frame. Left alone — see below.
+ continue;
} else {
(framesByFamily[family] ??= {}).add(prefix);
}
@@ -290,20 +291,30 @@ class MapTileWarmer {
for (final entry in framesByFamily.entries) {
final family = entry.key;
final frames = entry.value;
- if (flushFamilies.contains(family) ||
- frames.length > _maxFrameEvictionPatterns) {
+ final familyStillWanted = wantedFrames.any(
+ (wanted) => wanted.startsWith(family),
+ );
+ if (!familyStillWanted) {
+ // Nothing of this family survives the change — one prefix is both the
+ // cheapest needle and an exact one.
patterns.add(family);
- } else {
+ } else if (frames.length <= _maxFrameEvictionPatterns) {
patterns.addAll(frames);
}
- }
- // A retained frame can be the only stale member of its family, so it may
- // not have created a framesByFamily entry above.
- patterns.addAll(flushFamilies);
- for (final family in flushFamilies) {
- patterns.removeWhere(
- (pattern) => pattern != family && pattern.startsWith(family),
- );
+ // Otherwise: too many frames to name individually, and the family is
+ // still live. Evict nothing.
+ //
+ // A family prefix matches *every* tile of that overlay, so collapsing to
+ // it while the family is still wanted does not trim the working set — it
+ // wipes it. A device trace caught this costing a full refill on every
+ // camera nudge: 1,159 resident tiles, 737 of them stale, collapsed to
+ // `/api/v2/tiles/radar/`, and the next probe reported `l1-hit=0` and read
+ // megabytes back out of SQLite that had been in memory a moment earlier.
+ //
+ // Over-retention is bounded and self-correcting: the mirror is a byte-
+ // capped LRU that trims its own least-recently-used entries. Wiping live
+ // tiles is neither. When the choice is between holding stale bytes the
+ // LRU will reclaim and re-reading live ones from disk, hold them.
}
return patterns.toList(growable: false);
}
diff --git a/lib/shared/map/raster_frame_source.dart b/lib/shared/map/raster_frame_source.dart
index 7fd4442da..c61283b5c 100644
--- a/lib/shared/map/raster_frame_source.dart
+++ b/lib/shared/map/raster_frame_source.dart
@@ -20,6 +20,18 @@ abstract interface class RasterFrameSource {
/// Available frame ids, newest first; `Ok([])` when none.
Future>> frames();
+ /// Highest zoom this overlay's tiles genuinely exist for.
+ ///
+ /// Measured from the live endpoints, not guessed: radar / QPESUMS publish
+ /// real bytes for z3–12 and satellite / wind z0–11 (everything outside is
+ /// the empty placeholder), but each product's own resolution runs out around
+ /// z7–8 — deeper levels are the server resampling the same pixels, so a
+ /// request there costs a full viewport of round trips per zoom crossing and
+ /// gains no detail. The timeline passes this as the MapLibre source
+ /// `maxzoom`, so the renderer overzooms the top level instead of fetching
+ /// placeholders.
+ int get sourceMaxZoom;
+
/// XYZ raster tile URL **template** for [frame] (contains `{z}/{x}/{y}`).
String tileUrl(String frame);
diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart
index 9990731c2..da176182b 100644
--- a/lib/shared/map/raster_timeline_layer.dart
+++ b/lib/shared/map/raster_timeline_layer.dart
@@ -94,14 +94,22 @@ abstract class RasterTimelineLayer implements MapLayer {
/// Maximum frame candidates considered by one settled fill.
///
- /// 512 candidates are intentionally wider than the 48 MiB mirror can usually
- /// hold. The fill reads them centre-out in bounded batches and stops at 90%
- /// of the real native cap, so this maximises the useful L1 range without
- /// loading every candidate body into Dart or allowing a slow device to turn
- /// one settle into an unbounded whole-history scan. Near a series edge the
- /// unused side is given to the other side instead of wasting half the budget.
+ /// This was 512, chosen to be wider than the 48 MiB mirror can hold on the
+ /// reasoning that the fill stops at 90% of the native cap anyway. A device
+ /// trace showed why that reasoning does not survive contact: 512 candidates
+ /// is 12,288 tile URLs, whose **L1 presence probe alone cost 635 ms** — 32
+ /// platform messages — before a single byte was read, and the fill that
+ /// followed took 2.7 s and was superseded after scanning 3,456 of them. The
+ /// budget was never the mirror; it was the probe, and it was being paid in
+ /// full on every camera idle for a fill that rarely finished.
+ ///
+ /// 128 keeps a band several times wider than a fast drag can cross, costs a
+ /// quarter of the probe, and — being ~8 MiB of bodies — completes well inside
+ /// the mirror instead of racing it. A band that finishes is worth more than a
+ /// wider one that is cancelled. Near a series edge the unused side is given
+ /// to the other side instead of wasting half the budget.
@protected
- int get warmFrameBudget => 512;
+ int get warmFrameBudget => 128;
/// Mounted-source ceiling. This is deliberately much smaller than
/// [warmFrameBudget]: L1 holds compressed response bodies, while a mounted
@@ -314,7 +322,7 @@ abstract class RasterTimelineLayer implements MapLayer {
_refreshResidentOnNextSettle |= hadFrame;
_surfaceVisible = false;
_resumeBackgroundWork = false;
- _warmCentre = null;
+ _invalidateWarmBand();
_revealGeneration++;
source.cancelTileWarm();
_mapController = null;
@@ -413,6 +421,41 @@ abstract class RasterTimelineLayer implements MapLayer {
/// re-warm instead of one per frame.
int? _warmCentre;
+ /// The camera [_warmCentre] was warmed for.
+ ///
+ /// The band is a function of both the frame it centres on **and** the
+ /// rectangle it warms, so the skip-guard has to be keyed on both. It used to
+ /// be keyed on the centre alone, which meant a camera move — same centre, new
+ /// viewport — could not be told apart from a duplicate call. [onCameraIdle]
+ /// worked around that by nulling the centre, which defeated the guard
+ /// outright: every idle re-ran the whole fill for a centre it had just
+ /// finished, evicting the tiles it had spent seconds injecting. A device
+ /// trace showed three 512-frame band warms inside four seconds, the second
+ /// evicting 1,358 freshly injected tiles and the third being cancelled
+ /// mid-probe.
+ String? _warmCamera;
+
+ /// The camera, rounded to the precision the warm actually depends on.
+ ///
+ /// Comparing [CameraPosition] directly makes the guard useless: the camera
+ /// settles with sub-pixel jitter, so every idle reported a different position
+ /// and re-ran the band. The band depends on which tiles the viewport covers,
+ /// and that does not change for a ten-thousandth of a degree — roughly 10 m,
+ /// against a tile that is kilometres across at these zooms.
+ static String? _warmKeyFor(CameraPosition? camera) {
+ if (camera == null) return null;
+ return '${camera.target.latitude.toStringAsFixed(4)},'
+ '${camera.target.longitude.toStringAsFixed(4)},'
+ '${camera.zoom.toStringAsFixed(2)},'
+ '${camera.bearing.round()},${camera.tilt.round()}';
+ }
+
+ /// Forgets the last warmed band so the next call re-warms.
+ void _invalidateWarmBand() {
+ _warmCentre = null;
+ _warmCamera = null;
+ }
+
String _sourceId(String frameId) => '$id-src-$frameId';
String _layerId(String frameId) => '$id-lyr-$frameId';
@@ -509,7 +552,7 @@ abstract class RasterTimelineLayer implements MapLayer {
// timestamp. Both cancelled the old fill, so restart the wide L1
// fill and ready-resident preload instead of leaving only the core
// ring available to the next scrub.
- _warmCentre = null;
+ _invalidateWarmBand();
unawaited(
_warmThenPreload(
controller,
@@ -1167,40 +1210,84 @@ abstract class RasterTimelineLayer implements MapLayer {
/// displayed timestamp. Only the extra ready-resident sources are removed;
/// their compressed tile bodies remain in the native L1 and SQLite L2 caches
/// and are rebuilt by the idle pool after the surface is visible again.
- Future _trimHiddenResidents(MapLibreMapController controller) =>
- _enqueueMutation(() async {
- if (_surfaceVisible || _resident.isEmpty) return;
- final shown = _shownIndex;
- final keep = shown == null
- ? {?_shownFrameId}
- : _ringAt(shown).$3;
- final stale = [
- for (final candidate in _resident)
- if (!keep.contains(candidate)) candidate,
- ];
- if (stale.isEmpty) return;
- mapTrace(
- 'timeline/$id',
- () => 'hidden-trim start remove=${stale.length} keep=${keep.length}',
- );
- await source.abandonFrames(stale);
- for (final candidate in stale) {
- _resident.remove(candidate);
- _ring.remove(candidate);
- _readyFrames.remove(candidate);
- _lru.remove(candidate);
- await _removeFrame(controller, candidate);
- }
- mapTrace(
- 'timeline/$id',
- () => 'hidden-trim done resident=${_resident.length}',
- );
- });
+ Future _trimHiddenResidents(MapLibreMapController controller) async {
+ if (_surfaceVisible) return;
+ await _dropResidentsOutsideRing(controller, reason: 'hidden');
+ }
+
+ /// Removes every resident source outside the visible ring, outright.
+ ///
+ /// `visibility: none` does not release a source's decoded tile textures — the
+ /// source has to go. This is the only path that actually gives GPU memory
+ /// back, which is why both the hidden-tab edge and [onMemoryPressure] use it.
+ ///
+ /// [keep] defaults to the ring around the shown frame. The shown frame is
+ /// always retained: this releases speculation, never what the user is
+ /// looking at.
+ Future _dropResidentsOutsideRing(
+ MapLibreMapController controller, {
+ required String reason,
+ Set? keep,
+ }) => _enqueueMutation(() async {
+ if (_resident.isEmpty) return;
+ final shown = _shownIndex;
+ final retain =
+ keep ?? (shown == null ? {?_shownFrameId} : _ringAt(shown).$3);
+ final stale = [
+ for (final candidate in _resident)
+ if (!retain.contains(candidate)) candidate,
+ ];
+ if (stale.isEmpty) return;
+ mapTrace(
+ 'timeline/$id',
+ () => '$reason-trim start remove=${stale.length} keep=${retain.length}',
+ );
+ await source.abandonFrames(stale);
+ for (final candidate in stale) {
+ _resident.remove(candidate);
+ _ring.remove(candidate);
+ _readyFrames.remove(candidate);
+ _lru.remove(candidate);
+ await _removeFrame(controller, candidate);
+ }
+ mapTrace(
+ 'timeline/$id',
+ () => '$reason-trim done resident=${_resident.length}',
+ );
+ });
+
+ /// Gives memory back to the OS without blanking the map.
+ ///
+ /// Three things are released, cheapest-to-rebuild first: the speculative warm
+ /// band (bytes we merely expected to want), then every mounted source outside
+ /// the visible ring (decoded textures — the expensive part), and nothing
+ /// else. The displayed frame and its immediate neighbours stay mounted, so
+ /// this is invisible to the user beyond a slower scrub afterwards.
+ ///
+ /// Deliberately not gated on [_surfaceVisible]: a foreground map is exactly
+ /// the case that gets a process killed, and it is the case
+ /// [_trimHiddenResidents] cannot cover.
+ @override
+ Future onMemoryPressure(MapLibreMapController controller) async {
+ // Deliberately not [_suspendWarm]: that flag is sticky until a settle
+ // clears it, and pressure is a moment, not a mode. Cancel the fill that is
+ // running and invalidate the band so the next camera idle recomputes it —
+ // then normal warming resumes on its own.
+ _revealGeneration++;
+ _invalidateWarmBand();
+ source.cancelTileWarm();
+ MapTileCache.trace(
+ () =>
+ 'timeline=$id memory-pressure resident=${_resident.length} '
+ 'ring=${_ring.length} shown=$_shownFrameId',
+ );
+ await _dropResidentsOutsideRing(controller, reason: 'pressure');
+ }
void _suspendWarm() {
if (_warmSuspended) return;
_warmSuspended = true;
- _warmCentre = null;
+ _invalidateWarmBand();
source.cancelTileWarm();
MapTileCache.trace(() => 'timeline=$id warm-suspend');
}
@@ -1248,7 +1335,15 @@ abstract class RasterTimelineLayer implements MapLayer {
await _ensureSeam(controller);
await controller.addSource(
_sourceId(id),
- RasterSourceProperties(tiles: [source.tileUrl(id)], tileSize: 256),
+ RasterSourceProperties(
+ tiles: [source.tileUrl(id)],
+ tileSize: 256,
+ // Past this level MapLibre overzooms the top band instead of
+ // requesting tiles that only come back as the empty placeholder —
+ // and on Android every avoided request is a platform-thread round
+ // trip a pinch gesture no longer has to wait behind.
+ maxzoom: source.sourceMaxZoom.toDouble(),
+ ),
);
await controller.addRasterLayer(
_sourceId(id),
@@ -1416,8 +1511,11 @@ abstract class RasterTimelineLayer implements MapLayer {
// that cross the old band edge coalesce onto this one re-warm instead of
// each firing its own visible-region round-trip.
final previous = _warmCentre;
- if (previous == centre) return;
+ final previousCamera = _warmCamera;
+ final camera = _warmKeyFor(controller.cameraPosition);
+ if (previous == centre && previousCamera == camera) return;
_warmCentre = centre;
+ _warmCamera = camera;
final delta = previous == null ? 1 : centre - previous;
final frames = _spreadFrames(centre, direction: delta < 0 ? -1 : 1);
if (frames.length <= 1) return;
@@ -1425,6 +1523,7 @@ abstract class RasterTimelineLayer implements MapLayer {
MapTileCache.trace(
() =>
'timeline=$id warm-band start centre=$centre previous=$previous '
+ 'camera=$camera was=${previousCamera == camera ? 'same' : previousCamera} '
'direction=${delta < 0 ? 'backward' : 'forward'} '
'frames=${frames.length} immediate=$immediate '
'refresh=$refreshResident',
@@ -1433,7 +1532,9 @@ abstract class RasterTimelineLayer implements MapLayer {
final viewport = await _viewport(controller);
// Visibility can change while the platform answers the camera query. Do
// not start a fresh warmer generation after the hidden-edge cancellation.
- if (!_surfaceVisible || _warmCentre != centre) return;
+ if (!_surfaceVisible || _warmCentre != centre || _warmCamera != camera) {
+ return;
+ }
await source.warmFrameTiles(
frames: frames,
south: viewport.bounds.southwest.latitude,
@@ -1546,9 +1647,9 @@ abstract class RasterTimelineLayer implements MapLayer {
if (_warmSuspended) return;
final centre = _shownIndex;
if (centre == null) return;
- _warmCentre = null;
- // The viewport moved, so the warmed tiles are the wrong ones — re-warm for
- // where the camera actually is.
+ // No invalidation here. The band is keyed on the camera as well as the
+ // centre, so a real move re-warms on its own and an idle that reports the
+ // same camera is the duplicate it looks like.
MapTileCache.trace(() => 'timeline=$id camera-idle centre=$centre');
unawaited(_warmBand(controller, centre, immediate: true));
}
@@ -1558,7 +1659,7 @@ abstract class RasterTimelineLayer implements MapLayer {
if (!_surfaceVisible) return;
final centre = _shownIndex;
if (centre == null) return;
- _warmCentre = null;
+ _invalidateWarmBand();
await _warmBand(controller, centre, immediate: true);
}
@@ -1615,7 +1716,7 @@ abstract class RasterTimelineLayer implements MapLayer {
_requestedFrameId = null;
_shownFrameId = null;
_settledFrameId = null;
- _warmCentre = null;
+ _invalidateWarmBand();
_attached = false;
// A style reload drops every runtime layer, the seam included.
_seamMounted = false;
diff --git a/lib/shared/widgets/map_chip_button.dart b/lib/shared/widgets/map_chip_button.dart
index de29428e4..44a87d856 100644
--- a/lib/shared/widgets/map_chip_button.dart
+++ b/lib/shared/widgets/map_chip_button.dart
@@ -18,6 +18,7 @@ class MapChipButton extends StatelessWidget {
required this.tooltip,
required this.active,
required this.onTap,
+ this.label,
});
/// Whether the menu's settings differ from the defaults: tints the icon
@@ -27,6 +28,17 @@ class MapChipButton extends StatelessWidget {
/// Glyph shown on the chip (outlined variant by convention).
final IconData icon;
+ /// Optional current-value text beside the glyph.
+ ///
+ /// For a menu whose selection changes what the whole map means (the rainfall
+ /// accumulation window), where reading the current value is far more frequent
+ /// than changing it. The chip grows sideways only — its height is what the
+ /// compass parks under, so it must not move.
+ ///
+ /// A labelled chip drops the [active] marker dot: the label already says what
+ /// the dot was hinting at, and the dot would sit on top of the text.
+ final String? label;
+
final String tooltip;
final VoidCallback onTap;
@@ -75,13 +87,33 @@ class MapChipButton extends StatelessWidget {
children: [
Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
- child: Icon(
- icon,
- size: 22,
- color: active ? colors.primary : colors.onSurfaceVariant,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ icon,
+ size: 22,
+ color: active
+ ? colors.primary
+ : colors.onSurfaceVariant,
+ ),
+ if (label case final text?) ...[
+ const SizedBox(width: AppSpacing.xs),
+ Text(
+ text,
+ style: Theme.of(context).textTheme.labelLarge
+ ?.copyWith(
+ height: 1,
+ color: active
+ ? colors.primary
+ : colors.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ],
),
),
- if (active)
+ if (active && label == null)
Positioned(
top: 3,
right: 3,
diff --git a/lib/shared/widgets/map_color_legend.dart b/lib/shared/widgets/map_color_legend.dart
index 67eba80d5..0d6218c98 100644
--- a/lib/shared/widgets/map_color_legend.dart
+++ b/lib/shared/widgets/map_color_legend.dart
@@ -54,7 +54,12 @@ class MapLegendCard extends StatelessWidget {
/// The unit, when present, is always shown **below** the scale ([unit]); it is
/// never appended to every value label — a number column stays numbers.
class ColorScaleLegend extends StatelessWidget {
- const ColorScaleLegend({super.key, required this.stops, this.unit});
+ const ColorScaleLegend({
+ super.key,
+ required this.stops,
+ this.unit,
+ this.banded = false,
+ });
/// Ascending value → hex colour pairs (same order as MapLibre ramps), in
/// whatever colour the layer actually paints — corrected, or raster-exempt,
@@ -64,6 +69,15 @@ class ColorScaleLegend extends StatelessWidget {
/// Unit shown below the scale, e.g. `m/s` or `°C`.
final String? unit;
+ /// Draw hard-edged bands instead of a gradient.
+ ///
+ /// A banded scale is a table of categories, so each stop paints a solid cell
+ /// and its value is printed **on the boundary** it opens — the reading a
+ /// number marks is where one band ends and the next begins, not the middle of
+ /// a swatch. The lowest stop is the below-threshold band and prints no
+ /// number, which is why N stops show N-1 labels.
+ final bool banded;
+
static const double _cell = 14;
static const double _swatch = 8;
static const double _corner = 4;
@@ -95,25 +109,40 @@ class ColorScaleLegend extends StatelessWidget {
Container(
width: _swatch,
height: height,
+ clipBehavior: banded ? Clip.antiAlias : Clip.none,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_corner),
- gradient: LinearGradient(
- begin: Alignment.topCenter,
- end: Alignment.bottomCenter,
- colors: swatchColors,
- ),
+ gradient: banded
+ ? null
+ : LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: swatchColors,
+ ),
),
+ child: banded
+ ? Column(
+ children: [
+ for (final color in swatchColors)
+ Expanded(child: ColoredBox(color: color)),
+ ],
+ )
+ : null,
),
const SizedBox(width: AppSpacing.sm),
SizedBox(
height: height,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- for (final stop in rows)
- Expanded(child: Text(_label(stop.$1), style: labelStyle)),
- ],
- ),
+ child: banded
+ ? _BandBoundaryLabels(rows: rows, style: labelStyle)
+ : Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ for (final stop in rows)
+ Expanded(
+ child: Text(_label(stop.$1), style: labelStyle),
+ ),
+ ],
+ ),
),
],
),
@@ -334,3 +363,37 @@ class _LineSwatchPainter extends CustomPainter {
old.casingWidth != casingWidth ||
old.dash != dash;
}
+
+/// The number column of a banded scale.
+///
+/// Each label is centred on the line between two bands rather than inside one,
+/// which is how a published rainfall scale reads: `70` is the point the band
+/// changes, not a sample from within it. [rows] is strongest-first (top-down),
+/// so row *i* opens the boundary one cell below the top of its own band, and
+/// the last row — the below-threshold band — opens nothing and is skipped.
+class _BandBoundaryLabels extends StatelessWidget {
+ const _BandBoundaryLabels({required this.rows, required this.style});
+
+ final List rows;
+ final TextStyle? style;
+
+ @override
+ Widget build(BuildContext context) {
+ const cell = ColorScaleLegend._cell;
+ return Stack(
+ clipBehavior: Clip.none,
+ children: [
+ for (var i = 0; i < rows.length - 1; i++)
+ Positioned(
+ top: (i + 1) * cell - cell / 2,
+ left: 0,
+ height: cell,
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Text(ColorScaleLegend._label(rows[i].$1), style: style),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index 409242a02..4ef276279 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -9,14 +9,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "103.0.0"
- _flutterfire_internals:
- dependency: transitive
- description:
- name: _flutterfire_internals
- sha256: "78f98c1f9c4dbbd22c2bb7b7f17c4a5c06150e8b2cb791a0947979ad0d3dabd5"
- url: "https://pub.dev"
- source: hosted
- version: "1.3.73"
analyzer:
dependency: transitive
description:
@@ -73,6 +65,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.12.1"
+ awesome_notifications_fcm:
+ dependency: "direct main"
+ description:
+ name: awesome_notifications_fcm
+ sha256: "6b6db874792d101ee7341b49dff69acc6eae50c33a5446989bc74a14664d67f1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.12.0"
bluez:
dependency: transitive
description:
@@ -312,30 +312,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.9.0"
- firebase_messaging:
- dependency: "direct main"
- description:
- name: firebase_messaging
- sha256: ce21a510e5a9aed67a0404476981e19ec0361a0301eeba547dc93dc2e7dec99a
- url: "https://pub.dev"
- source: hosted
- version: "16.4.1"
- firebase_messaging_platform_interface:
- dependency: transitive
- description:
- name: firebase_messaging_platform_interface
- sha256: e10f6d521e7ed663d0ea2f4ec7de4c6729f8c2ce25d32faf6d6b4219da8515c2
- url: "https://pub.dev"
- source: hosted
- version: "4.9.0"
- firebase_messaging_web:
- dependency: transitive
- description:
- name: firebase_messaging_web
- sha256: "7ab45dfaf8efcd1a769baa9b8debbd0da281f5d3fc07274296b564396a980292"
- url: "https://pub.dev"
- source: hosted
- version: "4.2.1"
fixnum:
dependency: transitive
description:
@@ -736,8 +712,8 @@ packages:
dependency: "direct main"
description:
path: maplibre_gl
- ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c
- resolved-ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c
+ ref: "9804c2f9a10c03373e596acb7045b4c2e91a1fe2"
+ resolved-ref: "9804c2f9a10c03373e596acb7045b4c2e91a1fe2"
url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git"
source: git
version: "0.26.2"
@@ -745,8 +721,8 @@ packages:
dependency: "direct main"
description:
path: maplibre_gl_platform_interface
- ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c
- resolved-ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c
+ ref: "9804c2f9a10c03373e596acb7045b4c2e91a1fe2"
+ resolved-ref: "9804c2f9a10c03373e596acb7045b4c2e91a1fe2"
url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git"
source: git
version: "0.26.2"
@@ -754,8 +730,8 @@ packages:
dependency: "direct overridden"
description:
path: maplibre_gl_web
- ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c
- resolved-ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c
+ ref: "9804c2f9a10c03373e596acb7045b4c2e91a1fe2"
+ resolved-ref: "9804c2f9a10c03373e596acb7045b4c2e91a1fe2"
url: "https://github.com/ExpTechTW/flutter-maplibre-gl.git"
source: git
version: "0.26.2"
@@ -1107,62 +1083,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.2"
- sqflite:
- dependency: "direct main"
- description:
- name: sqflite
- sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082"
- url: "https://pub.dev"
- source: hosted
- version: "2.4.3"
- sqflite_android:
- dependency: transitive
- description:
- name: sqflite_android
- sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b
- url: "https://pub.dev"
- source: hosted
- version: "2.4.3"
- sqflite_common:
- dependency: transitive
- description:
- name: sqflite_common
- sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590"
- url: "https://pub.dev"
- source: hosted
- version: "2.5.11"
- sqflite_common_ffi:
+ sqlite3:
dependency: "direct dev"
description:
- name: sqflite_common_ffi
- sha256: "5ccd38136edb9beb3213f6927775d52db70dfdadcdb28dad1f625ca9f2b9824f"
+ name: sqlite3
+ sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c"
url: "https://pub.dev"
source: hosted
- version: "2.4.2"
- sqflite_darwin:
+ version: "3.5.2"
+ sqlite3_connection_pool:
dependency: transitive
description:
- name: sqflite_darwin
- sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f
+ name: sqlite3_connection_pool
+ sha256: "8f2df36dc9f0f51ec04506b90848769b5a4538a9f937472147a274910a92e7ee"
url: "https://pub.dev"
source: hosted
- version: "2.4.3+1"
- sqflite_platform_interface:
+ version: "0.2.9"
+ sqlite3_web:
dependency: transitive
description:
- name: sqflite_platform_interface
- sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50
+ name: sqlite3_web
+ sha256: aa6af15ef8bf8551d3a84203e3cbc022990567372e46ef98f91bdb2018fcfb0e
url: "https://pub.dev"
source: hosted
- version: "2.4.1"
- sqlite3:
- dependency: transitive
+ version: "0.9.4"
+ sqlite_async:
+ dependency: "direct main"
description:
- name: sqlite3
- sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478"
+ name: sqlite_async
+ sha256: "83aff15d2bb3b296d35e15419a85c3207df3baca62b64b778958a59eb291502d"
url: "https://pub.dev"
source: hosted
- version: "3.5.1"
+ version: "0.14.4"
stack_trace:
dependency: transitive
description:
@@ -1195,14 +1147,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
- synchronized:
- dependency: transitive
- description:
- name: synchronized
- sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423"
- url: "https://pub.dev"
- source: hosted
- version: "3.4.1+1"
talker:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 6313e123d..50be9eff4 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -14,6 +14,14 @@ environment:
dependencies:
awesome_notifications: ^0.12.1
+ # The remote-push half of awesome. `awesome_notifications` alone handles only
+ # local notifications: its own source says "we do not chain to a
+ # previously-installed delegate … FCM is handled by awesome_notifications_fcm".
+ # Without it a server push reaches awesome's willPresent, is claimed as its own
+ # (the payload's `content` key is awesome's own model format), and then has no
+ # pipeline to display it — the app showed nothing in the foreground. The
+ # pre-rewrite app shipped this package; the rewrite dropped it.
+ awesome_notifications_fcm: ^0.12.0
cupertino_icons: ^1.0.8
dio: ^5.10.0
# Firebase pinned deliberately — a newer major bumps the min iOS target and
@@ -21,7 +29,6 @@ dependencies:
# Pinned to the exact version (not `^`) so a plain `pub upgrade` can't drift
# the minor either.
firebase_core: 4.11.0
- firebase_messaging: 16.4.1
flutter:
sdk: flutter
flutter_localizations:
@@ -58,13 +65,13 @@ dependencies:
git:
url: https://github.com/ExpTechTW/flutter-maplibre-gl.git
path: maplibre_gl
- ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c
+ ref: 9804c2f9a10c03373e596acb7045b4c2e91a1fe2
# Direct (not just override) so tests can import the platform interface.
maplibre_gl_platform_interface:
git:
url: https://github.com/ExpTechTW/flutter-maplibre-gl.git
path: maplibre_gl_platform_interface
- ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c
+ ref: 9804c2f9a10c03373e596acb7045b4c2e91a1fe2
# LoRa mesh (Meshtastic) over BLE — off-grid emergency messaging. Requires
# Bluetooth + location permissions (Android manifest / iOS Info.plist below).
# Vendored (third_party/) with two upstream fixes: requestMtu is skipped off
@@ -86,7 +93,11 @@ dependencies:
package_info_plus: ^10.2.0
path_provider: ^2.1.6
provider: ^6.1.5+1
- sqflite: ^2.4.3
+ # SQLite access, all files, via one connection pool per file. Replaces
+ # sqflite: every operation runs on a background isolate through sqlite3 FFI,
+ # so a cold-start lock contention window no longer fails opens on the UI
+ # thread, and WAL + busy_timeout (lockTimeout) ship as sane defaults.
+ sqlite_async: ^0.14.4
talker_flutter: ^5.1.9
url_launcher: ^6.3.2
@@ -94,6 +105,10 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
+ # The synchronous SQLite API, for tests only: the in-memory helper
+ # (test/core/storage/memory_db.dart) wraps one open handle with
+ # SqliteDatabase.singleConnection so `:memory:` means one database.
+ sqlite3: ^3.5.2
# Virtual time for timer-driven state machines (the traceroute timeout).
fake_async: ^1.3.0
# Dart 3.13 (Flutter 3.47) makes `final` illegal on non-primary-constructor
@@ -103,7 +118,6 @@ dev_dependencies:
build_runner: ^2.16.0
freezed: ^4.0.0-dev.3
json_serializable: ^6.14.1
- sqflite_common_ffi: ^2.4.2
url_launcher_platform_interface: ^2.3.2
dependency_overrides:
@@ -116,12 +130,12 @@ dependency_overrides:
git:
url: https://github.com/ExpTechTW/flutter-maplibre-gl.git
path: maplibre_gl_platform_interface
- ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c
+ ref: 9804c2f9a10c03373e596acb7045b4c2e91a1fe2
maplibre_gl_web:
git:
url: https://github.com/ExpTechTW/flutter-maplibre-gl.git
path: maplibre_gl_web
- ref: e236229148eeb27e59fecb66c03e99f0ce9e8c7c
+ ref: 9804c2f9a10c03373e596acb7045b4c2e91a1fe2
flutter:
config:
diff --git a/shaders/weather/rain_on_glass.frag b/shaders/weather/rain_on_glass.frag
index a5dc87a6c..92ea19df8 100644
--- a/shaders/weather/rain_on_glass.frag
+++ b/shaders/weather/rain_on_glass.frag
@@ -160,7 +160,12 @@ void main() {
// is scaled by the fixed 1080 frame, so it also translates the lattice by
// `uSize.y - 1080`. Keep the two coordinates separate.
vec2 texXY = xy;
-#ifdef IMPELLER_TARGET_OPENGLES
+#if defined(IMPELLER_TARGET_OPENGLES) && !defined(IMPELLER_OPENGLES_UNFLIPPED_DEPRECATED)
+ // 3.47 changed the GLES backend to store render-to-texture top-down, like
+ // Metal and Vulkan (docs.flutter.dev → opengles-render-to-texture-top-down).
+ // On those releases this flip would mirror the backdrop, so it is gated off
+ // by the macro that marks the new orientation; older releases without the
+ // macro still need it.
texXY.y = uSize.y - xy.y;
#endif
diff --git a/test/core/astro/tle_source_test.dart b/test/core/astro/tle_source_test.dart
index 6c2e2d229..6d0d1f367 100644
--- a/test/core/astro/tle_source_test.dart
+++ b/test/core/astro/tle_source_test.dart
@@ -12,7 +12,8 @@ import 'package:dpip/core/astro/satellite.dart';
import 'package:dpip/core/astro/tle_source.dart';
import 'package:dpip/core/astro/tle_store.dart';
import 'package:flutter_test/flutter_test.dart';
-import 'package:sqflite_common_ffi/sqflite_ffi.dart';
+
+import '../storage/memory_db.dart';
/// The ISS on day 226 of 2026.
const _older = '''
@@ -44,21 +45,9 @@ class _Never implements TleSource {
}
/// A real `tle` table in memory, so the test exercises the schema rather than
-/// a stand-in for it.
-/// A fresh in-memory database per call.
-///
-/// `singleInstance: false` matters: sqflite hands back the *same* handle for a
-/// repeated path, and `:memory:` is a path — so without it every test in the
-/// file shares one database and the second test starts with the first one's
-/// rows. That is exactly the kind of shared state that makes a suite pass in
-/// isolation and fail as a group.
-Future _openMemory() => databaseFactoryFfi.openDatabase(
- inMemoryDatabasePath,
- options: OpenDatabaseOptions(singleInstance: false),
-);
-
+/// a stand-in for it. A fresh database per call — see [openMemoryDb].
Future _store({String? seed, DateTime? fetchedAt}) async {
- final db = await _openMemory();
+ final db = openMemoryDb();
await TleStore.createSchema(db);
final store = TleStore(db);
if (seed != null) {
@@ -69,7 +58,6 @@ Future _store({String? seed, DateTime? fetchedAt}) async {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
- sqfliteFfiInit();
var clock = DateTime.utc(2026, 8, 20);
diff --git a/test/core/logging/log_clean_test.dart b/test/core/logging/log_clean_test.dart
index aef825ea0..c7584816f 100644
--- a/test/core/logging/log_clean_test.dart
+++ b/test/core/logging/log_clean_test.dart
@@ -6,16 +6,17 @@
library;
import 'package:flutter_test/flutter_test.dart';
-import 'package:sqflite_common_ffi/sqflite_ffi.dart';
+import 'package:sqlite_async/sqlite_async.dart';
import 'package:dpip/core/logging/log.dart';
import 'package:dpip/core/logging/log_store.dart';
+import '../storage/memory_db.dart';
+
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
- sqfliteFfiInit();
- late Database db;
+ late SqliteDatabase db;
late LogStore store;
// Pinned, and handed to the store, because `flush` prunes anything older
// than [logRetention] in the same transaction as the insert. With the real
@@ -25,7 +26,7 @@ void main() {
final clock = DateTime.utc(2026, 8, 18, 12);
setUp(() async {
- db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
+ db = openMemoryDb();
await LogStore.createSchema(db);
store = LogStore(db, now: () => clock);
Log.store = store;
diff --git a/test/core/logging/log_store_test.dart b/test/core/logging/log_store_test.dart
index fefa9c1ad..ffa259870 100644
--- a/test/core/logging/log_store_test.dart
+++ b/test/core/logging/log_store_test.dart
@@ -10,23 +10,17 @@ library;
import 'package:dpip/core/logging/log_store.dart';
import 'package:flutter_test/flutter_test.dart';
-import 'package:sqflite_common_ffi/sqflite_ffi.dart';
+import 'package:sqlite_async/sqlite_async.dart';
-/// A fresh database per call — sqflite hands back the same handle for a
-/// repeated path, and `:memory:` is a path.
-Future _openMemory() => databaseFactoryFfi.openDatabase(
- inMemoryDatabasePath,
- options: OpenDatabaseOptions(singleInstance: false),
-);
+import '../storage/memory_db.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
- sqfliteFfiInit();
var clock = DateTime.utc(2026, 8, 15, 12);
- Future<(LogStore, Database)> makeStore({int flushAt = 64}) async {
- final db = await _openMemory();
+ Future<(LogStore, SqliteDatabase)> makeStore({int flushAt = 64}) async {
+ final db = openMemoryDb();
await LogStore.createSchema(db);
return (LogStore(db, now: () => clock, flushAt: flushAt), db);
}
@@ -110,8 +104,8 @@ void main() {
store.add(line('line $i', at: clock.add(Duration(seconds: i))));
}
await store.flush();
- final rows = await db.rawQuery('SELECT COUNT(*) AS n FROM $logTable');
- expect(rows.single['n'], logMaxRows);
+ final row = await db.get('SELECT COUNT(*) AS n FROM $logTable');
+ expect(row['n'], logMaxRows);
});
test('the ceiling keeps the newest lines, not the oldest', () async {
diff --git a/test/core/meshtastic/mesh_clock_defects_test.dart b/test/core/meshtastic/mesh_clock_defects_test.dart
index e1bb9323a..f23eb1af8 100644
--- a/test/core/meshtastic/mesh_clock_defects_test.dart
+++ b/test/core/meshtastic/mesh_clock_defects_test.dart
@@ -10,20 +10,18 @@ library;
import 'package:dpip/core/meshtastic/data/mesh_store.dart';
import 'package:dpip/core/meshtastic/data/meshtastic_client_impl.dart';
import 'package:flutter_test/flutter_test.dart';
-import 'package:sqflite_common_ffi/sqflite_ffi.dart';
+import 'package:sqlite_async/sqlite_async.dart';
-Future _open() async {
- final db = await databaseFactoryFfi.openDatabase(
- inMemoryDatabasePath,
- options: OpenDatabaseOptions(singleInstance: false),
- );
+import '../storage/memory_db.dart';
+
+Future