Skip to content

Perf/all - #525

Open
whes1015 wants to merge 114 commits into
mainfrom
perf/all
Open

Perf/all#525
whes1015 wants to merge 114 commits into
mainfrom
perf/all

Conversation

@whes1015

Copy link
Copy Markdown
Member

No description provided.

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.
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

🔍 OpenCodeReview found 10 issue(s) in this PR.

  • ✅ Successfully posted inline: 10 comment(s)

Comment on lines +39 to +45
/** 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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · low]
使用強制轉型 (as) 可能在系統服務回傳 null 時導致應用程式崩潰。此外,可以利用 Kotlin 的特性將其改寫得更簡潔且符合慣用法(Idiomatic Kotlin)。建議改用更安全的 API 或安全轉型 (as?),並配合單一表達式函式 (single-expression function) 來提高程式碼的可讀性與安全性。

Suggestion:

Suggested change
/** 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

Comment on lines +98 to +113
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
當文件遍歷數量達到 visitCap (100,000) 時,scan 方法會中斷遍歷並返回已累加的 bytes。這會導致 totalBytes 僅代表部分文件的總和,而非目錄的真實總大小,從而導致掃描結果在大型文件系統中顯著不準確,誤導用戶對存儲空間佔用的認知。建議在達到限制時,明確標記結果為「部分掃描」或調整邏輯以確保 totalBytes 的正確性(例如先獲取目錄大小,再進行詳細遍歷)。

Comment on lines +78 to +83
String? dirOf(String path) {
for (final dir in scan.dirs) {
if (path.startsWith(dir.path)) return dir.path;
}
return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[other · medium]
storageBreakdown 函數存在效能與邏輯風險。首先,它對每一種已知分類都會完整遍歷一次 scan.files,若檔案數量極多,效能會下降。其次,dirOf 函數使用 path.startsWith(dir.path) 來匹配目錄,若存在巢狀目錄(例如 /a/a/b),匹配結果會受 scan.dirs 列表順序影響,可能導致檔案被歸類到錯誤的目錄或導致 dirBytes 計算錯誤(甚至出現負值)。建議將目錄路徑按長度從長到短排序,以確保優先匹配最精確的目錄。

Suggestion:

Suggested change
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;
}

Comment on lines +146 to +157
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'),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[other · high]
StorageScanner.scan 方法對原生端傳回的資料結構高度依賴。雖然目前 Android (StorageScanChannel.kt) 與 iOS (StorageScanPlugin.swift) 的實作看起來是符合預期的(包含 totalBytes (num), dirs (List), files (List),以及子項目的 path (String) 與 bytes (num)),但若未來原生端協議變動,這段 Dart 程式碼會因型別轉換錯誤(例如 as Mapas List)而拋出異常,目前只會被 catch 並回傳空的掃描結果,這會讓除錯變得困難。建議在轉換前加入更明確的型別檢查或提供更詳細的錯誤資訊。

Suggestion:

Suggested change
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,
];
}
// ... 其餘部分也應進行類似的安全性檢查

Comment on lines +175 to +184
@override
void didChangeDependencies() {
super.didChangeDependencies();
final visibleTab = VisibleTabScope.of(context);
if (identical(visibleTab, _visibleTab)) return;
_visibleTab?.removeListener(_onTabChanged);
_visibleTab = visibleTab;
visibleTab?.addListener(_onTabChanged);
_syncRender();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
didChangeDependencies 中使用 identical(visibleTab, _visibleTab) 进行提前返回可能会导致在 VisibleTab 实例不变但其 value 变化时,无法触发 _syncRender。此外,缺少 didUpdateWidget 来处理 widget.tabIndex 的变化,这会导致当父组件传入新的 tabIndex 时,地图的渲染暂停状态无法即时更新。

Suggestion:

Suggested change
@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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance · medium]
_injectFill 方法中,used 變數的初始值被設為 0(這被註釋為「樂觀估算」)。如果快取在調用 warm 方法時已經存在大量資料,第一個 chunk 的注入可能會顯著超過 cap 限制,進而觸發原生層的 LRU 剔除,這可能導致剛注入的圖塊被立即刪除,造成效能抖動。

Comment on lines +216 to +230
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[maintainability · medium]
_injectFill 方法引入了複雜的分塊(chunk splitting)邏輯,包含嵌套迴圈與多重邊界條件判斷(例如 used + size + tiles[j].data.length > cap)。這種複雜的邏輯增加了維護難度,且若邊界條件計算不精確或與原生層的記憶體計算方式不一致,可能會導致無法達到預期的填充目標或造成錯誤的注入行為。

Comment on lines +15 to +36
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,
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[test · low]
測試案例中的邊界值判斷與實際邏輯一致。在 lib/core/platform/render_tier.dart 中,判定邏輯為 totalMb < 4096 ? RenderTier.low : RenderTier.high。測試中使用了 4095 MB 作為低階 Android 的上限,以及 4096 MB 作為高階 Android 的下限,這與實作邏輯完全吻合。

Comment on lines +12 to +50
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,
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
storageBreakdown 邏輯在處理數據不一致時(例如:大檔案的大小超過了其父目錄報告的大小)可能會導致計算出的總量 accounted 超過 scan.totalBytes。這會導致 UI 圓餅圖的百分比總和超過 100%。建議在計算 accounted 時進行截斷,或者確保 known 匹配過程中,扣除的容量不會使目錄大小變成負數。

Suggestion:

Suggested change
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant