Conversation
The weather backdrop is the only full-screen layer that keeps redrawing while its tab is hidden (60 fps ticker, 1792 rain particles, full-screen shaders) and it burns low-end GPUs. Five equivalent batches: - Recompute the ephemeris and keyframe ring on a daily/minute cadence; the LUT bake now runs once a minute instead of once a second - TickerMode mutes every ticker under the sheet while Home is hidden - Quantise the full-screen blur sigmas into 6 steps during drags - Tier by RAM on Android (< 4 GB): render scale 0.75->0.6, rain pool 1792->1024, snow 900->640 (native reports totalMemoryMb) - Hoist loop invariants out of the particle and cloud loops
Scrolling rebuilds _ScrollBlurredWeather every tick while the sky is visually frozen under it: - ImageFilter has no value equality, so a fresh blur() every tick made the full-screen blur layer recomposite constantly; the quantised sigma ladder now reuses one instance between steps - WeatherSkyBackground reuses its painter while the ticker is stopped, so the CustomPaint skips repaint on the rebuilds above Adds a widget test pinning the stopped sky to its painter across rebuilds and a fresh one when it restarts.
The shell's IndexedStack keeps every tab mounted, so both MapLibre platform views (home backdrop + map tab) kept rendering behind other tabs. BaseMap now subscribes to VisibleTabScope and calls setRenderPaused on the controller, so a hidden map stops burning the GPU. Adds the forked maplibre_gl setRenderPaused API (git-pinned platform interface and web packages) and the cupertino_icons dep.
iOS Settings reports the whole sandbox, which is far larger than the 150 MB ETag body budget: the SQLite file carries page/free-space overhead, the system NSURLCache keeps its own copy of responses, and ambient MapLibre data can linger. A native channel scans the sandbox (cache/support/document/tmp, top 30 files); the Developer page shows total usage, a categorized pie breakdown, and per-slice percentages. Growth is bounded: startup configures NSURLCache to 64 MB, and Clear cache now also compacts the SQLite file (VACUUM) and empties the system HTTP cache.
The trail buffer rasterized at full screen resolution every frame (toImageSync, a synchronous GPU round-trip on the UI thread), the stamp path allocated up to 6400 Offsets per frame, and each particle paid a log+tan projection. The buffer now renders at half resolution (or a third on low-end devices), stamping goes through preallocated Float32Lists with drawRawPoints, and the mercator projection is a LUT. The ticker also stops while the map tab is hidden, so the overlay no longer animates behind other tabs.
|
🔍 OpenCodeReview found 10 issue(s) in this PR.
|
| /** Total physical RAM in MiB — the cheap proxy for the low-end tier. */ | ||
| private fun totalMemoryMb(): Long { | ||
| val mem = ActivityManager.MemoryInfo() | ||
| (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) | ||
| .getMemoryInfo(mem) | ||
| return mem.totalMem / 1024 / 1024 | ||
| } |
There was a problem hiding this comment.
[maintainability · low]
使用強制轉型 (as) 可能在系統服務回傳 null 時導致應用程式崩潰。此外,可以利用 Kotlin 的特性將其改寫得更簡潔且符合慣用法(Idiomatic Kotlin)。建議改用更安全的 API 或安全轉型 (as?),並配合單一表達式函式 (single-expression function) 來提高程式碼的可讀性與安全性。
Suggestion:
| /** Total physical RAM in MiB — the cheap proxy for the low-end tier. */ | |
| private fun totalMemoryMb(): Long { | |
| val mem = ActivityManager.MemoryInfo() | |
| (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) | |
| .getMemoryInfo(mem) | |
| return mem.totalMem / 1024 / 1024 | |
| } | |
| /** Total physical RAM in MiB — the cheap proxy for the low-end tier. */ | |
| private fun totalMemoryMb(): Long = | |
| ActivityManager.MemoryInfo().apply { | |
| (context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.getMemoryInfo(this) | |
| }.totalMem / 1024 / 1024 |
| var visited = 0 | ||
| for case let url as URL in enumerator { | ||
| visited += 1 | ||
| if visited > StorageScanPlugin.visitCap { break } | ||
| guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) else { | ||
| continue | ||
| } | ||
| if values.isDirectory == true { continue } | ||
| let fileBytes = Int64(values.fileSize ?? 0) | ||
| guard fileBytes > 0 else { continue } | ||
| bytes += fileBytes | ||
| if fileBytes >= StorageScanPlugin.topFileFloor { | ||
| top.append((url.path, fileBytes)) | ||
| } | ||
| } | ||
| return (bytes, top) |
There was a problem hiding this comment.
[bug · high]
當文件遍歷數量達到 visitCap (100,000) 時,scan 方法會中斷遍歷並返回已累加的 bytes。這會導致 totalBytes 僅代表部分文件的總和,而非目錄的真實總大小,從而導致掃描結果在大型文件系統中顯著不準確,誤導用戶對存儲空間佔用的認知。建議在達到限制時,明確標記結果為「部分掃描」或調整邏輯以確保 totalBytes 的正確性(例如先獲取目錄大小,再進行詳細遍歷)。
| String? dirOf(String path) { | ||
| for (final dir in scan.dirs) { | ||
| if (path.startsWith(dir.path)) return dir.path; | ||
| } | ||
| return null; | ||
| } |
There was a problem hiding this comment.
[other · medium]
storageBreakdown 函數存在效能與邏輯風險。首先,它對每一種已知分類都會完整遍歷一次 scan.files,若檔案數量極多,效能會下降。其次,dirOf 函數使用 path.startsWith(dir.path) 來匹配目錄,若存在巢狀目錄(例如 /a 與 /a/b),匹配結果會受 scan.dirs 列表順序影響,可能導致檔案被歸類到錯誤的目錄或導致 dirBytes 計算錯誤(甚至出現負值)。建議將目錄路徑按長度從長到短排序,以確保優先匹配最精確的目錄。
Suggestion:
| String? dirOf(String path) { | |
| for (final dir in scan.dirs) { | |
| if (path.startsWith(dir.path)) return dir.path; | |
| } | |
| return null; | |
| } | |
| // 建議先對 dirs 按路徑長度降序排列,確保優先匹配最深層的目錄 | |
| final sortedDirs = [...scan.dirs]..sort((a, b) => b.path.length.compareTo(a.path.length)); | |
| String? dirOf(String path) { | |
| for (final dir in sortedDirs) { | |
| if (path.startsWith(dir.path)) return dir.path; | |
| } | |
| return null; | |
| } |
| List<StorageEntry> entries(String key) => [ | ||
| for (final row in (raw[key] as List? ?? const [])) | ||
| StorageEntry( | ||
| path: (row as Map)['path'] as String, | ||
| bytes: (row['bytes'] as num).toInt(), | ||
| ), | ||
| ]; | ||
| return StorageScan( | ||
| totalBytes: (raw['totalBytes'] as num?)?.toInt() ?? 0, | ||
| dirs: entries('dirs'), | ||
| files: entries('files'), | ||
| ); |
There was a problem hiding this comment.
[other · high]
StorageScanner.scan 方法對原生端傳回的資料結構高度依賴。雖然目前 Android (StorageScanChannel.kt) 與 iOS (StorageScanPlugin.swift) 的實作看起來是符合預期的(包含 totalBytes (num), dirs (List), files (List),以及子項目的 path (String) 與 bytes (num)),但若未來原生端協議變動,這段 Dart 程式碼會因型別轉換錯誤(例如 as Map 或 as List)而拋出異常,目前只會被 catch 並回傳空的掃描結果,這會讓除錯變得困難。建議在轉換前加入更明確的型別檢查或提供更詳細的錯誤資訊。
Suggestion:
| List<StorageEntry> entries(String key) => [ | |
| for (final row in (raw[key] as List? ?? const [])) | |
| StorageEntry( | |
| path: (row as Map)['path'] as String, | |
| bytes: (row['bytes'] as num).toInt(), | |
| ), | |
| ]; | |
| return StorageScan( | |
| totalBytes: (raw['totalBytes'] as num?)?.toInt() ?? 0, | |
| dirs: entries('dirs'), | |
| files: entries('files'), | |
| ); | |
| List<StorageEntry> entries(String key) { | |
| final list = raw[key]; | |
| if (list is! List) return []; | |
| return [ | |
| for (final row in list) | |
| if (row is Map && row['path'] is String && row['bytes'] is num) | |
| StorageEntry( | |
| path: row['path'] as String, | |
| bytes: (row['bytes'] as num).toInt(), | |
| ) | |
| else | |
| // 可以考慮拋出更具體的錯誤或記錄警告 | |
| continue, | |
| ]; | |
| } | |
| // ... 其餘部分也應進行類似的安全性檢查 |
| @override | ||
| void didChangeDependencies() { | ||
| super.didChangeDependencies(); | ||
| final visibleTab = VisibleTabScope.of(context); | ||
| if (identical(visibleTab, _visibleTab)) return; | ||
| _visibleTab?.removeListener(_onTabChanged); | ||
| _visibleTab = visibleTab; | ||
| visibleTab?.addListener(_onTabChanged); | ||
| _syncRender(); | ||
| } |
There was a problem hiding this comment.
[bug · medium]
在 didChangeDependencies 中使用 identical(visibleTab, _visibleTab) 进行提前返回可能会导致在 VisibleTab 实例不变但其 value 变化时,无法触发 _syncRender。此外,缺少 didUpdateWidget 来处理 widget.tabIndex 的变化,这会导致当父组件传入新的 tabIndex 时,地图的渲染暂停状态无法即时更新。
Suggestion:
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| final visibleTab = VisibleTabScope.of(context); | |
| if (identical(visibleTab, _visibleTab)) return; | |
| _visibleTab?.removeListener(_onTabChanged); | |
| _visibleTab = visibleTab; | |
| visibleTab?.addListener(_onTabChanged); | |
| _syncRender(); | |
| } | |
| @override | |
| void didUpdateWidget(BaseMap oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| if (oldWidget.tabIndex != widget.tabIndex) { | |
| _syncRender(); | |
| } | |
| } | |
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| final visibleTab = VisibleTabScope.of(context); | |
| if (identical(visibleTab, _visibleTab)) { | |
| _syncRender(); | |
| return; | |
| } | |
| _visibleTab?.removeListener(_onTabChanged); | |
| _visibleTab = visibleTab; | |
| visibleTab?.addListener(_onTabChanged); | |
| _syncRender(); | |
| } |
| /// current frame until it is near this cap, then stops — the mirror trims | ||
| /// LRU beyond it, dropping the frames a scrub swept past. | ||
| static const int defaultMemoryBytes = 24 * 1024 * 1024; | ||
| static const int defaultMemoryBytes = 48 * 1024 * 1024; |
There was a problem hiding this comment.
[other · low]
預設記憶體容量 defaultMemoryBytes 從 24MB 增加到了 48MB。雖然這能提升地圖滑動時的圖塊命中率,但在記憶體受限的低階裝置上,可能會增加 OOM (Out of Memory) 的風險。建議確認專案是否已具備根據裝置等級(如新增的 render_tier)動態調整此值的機制。
| Future<int> _injectFill(List<MapLibreTile> tiles, double fillUntil) async { | ||
| final cap = (_memoryLimit * fillUntil).floor(); | ||
| if (cap <= 0) return 0; | ||
| var used = 0; // No pre-inject usage query — start at the optimistic 0. |
There was a problem hiding this comment.
[performance · medium]
在 _injectFill 方法中,used 變數的初始值被設為 0(這被註釋為「樂觀估算」)。如果快取在調用 warm 方法時已經存在大量資料,第一個 chunk 的注入可能會顯著超過 cap 限制,進而觸發原生層的 LRU 剔除,這可能導致剛注入的圖塊被立即刪除,造成效能抖動。
| if (used + chunkBytes > cap) { | ||
| // Split the chunk at the goal — send only the tiles that fit. | ||
| final fits = <MapLibreTile>[]; | ||
| var size = 0; | ||
| for (var j = i; j < end; j++) { | ||
| if (used + size + tiles[j].data.length > cap) break; | ||
| fits.add(tiles[j]); | ||
| size += tiles[j].data.length; | ||
| } | ||
| if (fits.isEmpty) break; | ||
| final usage = await injectMapLibreTiles(fits); | ||
| used = usage?.used ?? used + size; | ||
| injected += fits.length; | ||
| break; | ||
| } |
There was a problem hiding this comment.
[maintainability · medium]
_injectFill 方法引入了複雜的分塊(chunk splitting)邏輯,包含嵌套迴圈與多重邊界條件判斷(例如 used + size + tiles[j].data.length > cap)。這種複雜的邏輯增加了維護難度,且若邊界條件計算不精確或與原生層的記憶體計算方式不一致,可能會導致無法達到預期的填充目標或造成錯誤的注入行為。
| test('a low-RAM Android phone is downgraded', () { | ||
| expect( | ||
| renderTierFor(device(totalMemoryMb: 3072), isAndroid: true), | ||
| RenderTier.low, | ||
| reason: '2–4 GB Android devices are the low-end GPU class', | ||
| ); | ||
| expect( | ||
| renderTierFor(device(totalMemoryMb: 4095), isAndroid: true), | ||
| RenderTier.low, | ||
| ); | ||
| }); | ||
|
|
||
| test('a mid/high-RAM Android phone keeps full quality', () { | ||
| expect( | ||
| renderTierFor(device(totalMemoryMb: 4096), isAndroid: true), | ||
| RenderTier.high, | ||
| ); | ||
| expect( | ||
| renderTierFor(device(totalMemoryMb: 12288), isAndroid: true), | ||
| RenderTier.high, | ||
| ); | ||
| }); |
There was a problem hiding this comment.
[test · low]
測試案例中的邊界值判斷與實際邏輯一致。在 lib/core/platform/render_tier.dart 中,判定邏輯為 totalMb < 4096 ? RenderTier.low : RenderTier.high。測試中使用了 4095 MB 作為低階 Android 的上限,以及 4096 MB 作為高階 Android 的下限,這與實作邏輯完全吻合。
| test('known big files are pulled out of their directory', () { | ||
| final s = scan( | ||
| totalBytes: 300 * 1024 * 1024, | ||
| dirs: const [ | ||
| StorageEntry(path: '/caches', bytes: 200 * 1024 * 1024), | ||
| StorageEntry(path: '/support', bytes: 100 * 1024 * 1024), | ||
| ], | ||
| files: const [ | ||
| StorageEntry( | ||
| path: '/caches/http_etag_cache.db', | ||
| bytes: 180 * 1024 * 1024, | ||
| ), | ||
| StorageEntry( | ||
| path: '/support/MapLibre/cache.db', | ||
| bytes: 60 * 1024 * 1024, | ||
| ), | ||
| ], | ||
| ); | ||
| final slices = storageBreakdown(s); | ||
| expect( | ||
| slices, | ||
| contains( | ||
| predicate<StorageSlice>((s) => s.label == 'ETag cache (SQLite)'), | ||
| ), | ||
| ); | ||
| expect( | ||
| slices.firstWhere((s) => s.label == 'ETag cache (SQLite)').bytes, | ||
| 180 * 1024 * 1024, | ||
| ); | ||
| expect( | ||
| slices.firstWhere((s) => s.label == 'MapLibre').bytes, | ||
| 60 * 1024 * 1024, | ||
| ); | ||
| // The cache directory keeps the leftover after the DB is subtracted. | ||
| expect( | ||
| slices.firstWhere((s) => s.label == 'caches').bytes, | ||
| 20 * 1024 * 1024, | ||
| ); | ||
| }); |
There was a problem hiding this comment.
[bug · medium]
storageBreakdown 邏輯在處理數據不一致時(例如:大檔案的大小超過了其父目錄報告的大小)可能會導致計算出的總量 accounted 超過 scan.totalBytes。這會導致 UI 圓餅圖的百分比總和超過 100%。建議在計算 accounted 時進行截斷,或者確保 known 匹配過程中,扣除的容量不會使目錄大小變成負數。
Suggestion:
| test('known big files are pulled out of their directory', () { | |
| final s = scan( | |
| totalBytes: 300 * 1024 * 1024, | |
| dirs: const [ | |
| StorageEntry(path: '/caches', bytes: 200 * 1024 * 1024), | |
| StorageEntry(path: '/support', bytes: 100 * 1024 * 1024), | |
| ], | |
| files: const [ | |
| StorageEntry( | |
| path: '/caches/http_etag_cache.db', | |
| bytes: 180 * 1024 * 1024, | |
| ), | |
| StorageEntry( | |
| path: '/support/MapLibre/cache.db', | |
| bytes: 60 * 1024 * 1024, | |
| ), | |
| ], | |
| ); | |
| final slices = storageBreakdown(s); | |
| expect( | |
| slices, | |
| contains( | |
| predicate<StorageSlice>((s) => s.label == 'ETag cache (SQLite)'), | |
| ), | |
| ); | |
| expect( | |
| slices.firstWhere((s) => s.label == 'ETag cache (SQLite)').bytes, | |
| 180 * 1024 * 1024, | |
| ); | |
| expect( | |
| slices.firstWhere((s) => s.label == 'MapLibre').bytes, | |
| 60 * 1024 * 1024, | |
| ); | |
| // The cache directory keeps the leftover after the DB is subtracted. | |
| expect( | |
| slices.firstWhere((s) => s.label == 'caches').bytes, | |
| 20 * 1024 * 1024, | |
| ); | |
| }); | |
| if (sum > 0) { | |
| slices[label] = (slices[label] ?? 0) + sum; | |
| } | |
| } | |
| for (final dir in scan.dirs) { | |
| final bytes = dirBytes[dir.path] ?? 0; | |
| if (bytes <= 0) { | |
| continue; | |
| } | |
| slices[dir.name] = (slices[dir.name] ?? 0) + bytes; | |
| } | |
| var accounted = slices.values.fold(0, (a, b) => a + b); | |
| // 確保 accounted 不會超過 totalBytes | |
| if (accounted > scan.totalBytes) { | |
| accounted = scan.totalBytes; | |
| } |
VisibleTabScope handed every page the same notifier instance, so its InheritedWidget never notified on a value change and the home sheet's TickerMode plus the wind overlay's ticker gate froze at their first value — both kept animating behind hidden tabs. Subscribe to the notifier itself (as BaseMap and RefreshOnAppear already did) and pin the contract with tests.
Switching the typhoon weather underlay to satellite swaps the county frame to the bare bright-yellow line the standalone B13 layer uses — the shared cased stroke reads as black over opaque IR. Removal is unconditional on either side so toggling or switching never leaves a stale frame behind.
adminBaseLayerId anchored frames below the bottommost admin stroke, which is the global casing once 國界 is on — so a scrubbed frame still covered the county and town lines. Anchor below the topmost admin line instead, and apply the same anchoring to radar and QPESUMS (their later frames stacked over their own borders and scan-range outline). 國界 now ships on for every raster layer (radar, wind, QPESUMS, satellite); the menus' "not the defaults" dot and their tests follow.
SQLite cache entries no longer expire by age — only the byte budget trims, and only once the store is actually over 350 MB, dropping least-recently-used rows until it is back under. Debug kernel snapshots (*.dill) count as engine in the storage pie and the largest files now show their directory, so a tmp pile-up is attributable at a glance.
MapLibre's native downloads already persist through the Dart tile bridge into the app's own ETag SQLite, so NSURLCache's disk copy was pure overhead — a second, un-metered copy of the same bytes that only the system could evict. configure() now sets diskCapacity to 0 (memory-only 16 MB stays, so a SQLite miss can still skip the network), drops any residue left by older builds, and the storage breakdown marks the System HTTP cache slice as residue-only.
flutter run leaves main.dart.dill / .swap.dill (~87 MB each) in tmp on every debug launch and iOS keeps tmp across app updates, so a dev device that runs release picks up hundreds of MB of JIT kernels it cannot use. Release startup clears tmp once — release has nothing of its own there, and Android's handler is a no-op by design.
The perf rewrite counted each bucket's points in a Uint8List, and a whole 6400-particle population can land in one bucket under strong wind — the count then wraps at 255, dropping the bucket (or most of it) so new particles vanish and stale trails outlive a rotation. Count in 16 bits, and make the streak tests actually see the particles: the sampled boundary was Scaffold's white one (blank overlays passed), and the z7 viewport held too few particles to trip the wrap. A zoomed-in Taiwan field now puts thousands of points in one bucket, pinning the count at 300+ bright pixels — the buggy build measures ~60.
The AIFFs sat loose in ios/Runner and the OGGs beside the Android resources, sized 5.0 MB and 287 KB between them with no common spec — several were already clipping at 0 dBFS while others sat 3 dB quieter, and the OGGs were Vorbis stereo. Move the iOS sounds into Runner/Sounds (pbxproj paths updated) and re-encode everything: 44.1 kHz mono, peak normalised to -1 dBFS, Android as 128 kbps MP3 and iOS as IMA4 AIFF (notification sounds must stay in an Apple container, so MP3 is not an option there). iOS drops from 5.0 MB to 640 KB.
Flutter 3.44.8 -> 3.47.0 (Dart 3.13) via mise; SDK floor to ^3.13.0. Dart 3.13 reserves `final` on parameters for primary constructors, so the freezed 3.x codegen no longer compiles — freezed 4.0.0-dev.3 + build_runner 2.16 regenerate all 23 models (output otherwise unchanged). Firebase stays pinned 4.11.0/16.4.1 (exact, not ^, so pub upgrade can't drift it). Dependency bumps: dio 5.11, go_router 17.5, package_info_plus 10.2.1, talker 5.1.20, json_serializable 6.14.1. All 38 touched files are the Dart 3.13 formatter's reflow plus one lint fix (unawaited_return_in_try_block in MapTileCache.warm).
The first `_refresh()` only seeds `_status` — its "previous" is the optimistic initial value, not a confirmed usable state. If a fix published the township while that refresh was in flight (slow geolocator channel), the GPS-lost branch then overrode it with null. The lost branch now requires `_seeded`, so a seed refresh can never clobber a fix that already landed.
The 19 bundled marker PNGs (intensity-1…9, dark variants, cross) are now painted locally into the same badge geometry — rounded-square shell + the discrete intensity colour from IntensityColors (single source of truth, can't drift from the legend) + level digit — and cached PNG bytes feed MapLibre exactly as the assets did. Removes ~28KB of assets and the pubspec declarations; structural tests pin the geometry.
Flutter ≥3.35 auto-unions its 3-ABI abiFilters with the app's, dragging the map SDK's libmaplibre.so (10MB/ABI) in for architectures the engine doesn't ship. Clearing and pinning arm64-v8a (minSdk 26, emulators run debug) cuts the release APK 53MB → 37.6MB; CI's redundant --target-platform flag goes away with it. android/build + android/app/build join the ignore list.
The breakdown subtracts known big files (the SQLite DB etc.) from the directory that contains them, but on iOS the dirs and files came out of different APIs, so path styles could differ (/private/var vs /var) and the subtraction silently missed — the same 123MB appeared as both "Caches" and "ETag cache (SQLite)", summing past 100%. Standardize both path sets to the resolved spelling, tolerate the /var spelling in the Dart matcher, and label a directory that gave up a known file "(other)" so the pie reads ETag as part of Caches, not a sibling.
Port the reference CWA travel-time grid (depth × dist, P + S–P) into the domain and pre-interpolate one depth into two 1-D curves per event, so each distance↔time query is a single bisect + linear interp instead of a linear scan. The replay map caches one source per alert across ticks; wave-radius goldens are unchanged (depth 0).
Gzip box.json (7 KB → 0.8 KB) and drop the redundant uncompressed travel_time.json; re-encode the two purely-visual sky textures lossy (starmap 96 → 64 KB, sun_rays 34 → 4 KB) with the generator tool updated to match; re-gzip location.json at level 9.
Firebase, prefs, the SQLite cache, the town directory and package info now load concurrently instead of serially; notification init moved after the first frame so FCM never gates launch. The town-boundary binary, town directory and travel-time grid decode in background isolates (their gzip + parse previously stalled the UI isolate), and the realtime feeds stagger their first polls so the post-first-frame burst doesn't hit the network and JSON decode all at once. Log.sinceStart marks bootstrap-ready and first-frame times.
Sweep the markdown after the perf rewrite: Flutter 3.47 / mise toolchain, ApiClient+ApiTier+ApiPaths networking (no more redundant/exclusive/external apis), the 15 shipped features, wind/DPM-restroom-shelter endpoints, the real weather-shader layer stack, and the DPIP repo slug (no longer DPIP-Pocket).
- scope the camera-epoch rebuild to the overlay subtree, so a pan/zoom settle no longer rebuilds the platform view, chrome and legend - memoise the base-map style string (varies only by palette) - fast-path the geo-circle ring math (cached bearing table, hoisted centre/delta constants) and memoise frame-id time parsing - replace the wind particle 1/cos(lat) per particle per frame with a LUT - skip empty-EEW and same-payload re-pushes on the replay and RTS layers - parallelise independent platform round trips (timeline neighbour mount, typhoon overlay visibility) - cache the radar scan-range ring and lightning same-frame shows
- home sheet/map blur: reuse the ImageFilter across drag ticks instead of rebuilding it (and recompositing the full-screen blur) every frame — sigma quantises to the same step, so the filter only changes on a level crossing - weather sky: bake the four-layer star field and the sun glare into textures once instead of re-rasterising the fragment shader every frame - report list / weather ranking: memoise DateFormat instances per locale - rain trend: memoise label widths Splits the star layers out of night.frag into night_field.frag (RGBA = bright-pass core/glow, medium, faint) and pins the bake with a shader test that each channel actually lights pixels.
- vendor meshtastic_flutter (third_party/) with two upstream fixes: requestMtu is skipped off Android (CoreBluetooth negotiates MTU and flutter_blue_plus throws there), and text/JSON payloads decode as UTF-8 (fromCharCodes garbles CJK) - MeshtasticService (domain) + MeshtasticClientImpl (data): BLE transport over the vendored package, with platform-aware permission handling and a package:logging bridge into the app Log - MeshLink: session owner created in bootstrap — persists the chosen radio, reconnects across pages/restarts, and only detach() stops it - DpipMeshGateway + DpipMeshPacket: PRIVATE_APP payloads in a versioned 5-byte envelope on the fixed DPIP channel; wire codes pinned by tests - typed failures for radio channel slot exhaustion and key conflicts - preferences keys for the persisted radio and the message log
…line VisibleTab reports two independent things through one notifier — which branch is selected, and whether a page covers the shell. The map scaffold read "the notifier fired" as "the tab came back", so every cover and uncover re-loaded the active timeline: the radar re-fetched for the act of opening settings, and again on the way back. _onTabChanged now fires on the hidden → visible *edge* instead, tracked via _wasVisible; VisibleTab's contract is pinned by a new test group.
The particle population is a function of zoom, so a pinch re-sizes it on every frame: growing seeds particles into a viewport that is still moving, shrinking truncates the list, and the field arrives at the final zoom carrying whatever that churn produced. The layer now tears the field down for the whole gesture and reseeds once on release — cheaper, and always correct, since the reseed starts from the camera the gesture settled on.
… frame Fading the hero card to opacity 0 stopped the ticker, which made the resume path (scheduled by the stopped-ticker check) restart it — so a card whose painter returns without drawing a pixel kept advancing two solvers and marking the frame dirty every vsync. The opacity term was missing from both the stop-check and the resume gate; they now share one gate set, so a resume can never undo a stop it is not allowed to beat.
A sigma of 0 still pushes the layer, reads the backdrop back and resamples it — and the sheet quantises its travel to 6 steps, so the top step lands on exactly 0 as the *resting* posture at full extent, directly over the map platform view: the backdrop-filter path Flutter's own docs call out as expensive on iOS. BackdropFilter.enabled short-circuits in RenderBackdropFilter.paint before the filter resolves or the layer pushes, while the widget/element/render tree stay in place, so no re-parent flash. The blur re-engages as soon as the surface starts moving again.
…s read Audit of DateTime.now(): most uses are legitimately wall-clock (LRU last-used ordering, elapsed measurements, log retention, relative UI readouts), but four compared against stamps that were written with AppTime or that must not move with a user-set clock: - nowFrameIndex's default clock was the wall clock while the frame times it orders are server timestamps; every caller already passed AppTime.utc, so the fallback is now the only thing that could have drifted. - The location last-known-fresh gate (10 min) used the wall clock: a user who set their clock forward ages a good fix out (a wasted 10-second request), set it back and the app keeps trusting a fix that is 20 minutes old — the latter is wrong-place hazard data. - The mesh "x ago" readouts compared AppTime-stamped samples against the wall clock; one baseline everywhere now.
Every commit on the rollback journal is a journal write, an fsync, a directory fsync, the page write-back, the journal delete and another directory fsync — several barriers and a double write of every changed page, for the handful of rows a buffered log flush or a mesh-node update actually touches. WAL appends those pages to one long-lived file and defers the write-back to a checkpoint that amortizes over many commits. `synchronous` stays FULL here. Settings, the mesh conversation and the log cannot be fetched again, so a commit still fsyncs before it counts; WAL removes the journal dance, not the durability. Configured in `onConfigure` because that is the only sqflite callback that runs outside a transaction, and `journal_mode` cannot change inside one. Best-effort, like the open itself: a database that will not take WAL keeps working on the rollback journal. The HTTP cache file is deliberately left alone. VACUUM does not truncate a WAL database — the reclaimed image lands in the -wal and the file only shrinks at a checkpoint, and this app never closes the connection — so putting it in WAL would quietly break what the developer page promises when it says "the database file is compacted".
…data Both of these already move their expensive work off the UI isolate, and both then hand the result back in a shape that costs almost as much to adopt as it did to produce — inside the first-frame window, which is the one place the isolates exist to protect. TownBoundaries.fromDecoded: the decode isolate builds each ring as a Float64List and typed data survives the hand-off as itself, but the comprehension re-listed it anyway — boxing all 350k vertices into a growable List<dynamic>, with its doubling reallocations, then copying them a second time into a fresh Float64List. Taking the buffer as it arrives measures 10.4 ms -> 0.45 ms on the bundled boundary set. Aliasing is safe: rings are only ever read, and both callers discard the decoded map on return. SeismicTravelTimeSource: the isolate only gunzipped and parsed the JSON; the 25,016-record table was then assembled on the UI isolate — 106 int.parse, 25,016 record allocations and 75,048 num->double casts. Building it inside the isolate also shrinks what crosses the boundary, since records go over instead of the JSON maps they were built from. The comment claiming a 35 KB asset loaded when the replay map opens was wrong on both counts: it is 213 KB on disk, 834 KB inflated, and it is loaded at bootstrap.
…per row The native tile bridge asks for tiles in batches, and readBytesBatch was turning each batch into per-row bookkeeping: an LRU-touch write and a cache-hit metering write for every tile it returned. During a pan or a radar scrub that is a write transaction every few milliseconds, on the same file the rest of the app is reading through. Both are now folded to one write per batch, behind a single arming path so the two cannot drift apart. The LRU flush no longer fires for a sweep that will not run, and the eviction scan pages through victims instead of materialising the whole table across the platform channel to drop a fraction of it. Retention and budget semantics are unchanged — the same rows are evicted, in the same order, at the same threshold.
toImageSync rasterises the picture but does not take ownership of it, so each of these left the recorded display list alive with nothing holding a reference to free it. The card-water composite does it on every painted frame, twice, for as long as rain is running — the sprite bakes only once per mount, but they leak just the same. The image outlives the picture in every case here: toImageSync has already produced it, and the two sprite bakers read the image out before returning, so the picture has no remaining reader at the point it is dropped.
_TempSparklinePainter.shouldRepaint compared a List<double> the enclosing build had just allocated, so the identity check was unconditionally true and the painter reported dirty on every rebuild. HomeForecastSection sits inside HomeContent's ListenableBuilder on the sheet's scroll controller, which rebuilds the whole hero panel on every scroll tick. Past the first 140 px every other derived value in the dashboard has clamped, so this was the only thing still dirtying paint — re-rasterising the dashboard layer at display rate to produce pixel-identical output for the rest of the gesture. listEquals over the samples, plus the colour, which is the only other input the painter reads.
ExpTechTW/flutter-maplibre-gl 0674b9be -> 37e4eb9d, which makes map#pause actually stop a map on both platforms. Until now setRenderPaused was inert everywhere: iOS raised the display link to its maximum rate instead of lowering it, and Android called a renderer method whose body is a bare return. Every hidden tab's map has been rendering at full rate. BaseMap already issues the calls, so nothing here changes — the plugin side just starts honouring them.
Restores the half of the channel-name fix that was left out of the tree: `meshtastic_page` was committed calling `controller.channelNames` while the controller that defines it was not, which broke `flutter analyze` and, being a compile error, the Android build with it. Mesh history, kept 24h: - the radio's own pack voltage (percent pins at 101% on external power, so the volts are the only figure that shows a cell ageing) and how many nodes it could see, total and online — a coverage collapse shows there first; - each neighbour's battery, voltage and SNR in a new `mesh_node_metrics` table, per node rather than averaged. The in-memory ring it replaces is bounded by count, so on a busy mesh it held minutes. Retention is now one service on one schedule (start, then hourly) instead of each store pruning on whatever it happened to be doing. Three stores never pruned on an idle app at all: `LogStore.flush` and `NetworkUsageStore.flush` both return early when nothing is buffered, and they were the only things that trimmed; `Log.pruneOlderThan` — the in-memory Talker ring the log viewer reads — was never called from anywhere. Debug page: per-table row counts and bytes, biggest first. "The database is 40 MB" is not actionable; "mesh_node_metrics is 38 MB across 900,000 rows" names both the table and the window that is wrong.
…ow why Two halves of one investigation into why Android background location stops reporting. They are one commit because the fix and the readout that proves it worked touch the same methods. THE FIX. BackgroundLocationChannel's "start" cancelled the alarm fallback before it tried to arm the geofence, and arming is all-or-nothing on FusedFix.get() returning a location — which fails whenever location is off at that moment, the 15 s BALANCED request times out indoors, or there is no fresh cached fix. The device was then left with no geofence, no alarm and nothing scheduled to retry, so reporting stopped silently until the user next opened the app. The alarm now stays until Play services confirms a fence is live, and every other path out of armGeofence re-schedules it. Confirming that needed GeofenceManager.register to say whether it worked; addGeofences is asynchronous, so returning told the caller nothing. It reports through an optional callback rather than a blocking await because LocationBootReceiver and GeofenceReceiver's error path both call it straight from onReceive, where Tasks.await throws. THE READOUT. A "Background location" section on the developer page, fed by a new `diagnostics` method both platforms answer with the same keys. It reports whether something is monitoring *now*, which mechanism, the authorization, whether native holds a push token, where the fence or region is centred, and when the last report was attempted and how it went. Armed is deliberately observed rather than remembered. iOS accepts startMonitoringSignificantLocationChanges() without Always authorization and then delivers nothing, so a stored "we called start" bit would read healthy on exactly the broken device; it is derived from monitoredRegions, the one thing Core Location confirms back. Android's Geofencing API cannot be queried at all, so the arm result is recorded when it lands and cleared on removal, refusal and disable; the alarm is probed with FLAG_NO_CREATE. Recording the last report — including failures, with the status code — is what separates "never fires" from "fires and cannot reach the server". Both platforms previously swallowed the outcome, so the two looked identical from outside. The three Dart gates that silently disable everything (no push token, no "Always" grant) now log, so the in-app log page can answer this from a user's phone instead of needing logcat.
bc5ee1c closed this hole in one of the three places that arm a geofence and left the other two open, which is the worse half: the channel only arms when the user opens the app, while the receivers are what keep a closed app reporting. The reachable failure is a user turning Location off. Play services drops the fence and broadcasts GEOFENCE_NOT_AVAILABLE; GeofenceReceiver.reArm re-registers in the same breath — while Location is still off — so addGeofences refuses. It passed no result callback, so that refusal only set armed=false and wrote a logcat line. The alarm had already been cancelled when the fence first armed, so the device was left with no fence, no alarm and nothing that would ever notice. Turning Location back on does not help: Play services does not restore removed geofences and no broadcast brings us back, so reporting was over until the next app open. The fallback moves out of BackgroundLocationChannel into LocationAlarmScheduler.ensure, and all five register call sites now act on the result: channel start fail -> alarm (already did) exit re-centre fail -> alarm reArm after an error fail -> alarm boot re-arm fail -> alarm alarm fire ok -> cancel the alarm That last one is the way back up. Without it a device degraded to the alarm stays there, paying Doze-throttled wakeups the geofence would not, until someone opens the app — and the users this spine exists for are exactly the ones who do not. LocationBootReceiver also gains the goAsync() the other two receivers already had. Registration is a binder call into Play services, so returning from onReceive can strand it; and BOOT_COMPLETED regularly lands before GMS location is ready, where a not-yet-initialised network location provider returns the same GEOFENCE_NOT_AVAILABLE — a boot that failed to arm used to leave the device silent.
Equivalence-preserving only — every change is a hoist, a strength reduction, or a buffer reuse, pinned by the existing parity/golden suites (wind web parity, card water pipeline, sky gradient, EEW goldens), all unchanged. Map: - mesh node layer: coalesce store notifications into one trailing GeoJSON push. The store notifies per packet and a config download replays the whole node table — ~250 notifications, each re-serialising a 250-feature GeoJSON across the platform channel, which is what the connect-time jank was. - mesh node sort: resolve the online cutoff once per access instead of per comparison (thousands of DateTime subtractions a second on a busy mesh), and give the page badge a count that does not pay for the sort. - wind sim: hoist the four view-bound products out of the 6400-particle loop; stamp and record the trail head in one pass instead of two; divide by the speed scale once, not per particle. Home: - card water solver: reuse per-iteration scratch buffers (four Float32Lists five times per 20 ms tick — a thousand allocations a second of GC churn while it rains) and hoist diameter² out of the neighbour search. One observable timing change, deliberate: mesh nodes now land on the map at most 250 ms after the store hears them, instead of once per packet.
Android's unused-app restrictions end background reporting for exactly the people it exists for: someone installs DPIP, grants "Allow all the time", and never opens it again because no disaster has happened. After a few months of no interaction Android revokes the runtime permissions, and from Android 12 force-stops the package and clears its caches. targetSdk resolves to 36, so both apply. Nothing in the app survived that or noticed it. A force-stopped package is in the stopped state and receives no broadcasts at all — BOOT_COMPLETED included — until the user launches it by hand, so the boot re-arm cannot recover it; on Android 15 force-stop also cancels the app's PendingIntents, taking the geofence and the fallback alarm outright. BgLocationStore.enabled stayed true throughout, so every internal check still read healthy. The exemption is a user-set system toggle, not a permission, so the new row reports the state and opens the page rather than requesting anything. It is separate from the existing battery-optimization row: that one covers Doze and does nothing about this. Three states, not a bool. `unavailable` hides the row entirely — a device too old for the API, or without the Play services that back-port it, has nothing the user could change, and an un-actionable warning on a disaster app's permission page is worse than no row. DISABLED is the good state (the user has turned restrictions off); every flavour of "on" reports the same, because the action required is identical and the API level is already on the developer page. Both androidx dependencies are pinned explicitly rather than taken from the transitive graph. concurrent-futures is needed because getUnusedAppRestrictionsStatus returns a ListenableFuture and androidx.core declares guava's listenablefuture without putting it on a consumer's compile classpath. Not covered here: OEM "sleeping apps" managers (Samsung, MIUI, EMUI) apply the same stopped state on a days-not-months timescale and are not reachable through this API — they need the user to exempt the app in the vendor's own battery UI.
… cost Two audited rounds (findings adversarially verified against the code before any edit; 26 plausible claims refuted and dropped). Everything here is behaviour-preserving; the full suite, golden parity tests and gates pass. Rebuilds that served nobody: - report sheet: dragging re-grouped and re-sorted every felt township per frame — the expanded/peek subtrees are built once per State build and the extent builder now short-circuits them by identity - home page watches only the freezed weather slice (identical fetches no longer rebuild the dashboard), and the scroll-driven panel rebuilds only while a dial can still change (all three saturate by 200 px) - mesh chat: the controller no longer blanket-forwards node-store notifications (every packet rebuilt the page); badge, node sheet and sender names each select their own slice - moon page: the fragment shader is minted once at load — a new native shader per rebuild kept shouldRepaint permanently true and leaked the old instance every timeline tick Timers that outlived their audience: - the three EEW countdown cards share a SecondTicker mixin: stopped under the lock screen and behind other tabs, snapped current on return (a Timer is not a Ticker, so TickerMode and lifecycle never reached them) - RTS: station GeoJSON uploads and the 5 Hz wavefront ticker stop while the map surface is hidden (tab switch or background) and flush once on return; polling itself never stops — it is a safety feed - replay: page-scoped RTS/EEW channels (deliberately outside RealtimeService) now pause on background, as does the 1 Hz blink; also fixes the frozen last wavefront — the isEmpty skip made the clearing write unreachable once a replayed alert expired - retention's first sweep waits out the launch window; screen wake on the mesh page holds only while a radio is connected UI-isolate JSON, both cache paths: - 304 revalidation (the hottest path: 65-130 KB station catalogues that almost never change) parses inside the store's existing gunzip worker hop via readJson — Dio's own isolate offload never sees a 304 - fresh 200s encode the body once instead of twice (byte metering is bit-identical) Launch and memory: - town boundaries decode overlaps the DB opens; schema re-runs collapse ~12 serial round trips into batches; the location reporter no longer queues behind a cold GPS fix - moon textures (~10 MB RGBA), the rain-card sprite bake and the star catalog are decoded once per app run instead of per open (the per-mount sprite bake also leaked); icon bakes dispose their Picture/Image handles
The label was drawn at TownDirectory's point, which is the administrative seat, not the middle. For a mountain township that is the inhabited valley at one corner of a shape running tens of kilometres into the range: 臺中市和平區's label sat 44 km from where the name belongs. Across all 367 the median label was 2.1 km out and 184 were more than 2 km out. The new point is the pole of inaccessibility — of every point inside the polygon, the one furthest from any edge. That is the standard place to label an area, and unlike a centroid it is always inside the shape, which a centroid is not for anything as concave as Taiwan's coastal and mountain townships. Computed offline with Mapbox's polylabel and baked into a generated table, because the search is far too slow to run at startup. TownDirectory's own lat/lng is deliberately untouched. It anchors TownDirectory.nearest, the GPS→township fallback used at sea, in boundary gaps and before the polygons load, and that answer decides which township an alert is addressed to. The nearest settlement is the right answer there; the geometric middle is not. Picking which polygon is the township's main body needed care. Choosing the roomiest put 雲林縣口湖鄉's label in the Taiwan Strait: it carries a second polygon that is a 9-vertex box over ~270 km² of sea, far roomier than its ~80 km² of land, and the label was still legitimately "inside 口湖" so the obvious test passed. It now prefers the polygon containing the administrative seat, which is on the main body by definition. 23 townships have more than one polygon; that rule resolves 22 of them. The label GeoJSON is also memoised. It is a ~41 KB string over 368 features rebuilt on every BaseMap build, and it only changes when the directory instance does — once, at bootstrap. One thing this surfaced that is not a label problem: 新竹市香山區 has no polygon at all in the boundary source, and 新竹市北區's covers its ground, so TownBoundaries.codeAt answers 北區 for a GPS fix anywhere in 香山. That misroutes township-level alert targeting for everyone there and can only be fixed in the source data. The test pins the gap at exactly that one township so a wider one is caught.
…own section Two changes to the same page, from one request. THE DOT. Nothing outside the permission page said anything was wrong, so a user whose notification or location grant had lapsed — or whom Android had quietly revoked after months of not opening the app — had no reason to go looking. A Material badge now rides the More tab and the 權限檢查 row it leads to, both reading one app-wide PermissionHealth so the two surfaces cannot disagree. It counts only the three the app cannot work without: notifications (no alert is delivered at all), foreground location (nothing knows which township to warn about), and background location (the township goes stale the moment the app closes). The Android battery exemption and unused-app restrictions are deliberately excluded even though they matter — they are optimisations a user may knowingly decline, and a dot that cannot be cleared teaches people to ignore dots. They stay on the permission page. Permission state changes outside the app and raises no event, so it is re-read on foreground — the moment after any trip to system settings — and on demand once the checklist acts. Read with `select`, so the whole shell and every mounted tab under it do not rebuild when an unrelated field moves. Both icons are badged, not just the unselected one: NavigationBar fades to selectedIcon on tap, so badging `icon` alone made the dot vanish exactly when the user opened the tab it was pointing at. THE MESH SECTION. Meshtastic moves out of 進階 into its own section. The LoRa mesh is the app's off-grid reception path, not a developer curiosity, and the radio it pairs with is a physical thing the user owns and manages.
Names collide on a mesh — several radios ship with the same default — and the hex node id is what tells two of them apart, so the sender line carries both. A node with no reported name shows the id alone. Also drops the controller's senderLabel(), dead since the bubbles moved to a per-sender MeshNodeStore select.
The off-grid reception path outranks the developer drawer — the section already says as much in its own comment; now the order does too.
The core of the colour-blindness setting: the daltonisation, the setting key, the controller, and the facade the ~270 colour definitions across the app will read. Nothing is routed through it yet — that lands next — so this commit changes no pixel. Three decisions worth stating, because each rules out the obvious approach. CORRECTION, NOT SIMULATION. Simulating shows a typical eye what a deficient one sees. That helps a designer and does nothing for the user. This works out what information the eye is losing and redistributes it into channels that eye can still separate, so two colours that would collapse into one stay apart. IN LINEAR RGB, NOT AS A ColorFilter. The Machado matrices are defined in linear light and every sRGB value has to be un-gamma'd first. Flutter's own ColorFilter.matrix multiplies gamma-encoded sRGB, which skews everything and is worst in the darks — and this app's map is drawn on #1f2025. A root ColorFiltered was also ruled out for two independent reasons: it never reaches the MapLibre platform view on iOS and only sometimes does on Android (TextureLayer vs PlatformViewLayer, decided per device at runtime), and it would transform a Flutter-drawn legend while leaving the MapLibre-drawn dots beside it alone. AT THE DEFINITION, NEVER IN A CONVERTER. A colour that reaches the map as a hex string and its legend as a Color must transform exactly once. Transforming inside colorFromHexRgb / toHexRgb would double any value that round-trips and miss every value that never converts, which is precisely the map↔legend agreement that color_hex.dart and intensity_colors.dart document as unbreakable. Access is a global facade beside the enum, in the same shape and for the same reason as AppTime: the colours needing transformation live in map layers and paint tables that are not widgets and never see a BuildContext. rasterExempt() is the identity. It exists so a deliberately untransformed colour says so at the call site: server-rendered radar reflectivity and satellite composite arrive as finished PNG tiles and MapLibre's raster layer has no colour matrix, so those pixels cannot be corrected — and their legends therefore must not be either. The tests pin what is easy to get wrong: greys must not tint (or the whole app chrome shifts), alpha survives, rgba() keeps its functional form, and anything that is not a colour — a MapLibre expression, an interpolation — passes through untouched, because mangling one paint value takes a whole layer off the map.
…ansform 172 colour definitions across 33 files now read the correction added in d436660, and 68 are marked as deliberate exemptions. Nothing is wired to a setting yet, so the transform is the identity and no pixel moves — the wiring lands next. Transformed at the definition, never in a converter. lib/shared/color_hex.dart is untouched: a colour that reaches the map as a hex string and its legend as a Color has to transform exactly once, and doing it in colorFromHexRgb / toHexRgb would double any value that round-trips while missing every value that never converts. RASTER IS EXEMPT, AND SAYS SO. Radar reflectivity and the satellite bands arrive as finished PNG tiles and MapLibre's raster layer exposes no colour matrix, so those pixels cannot be corrected — which means their legends must not be either. A key that disagrees with the picture is worse than one that is hard to read. Those stops are wrapped in ColorVisionFilter.rasterExemptHex, an identity function whose only job is to say at the call site that the absence of a transform is a decision rather than an oversight. The fan-out's own review caught seven legend↔map pairs converted on one side only, each of which would have made a key name colours that are not in the picture. All are closed here: the typhoon track ramp (TyphoonIntensity.colorHex), the wind ramp (windBuckets — one table that the legend, the map arrows and the station trend plot all read, so one fix covers three consumers), the QPESUMS scan-range ring, the satellite outline duplicates in satellite_legend, the fourth hand-copy of the mesh MQTT purple in mesh_node_sheet, the EEW wavefront duplicated into report_replay_page, and the four station ramps whose shared doc already promised they arrived corrected. IntensityColors is memoised per setting. The conversion had left it rebuilding and re-transforming all ten scale colours on every discrete() call — a function read once per station dot, per legend swatch and per badge, thousands of times while an RTS frame lands. Colors.grey became an explicit 0xFF9E9E9E in the process; it is the same value. map_color_legend's header claimed both the radar and satellite legends used the raster exemption. Radar did; satellite did not — its scales were merely untransformed, which is correct behaviour with no way for a reader to tell it from a miss. They are marked now and the doc names both files. Known and not addressed here: several layers bake their marker PNGs once and cache them for the life of the process, so a live change of the setting will not re-bake them until the style is rebuilt. That belongs with the wiring.
…sion Wires the colour-vision transform to a setting and adds the three type and contrast controls beside it, so everything on the Display page now changes how the whole app is drawn. TEXT SIZE does not go through the theme. This ThemeData declares only a colorScheme, so its text theme is Material's colour-only default and textTheme.apply(fontSizeFactor:) asserts on it. The size travels as a TextScaler on MediaQuery instead — which is also what lets it *compose* with the platform's own accessibility scale rather than overwrite it. A user who has already enlarged text system-wide has said something; replacing that would shrink the app for exactly the people who need it largest. ComposedTextScaler overrides == because the framework uses that to decide whether text widgets relayout, and derives textScaleFactor from scale() rather than calling the deprecated getter. TEXT WEIGHT moves every style up the weight ladder rather than setting one absolute weight, so the hierarchy survives: a heading that was heavier than its body stays heavier. The app's 123 explicit fontWeight: overrides beat the theme wherever they appear, which is why it shifts rather than flattens. CONTRAST is ColorScheme.fromSeed's own contrastLevel. COLOUR VISION now reaches the map. The setting rebuilds from the app root, which re-derives MapColors, which changes the style-string memo key, which makes MapLibre reload the style and every layer re-apply its paint. Without that the legends would recolour and the map would not. Baked bitmaps needed the same treatment and did not have it: the wind arrows, the lightning strikes, the disaster-map markers and the intensity icons each render once and keep the result for the life of the process, with the corrected colours painted in. Each now remembers which setting it was baked for. The cache checks the setting rather than the setting notifying the caches, so adding another baked asset cannot forget to register itself. Every default is stored as absence, so an untouched install carries no keys rather than four magic strings. The four segmented rows scroll horizontally. At the largest text size four segments do not fit a phone's width, and a squeezed segment that clips its own label is worse than one the user swipes to. Strings in all 11 locales.
… offline history Unread is now a first-class thing the chat page tracks: - the channel picker's number is the *unread* count (red pill), not the total — "3 new" is the reason to tap, a total says nothing; the collapsed button keeps a dot when any other conversation has unread - a red rule with a NEW pill sits where the unseen messages begin. The position is snapshotted when the conversation is opened — opening is also what marks everything read, so a line derived live from last_read would vanish in the frame it appeared; it holds for the visit and clears on leaving, as Discord's does - read positions persist in a new mesh_reads table — its own table because mesh_channels is replaced wholesale from the radio and holds only named channels, either of which would silently reset read state. Own sends never count as unread; a message landing in the open conversation is read on arrival. Counting runs in SQL (LEFT JOIN against the read positions) so a month of a busy mesh never crosses into Dart just to badge a menu And the disconnected page no longer shows history in the "wrong" channel — three real causes, each pinned by a test: - the DPIP slot now survives a *restart*, not just a drop (persisted, and forgotten with the radio on detach): a relaunched disconnected app used to open on whatever channel sorted first instead of the DPIP conversation - history restores per conversation instead of one global newest-300 window, so a busy channel can no longer evict a quiet one's messages offline - legacy rows whose channel field holds a hash (written before the channel-hash guard) are pruned, and log-derived picker entries are guarded, so phantom CH242 conversations can no longer appear
None of these settings can be judged from its name. "Medium contrast" and "deuteranopia" mean nothing until you see them, and the whole point of the page is choosing what you can actually read — so every option now draws the same mock screen under its own settings, and the row is a comparison rather than four assertions. Each card renders with ITS OWN option, not the one currently in force, which is why the preview transforms colours through ColorVisionFilter.transform with an explicit vision rather than the `.vision` extension: the extension reads the global setting, and here each of the four cards needs a different one. The mock carries the felt-intensity ramp on purpose. It is the app's signature colour semantics and the sequence a red- or green-weak eye collapses worst, so it is the thing that makes a colour-vision option visible at all — a thumbnail made only of theme greys would look identical in all four. It also draws real text at the option's size and weight, which is the only way a type setting reads as anything at thumbnail scale. The card is now tappable as a whole. Only the preview took the tap before, so the label — which is what a reader looks at to decide, and therefore what they reach for — was inert. That was true of the pre-existing theme picker too; a test written for the new rows is what surfaced it. The test pins the claim the previews make: that the four colour-vision options do not paint the same picture. Handing every card the current setting instead of its own is an easy mistake that would leave the row looking broken in exactly the way this feature exists to fix.
Unread bookkeeping moves out of the chat controller into MeshUnread in core/: two features read it (the chat page's per-channel pills and the More tab's Meshtastic row), and a feature may not import another feature's presentation, so state both need lives below both — the same reason MeshAlerts sits there. The chat controller stays the writer: it is the only place that knows whether an arriving packet was genuinely new (the store's unique index answers that, deduplicating reconnect replays). The More tab's red dot now means "something in More needs you" — a permission needing attention or unread mesh messages — one dot on one icon instead of two. The unread divider survives the move: the read position is snapshotted when the conversation opens, before it advances, so the "new begins here" line cannot vanish in the frame it appears.
Four charts where there was one, all fed by the same day of samples: utilization, battery (with avg/peak/drain/trend and a time-left estimate), node totals/online, and traffic. Traffic is stored as deltas since the previous sample — not the transport's cumulative counters — so the series survives a reconnect zeroing them, and the battery chart's maths is pinned by tests rather than left to the eye. The node sheet's trend now spans a day too: the persisted 2-minute series with the live in-memory ring appended on top, which alone covered only minutes on a busy mesh. The ring renders immediately while the day loads.
One sample pinned at the top, the five settings below it — the preview that scrolls away as you reach for the control that changes it is no preview at all. It redraws with the rest of the app on the next frame, because every one of these settings applies at the app root. Landscape and tablets get the sample side-by-side, where stacking would squeeze it into a strip. The seventeen thumbnails are gone; each option now demonstrates itself — an A drawn at its own size, or a swatch built at its own contrast. That also retires seventeen ColorScheme.fromSeed constructions per build: swatches are cached per (brightness, contrast), keyed only on what AppTheme.scheme actually takes. IntensityColors.published is added for the colour-vision picker, which has to paint each option under its own setting — discrete() has already applied the current one, and correcting it again daltonises an already-daltonised colour. The numbered-report gold moves to ReportColors so the sample draws it without a second copy.
Superseded by mesh_charts.dart, which draws the same series beside battery, node, and traffic history.
No description provided.