Skip to content

Perf/all - #525

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

Perf/all#525
whes1015 wants to merge 54 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
- page with scan/connect/disconnect, node list and a chat composer bound
  to MeshChatController (message log persisted in prefs)
- route registered in app_router.dart + More entry under Advanced
- 18 meshtastic keys added to all 11 ARB files and regenerated
- Android: legacy BLUETOOTH/BLUETOOTH_ADMIN pair (maxSdkVersion 30) plus
  BLUETOOTH_CONNECT/SCAN with neverForLocation for Android 12+
- iOS: NSBluetoothAlways/Peripheral usage descriptions (also gates the
  permission_handler SPM target)
- iOS Package.resolved refreshed for flutter_blue_plus and friends
Generated by `flutter create --platforms=macos` (network entitlements
added for the sandboxed app). Also refreshes .metadata to the pinned
Flutter revision.
CLAUDE.md: transport / session / data-plane split, the PRIVATE_APP
envelope on the fixed DPIP channel, and the best-effort delivery
caveat. ARCHITECTURE.md: core/meshtastic + features/meshtastic in the
tree map.
Bottom sheet showing everything the attached radio knows: identity,
battery/uptime, LoRa settings (region, preset, hop limit, TX power,
channel utilisation), live packet traffic by port, and the channel
table. Raw values on purpose — a wrong-looking number is the point.
Also a "connect anyway" affordance when another app holds the radio.
New keys in all 11 ARB files, regenerated.
A mesh conversation can sit open for a long stretch, and the radio is
watched while it happens — let the page ask for the display to stay on
through a platform channel (idle-timer disable on iOS, keep-screen-on
window flag on Android). Backgrounding still neutralises it, and the
flag is cleared when the page that asked for it goes away.
The mesh becomes a first-class channel, not just a transport demo:

- conversations and 24h airtime are stored in their own database
  (application-support, not the purgeable HTTP cache) with a migration
  from the in-memory log era
- heard nodes are tracked and surfaced, and new nodes raise a local
  notification — off-grid, no server involved
- alerts for incoming messages route to the mesh notification channel
- the traffic counters move behind a dedicated class the UI and the
  recorder share, and the chat page gains a utilisation chart plus a
  keep-awake toggle
Heard radios appear as a map layer with a tap-through sheet showing
what the node reports (position, last seen, distance). The layer
subscribes to the node store, so nodes appear and age as the radio
hears them.
A lunar phase page under 資料 with a scrubbable daily timeline like
the radar's. The phase itself is a pure local computation (Meeus
closed form — no ephemeris table, no network), and the disc is the
real Moon: NASA's CGI Moon Kit colour and elevation maps bundled as
assets, projected orthographically and lit per frame by a shader
(Lommel-Seeliger scattering, opposition surge, terminator softening,
earthshine). The timeline gains optional time-format and slot-width
parameters; the defaults keep the radar's look unchanged.
Adds the moon catalogue entry, phase names, age and next-full-moon
readouts, and the timeline caption across all eleven locales, with
Traditional Chinese as the source for the zh variants.
The page rewrite replaced the old scan/connect flow; these keys are no
longer referenced anywhere.
Sync the map/EEW fix (#526): the replay map's township-fill tint, the
switchable admin-outline chrome (radar/qpesums/wind rasters now anchor
under the township labels), and the EEW card redirect. The raster layers
keep the code-drawn intensity icons and the memoised style string from
perf/all; the travel-time table and its loader revert to main's
rowsByDepth layout (perf/all's grid rewrite dropped the CWA depth keys).
MoonPhase becomes a thin reading of a new MoonEphemeris: longitude,
latitude, distance, parallax, angular diameter and the sun's longitude
come from a single evaluation of the table 45.A/45.B truncation instead
of a hand-written series per readout, so phase, distance, libration and
rise/set can never disagree with each other. New MoonRiseSet walks the
moon's altitude across the day with refraction and parallax-corrected
horizon (h0 = 0.7275π − 34′), bisecting each crossing; rise/set is null
when the ~50-min daily slip skips a day. nextAngle now coasts to the
target at the mean rate and re-measures (no scan/bracket), and
distanceKm / apparentDiameterDegrees / nextNewMoon join the surface.
Measured against JPL Horizons (2024–2027) and the USNO (Taipei, Sydney,
Reykjavík); Meeus's worked example 45.a is pinned as a test.
The page now shows the moon's distance and apparent diameter alongside
the phase readouts, and the shader lookup is fixed to the near side: the
maps centre on 0° longitude, so the disc centre must land on u = 0.5 —
atan(x, z) instead of atan(x, −z), which quietly rendered the far side;
the libration rotations are re-derived as the selenographic point facing
Earth, and the moon_glyph / moon_calendar widgets back the phase legend
and a per-day picker.
What leaves is only ever ours — chat and DPIP — so a per-port table adds
nothing the totals don't already say; what arrives comes from every app
on the mesh, which is where the breakdown is informative.
Superseded by the NASA map + moon_display lighting; nothing has loaded
it since the texture switch.
One shared ephemeris spine for the whole sky — sun_ephemeris and
planet_ephemeris feed daylight, twilight, the golden/blue hour and the
planets page; solar_terms marks the 24 solar terms on a calendar strip.
The data hub's astronomy section becomes a two-column card grid, and the
moon page gains observer-tilt rendering (moon_orientation) so the globe
leans the way it actually appears from the chosen place.
An interactive maplibre layer fires feature#onTap instead of map#onMapClick,
and nothing listens to feature taps — a tap on a dot went nowhere while a tap
on empty sea still reached onMapTap. The hit test is Dart-side, so the layers
never need interaction: disable it and every tap lands in the map handler.
The store now keeps a per-node ring of recent telemetry (in memory, de-duped
against re-emitted bursts) and can measure great-circle distance from the
radio's own node. The sheet shows the distance beside the coordinates and
sparklines the last readings, with the latest value on the header — SNR and
battery read as a story instead of a single snapshot.
The scaffold had no lifecycle hook: frames were fetched once on entry, so
returning from the background showed the timeline frozen at the pre-
background "now" (a half-hour away is three radar frames missed). Subscribe
to app lifecycle and the shell's visible tab, and re-fetch + re-centre on
the present whenever the surface comes back on screen — hidden tabs stay
idle. The "now" frame is picked with the calibrated clock (AppTime.utc)
instead of device time in both the scaffold and the timeline, so the NTP
resync on foreground makes the selection land on the real present.
The sky above the app: eclipse.dart computes lunar (and locally visible
solar) eclipses from the ephemeris positions, satellite.dart propagates
TLEs (SGP4) with a TLE table bundled for the ISS, star_catalog.dart ships a
compressed star/constellation catalog, and meteor_showers / night_window /
tidal_forcing / deep_sky cover the rest of the night-observing questions.
Observer gains topocentric parallax (Meeus ch. 40) so a solar eclipse is
computed from where you stand, and the lunisolar 歲 boundary now resolves
both directions around the winter solstice instead of only one. The tool/
scripts are the scratch verifiers used against reference vectors.
…cratch

The astro batch landed unformatted — dart format --set-exit-if-changed
caught 11 files, so CI's format gate would have failed. satellite_test.dart
is the permanent home of the Spacetrack Report #3 golden vectors (pinned to
metres) plus ISS physics checks, superseding the two tool/_*_check scratch
scripts; the scratch files are dropped.
The night-window solver ran midnight-to-midnight, so the first dusk it
found was this evening's and the first dawn this morning's — a pair in the
wrong order describing a night that already ended. Solve from local noon,
where a night actually sits. TLEs are good for days, and the bundled
snapshot decays; tle_source now tries a daily fetch, falls back to the last
cached set in Prefs, and only then to the bundled file — with the snapshot
trimmed to just ISS/POISK/CSS.
Four data-hub pages on the night-sky stack: tonight_report.dart assembles
the observing window (dark hours, moon, ISS passes, meteor showers) into
one TonightPage; AlmanacPage shows the lunisolar date with upcoming
eclipses; SkyChartPage draws the star catalog and planets; TidePage plots
the tidal forcing. All routes registered under the data branch, and the
full l10n set for the new pages lands with them.
tonight_report_test pins the assembled observing window, tle_source_test
covers the fetch→cache→bundle fallback tiers, data_page_test guards the
hub's new entries, and sky_features_test grows to cover the noon-solved
night window.
TonightPage reads the cached element sets through Prefs, so core providers
hand the shared instance down instead of the page constructing its own.
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