From f15bb7ec31600395c6e94dbfb8c190a5281e244f Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Wed, 2 Sep 2026 13:24:53 +0800 Subject: [PATCH 01/32] fix(highlights): name the cycle, not the train that is installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正重點整理頁在 26.2 上仍寫著 26.1,整個 26 週期現在都顯示 26.x Fix(en-US): fix the highlights page reading 26.1 on a 26.2 build; it now names the whole cycle --- lib/core/version/app_build.dart | 14 ++++++++++++ .../pages/version_notes_page.dart | 12 +++++----- .../data/release_highlight_repository.dart | 22 ++++++++++--------- .../pages/release_highlights_page.dart | 4 ++-- .../assets/{26.1 => 26.x}/advanced/cards.json | 20 ++++++++--------- .../assets/{26.1 => 26.x}/normal/cards.json | 20 ++++++++--------- .../lib/{26.1 => 26.x}/advanced.dart | 6 ++--- .../lib/{26.1 => 26.x}/normal.dart | 6 ++--- release_highlights/pubspec.yaml | 4 ++-- test/core/version/app_build_test.dart | 17 ++++++++++++++ .../release_highlights_page_test.dart | 9 +++++--- 11 files changed, 85 insertions(+), 49 deletions(-) rename release_highlights/assets/{26.1 => 26.x}/advanced/cards.json (99%) rename release_highlights/assets/{26.1 => 26.x}/normal/cards.json (98%) rename release_highlights/lib/{26.1 => 26.x}/advanced.dart (99%) rename release_highlights/lib/{26.1 => 26.x}/normal.dart (98%) diff --git a/lib/core/version/app_build.dart b/lib/core/version/app_build.dart index e68658b97..09f8b36f1 100644 --- a/lib/core/version/app_build.dart +++ b/lib/core/version/app_build.dart @@ -53,6 +53,20 @@ abstract final class AppBuild { /// page version card shows it as the big number, above the label. static String get train => _train; + /// The release cycle this build belongs to, written `26.x`. + /// + /// Every train in a cycle ships the same highlights, so the two pages that + /// present them name the cycle rather than whichever train happens to be + /// installed: `26.1` and `26.2` both read `26.x`, and the trains after them + /// read `27.x`. Anything naming the *build* still uses [train] — the More + /// page version card and Apple's marketing version both need the real + /// number. + static String get cycle { + if (_train.isEmpty) return _train; + final dot = _train.indexOf('.'); + return '${dot < 0 ? _train : _train.substring(0, dot)}.x'; + } + /// The version the platform itself records for this build — what the OS /// shows under Settings → app. For a local debug run that is the pubspec /// placeholder (`26.1.0`); CI stamps `--build-name` on iOS and `DPIP_LABEL` diff --git a/lib/features/changelog/presentation/pages/version_notes_page.dart b/lib/features/changelog/presentation/pages/version_notes_page.dart index 65c465240..836524fd5 100644 --- a/lib/features/changelog/presentation/pages/version_notes_page.dart +++ b/lib/features/changelog/presentation/pages/version_notes_page.dart @@ -94,11 +94,11 @@ class VersionNotesPage extends StatelessWidget { AppSpacing.xl + MediaQuery.paddingOf(context).bottom, ), children: [ - // The version's own story, one level further in: the train's - // key highlights, named for the release (e.g. 26.1 重點整理) + // The version's own story, one level further in: the cycle's + // key highlights, named for the cycle (e.g. 26.x 重點整理) // rather than this build. Sits right under the app bar so the // reader finds the summary first, before this build's note. - _HighlightsEntry(train: AppBuild.train), + _HighlightsEntry(cycle: AppBuild.cycle), const SizedBox(height: AppSpacing.md), _Header(note: note, isStable: stable), const SizedBox(height: AppSpacing.md), @@ -206,9 +206,9 @@ class _Header extends StatelessWidget { /// level further in from this build's own note. Label carries the train /// number so the reader sees where the note they just read fits. class _HighlightsEntry extends StatelessWidget { - const _HighlightsEntry({required this.train}); + const _HighlightsEntry({required this.cycle}); - final String train; + final String cycle; @override Widget build(BuildContext context) { @@ -252,7 +252,7 @@ class _HighlightsEntry extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - l10n.releaseHighlightsTitle(train), + l10n.releaseHighlightsTitle(cycle), style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, letterSpacing: -0.2, diff --git a/lib/features/release_highlights/data/release_highlight_repository.dart b/lib/features/release_highlights/data/release_highlight_repository.dart index e5eaccf7f..073d58d18 100644 --- a/lib/features/release_highlights/data/release_highlight_repository.dart +++ b/lib/features/release_highlights/data/release_highlight_repository.dart @@ -1,18 +1,20 @@ -/// Loads the current version's highlight cards from the content package. +/// Loads the current cycle's highlight cards from the content package. /// -/// Each version's cards live as Dart source in `package:dpip_release_highlights` -/// (`lib//{normal,advanced}.dart`) — the *current* version's files -/// are imported below. Older versions stay in the package as the archive and -/// are never compiled into a build. When a new version ships, replace these two -/// imports with the new version's; nothing else changes. +/// Highlights are written once per *cycle*, not per train: every 26 release +/// reads the same `26.x` deck, and 27 opens a new one. They live as Dart source +/// in `package:dpip_release_highlights` (`lib//{normal,advanced}.dart`) +/// — the current cycle's files are imported below. Closed cycles stay in the +/// package as the archive and are never compiled into a build. When a cycle +/// opens, replace these two imports with its own; nothing else changes. /// -/// Content is authored as JSON at `release_highlights//…/cards.json` -/// and compiled to Dart by `tool/gen/release_highlights.py`. +/// Content is authored as JSON at +/// `release_highlights/assets//…/cards.json` and compiled to Dart by +/// `tool/gen/release_highlights.py`. library; import 'package:dpip/features/release_highlights/domain/release_highlight.dart'; -import 'package:dpip_release_highlights/26.1/advanced.dart' as current_advanced; -import 'package:dpip_release_highlights/26.1/normal.dart' as current_normal; +import 'package:dpip_release_highlights/26.x/advanced.dart' as current_advanced; +import 'package:dpip_release_highlights/26.x/normal.dart' as current_normal; /// Stateless loader that assembles [HighlightDeck]s from the current version's /// Dart content. diff --git a/lib/features/release_highlights/presentation/pages/release_highlights_page.dart b/lib/features/release_highlights/presentation/pages/release_highlights_page.dart index 3790932f7..603e77037 100644 --- a/lib/features/release_highlights/presentation/pages/release_highlights_page.dart +++ b/lib/features/release_highlights/presentation/pages/release_highlights_page.dart @@ -10,7 +10,7 @@ import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -/// The page behind the version card's chevron — the train's key highlights +/// The page behind the version card's chevron — the cycle's key highlights /// and technical notes. class ReleaseHighlightsPage extends StatelessWidget { const ReleaseHighlightsPage({super.key}); @@ -22,7 +22,7 @@ class ReleaseHighlightsPage extends StatelessWidget { length: 2, child: Scaffold( appBar: AppBar( - title: Text(l10n.releaseHighlightsTitle(AppBuild.train)), + title: Text(l10n.releaseHighlightsTitle(AppBuild.cycle)), bottom: TabBar( tabs: [ Tab(text: l10n.releaseHighlightsTabNormal), diff --git a/release_highlights/assets/26.1/advanced/cards.json b/release_highlights/assets/26.x/advanced/cards.json similarity index 99% rename from release_highlights/assets/26.1/advanced/cards.json rename to release_highlights/assets/26.x/advanced/cards.json index 699cc37b5..cb59788ee 100644 --- a/release_highlights/assets/26.1/advanced/cards.json +++ b/release_highlights/assets/26.x/advanced/cards.json @@ -1,16 +1,16 @@ { - "version": "26.1", + "version": "26.x", "kind": "advanced", "title": { - "zh_Hant": "DPIP 26.1 技術變更", - "zh_Hans": "DPIP 26.1 技术变更", - "en": "DPIP 26.1 technical changes", - "ja": "DPIP 26.1 技術変更", - "ko": "DPIP 26.1 기술 변경 사항", - "th": "การเปลี่ยนแปลงทางเทคนิคใน DPIP 26.1", - "vi": "Thay đổi kỹ thuật trong DPIP 26.1", - "id": "Perubahan teknis DPIP 26.1", - "fil": "Mga teknikal na pagbabago sa DPIP 26.1" + "zh_Hant": "DPIP 26.x 技術變更", + "zh_Hans": "DPIP 26.x 技术变更", + "en": "DPIP 26.x technical changes", + "ja": "DPIP 26.x 技術変更", + "ko": "DPIP 26.x 기술 변경 사항", + "th": "การเปลี่ยนแปลงทางเทคนิคใน DPIP 26.x", + "vi": "Thay đổi kỹ thuật trong DPIP 26.x", + "id": "Perubahan teknis DPIP 26.x", + "fil": "Mga teknikal na pagbabago sa DPIP 26.x" }, "subtitle": { "zh_Hant": "以下內容已直接對照現行與 legacy 程式碼,只保留可由實作確認的差異。", diff --git a/release_highlights/assets/26.1/normal/cards.json b/release_highlights/assets/26.x/normal/cards.json similarity index 98% rename from release_highlights/assets/26.1/normal/cards.json rename to release_highlights/assets/26.x/normal/cards.json index 535f3e4ec..7b622632d 100644 --- a/release_highlights/assets/26.1/normal/cards.json +++ b/release_highlights/assets/26.x/normal/cards.json @@ -1,16 +1,16 @@ { - "version": "26.1", + "version": "26.x", "kind": "normal", "title": { - "zh_Hant": "DPIP 26.1 更新重點", - "zh_Hans": "DPIP 26.1 更新重点", - "en": "DPIP 26.1 highlights", - "ja": "DPIP 26.1 更新内容", - "ko": "DPIP 26.1 주요 변경 사항", - "th": "ไฮไลต์ของ DPIP 26.1", - "vi": "Điểm nổi bật của DPIP 26.1", - "id": "Sorotan DPIP 26.1", - "fil": "Mga highlight ng DPIP 26.1" + "zh_Hant": "DPIP 26.x 更新重點", + "zh_Hans": "DPIP 26.x 更新重点", + "en": "DPIP 26.x highlights", + "ja": "DPIP 26.x 更新内容", + "ko": "DPIP 26.x 주요 변경 사항", + "th": "ไฮไลต์ของ DPIP 26.x", + "vi": "Điểm nổi bật của DPIP 26.x", + "id": "Sorotan DPIP 26.x", + "fil": "Mga highlight ng DPIP 26.x" }, "subtitle": { "zh_Hant": "本版改善即時資料傳輸、網路與地圖快取、時間校正,以及資料儲存方式。", diff --git a/release_highlights/lib/26.1/advanced.dart b/release_highlights/lib/26.x/advanced.dart similarity index 99% rename from release_highlights/lib/26.1/advanced.dart rename to release_highlights/lib/26.x/advanced.dart index 9209f5586..416bacd69 100644 --- a/release_highlights/lib/26.1/advanced.dart +++ b/release_highlights/lib/26.x/advanced.dart @@ -1,11 +1,11 @@ -// Version-highlight card content for DPIP 26.1 (advanced). +// Version-highlight card content for DPIP 26.x (advanced). // -// GENERATED from `release_highlights/assets/26.1/advanced/cards.json` by `tool/gen/release_highlights.py` — edit the +// GENERATED from `release_highlights/assets/26.x/advanced/cards.json` by `tool/gen/release_highlights.py` — edit the // JSON, not this file. Rendering lives in `lib/features/release_highlights`; // this package carries only data. library; -const title = {"zh_Hant": "DPIP 26.1 技術變更", "zh_Hans": "DPIP 26.1 技术变更", "en": "DPIP 26.1 technical changes", "ja": "DPIP 26.1 技術変更", "ko": "DPIP 26.1 기술 변경 사항", "th": "การเปลี่ยนแปลงทางเทคนิคใน DPIP 26.1", "vi": "Thay đổi kỹ thuật trong DPIP 26.1", "id": "Perubahan teknis DPIP 26.1", "fil": "Mga teknikal na pagbabago sa DPIP 26.1"}; +const title = {"zh_Hant": "DPIP 26.x 技術變更", "zh_Hans": "DPIP 26.x 技术变更", "en": "DPIP 26.x technical changes", "ja": "DPIP 26.x 技術変更", "ko": "DPIP 26.x 기술 변경 사항", "th": "การเปลี่ยนแปลงทางเทคนิคใน DPIP 26.x", "vi": "Thay đổi kỹ thuật trong DPIP 26.x", "id": "Perubahan teknis DPIP 26.x", "fil": "Mga teknikal na pagbabago sa DPIP 26.x"}; const subtitle = {"zh_Hant": "以下內容已直接對照現行與 legacy 程式碼,只保留可由實作確認的差異。", "zh_Hans": "以下内容已直接对照现行与 legacy 代码,只保留可由实现确认的差异。", "en": "Each item was checked directly against the current and legacy code; only implementation-backed differences remain.", "ja": "現行版と旧版のコードを直接照合し、実装で確認できる差分だけを残しました。", "ko": "현재 코드와 레거시 코드를 직접 대조해 구현으로 확인되는 차이만 남겼습니다.", "th": "ตรวจสอบแต่ละรายการกับโค้ดปัจจุบันและ legacy โดยตรง และคงไว้เฉพาะความต่างที่ยืนยันได้จากการทำงานจริง", "vi": "Mỗi mục đã được đối chiếu trực tiếp với mã hiện tại và legacy; chỉ giữ lại khác biệt có thể xác nhận từ phần triển khai.", "id": "Setiap item diperiksa langsung terhadap kode saat ini dan legacy; hanya perbedaan yang didukung implementasi yang dipertahankan.", "fil": "Direktang inihambing ang bawat item sa kasalukuyan at legacy code; mga pagkakaibang napapatunayan ng implementation lang ang nanatili."}; const cards = >[ { diff --git a/release_highlights/lib/26.1/normal.dart b/release_highlights/lib/26.x/normal.dart similarity index 98% rename from release_highlights/lib/26.1/normal.dart rename to release_highlights/lib/26.x/normal.dart index 4afcef91f..888b19894 100644 --- a/release_highlights/lib/26.1/normal.dart +++ b/release_highlights/lib/26.x/normal.dart @@ -1,11 +1,11 @@ -// Version-highlight card content for DPIP 26.1 (normal). +// Version-highlight card content for DPIP 26.x (normal). // -// GENERATED from `release_highlights/assets/26.1/normal/cards.json` by `tool/gen/release_highlights.py` — edit the +// GENERATED from `release_highlights/assets/26.x/normal/cards.json` by `tool/gen/release_highlights.py` — edit the // JSON, not this file. Rendering lives in `lib/features/release_highlights`; // this package carries only data. library; -const title = {"zh_Hant": "DPIP 26.1 更新重點", "zh_Hans": "DPIP 26.1 更新重点", "en": "DPIP 26.1 highlights", "ja": "DPIP 26.1 更新内容", "ko": "DPIP 26.1 주요 변경 사항", "th": "ไฮไลต์ของ DPIP 26.1", "vi": "Điểm nổi bật của DPIP 26.1", "id": "Sorotan DPIP 26.1", "fil": "Mga highlight ng DPIP 26.1"}; +const title = {"zh_Hant": "DPIP 26.x 更新重點", "zh_Hans": "DPIP 26.x 更新重点", "en": "DPIP 26.x highlights", "ja": "DPIP 26.x 更新内容", "ko": "DPIP 26.x 주요 변경 사항", "th": "ไฮไลต์ของ DPIP 26.x", "vi": "Điểm nổi bật của DPIP 26.x", "id": "Sorotan DPIP 26.x", "fil": "Mga highlight ng DPIP 26.x"}; const subtitle = {"zh_Hant": "本版改善即時資料傳輸、網路與地圖快取、時間校正,以及資料儲存方式。", "zh_Hans": "本版改进实时数据传输、网络与地图缓存、时间校正,以及数据存储方式。", "en": "This release improves realtime delivery, network and map caching, calibrated time, and data storage.", "ja": "リアルタイム配信、ネットワークと地図のキャッシュ、時刻補正、データ保存を改善しました。", "ko": "실시간 전송, 네트워크 및 지도 캐시, 시간 보정, 데이터 저장 방식을 개선했습니다.", "th": "รุ่นนี้ปรับปรุงการส่งข้อมูลเรียลไทม์ แคชเครือข่ายและแผนที่ การเทียบเวลา และการจัดเก็บข้อมูล", "vi": "Bản này cải thiện truyền dữ liệu thời gian thực, cache mạng và bản đồ, hiệu chỉnh thời gian và lưu trữ dữ liệu.", "id": "Rilis ini meningkatkan pengiriman realtime, cache jaringan dan peta, kalibrasi waktu, serta penyimpanan data.", "fil": "Pinahusay ng release na ito ang realtime delivery, network at map cache, calibrated time, at data storage."}; const cards = >[ { diff --git a/release_highlights/pubspec.yaml b/release_highlights/pubspec.yaml index 86de43c53..e614391a8 100644 --- a/release_highlights/pubspec.yaml +++ b/release_highlights/pubspec.yaml @@ -1,8 +1,8 @@ name: dpip_release_highlights description: > Version-highlight cards for DPIP. Each version's card content lives here as - Dart source, per version and per kind (`lib/26.1/normal.dart`, - `lib/26.1/advanced.dart`). The app depends on this package via a path + Dart source, per cycle and per kind (`lib/26.x/normal.dart`, + `lib/26.x/advanced.dart`). The app depends on this package via a path dependency and imports only the *current* version — older versions stay here as the archive and are never compiled into a build. publish_to: 'none' diff --git a/test/core/version/app_build_test.dart b/test/core/version/app_build_test.dart index 5e8c544d4..c3ad72ee9 100644 --- a/test/core/version/app_build_test.dart +++ b/test/core/version/app_build_test.dart @@ -17,6 +17,23 @@ void main() { expect(AppBuild.label, isNot('26.1.0')); }); + test('the cycle names the year, never the train inside it', () { + // The highlights belong to the whole cycle, so 26.1 and 26.2 have to + // reach the same heading — otherwise the second train reads as though the + // first one's highlights were never written. + AppBuild.debugSet(label: '26w35a', code: 42, train: '26.1'); + expect(AppBuild.cycle, '26.x'); + AppBuild.debugSet(label: '26w36e', code: 43, train: '26.2'); + expect(AppBuild.cycle, '26.x'); + AppBuild.debugSet(label: '27w01a', code: 44, train: '27.1'); + expect(AppBuild.cycle, '27.x'); + // A build outside the repository has no train to shorten; a heading of + // '.x' would be worse than none. + AppBuild.debugSet(label: 'dev', code: 0, train: ''); + expect(AppBuild.cycle, isEmpty); + addTearDown(() => AppBuild.debugSet(label: 'dev', code: 0)); + }); + test('an unknown ordinal never counts as older', () { // Not knowing is not evidence of being behind, and prompting an update on // no evidence is how a user gets pushed off a build that works. diff --git a/test/features/release_highlights/release_highlights_page_test.dart b/test/features/release_highlights/release_highlights_page_test.dart index 8ee32b892..d3a22f808 100644 --- a/test/features/release_highlights/release_highlights_page_test.dart +++ b/test/features/release_highlights/release_highlights_page_test.dart @@ -47,7 +47,7 @@ Future _pumpPage(WidgetTester tester) async { } void main() { - testWidgets("the app bar names the train's highlights", (tester) async { + testWidgets('the app bar names the cycle, not the train', (tester) async { AppBuild.debugSet(label: '26w35a', code: 42, train: '26.1'); addTearDown(() => AppBuild.debugSet(label: 'dev', code: 0)); await _pumpPage(tester); @@ -55,8 +55,11 @@ void main() { final l10n = AppLocalizations.of( tester.element(find.byType(ReleaseHighlightsPage)), ); - // The page's own name stays in the app bar… plus the train number. - expect(find.text(l10n.releaseHighlightsTitle('26.1')), findsOneWidget); + // The page's own name stays in the app bar… over the cycle, so every + // train in it reaches the same heading. A build riding 26.1 must not + // title the page 26.1, or the highlights read as this build's alone. + expect(find.text(l10n.releaseHighlightsTitle('26.x')), findsOneWidget); + expect(find.text(l10n.releaseHighlightsTitle('26.1')), findsNothing); }); testWidgets('renders both decks without overflow on a narrow phone', ( From 6d868e3e656a827f852a5aa54811cc60cd0f7a12 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 7 Sep 2026 15:40:44 +0800 Subject: [PATCH 02/32] fix(map): stop the monitor's EEW countdown ticking behind other tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 強震監視器的 EEW 倒數卡切到其他分頁後不再每秒喚醒手機 Fix(en-US): the monitor's EEW countdown no longer wakes the phone once a second from another tab --- .../widgets/monitor_eew_card.dart | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/lib/features/map/presentation/widgets/monitor_eew_card.dart b/lib/features/map/presentation/widgets/monitor_eew_card.dart index c3e182c7c..8839eb44e 100644 --- a/lib/features/map/presentation/widgets/monitor_eew_card.dart +++ b/lib/features/map/presentation/widgets/monitor_eew_card.dart @@ -2,7 +2,8 @@ /// monitor's `EewCard`, sharing its domain math (`estimateLocalShaking`) and /// its tile styling (`EewEstimateTile`), so the map overlay's numbers and /// colours can never drift from the monitor's. The S-wave countdown ticks -/// against the calibrated [AppTime] clock and stops on dispose. +/// against the calibrated [AppTime] clock, pauses while the map tab is not the +/// selected branch, and stops on dispose. /// /// Lives in this feature (not `features/earthquake`) because the layering gate /// forbids `features/map` importing another feature's presentation; the home @@ -60,9 +61,30 @@ class MonitorEewCard extends StatefulWidget { class _MonitorEewCardState extends State with SecondTicker { /// The RTS panel's own gate suppresses feed-notify rebuilds behind other /// tabs, but the countdown has its own timer — same gate here. + /// + /// Deliberately the tab test only, not [VisibleTab.isOnScreen], and for a + /// sharper reason than [RefreshOnAppear]'s: `isOnScreen` also goes false for + /// *any* root-navigator push, and `showDialog` defaults to that navigator + /// while painting a translucent barrier. Gating on it would freeze a live + /// S-wave countdown at whatever second it held, in full view around the + /// dialog — a stale safety number presented as current. An unselected branch + /// is genuinely unpainted (`_RenderIndexedStack` paints only the selected + /// child), so the branch test alone carries the whole saving safely. @override bool get secondTickerActive => - VisibleTabScope.of(context)?.isOnScreen(MapPage.tabIndex) ?? true; + (_visibleTab?.value ?? MapPage.tabIndex) == MapPage.tabIndex; + + /// The shell's visible-tab notifier, subscribed to rather than merely read. + /// + /// [SecondTicker] re-reads [secondTickerActive] on every [syncSecondTicker], + /// so the gate is not latched — but nothing *calls* that sync on a tab + /// change, because [VisibleTabScope] hands the same instance down for the + /// page's whole life and so never notifies its dependents. Reading the scope + /// is how a consumer finds the notifier; only the subscription is a change + /// signal. Before this, the timer stayed in whatever state the lifecycle + /// edges last left it in. The panel that hosts this card subscribes the same + /// way for the same reason. + VisibleTab? _visibleTab; /// The CWA P/S travel-time table once it resolves — the countdown settles on /// the table's arrival time the moment it loads (see [estimateLocalShaking]). @@ -87,6 +109,26 @@ class _MonitorEewCardState extends State with SecondTicker { }); } + @override + void didChangeDependencies() { + // Ahead of `super`, which runs [SecondTicker]'s own first sync: the gate + // above has to find the notifier before it is evaluated, or that sync + // reads the null fallback and starts the timer on a hidden card. + final visibleTab = VisibleTabScope.of(context); + if (!identical(visibleTab, _visibleTab)) { + _visibleTab?.removeListener(syncSecondTicker); + _visibleTab = visibleTab; + visibleTab?.addListener(syncSecondTicker); + } + super.didChangeDependencies(); + } + + @override + void dispose() { + _visibleTab?.removeListener(syncSecondTicker); + super.dispose(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); From eedf67f3c06529bfef15e2f50633ac4d04a18403 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 7 Sep 2026 15:40:53 +0800 Subject: [PATCH 03/32] perf: stop paying for paint and layout nothing on screen can show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 多個頁面不再重複計算或繪製看不見的內容,搜尋輸入、清單捲動與分頁切換都更省電 Optimization(en-US): several pages no longer recompute or repaint what nothing on screen shows, so typing, scrolling and tab switches cost less battery --- .../presentation/pages/bug_list_page.dart | 4 +- .../widgets/bug_avatar_image.dart | 51 +++- .../presentation/pages/changelog_page.dart | 27 +- .../data/presentation/pages/planets_page.dart | 20 +- .../widgets/report_filter_sheet.dart | 3 +- .../presentation/widgets/event_timeline.dart | 101 ++++--- .../presentation/pages/region_city_page.dart | 38 ++- .../pages/region_select_page.dart | 11 +- .../widgets/typhoon_forecast_callouts.dart | 29 +- .../more/presentation/pages/more_page.dart | 65 ++--- .../presentation/widgets/highlight_card.dart | 270 +++++++++--------- .../pages/server_status_page.dart | 2 +- .../widgets/weather_ranking_row.dart | 2 +- lib/shared/map/map_timeline.dart | 8 + test/features/more/more_page_test.dart | 10 +- 15 files changed, 384 insertions(+), 257 deletions(-) diff --git a/lib/features/bug_tracker/presentation/pages/bug_list_page.dart b/lib/features/bug_tracker/presentation/pages/bug_list_page.dart index 3f53788a6..48b765c9e 100644 --- a/lib/features/bug_tracker/presentation/pages/bug_list_page.dart +++ b/lib/features/bug_tracker/presentation/pages/bug_list_page.dart @@ -354,11 +354,13 @@ class _ThreadCard extends StatelessWidget { final BugThread thread; final AvatarFetch avatarFor; + static final DateFormat _date = DateFormat('yyyy/MM/dd'); + @override Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - final date = DateFormat('yyyy/MM/dd').format(thread.createdAt.toLocal()); + final date = _date.format(thread.createdAt.toLocal()); return Card( margin: EdgeInsets.zero, color: colors.surfaceContainerHigh, diff --git a/lib/features/bug_tracker/presentation/widgets/bug_avatar_image.dart b/lib/features/bug_tracker/presentation/widgets/bug_avatar_image.dart index 1ff8ede5b..fdf416539 100644 --- a/lib/features/bug_tracker/presentation/widgets/bug_avatar_image.dart +++ b/lib/features/bug_tracker/presentation/widgets/bug_avatar_image.dart @@ -32,18 +32,61 @@ class BugAvatarImage extends ImageProvider { BugAvatarImage key, ImageDecoderCallback decode, ) { - return MultiFrameImageStreamCompleter(codec: _codec(key), scale: 1); + return MultiFrameImageStreamCompleter(codec: _codec(key, decode), scale: 1); } - Future _codec(BugAvatarImage key) async { + /// The decode cap, in pixels, on the longer side of the source. + /// + /// Every call site is a small circle — `radius: 9`, `14`, `15`, so 30 logical + /// px across at the widest; 256 is headroom rather than a fitted bound, since + /// the framework promises no ceiling on the device pixel ratio and Android's + /// display-size setting and desktop display scaling both raise it past a + /// panel's nominal one. The URL is server-supplied — `users[].img` copied + /// straight out of the tracker payload, commonly a Discord CDN avatar served + /// at 1024² — and opaque to this app, which is exactly why the cap belongs in + /// the decode and not in the URL: that string is also the ETag identity and + /// has to reach the CDN unchanged. A 1024² source is 4 MB of RGBA held for + /// the session by Flutter's image cache, against 256 KB here. + /// + /// Static sources only. `ImageDescriptor.instantiateCodec` forwards a target + /// size on its single-frame path alone, so Discord's animated `a_*` avatars + /// go on decoding at native size. + static const int _maxSide = 256; + + Future _codec( + BugAvatarImage key, + ImageDecoderCallback decode, + ) async { final bytes = await fetch(key.url); if (bytes == null || bytes.isEmpty) { // CircleAvatar paints its background colour; nothing else to do. throw StateError('avatar unavailable: ${key.url}'); } final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); - final descriptor = await ui.ImageDescriptor.encoded(buffer); - return descriptor.instantiateCodec(); + // Give the decoder one side only — `dart:ui` scales the omitted dimension + // to keep the aspect ratio, whereas passing both is a stretch-to-fit that + // would squash a non-square source `BoxFit.cover` centre-crops today. + // Going through the framework's own `decode` also disposes `buffer`, which + // the hand-rolled `ImageDescriptor` path used to leave to the collector. + return decode( + buffer, + getTargetSize: (width, height) { + if (width <= _maxSide && height <= _maxSide) { + return const ui.TargetImageSize(); + } + // `dart:ui` derives the omitted side by integer division, which + // truncates to zero once one dimension exceeds [_maxSide] times the + // other — and it clamps before that arithmetic, not after. Such a + // source is already small in its short dimension; decode it whole + // rather than ask the engine for a zero-pixel image. + if (width > height * _maxSide || height > width * _maxSide) { + return const ui.TargetImageSize(); + } + return width >= height + ? const ui.TargetImageSize(width: _maxSide) + : const ui.TargetImageSize(height: _maxSide); + }, + ); } @override diff --git a/lib/features/changelog/presentation/pages/changelog_page.dart b/lib/features/changelog/presentation/pages/changelog_page.dart index f41d777c2..c9c624cc1 100644 --- a/lib/features/changelog/presentation/pages/changelog_page.dart +++ b/lib/features/changelog/presentation/pages/changelog_page.dart @@ -236,11 +236,15 @@ class _ChangelogPageState extends State { }); } + /// Compiled once. `_isCurrent` runs per visible tile per rebuild, and Dart + /// interns nothing — every `RegExp(...)` compiles a fresh pattern. + static final RegExp _vPrefix = RegExp(r'^v'); + bool _isCurrent(ReleaseNote note) { final installed = _installedVersion; if (installed == null) return false; - final tag = note.tagName.replaceFirst(RegExp(r'^v'), ''); - final name = note.name.replaceFirst(RegExp(r'^v'), ''); + final tag = note.tagName.replaceFirst(_vPrefix, ''); + final name = note.name.replaceFirst(_vPrefix, ''); return tag == installed || name == installed; } } @@ -267,6 +271,10 @@ class _ReleaseTile extends StatelessWidget { static const _prerelease = Color(0xFFEF6C00); static const _railWidth = 28.0; + /// Parsing a locale's date pattern is not free — memoised per locale, since + /// every visible tile formats its date again on every page rebuild. + static final Map _dateFormats = {}; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -277,9 +285,10 @@ class _ReleaseTile extends StatelessWidget { ? Icons.science_outlined : Icons.verified_outlined; final title = note.name.isEmpty ? note.tagName : note.name; - final date = DateFormat.yMMMd( - intlDateLocale(Localizations.localeOf(context)), - ).format(note.publishedAt.toLocal()); + final dateLocale = intlDateLocale(Localizations.localeOf(context)); + final date = _dateFormats + .putIfAbsent(dateLocale, () => DateFormat.yMMMd(dateLocale)) + .format(note.publishedAt.toLocal()); final emphasized = isCurrent || expanded; return CustomPaint( @@ -407,8 +416,12 @@ class _ReleaseTile extends StatelessWidget { thickness: 1, color: colors.outlineVariant.withValues(alpha: 0.55), ), - if (contributorsFromBody(note.body).isNotEmpty || - note.htmlUrl.isNotEmpty) + // `htmlUrl` first: it is a field read, while the contributor + // test walks the whole multi-language body, and a GitHub release + // always carries a URL — so this drops the guard's own scan. The + // strip below still runs one of its own for the badges it draws. + if (note.htmlUrl.isNotEmpty || + contributorsFromBody(note.body).isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.lg, diff --git a/lib/features/data/presentation/pages/planets_page.dart b/lib/features/data/presentation/pages/planets_page.dart index be5138807..cd88342bc 100644 --- a/lib/features/data/presentation/pages/planets_page.dart +++ b/lib/features/data/presentation/pages/planets_page.dart @@ -49,15 +49,17 @@ class PlanetsPage extends StatelessWidget { ? null : Observer(latitude: town.lat, longitude: town.lng); - final entries = [ - for (final planet in Planet.values) + // `PlanetEphemeris.at` solves Kepler three times — Earth, the planet, then + // the planet again for light-time — not a lookup, so bind it once per + // planet and reuse it for the horizontal look-up below. + final entries = <_Entry>[]; + for (final planet in Planet.values) { + final body = PlanetEphemeris.at(planet, now); + entries.add( _Entry( planet: planet, - body: PlanetEphemeris.at(planet, now), - now: observer?.lookAt( - PlanetEphemeris.at(planet, now).equatorial, - now, - ), + body: body, + now: observer?.lookAt(body.equatorial, now), events: observer == null ? null : RiseSet.solve( @@ -67,7 +69,9 @@ class PlanetsPage extends StatelessWidget { horizon: (_) => pointHorizon, ), ), - ]..sort((a, b) => b.rank.compareTo(a.rank)); + ); + } + entries.sort((a, b) => b.rank.compareTo(a.rank)); return Scaffold( appBar: AppBar(title: Text(l10n.planetsTitle)), diff --git a/lib/features/earthquake/presentation/widgets/report_filter_sheet.dart b/lib/features/earthquake/presentation/widgets/report_filter_sheet.dart index ca52ae442..0da4d2521 100644 --- a/lib/features/earthquake/presentation/widgets/report_filter_sheet.dart +++ b/lib/features/earthquake/presentation/widgets/report_filter_sheet.dart @@ -292,9 +292,8 @@ class _ReportFilterSheetState extends State<_ReportFilterSheet> { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; - final media = MediaQuery.of(context); final dateFmt = DateFormat('yyyy/MM/dd'); - final height = media.size.height; + final height = MediaQuery.sizeOf(context).height; return SizedBox( height: height, diff --git a/lib/features/events/presentation/widgets/event_timeline.dart b/lib/features/events/presentation/widgets/event_timeline.dart index 2ddefa0da..aabed2e7d 100644 --- a/lib/features/events/presentation/widgets/event_timeline.dart +++ b/lib/features/events/presentation/widgets/event_timeline.dart @@ -68,54 +68,81 @@ class _EventTile extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colors = theme.colorScheme; - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _Connector( + return Stack( + children: [ + // The rail spans the whole tile, but the tile's height comes from the + // text beside it — or the dot, whichever is taller — which a Row can + // only hand back through an IntrinsicHeight, i.e. a speculative pass + // that re-measures all three Texts on every layout of the tile, not + // just on inflation: a width or text-scale change re-runs it too. + // Positioning the connector against the Stack gets it the same tight + // height for nothing: the Row below sizes the Stack, the connector then + // fills it. + PositionedDirectional( + start: 0, + top: 0, + bottom: 0, + width: _Connector._dotSize, + child: _Connector( icon: eventTypeIcon(event.type.iconKey), isFirst: isFirst, isLast: isLast, ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.xl), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _clockFormat.format(event.time), - style: theme.textTheme.labelMedium?.copyWith( - color: colors.onSurfaceVariant, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Holds open the column the connector is positioned over, plus the + // gap after it. Its height is the connector's own: a tile with very + // little text must still be tall enough for the dot, which is what + // IntrinsicHeight used to guarantee. + const SizedBox( + width: _Connector._dotSize + AppSpacing.md, + height: _Connector._minHeight, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _clockFormat.format(event.time), + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + ), ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - event.title, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, + const SizedBox(height: AppSpacing.xs), + Text( + event.title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - event.description, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, + const SizedBox(height: AppSpacing.xs), + Text( + event.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), ), - ), - ], + ], + ), ), ), - ), - ], - ), + ], + ), + ], ); } } -/// The left rail: a connecting line with an icon dot, so consecutive events read -/// as one thread ([isFirst]/[isLast] trim the line at the ends). +/// The leading rail — start-side, so it mirrors to the right under RTL: a +/// connecting line with an icon dot, so consecutive events read as one thread +/// ([isFirst]/[isLast] trim the line at the ends). +/// +/// [_EventTile] positions this to the full height of its tile, so the trailing +/// [Expanded] line can fill whatever is left below the dot. class _Connector extends StatelessWidget { const _Connector({ required this.icon, @@ -129,6 +156,10 @@ class _Connector extends StatelessWidget { static const double _dotSize = 36; + /// Stub plus dot — the shortest this can draw itself. [_EventTile] reserves + /// it in its Row so the tile is never too short to hold the dot. + static const double _minHeight = AppSpacing.sm + _dotSize; + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; diff --git a/lib/features/location/presentation/pages/region_city_page.dart b/lib/features/location/presentation/pages/region_city_page.dart index b4bda67e8..5baf5c0c1 100644 --- a/lib/features/location/presentation/pages/region_city_page.dart +++ b/lib/features/location/presentation/pages/region_city_page.dart @@ -44,6 +44,27 @@ class RegionCityPage extends StatefulWidget { class _RegionCityPageState extends State { final _searchController = TextEditingController(); + /// The townships of [RegionCityPage.city]. The directory is one immutable + /// instance for the app's life, so scan it once per city instead of once per + /// build: [build] re-runs on every keystroke and the scan walks all 368 + /// towns, building a `cityName` string for each. + late List _towns = context.read().townsInCity( + widget.city, + ); + + @override + void didUpdateWidget(covariant RegionCityPage oldWidget) { + super.didUpdateWidget(oldWidget); + // go_router keys a page by its route *pattern* (`:city`), not by the + // resolved city, so a `go()` or a deep link to another city reuses this + // State with a different `widget.city`. The push path used today mints a + // fresh one, which is what makes the scan above an optimisation and this + // re-scan the thing that keeps it correct. + if (oldWidget.city != widget.city) { + _towns = context.read().townsInCity(widget.city); + } + } + @override void dispose() { _searchController.dispose(); @@ -53,15 +74,15 @@ class _RegionCityPageState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - final directory = context.read(); final store = context.watch(); - final towns = directory.townsInCity(widget.city); + // Read `savedCodes` once (the getter allocates a fresh list each call). + final savedCodes = store.savedCodes; final query = GoRouterState.of(context).uri.queryParameters; final effectiveReplaceCode = widget.replaceCode ?? query['replace']; final needle = _searchController.text.trim().toLowerCase(); final shown = [ - for (final town in towns) + for (final town in _towns) if (needle.isEmpty || town.townName.toLowerCase().contains(needle)) town, ]; @@ -100,10 +121,7 @@ class _RegionCityPageState extends State { ), ), SectionHeader( - l10n.regionSelectCount( - store.savedCodes.length, - RegionStore.maxSaved, - ), + l10n.regionSelectCount(savedCodes.length, RegionStore.maxSaved), ), if (shown.isEmpty) EmptyView( @@ -114,15 +132,15 @@ class _RegionCityPageState extends State { for (final town in shown) _TownTile( town: town, - saved: store.savedCodes.contains(town.code), + saved: savedCodes.contains(town.code), enabled: effectiveReplaceCode == null || town.code == effectiveReplaceCode || - !store.savedCodes.contains(town.code), + !savedCodes.contains(town.code), canAdd: effectiveReplaceCode != null || store.canSave(town.code) || - store.savedCodes.contains(town.code), + savedCodes.contains(town.code), onToggle: () => _toggle(context, store, town), ), ], diff --git a/lib/features/location/presentation/pages/region_select_page.dart b/lib/features/location/presentation/pages/region_select_page.dart index aa5991a0b..f2415839a 100644 --- a/lib/features/location/presentation/pages/region_select_page.dart +++ b/lib/features/location/presentation/pages/region_select_page.dart @@ -35,6 +35,15 @@ class RegionSelectPage extends StatefulWidget { class _RegionSelectPageState extends State { final _searchController = TextEditingController(); + /// The city list, read once for the page's lifetime. + /// + /// [TownDirectory.cities] is a getter that walks every township to collapse + /// them into the ~20 city names, and [build] runs on every character typed + /// into the search field. The directory is built once at bootstrap and never + /// mutated, so the result cannot differ between keystrokes — the filter below + /// narrows this list instead of rebuilding it. + late final List _cities = context.read().cities; + @override void dispose() { _searchController.dispose(); @@ -57,7 +66,7 @@ class _RegionSelectPageState extends State { }; final needle = _searchController.text.trim().toLowerCase(); final cities = [ - for (final city in directory.cities) + for (final city in _cities) if (needle.isEmpty || city.toLowerCase().contains(needle)) city, ]; diff --git a/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart b/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart index b63785a9b..7b9f7dbd7 100644 --- a/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart +++ b/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart @@ -603,6 +603,12 @@ class _LeaderPainter extends CustomPainter { ..strokeWidth = 1.4 ..style = PaintingStyle.stroke; final fill = Paint()..color = color; + // The ring never varies with the callout, so it is built once beside + // `paint` and `fill` instead of once per anchor. + final ring = Paint() + ..color = Colors.white.withValues(alpha: 0.9) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2; for (final c in callouts) { final attach = Offset( c.tip.dx + c.width / 2, @@ -610,29 +616,34 @@ class _LeaderPainter extends CustomPainter { ); _dashLine(canvas, paint, attach, c.anchor); canvas.drawCircle(c.anchor, 3, fill); - canvas.drawCircle( - c.anchor, - 3, - Paint() - ..color = Colors.white.withValues(alpha: 0.9) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.2, - ); + canvas.drawCircle(c.anchor, 3, ring); } } + /// Draws a dashed leader from [a] to [b] as one stroked path. + /// + /// The path stays per-leader on purpose. Its own dashes are collinear and + /// disjoint (butt caps, 3 px gap) so one stroke covers exactly what separate + /// lines would; a path shared across callouts would not, because the + /// leader colour is translucent — a crossing would blend once instead of + /// twice — and it would force every anchor dot above or below every leader. void _dashLine(Canvas canvas, Paint paint, Offset a, Offset b) { const dash = 4.5; const gap = 3.0; final total = (b - a).distance; if (total < 1) return; final dir = (b - a) / total; + final path = Path(); var t = 0.0; while (t < total) { final t2 = math.min(t + dash, total); - canvas.drawLine(a + dir * t, a + dir * t2, paint); + final from = a + dir * t; + final to = a + dir * t2; + path.moveTo(from.dx, from.dy); + path.lineTo(to.dx, to.dy); t = t2 + gap; } + canvas.drawPath(path, paint); } @override diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index df09eeef2..0234a5e06 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -161,7 +161,8 @@ class MorePage extends StatelessWidget { title: l10n.meshtasticTitle, // A message arrived in a conversation the user has not read — // the same state as the chat page's unread pills, selected - // down to one boolean so only this tile rebuilds. + // down to one boolean so the page rebuilds only when that + // boolean flips, not on every mesh packet. alert: context.select((u) => u.hasUnread), onTap: () => context.pushNamed(AppRoutes.meshtastic), ), @@ -172,7 +173,9 @@ class MorePage extends StatelessWidget { children: [ // Hidden until ten taps on the Developer page's version row // (ExperimentalSettings.unlocked). - if (context.watch().unlocked) + if (context.select( + (s) => s.unlocked, + )) _MoreTile( icon: Icons.science_outlined, title: l10n.experimentalFeatures, @@ -188,11 +191,15 @@ class MorePage extends StatelessWidget { title: l10n.moreBugReports, // The count rides the same ETag-cached index the page reads; // loaded once per session here, resynced by the list's own - // pull-to-refresh. - trailing: _BugReportCount( - counter: context.watch(), - onLoad: () => - context.read().ensureLoaded(), + // pull-to-refresh. Read through a Consumer rather than on the + // page's context: that refresh notifies while the bug list is + // pushed over this page, and only this trailing slot wants + // the number. + trailing: Consumer( + builder: (context, counter, _) => _BugReportCount( + counter: counter, + onLoad: counter.ensureLoaded, + ), ), onTap: () => context.pushNamed(AppRoutes.bugTracker), ), @@ -438,10 +445,14 @@ class _MoreGroup extends StatelessWidget { ), ); } - rows.add(Material(type: MaterialType.transparency, child: children[i])); + rows.add(children[i]); } // Material (not DecoratedBox) so ListTile ink paints on this ancestor — - // a colored DecoratedBox between tile and Material asserts in debug. + // a colored DecoratedBox between tile and Material asserts in debug. One + // Material for the whole card is all it takes: every row's ink comes from + // a ListTile or a button, so the splash is bounded by its own InkWell and + // clipped by this card's rounded rect either way — a transparent Material + // per row would host nothing this one does not. return Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.lg, @@ -931,32 +942,18 @@ class _DeveloperNoteCard extends StatelessWidget { /// ranking is carried by the gold alone, rendered flat: a warm champagne /// fill, a hairline along the edge, and a filled badge holding the most /// saturated step. -class _SupportCallout extends StatefulWidget { +class _SupportCallout extends StatelessWidget { const _SupportCallout(); - @override - State<_SupportCallout> createState() => _SupportCalloutState(); -} - -class _SupportCalloutState extends State<_SupportCallout> - with SingleTickerProviderStateMixin { - /// The border's breathing pulse — a slow sine that keeps the gold border - /// gently swelling, so the card draws the eye without any of the strobing - /// an opacity blink would. Repeats forever, but costs nothing when the card - /// is off screen (the ticker pauses) and the test suite treats it as a - /// plain animation. - late final AnimationController _breath = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1800), - lowerBound: 0.55, - upperBound: 1.0, - )..repeat(reverse: true); - - @override - void dispose() { - _breath.dispose(); - super.dispose(); - } + /// The hairline's alpha, formerly sampled from a repeating controller that + /// nothing listened to — no `AnimatedBuilder`, no listener, no `setState`. + /// The controller pumped frames for as long as this page was mounted and the + /// border never animated once: `build` read `.value` at whatever phase the + /// sine happened to be in, so a cold first build drew the 0.55 lower bound + /// and every later rebuild (theme, locale, an unread-count change) froze an + /// arbitrary brighter edge until the next one. 0.55 is the resting value + /// that was, and the only one the card reliably showed. + static const double _edgeAlpha = 0.55; @override Widget build(BuildContext context) { @@ -967,7 +964,7 @@ class _SupportCalloutState extends State<_SupportCallout> decoration: BoxDecoration( color: gold.fill, borderRadius: AppRadius.large, - border: Border.all(color: gold.edge.withValues(alpha: _breath.value)), + border: Border.all(color: gold.edge.withValues(alpha: _edgeAlpha)), // No gradient: the card sits on the same tonal plane as its two // neighbours, and the ranking is carried by the gold colour alone — // the badge is what reads as paid, not the sheen. diff --git a/lib/features/release_highlights/presentation/widgets/highlight_card.dart b/lib/features/release_highlights/presentation/widgets/highlight_card.dart index 62cb0d089..2506c87cc 100644 --- a/lib/features/release_highlights/presentation/widgets/highlight_card.dart +++ b/lib/features/release_highlights/presentation/widgets/highlight_card.dart @@ -69,100 +69,98 @@ class _ReleaseHighlightTile extends StatelessWidget { : localized(card.headline!, tag); final stat = card.stat == null ? null : localized(card.stat!, tag); - return Theme( - data: theme.copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - key: PageStorageKey('release-highlight-${card.id}'), - maintainState: true, - backgroundColor: Colors.transparent, - collapsedBackgroundColor: Colors.transparent, - shape: const Border(), - collapsedShape: const Border(), - tilePadding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.sm, - AppSpacing.md, - AppSpacing.sm, - ), - childrenPadding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - 0, - AppSpacing.lg, - AppSpacing.lg, - ), - leading: Icon( - highlightIcon(card.icon), - color: colors.primary, - size: 24, - ), - title: Text( - localized(card.title, tag), - style: theme.textTheme.titleMedium?.copyWith( - color: colors.onSurface, - fontWeight: FontWeight.w700, - height: 1.3, - ), + // `shape` and `collapsedShape` are both load-bearing, and neither is + // decoration: ExpansionTile falls back to a Border built from + // `theme.dividerColor` for the expanded state and to a transparent one for + // the collapsed state, so dropping `shape` draws a line above and below + // every expanded segment. They are why the `Theme(dividerColor: + // transparent)` wrapper that used to sit here was inert. + return ExpansionTile( + key: PageStorageKey('release-highlight-${card.id}'), + backgroundColor: Colors.transparent, + collapsedBackgroundColor: Colors.transparent, + shape: const Border(), + collapsedShape: const Border(), + tilePadding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + childrenPadding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.lg, + ), + leading: Icon(highlightIcon(card.icon), color: colors.primary, size: 24), + title: Text( + localized(card.title, tag), + style: theme.textTheme.titleMedium?.copyWith( + color: colors.onSurface, + fontWeight: FontWeight.w700, + height: 1.3, ), - subtitle: headline == null && stat == null - ? null - : Padding( - padding: const EdgeInsets.only(top: AppSpacing.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (headline != null) - Text( - headline, - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - height: 1.45, - ), + ), + subtitle: headline == null && stat == null + ? null + : Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (headline != null) + Text( + headline, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + height: 1.45, ), - if (stat != null) ...[ - const SizedBox(height: AppSpacing.sm), - Text( - stat, - style: theme.textTheme.labelLarge?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, - fontFeatures: const [FontFeature.tabularFigures()], - ), + ), + if (stat != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + stat, + style: theme.textTheme.labelLarge?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], ), - ], + ), ], - ), - ), - expandedCrossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (card.body != null) - Text( - localized(card.body!, tag), - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - height: 1.6, + ], ), ), - if (card.statLabel != null) ...[ - if (card.body != null) const SizedBox(height: AppSpacing.md), - Text( - localized(card.statLabel!, tag), - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - height: 1.5, - ), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (card.body != null) + Text( + localized(card.body!, tag), + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + height: 1.6, ), - ], - if (card.highlights.isNotEmpty) ...[ - if (card.body != null || card.statLabel != null) - const SizedBox(height: AppSpacing.lg), - for (var index = 0; index < card.highlights.length; index++) ...[ - _Bullet(text: localized(card.highlights[index], tag)), - if (index < card.highlights.length - 1) - const SizedBox(height: AppSpacing.sm), - ], + ), + if (card.statLabel != null) ...[ + if (card.body != null) const SizedBox(height: AppSpacing.md), + Text( + localized(card.statLabel!, tag), + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + height: 1.5, + ), + ), + ], + if (card.highlights.isNotEmpty) ...[ + if (card.body != null || card.statLabel != null) + const SizedBox(height: AppSpacing.lg), + for (var index = 0; index < card.highlights.length; index++) ...[ + _Bullet(text: localized(card.highlights[index], tag)), + if (index < card.highlights.length - 1) + const SizedBox(height: AppSpacing.sm), ], ], - ), + ], ); } } @@ -238,61 +236,59 @@ class _TechnicalHighlightTile extends StatelessWidget { final colors = theme.colorScheme; final tag = localeTagOf(context); - return Theme( - data: theme.copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - key: PageStorageKey('technical-highlight-${card.id}'), - maintainState: true, - backgroundColor: Colors.transparent, - collapsedBackgroundColor: Colors.transparent, - shape: const Border(), - collapsedShape: const Border(), - tilePadding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - AppSpacing.sm, - AppSpacing.md, - AppSpacing.sm, - ), - childrenPadding: const EdgeInsets.fromLTRB( - AppSpacing.lg, - 0, - AppSpacing.lg, - AppSpacing.lg, - ), - leading: Icon( - highlightIcon(card.icon), - color: colors.primary, - size: 24, - ), - title: Text( - localized(card.title, tag), - style: theme.textTheme.titleMedium?.copyWith( - color: colors.onSurface, - fontWeight: FontWeight.w700, - height: 1.35, - ), + // `shape` and `collapsedShape` are both load-bearing, and neither is + // decoration: ExpansionTile falls back to a Border built from + // `theme.dividerColor` for the expanded state and to a transparent one for + // the collapsed state, so dropping `shape` draws a line above and below + // every expanded segment. They are why the `Theme(dividerColor: + // transparent)` wrapper that used to sit here was inert. + return ExpansionTile( + key: PageStorageKey('technical-highlight-${card.id}'), + backgroundColor: Colors.transparent, + collapsedBackgroundColor: Colors.transparent, + shape: const Border(), + collapsedShape: const Border(), + tilePadding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + childrenPadding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.lg, + ), + leading: Icon(highlightIcon(card.icon), color: colors.primary, size: 24), + title: Text( + localized(card.title, tag), + style: theme.textTheme.titleMedium?.copyWith( + color: colors.onSurface, + fontWeight: FontWeight.w700, + height: 1.35, ), - expandedCrossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (card.body != null) - Text( - localized(card.body!, tag), - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - height: 1.65, - ), + ), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (card.body != null) + Text( + localized(card.body!, tag), + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + height: 1.65, ), - if (card.details.isNotEmpty) ...[ - if (card.body != null) const SizedBox(height: AppSpacing.lg), - _TechnicalDetails(details: card.details, tag: tag), - ], - if (card.stats.isNotEmpty) ...[ - if (card.body != null || card.details.isNotEmpty) - const SizedBox(height: AppSpacing.lg), - _StatRows(stats: card.stats, tag: tag), - ], + ), + if (card.details.isNotEmpty) ...[ + if (card.body != null) const SizedBox(height: AppSpacing.lg), + _TechnicalDetails(details: card.details, tag: tag), ], - ), + if (card.stats.isNotEmpty) ...[ + if (card.body != null || card.details.isNotEmpty) + const SizedBox(height: AppSpacing.lg), + _StatRows(stats: card.stats, tag: tag), + ], + ], ); } } diff --git a/lib/features/status/presentation/pages/server_status_page.dart b/lib/features/status/presentation/pages/server_status_page.dart index 39f37cb8f..183e031e8 100644 --- a/lib/features/status/presentation/pages/server_status_page.dart +++ b/lib/features/status/presentation/pages/server_status_page.dart @@ -381,7 +381,7 @@ class _ClientEndpoints extends StatelessWidget { children: [ _SummaryBanner(summary: summary), const SizedBox(height: AppSpacing.md), - _Legend(), + const _Legend(), const SizedBox(height: AppSpacing.md), for (final g in _groups) ...[ _ServiceTable( diff --git a/lib/features/weather/presentation/widgets/weather_ranking_row.dart b/lib/features/weather/presentation/widgets/weather_ranking_row.dart index ff95d2dc7..35409f327 100644 --- a/lib/features/weather/presentation/widgets/weather_ranking_row.dart +++ b/lib/features/weather/presentation/widgets/weather_ranking_row.dart @@ -93,7 +93,7 @@ class WeatherRankingRow extends StatelessWidget { vertical: AppSpacing.xs, ), child: Material( - color: Colors.transparent, + type: MaterialType.transparency, child: InkWell( onTap: onTap, borderRadius: AppRadius.small, diff --git a/lib/shared/map/map_timeline.dart b/lib/shared/map/map_timeline.dart index daf6d4d91..f20b9a40f 100644 --- a/lib/shared/map/map_timeline.dart +++ b/lib/shared/map/map_timeline.dart @@ -601,6 +601,14 @@ class _MapTimelineState extends State { controller: _scroll, scrollDirection: Axis.horizontal, physics: const _ScrubPhysics(), + // A tick is a hairline and a label — cheaper to repaint + // than to composite, and the whole ruler moves together + // when scrubbed, so per-child layers would all be + // invalidated at once anyway. Nothing here holds state + // worth keeping alive off screen either: [_Tick] is + // stateless and rebuilt from `frames` on demand. + addRepaintBoundaries: false, + addAutomaticKeepAlives: false, padding: EdgeInsets.symmetric(horizontal: pad), itemExtent: widget.itemExtent, itemCount: widget.frames.length, diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index d57adbcf3..a921fb175 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -251,7 +251,7 @@ void main() { testWidgets('lists formal data sources quietly under About', (tester) async { await _pump(tester, _router([])); await tester.fling(find.byType(ListView), const Offset(0, -5000), 5000); - // The support card breathes forever; a fixed pump completes the fling. + // Enough for the fling's ballistic scroll to carry the list to the end. await tester.pump(const Duration(milliseconds: 600)); const sources = [ '探索智慧科技有限公司 — TREM-Net', @@ -364,9 +364,7 @@ void main() { final visited = []; await _pump(tester, _router(visited)); await tester.tap(find.widgetWithText(ListTile, label)); - // pumpAndSettle would time out: the support card's border breathes - // forever. A fixed pump covers the navigation transition. - await tester.pump(const Duration(milliseconds: 600)); + await tester.pumpAndSettle(); expect(visited, [route]); }); } @@ -536,9 +534,7 @@ void main() { await _pump(tester, _router(visited)); // The card is the DPIP row with the chevron — tap its label. await tester.tap(find.text('DPIP').first); - // pumpAndSettle would time out: the support card's border breathes - // forever. A fixed pump covers the navigation transition. - await tester.pump(const Duration(milliseconds: 600)); + await tester.pumpAndSettle(); expect(visited, [AppRoutes.versionNotes]); }); From 26c70bbcc5f6e13b4f4b448702e7c308367b9dc6 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Mon, 7 Sep 2026 15:57:03 +0800 Subject: [PATCH 04/32] fix(home): blur the map the sheet climbs over, not the scrim above it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 首頁面板上滑時地圖真的會糊化了,之前那層模糊沒有作用在地圖上 Fix(en-US): raising the home sheet now actually blurs the map behind it --- .../home/presentation/pages/home_page.dart | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 302a6cff7..0585cd329 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -69,7 +69,7 @@ class _HomePageState extends State { /// content and is never dimmed with it. static const double _mapDimPeak = 0.35; - /// The filter instance last handed to the [ImageFiltered] — [ImageFilter] + /// The filter instance last handed to the [BackdropFilter] — [ImageFilter] /// has no value equality, so a fresh `blur(...)` per drag tick would /// recomposite the full-screen blur every frame even though the sigma /// quantises to the same step (same pattern as [_CachedBlur] in HomeSheet). @@ -226,14 +226,23 @@ class _HomePageState extends State { sigmaY: sigma, ); } - // The tree's shape never changes — no SizedBox/ImageFiltered - // swap at t=0, which would re-parent the subtree right over - // the map platform view at the exact edge the sheet starts - // climbing (the same re-parent flash as the sheet's sky). - // `enabled` makes the filter a no-op at rest without - // touching the tree. - return ImageFiltered( - imageFilter: _mapBlur!, + // [BackdropFilter], not [ImageFiltered]: the filter has + // to reach the map painted *beneath* this layer, and the + // map backdrop is the Stack child directly below. An + // ImageFiltered filters its own child instead, and that + // child is a uniform ColoredBox — blurring a flat colour + // moves nothing but the feathering of its outer edge, so + // the map stayed sharp however high the sheet climbed. + // + // The tree's shape never changes — no SizedBox/Backdrop- + // Filter swap at t=0, which would re-parent the subtree + // right over the map platform view at the exact edge the + // sheet starts climbing (the same re-parent flash as the + // sheet's sky). `enabled` makes the filter a no-op at + // rest without touching the tree: disabled, it paints the + // child straight through and reads back nothing. + return BackdropFilter( + filter: _mapBlur!, enabled: t > 0, child: ColoredBox( color: Colors.black.withValues(alpha: dim), From 7d476015cc99444b2ed4cda5ca208e92a7a4e6dd Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 11:36:58 +0800 Subject: [PATCH 05/32] perf(map): draw the chrome over the map as a flat tint on Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Optimization(zh-Hant): 地圖與首頁在 Android 上不再對地圖做毛玻璃模糊,平移與拖動雷達時間軸在低階手機上順很多 Optimization(en-US): the map and home screen stop blurring the map on Android, so panning and scrubbing the radar timeline are much smoother on low-end phones --- DESIGN.md | 7 +++ .../home/presentation/pages/home_page.dart | 9 ++- .../home/presentation/widgets/home_sheet.dart | 10 +++- .../widgets/typhoon_forecast_callouts.dart | 12 +++- lib/shared/widgets/frosted_surface.dart | 57 ++++++++++++++++-- test/shared/widgets/frosted_surface_test.dart | 59 +++++++++++++++++++ 6 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 test/shared/widgets/frosted_surface_test.dart diff --git a/DESIGN.md b/DESIGN.md index ed9f5a95d..d4f09883f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -94,6 +94,13 @@ itself (sky colour at 20 % alpha, HSL-lightness shifted by time of day — see `skyCardTint`). Ink follows the sky, not the theme. Shared surfaces built from it: `shared/widgets/frosted_surface.dart`, `sheet_surface.dart`. +Frost over the **map** is iOS-only. On Android the map is a platform view and a +`BackdropFilter` over it is either blind (HCPP) or the reason every map frame +re-rasterises the whole Flutter scene (virtual display), so every map-chrome +blur is gated on `mapChromeBlursBackdrop` (`frosted_surface.dart`) and Android +draws the same panel as a slightly stronger flat tint. Do not add a blur over +the map without going through that gate. + ## Shared components — `lib/shared/widgets/` - `SectionHeader(title)` — the small primary-tinted header above a settings/menu diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 0585cd329..369c2c3ef 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -22,6 +22,8 @@ import 'package:dpip/shared/map/base_map.dart'; import 'package:dpip/shared/map/map_camera_handoff.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; +import 'package:dpip/shared/widgets/frosted_surface.dart' + show mapChromeBlursBackdrop; import 'package:dpip/shared/widgets/region_bar.dart'; import 'package:dpip/shared/widgets/region_swipe_area.dart'; import 'package:flutter/material.dart'; @@ -241,9 +243,14 @@ class _HomePageState extends State { // sheet's sky). `enabled` makes the filter a no-op at // rest without touching the tree: disabled, it paints the // child straight through and reads back nothing. + // + // Android keeps only the dim: over a platform-view map + // the blur is blind (HCPP) or makes every map frame + // re-rasterise the whole screen through it (virtual + // display) — see [mapChromeBlursBackdrop]. return BackdropFilter( filter: _mapBlur!, - enabled: t > 0, + enabled: t > 0 && mapChromeBlursBackdrop, child: ColoredBox( color: Colors.black.withValues(alpha: dim), ), diff --git a/lib/features/home/presentation/widgets/home_sheet.dart b/lib/features/home/presentation/widgets/home_sheet.dart index a1c16b254..f290f9791 100644 --- a/lib/features/home/presentation/widgets/home_sheet.dart +++ b/lib/features/home/presentation/widgets/home_sheet.dart @@ -7,6 +7,8 @@ import 'package:dpip/core/settings/sky_time_mode.dart'; import 'package:dpip/core/settings/weather_mode.dart'; import 'package:dpip/features/home/presentation/widgets/home_content.dart'; import 'package:dpip/features/home/presentation/widgets/weather_sky/weather_sky_background.dart'; +import 'package:dpip/shared/widgets/frosted_surface.dart' + show mapChromeBlursBackdrop; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -244,9 +246,15 @@ class _CachedBlurState extends State<_CachedBlur> { // filter resolves or the layer is pushed, and leaves the widget, element and // render object in place — so it cannot cause the re-parent flash this file // warns about in [_ScrollBlurredWeather]. + // + // Android never blurs here: the map under this sheet is a platform view, + // and a backdrop filter over it is either blind (HCPP) or the reason every + // map frame re-rasterises the whole sheet (virtual display) — see + // [mapChromeBlursBackdrop]. The tint alone is what those phones showed + // through the frost anyway; the tree keeps its shape either way. return BackdropFilter( filter: _filter!, - enabled: _sigma > 0, + enabled: _sigma > 0 && mapChromeBlursBackdrop, child: widget.child, ); } diff --git a/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart b/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart index 7b9f7dbd7..d2dffb7ed 100644 --- a/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart +++ b/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart @@ -16,6 +16,8 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/features/map/presentation/layers/typhoon_layer.dart'; import 'package:dpip/features/typhoon/domain/compass_direction.dart'; import 'package:dpip/features/typhoon/domain/typhoon_track.dart'; +import 'package:dpip/shared/widgets/frosted_surface.dart' + show mapChromeBlursBackdrop; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -445,6 +447,8 @@ class _PlacedCallout { class _CalloutCard extends StatelessWidget { const _CalloutCard({required this.data, required this.selected}); + static final ImageFilter _blur = ImageFilter.blur(sigmaX: 12, sigmaY: 12); + final ForecastCalloutData data; final bool selected; @@ -456,10 +460,16 @@ class _CalloutCard extends StatelessWidget { ? colors.tertiary : colors.outline.withValues(alpha: 0.35); + // One filter for every card: [ImageFilter] has no value equality, so a + // fresh one per build recomposited each card's backdrop on every + // reprojection. Android draws the card flat — the blur cannot reach a + // platform-view map there, or costs a whole-scene re-raster per map frame + // (see [mapChromeBlursBackdrop]). return ClipRRect( borderRadius: AppRadius.small, child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + filter: _blur, + enabled: mapChromeBlursBackdrop, child: DecoratedBox( decoration: BoxDecoration( color: colors.surface.withValues(alpha: 0.88), diff --git a/lib/shared/widgets/frosted_surface.dart b/lib/shared/widgets/frosted_surface.dart index ac9d8e4a9..973bfc282 100644 --- a/lib/shared/widgets/frosted_surface.dart +++ b/lib/shared/widgets/frosted_surface.dart @@ -7,6 +7,7 @@ library; import 'dart:ui' show ImageFilter; import 'package:dpip/app/theme/app_radius.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// Default blur for compact map chrome (legend chip, layer button, timeline). @@ -15,6 +16,34 @@ const double kMapFrostBlurSigma = 48; /// Default surface tint alpha over the blurred map. const double kMapFrostSurfaceAlpha = 0.72; +/// Extra tint a flat (unblurred) panel carries so its text stays legible over +/// a busy radar echo — a blur softens what is under the text, a flat tint has +/// only its own opacity to do that with. +const double kMapFrostFlatAlphaBoost = 0.08; + +/// Whether chrome drawn over the native map may blur what is beneath it. +/// +/// On iOS the map is a `UiKitView` and Flutter composites a `BackdropFilter` +/// over it through `UIVisualEffectView`, so the frost genuinely shows the map +/// through it. On Android the map is a platform view in one of two modes, and +/// in neither is the blur worth what it costs on the phones that matter: +/// +/// - **HCPP** (API 34 + Vulkan, `EnableHcpp` in the manifest): the map is its +/// own `SurfaceControl` layer *under* Flutter's surface. A backdrop filter +/// can only read Flutter's own pixels, which are the transparent hole the +/// map shows through — so the blur samples nothing, draws nothing, and still +/// pays a full offscreen pass per filter per Flutter frame. +/// - **Virtual display** (everything else — every low-end phone): the map is a +/// texture in Flutter's scene, so the blur *does* reach it, but every map +/// frame then re-rasterises the whole Flutter layer tree through every +/// backdrop filter on screen. Five sigma-48 frosts over a panning radar was +/// the single largest GPU cost on those devices. +/// +/// So Android draws the frost as a flat tint. `defaultTargetPlatform`, not +/// `dart:io`, so a test can override it. +bool get mapChromeBlursBackdrop => + defaultTargetPlatform != TargetPlatform.android; + /// A clipped, blurred panel with a translucent [ColorScheme.surface] tint. class FrostedSurface extends StatelessWidget { const FrostedSurface({ @@ -34,9 +63,28 @@ class FrostedSurface extends StatelessWidget { /// Soft lift shadow (timeline / legend cards); off for nested chrome. final bool shadow; + /// One [ImageFilter] per sigma, shared by every panel. + /// + /// [ImageFilter] has no value equality, so a fresh `blur(...)` on each build + /// marks the backdrop layer changed and recomposites it even when nothing + /// under the panel moved — the same trap the home sheet's cached blur + /// documents. Sigma is a constant at nearly every call site, so this map + /// holds one or two entries for the life of the app. + static final Map _filters = {}; + + static ImageFilter _filterFor(double sigma) => _filters.putIfAbsent( + sigma, + () => ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + ); + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final blur = mapChromeBlursBackdrop && blurSigma > 0; + final alpha = blur + ? surfaceAlpha + : (surfaceAlpha + kMapFrostFlatAlphaBoost).clamp(0.0, 1.0); + final tint = ColoredBox(color: colors.surface.withValues(alpha: alpha)); return DecoratedBox( decoration: BoxDecoration( borderRadius: borderRadius, @@ -55,12 +103,9 @@ class FrostedSurface extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), - child: ColoredBox( - color: colors.surface.withValues(alpha: surfaceAlpha), - ), - ), + child: blur + ? BackdropFilter(filter: _filterFor(blurSigma), child: tint) + : tint, ), child, ], diff --git a/test/shared/widgets/frosted_surface_test.dart b/test/shared/widgets/frosted_surface_test.dart new file mode 100644 index 000000000..2aa4161df --- /dev/null +++ b/test/shared/widgets/frosted_surface_test.dart @@ -0,0 +1,59 @@ +/// The frost's one platform rule: Android never blurs what is under it. +library; + +import 'package:dpip/shared/widgets/frosted_surface.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Widget harness() => const MaterialApp( + home: Scaffold(body: FrostedSurface(child: Text('chrome'))), + ); + + // The binding checks foundation debug variables when a test body ends — + // before any tearDown — so each test restores the override itself. + testWidgets('Android draws a flat tint — no backdrop filter', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + await tester.pumpWidget(harness()); + debugDefaultTargetPlatformOverride = null; + + expect( + find.byType(BackdropFilter), + findsNothing, + reason: + 'over a platform-view map the blur is blind (HCPP) or re-rasterises ' + 'the whole scene per map frame (virtual display) — see ' + 'mapChromeBlursBackdrop', + ); + final tint = tester.widget( + find.descendant( + of: find.byType(FrostedSurface), + matching: find.byType(ColoredBox), + ), + ); + expect( + tint.color.a, + closeTo(kMapFrostSurfaceAlpha + kMapFrostFlatAlphaBoost, 1e-6), + reason: 'a flat tint carries a little more opacity than a frosted one', + ); + }); + + testWidgets('iOS blurs, reusing one filter across rebuilds', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await tester.pumpWidget(harness()); + final first = tester.widget(find.byType(BackdropFilter)); + + await tester.pumpWidget(harness()); + final second = tester.widget(find.byType(BackdropFilter)); + debugDefaultTargetPlatformOverride = null; + + expect( + identical(first.filter, second.filter), + isTrue, + reason: + 'ImageFilter has no value equality — a fresh one per build would ' + 'recomposite the backdrop even when nothing under it moved', + ); + }); +} From 6695d491d3fe1eed857dea8ba4344d69809d5e00 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 11:37:00 +0800 Subject: [PATCH 06/32] fix(map): preload timeline frames at an opacity MapLibre will fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 雷達、衛星等時間軸切到還沒載過的時間點時,不再先呆等兩秒才開始載入 Fix(en-US): the radar and satellite timelines no longer sit idle for two seconds before loading a frame they have not seen yet --- lib/shared/map/raster_timeline_layer.dart | 30 ++++++++-- test/features/map/qpesums_layer_test.dart | 3 +- test/features/map/radar_layer_test.dart | 27 ++++++--- test/features/map/satellite_layer_test.dart | 62 ++++++++++----------- 4 files changed, 77 insertions(+), 45 deletions(-) diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index f6ec5369f..2df139b05 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -29,7 +29,8 @@ enum _IdlePreloadOutcome { ready, timeout, cancelled } /// /// ## Four tiers /// - **The ring** — frames within [ringRadius] are mounted **visible**, only -/// the current one at full [opacity] and its neighbours at zero. During a +/// the current one at full [opacity] and its neighbours at +/// [preloadOpacity] (not zero — at zero MapLibre would not fetch them). During a /// drag, an L1-complete frame may join this set on demand; [maxResident] /// bounds the extra sources. A frame replaces the current timestamp only /// after [RasterFrameSource.frameTileReadiness] proves that one complete @@ -504,6 +505,23 @@ abstract class RasterTimelineLayer implements MapLayer { rasterOpacityTransition: _instantTransition, ); + /// What a mounted-but-not-shown frame is drawn at. + /// + /// Not zero, and the difference is whether the frame loads at all. MapLibre + /// Native's `RenderRasterLayer::evaluate` sets the layer's render pass to + /// *none* when `raster-opacity` is exactly 0; a layer with no pass does not + /// `needsRendering()`, and the tile pyramid then marks its tiles *optional* + /// — served from cache if present, never requested. So a "transparent" + /// mount at 0 preloaded nothing: every cold settle sat out the full + /// readiness timeout waiting for tiles nobody had asked for, and the idle + /// preload timed out candidate after candidate the same way. + /// + /// One 8-bit level is under what a pixel can show over the opaque current + /// frame, and it is enough for the renderer to treat the layer as drawn — + /// which is what makes it fetch. + @visibleForTesting + static const double preloadOpacity = 1 / 255; + static const RasterLayerProperties _hidden = RasterLayerProperties( visibility: 'none', rasterOpacity: 0, @@ -663,7 +681,9 @@ abstract class RasterTimelineLayer implements MapLayer { if (previous != null && previous != frameId) ( layerId: _layerId(previous), - properties: _ring.contains(previous) ? _opacity(0) : _hidden, + properties: _ring.contains(previous) + ? _opacity(preloadOpacity) + : _hidden, ), (layerId: _layerId(frameId), properties: _opacity(opacity)), ], skipNulls: true); @@ -706,7 +726,7 @@ abstract class RasterTimelineLayer implements MapLayer { // timestamp remains fully opaque below them. await Future.wait([ for (final id in ring) - if (id != _shownFrameId) _mount(controller, id, 0), + if (id != _shownFrameId) _mount(controller, id, preloadOpacity), ]); _ring.addAll(ring); MapTileCache.trace( @@ -871,7 +891,7 @@ abstract class RasterTimelineLayer implements MapLayer { MapLibreMapController controller, String frameId, ) async { - await _mount(controller, frameId, 0); + await _mount(controller, frameId, preloadOpacity); _ring.add(frameId); final keep = {frameId}; final shown = _shownFrameId; @@ -1120,7 +1140,7 @@ abstract class RasterTimelineLayer implements MapLayer { await _enqueueMutation(() async { if (!current()) return; - await _mount(controller, candidate, 0); + await _mount(controller, candidate, preloadOpacity); _ring.add(candidate); await _evictOverflow(controller, keep: preloadSet); }); diff --git a/test/features/map/qpesums_layer_test.dart b/test/features/map/qpesums_layer_test.dart index 9baa71203..76290fafe 100644 --- a/test/features/map/qpesums_layer_test.dart +++ b/test/features/map/qpesums_layer_test.dart @@ -1,6 +1,7 @@ import 'package:dpip/features/map/presentation/layers/qpesums_layer.dart'; import 'package:dpip/features/weather/domain/qpesums_repository.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/raster_timeline_layer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -60,7 +61,7 @@ void main() { await layer.show(controller, frames[5], scrubbing: true); expect(controller.calls, [ - 'set:qpesums-lyr-${frames[4].id}:0.0', + 'set:qpesums-lyr-${frames[4].id}:${RasterTimelineLayer.preloadOpacity}', 'set:qpesums-lyr-${frames[5].id}:0.85', ]); expect( diff --git a/test/features/map/radar_layer_test.dart b/test/features/map/radar_layer_test.dart index 5c5f30fac..2b85a6b77 100644 --- a/test/features/map/radar_layer_test.dart +++ b/test/features/map/radar_layer_test.dart @@ -15,6 +15,7 @@ import 'package:dpip/features/map/presentation/layers/radar_layer.dart'; import 'package:dpip/features/map/presentation/layers/radar_scan_range.dart'; import 'package:dpip/features/weather/domain/radar_repository.dart'; import 'package:dpip/shared/map/raster_frame_source.dart'; +import 'package:dpip/shared/map/raster_timeline_layer.dart'; import 'package:flutter_test/flutter_test.dart'; import 'raster_timeline_harness.dart'; @@ -141,7 +142,10 @@ void main() { 'visible', reason: 'neighbours stay visible so their tiles stay loaded', ); - expect(controller.opacityOf('radar-lyr-${frames[i].id}'), '0.0'); + expect( + controller.opacityOf('radar-lyr-${frames[i].id}'), + '${RasterTimelineLayer.preloadOpacity}', + ); } }); @@ -253,7 +257,7 @@ void main() { reason: 'one scrub frame must cross the platform channel only once', ); expect(controller.calls, [ - 'set:radar-lyr-${frames[4].id}:0.0', + 'set:radar-lyr-${frames[4].id}:${RasterTimelineLayer.preloadOpacity}', 'set:radar-lyr-${frames[5].id}:0.85', ]); expect( @@ -318,7 +322,10 @@ void main() { expect(layer.readyVisibleFrameId.value, frames[2].id); await layer.show(controller, frames[3], scrubbing: true); - expect(controller.opacityOf('radar-lyr-${frames[2].id}'), '0.0'); + expect( + controller.opacityOf('radar-lyr-${frames[2].id}'), + '${RasterTimelineLayer.preloadOpacity}', + ); expect(controller.opacityOf('radar-lyr-${frames[3].id}'), '0.85'); expect(layer.readyVisibleFrameId.value, frames[3].id); expect( @@ -342,7 +349,10 @@ void main() { layer.onMapIdle(); await Future.delayed(const Duration(milliseconds: 80)); - expect(controller.opacityOf('radar-lyr-${frames[2].id}'), '0.0'); + expect( + controller.opacityOf('radar-lyr-${frames[2].id}'), + '${RasterTimelineLayer.preloadOpacity}', + ); expect(controller.opacityOf('radar-lyr-${frames[3].id}'), '0.85'); }); @@ -446,11 +456,14 @@ void main() { await layer.show(controller, frames[1], scrubbing: true); - expect(controller.opacityOf('radar-lyr-${frames[4].id}'), '0.0'); + expect( + controller.opacityOf('radar-lyr-${frames[4].id}'), + '${RasterTimelineLayer.preloadOpacity}', + ); expect(controller.opacityOf('radar-lyr-${frames[1].id}'), '0.85'); expect(controller.calls, [ - 'set:radar-lyr-${frames[1].id}:0.0', - 'set:radar-lyr-${frames[4].id}:0.0', + 'set:radar-lyr-${frames[1].id}:${RasterTimelineLayer.preloadOpacity}', + 'set:radar-lyr-${frames[4].id}:${RasterTimelineLayer.preloadOpacity}', 'set:radar-lyr-${frames[1].id}:0.85', ]); expect( diff --git a/test/features/map/satellite_layer_test.dart b/test/features/map/satellite_layer_test.dart index a50e27c03..4ec472ed5 100644 --- a/test/features/map/satellite_layer_test.dart +++ b/test/features/map/satellite_layer_test.dart @@ -2,6 +2,7 @@ import 'package:dpip/features/map/presentation/layers/satellite_layer.dart'; import 'package:dpip/features/weather/domain/satellite_channel.dart'; import 'package:dpip/features/weather/domain/satellite_repository.dart'; import 'package:dpip/shared/map/map_style.dart'; +import 'package:dpip/shared/map/raster_timeline_layer.dart'; import 'package:flutter_test/flutter_test.dart'; import 'raster_timeline_harness.dart'; @@ -44,38 +45,35 @@ void main() { expect(controller.opacityOf('satellite-lyr-${frames[2].id}'), '1.0'); }); - test( - 'scrubbing inside the ring is two opacity writes, nothing else', - () async { - final layer = SatelliteMapLayer( - _FakeSatelliteRepository(_ids(9)), - channel: SatelliteChannel.irClean, - referenceOutline: testReferenceOutline(), - ); - final frames = (await layer.frames()).valueOrNull!; - final controller = RecordingMapController(); - - await layer.prepare(controller, frames); - await layer.show(controller, frames[4]); - controller.calls.clear(); - controller.sentKeys.clear(); - - await layer.show(controller, frames[3], scrubbing: true); - - expect(controller.calls, [ - 'set:satellite-lyr-${frames[4].id}:0.0', - 'set:satellite-lyr-${frames[3].id}:1.0', - ]); - expect( - controller.sentKeys, - everyElement(equals({'raster-opacity', 'raster-opacity-transition'})), - reason: - 'the scrub path must not re-send visibility or the seven other ' - 'raster properties the layer type has — only the opacity and the ' - 'zero cross-fade that keeps a scrub a loop instead of a smear', - ); - }, - ); + test('scrubbing inside the ring is two opacity writes, nothing else', () async { + final layer = SatelliteMapLayer( + _FakeSatelliteRepository(_ids(9)), + channel: SatelliteChannel.irClean, + referenceOutline: testReferenceOutline(), + ); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[4]); + controller.calls.clear(); + controller.sentKeys.clear(); + + await layer.show(controller, frames[3], scrubbing: true); + + expect(controller.calls, [ + 'set:satellite-lyr-${frames[4].id}:${RasterTimelineLayer.preloadOpacity}', + 'set:satellite-lyr-${frames[3].id}:1.0', + ]); + expect( + controller.sentKeys, + everyElement(equals({'raster-opacity', 'raster-opacity-transition'})), + reason: + 'the scrub path must not re-send visibility or the seven other ' + 'raster properties the layer type has — only the opacity and the ' + 'zero cross-fade that keeps a scrub a loop instead of a smear', + ); + }); test('bright yellow county and town outlines are added once and removed on clear', () async { final layer = SatelliteMapLayer( From 4832b898cb9aac3905798b45931c0121f777b01c Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 11:37:02 +0800 Subject: [PATCH 07/32] perf(map): let the camera settle before warming the timeline band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 平移地圖時不再每停一下就在背景讀取整段雷達快取,低階手機更不容易卡 Optimization(en-US): panning the map no longer starts a background cache read at every pause, which keeps low-end phones responsive --- lib/shared/map/raster_timeline_layer.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index 2df139b05..5da3ce6f0 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -1665,8 +1665,16 @@ abstract class RasterTimelineLayer implements MapLayer { // No invalidation here. The band is keyed on the camera as well as the // centre, so a real move re-warms on its own and an idle that reports the // same camera is the duplicate it looks like. + // + // Not immediate: the warmer's settle delay is what lets a step-pan (move, + // pause, move) coalesce. Started at once, each pause began a + // [warmFrameBudget]-frame L1 probe and SQLite fill on the platform thread + // — the same thread the newly visible tiles have to come back through — + // only for the next step to cancel it mid-probe. Nothing the fill would + // have done in those first 120 ms is lost: it runs the same, once the + // camera has genuinely stopped. MapTileCache.trace(() => 'timeline=$id camera-idle centre=$centre'); - unawaited(_warmBand(controller, centre, immediate: true)); + unawaited(_warmBand(controller, centre)); } @override From 0a8275452d3f77bc167e4d71cb2545bc17d856b6 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 11:37:30 +0800 Subject: [PATCH 08/32] perf(map): stop re-uploading an unchanged detection-box grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 強震監視器在大型地震期間不再每秒六十次重送相同的偵測格網,手機更不發熱 Optimization(en-US): the monitor no longer re-sends an unchanged detection grid sixty times a second during a large quake, so the phone runs cooler --- .../map/presentation/layers/rts_layer.dart | 62 +++++- .../layers/rts_layer_box_push_test.dart | 181 ++++++++++++++++++ 2 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 test/features/map/presentation/layers/rts_layer_box_push_test.dart diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index d89ea3ad4..85163203a 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -4,6 +4,7 @@ library; import 'dart:async'; +import 'dart:math' as math; import 'package:dpip/core/a11y/color_vision.dart'; import 'package:dpip/core/geo/town_directory.dart'; @@ -185,6 +186,17 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { bool _boxVisible = true; bool _epicenterVisible = true; + /// Signature of the box collection currently on the map (see [_pushBox]). + /// + /// The wavefront ticker calls [_pushBox] at display rate, but the grid it + /// draws only changes when the feed's box set changes (~1 Hz) or a box is + /// swept past by the S wave (once, ever, per box). Every other tick used to + /// re-serialise the whole grid, ship it across the platform channel and make + /// MapLibre re-tile it — sixty times a second, during an alert, on the + /// device's worst minute. `null` means nothing is known to be there, so the + /// next push always lands. + String? _boxOnMap; + /// Whether the EEW source on the map currently holds [_emptyCollection]. /// /// [_pushUpdate] ends with an unconditional [_pushEew], and the RTS feed @@ -396,6 +408,8 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { _appliedStatus = null; // [_setupEew] has just seeded the source with [_emptyCollection]. _eewSourceEmpty = true; + // The box source was just re-added empty above. + _boxOnMap = null; await _pushUpdate(); if (!_listening) { _feed.addListener(_onFeed); @@ -632,7 +646,11 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { final hasBox = (_feed.state.data?.box.isNotEmpty) ?? false; try { if (hasBox) { - await controller.setGeoJsonSource(_boxSourceId, _boxGeoJson(grid)); + final (geoJson, signature) = _boxGeoJson(grid); + if (signature != _boxOnMap) { + await controller.setGeoJsonSource(_boxSourceId, geoJson); + _boxOnMap = signature; + } } if (hasBox != _boxVisible) { _boxVisible = hasBox; @@ -648,11 +666,17 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { /// S-wave has already fully swept past (see [_isBoxFullyCovered]) so it /// stops blinking instead of blinking forever once it's no longer live /// information. - Map _boxGeoJson(RtsBoxGrid grid) { + /// + /// Also returns the collection's signature — the surviving ids with their + /// intensities, in order — which is everything the geometry depends on, + /// since a ring is a function of its id alone. [_pushBox] compares it + /// against what the map already holds. + (Map, String) _boxGeoJson(RtsBoxGrid grid) { final box = _feed.state.data?.box ?? const {}; final alerts = _eew.state.data ?? const []; final now = AppTime.utc; final features = >[]; + final signature = StringBuffer(); for (final entry in box.entries) { final id = int.tryParse(entry.key); final ring = id == null ? null : grid.rings[id]; @@ -661,6 +685,11 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { _isBoxFullyCovered(ring, alerts, _travelTime!, now)) { continue; } + signature + ..write(entry.key) + ..write(':') + ..write(entry.value) + ..write(','); features.add({ 'type': 'Feature', 'geometry': { @@ -670,9 +699,18 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { 'properties': {'i': entry.value}, }); } - return {'type': 'FeatureCollection', 'features': features}; + return ( + {'type': 'FeatureCollection', 'features': features}, + signature.toString(), + ); } + /// Metres per degree of latitude on the sphere [geo.LatLng.distanceTo] + /// measures on — the great-circle distance between two points is never less + /// than their meridional separation, so a corner whose latitude alone puts + /// it past the radius is outside it without the trig. + static const double _metresPerLatDegree = 6378137 * math.pi / 180; + /// Whether every corner of [ring] is already within some active alert's /// S-wave radius — ported from the legacy monitor's `checkBoxSkip`, which /// dropped a detection box from the map (not just its blink) the instant @@ -693,13 +731,16 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { final radiusKm = table.waveRadius(info.depth, elapsed).s; if (radiusKm <= 0) continue; final epicenter = info.latlng; - final allCornersCovered = ring - .take(4) - .every( - (point) => - epicenter.distanceTo(geo.LatLng(point[1], point[0])) / 1000 <= - radiusKm, - ); + final radiusMetres = radiusKm * 1000; + final allCornersCovered = ring.take(4).every((point) { + final lat = point[1]; + // Exact reject: meridional distance is a lower bound on the geodesic. + if ((lat - epicenter.latitude).abs() * _metresPerLatDegree > + radiusMetres) { + return false; + } + return epicenter.distanceTo(geo.LatLng(lat, point[0])) <= radiusMetres; + }); if (allCornersCovered) return true; } return false; @@ -926,6 +967,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { @override void onStyleReset() { _added = false; + _boxOnMap = null; // The style reload wipes the base style's county/town fill back to // default — this cache would otherwise think a still-active alert's // tint is already applied and skip re-painting it. diff --git a/test/features/map/presentation/layers/rts_layer_box_push_test.dart b/test/features/map/presentation/layers/rts_layer_box_push_test.dart new file mode 100644 index 000000000..bf1e8c5bf --- /dev/null +++ b/test/features/map/presentation/layers/rts_layer_box_push_test.dart @@ -0,0 +1,181 @@ +/// Pins how often [RtsMapLayer] re-uploads a large event's detection boxes. +/// +/// The wavefront ticker repaints at display rate while an alert is live and +/// ends every tick with a box push. The grid it draws only changes when the +/// feed's box set changes or the S wave sweeps a box out, so every other tick +/// used to serialise the same polygons, ship them across the platform channel +/// and make MapLibre re-tile them — sixty times a second, during an +/// earthquake. The guard is on the *content*: an identical collection is not +/// sent again, a changed one always is. +library; + +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/realtime/clock.dart'; +import 'package:dpip/core/realtime/elapsed.dart'; +import 'package:dpip/core/realtime/realtime_channel.dart'; +import 'package:dpip/core/realtime/realtime_config.dart'; +import 'package:dpip/core/realtime/realtime_notifier.dart'; +import 'package:dpip/core/realtime/realtime_source.dart'; +import 'package:dpip/core/realtime/ticker.dart'; +import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/rts.dart'; +import 'package:dpip/features/earthquake/domain/rts_box_grid.dart'; +import 'package:dpip/features/earthquake/domain/seismic_station.dart'; +import 'package:dpip/features/earthquake/domain/seismic_travel_time.dart'; +import 'package:dpip/features/earthquake/domain/trem_station_repository.dart'; +import 'package:dpip/features/map/presentation/layers/rts_layer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../raster_timeline_harness.dart'; + +const String _boxSourceId = 'rts-box-src'; + +class _BoxRecordingController extends RecordingMapController { + final List> boxPushes = []; + + @override + Future setGeoJsonSource( + String sourceId, + Map geojson, { + String? promoteId, + }) async { + if (sourceId == _boxSourceId) boxPushes.add(geojson); + calls.add('setGeoJsonSource:$sourceId'); + } +} + +class _FakeClock implements Clock { + _FakeClock(this.current); + DateTime current; + @override + DateTime now() => current; +} + +class _FakeElapsed implements Elapsed { + Duration value = Duration.zero; + @override + Duration get elapsed => value; +} + +class _FakeTicker implements Ticker { + @override + TickerHandle start(Duration interval, void Function() onTick) => + _NoopHandle(); +} + +class _NoopHandle implements TickerHandle { + @override + void cancel() {} +} + +/// A source whose answer the test can change between refreshes. +class _MutableSource extends RealtimeSource { + Rts data = const Rts(); + + @override + Future> fetch() async => Ok(data); + + @override + DateTime? timestampOf(Rts value) => null; + + @override + bool sameData(Rts? a, Rts? b) => a == b; +} + +class _EmptyStations implements TremStationRepository { + @override + Future>> stations() async => + const Ok({}); +} + +/// A square box around Hualien — four corners, `[lng, lat]`, then closed. +const List> _ring = [ + [121.5, 23.5], + [121.6, 23.5], + [121.6, 23.6], + [121.5, 23.6], + [121.5, 23.5], +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('an unchanged box collection is uploaded once, not per tick', () async { + final source = _MutableSource()..data = const Rts(box: {'7': 4}); + final rtsChannel = RealtimeChannel( + source: source, + clock: _FakeClock(DateTime.utc(2026, 8, 12, 12)), + elapsed: _FakeElapsed(), + ticker: _FakeTicker(), + config: RealtimeConfig.rts, + label: 'rts', + ); + await rtsChannel.refreshNow(); + final eewChannel = RealtimeChannel>( + source: _StaticEew(), + clock: _FakeClock(DateTime.utc(2026, 8, 12, 12)), + elapsed: _FakeElapsed(), + ticker: _FakeTicker(), + config: RealtimeConfig.eew, + label: 'eew', + ); + await eewChannel.refreshNow(); + final layer = RtsMapLayer( + RealtimeNotifier(rtsChannel), + _EmptyStations(), + eew: RealtimeNotifier>(eewChannel), + travelTimeTable: Future.value( + const SeismicTravelTimeTable({ + 0: [(p: 1, r: 5, s: 2), (p: 10, r: 50, s: 20)], + }), + ), + boxGrid: Future.value(const RtsBoxGrid({7: _ring})), + townDirectory: const TownDirectory({}), + ); + final controller = _BoxRecordingController(); + + await layer.render(controller); + await pumpEventQueue(); // the grid future lands and pushes once + final afterAttach = controller.boxPushes.length; + expect(afterAttach, greaterThan(0), reason: 'attaching draws the box'); + + // The same feed snapshot notifying again (status recompute, or the + // display-rate wavefront tick) must not re-send identical polygons. + for (var i = 0; i < 20; i++) { + rtsChannel.recomputeStatus(); + await pumpEventQueue(); + } + expect( + controller.boxPushes.length, + afterAttach, + reason: 'identical box geometry is not re-uploaded', + ); + + // A genuinely different set is. + source.data = const Rts(box: {'7': 6}); + await rtsChannel.refreshNow(); + await pumpEventQueue(); + expect( + controller.boxPushes.length, + afterAttach + 1, + reason: 'a changed intensity is a new collection', + ); + final last = controller.boxPushes.last['features'] as List; + expect((last.single as Map)['properties'], {'i': 6}); + + await layer.clear(controller); + }); +} + +class _StaticEew extends RealtimeSource> { + @override + Future>> fetch() async => const Ok([]); + + @override + DateTime? timestampOf(List value) => null; + + @override + bool sameData(List? a, List? b) => + (a?.isEmpty ?? true) && (b?.isEmpty ?? true); +} From 49221495c4ca3e64d460a63de1d85416881602f6 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 11:37:33 +0800 Subject: [PATCH 09/32] perf(home): stop recomputing the sky backdrop's constants per frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 首頁天氣背景每一幀不再重算不會變的光照、太陽與雨滴資料,低階手機更省電 Optimization(en-US): the home weather backdrop stops recomputing unchanging lighting, sun and raindrop data every frame, saving battery on low-end phones --- lib/app/theme/app_glass.dart | 43 ++++- .../widgets/weather_sky/card_water_field.dart | 68 +++++--- .../weather_sky/precipitation_field.dart | 34 +++- .../widgets/weather_sky/rain_on_card.dart | 156 ++++++++++++------ .../widgets/weather_sky/rain_on_glass.dart | 11 +- .../widgets/weather_sky/sky_clouds.dart | 22 +-- .../widgets/weather_sky/sky_lut_cache.dart | 25 ++- .../widgets/weather_sky/solar_time.dart | 49 ++++++ .../weather_sky/weather_sky_background.dart | 37 +++-- .../weather_sky/weather_sky_painter.dart | 140 ++++++++++------ .../home/weather_sky/rain_on_glass_test.dart | 21 +++ 11 files changed, 453 insertions(+), 153 deletions(-) diff --git a/lib/app/theme/app_glass.dart b/lib/app/theme/app_glass.dart index f5bc297c4..b89ddbe26 100644 --- a/lib/app/theme/app_glass.dart +++ b/lib/app/theme/app_glass.dart @@ -61,7 +61,7 @@ Color glassSurface(ColorScheme colors, double reveal, {Color? sky, int? hour}) { if (colors.brightness == Brightness.light) return colors.surfaceContainerLow; final revealed = sky == null ? colors.surface.withValues(alpha: 0.92) - : skyCardTint(sky, hour: hour ?? AppTime.utc8.hour); + : _skyCardTintMemo(sky, hour ?? _taipeiHour()); return Color.lerp( colors.surfaceContainerHighest.withValues(alpha: 0.55), revealed, @@ -69,6 +69,31 @@ Color glassSurface(ColorScheme colors, double reveal, {Color? sky, int? hour}) { )!; } +/// The current Taipei wall-clock hour, from the calibrated instant's epoch +/// arithmetic: `AppTime.utc8` is exactly `utc + 8 h`, and a UTC-flagged +/// `DateTime`'s `hour` is `(ms ~/ 1 h) % 24` for any post-epoch instant — so +/// this is the same integer without the second `DateTime` per call. +int _taipeiHour() => + ((AppTime.utc.millisecondsSinceEpoch + 8 * Duration.millisecondsPerHour) ~/ + Duration.millisecondsPerHour) % + 24; + +/// [skyCardTint] behind a one-entry memo. Every glass card on Home asks for +/// the same `(panelAmbient, hour)` pair on every sheet-drag rebuild, and the +/// two HSL round trips behind it are pure — the answer only moves when the +/// sky re-bakes (once a minute) or the hour bucket turns. +Color _skyCardTintMemo(Color sky, int hour) { + final cached = _cardTintMemo; + if (cached != null && cached.sky == sky && cached.hour == hour) { + return cached.tint; + } + final tint = skyCardTint(sky, hour: hour); + _cardTintMemo = (sky: sky, hour: hour, tint: tint); + return tint; +} + +({Color sky, int hour, Color tint})? _cardTintMemo; + /// Ink for content **inside** a [glassSurface] card. /// /// Dark theme: at rest the card is its own plate and the theme's on-surface @@ -135,11 +160,25 @@ bool weatherSkyIsLight(WeatherMode mode) => switch (mode) { /// luminance cutoff — it is the same "is this background light or dark" /// judgment Flutter already ships and tunes, so a border-hue sky (dawn, a /// hazy overcast) resolves the way the rest of the framework would resolve it. +/// +/// Memoised on the last [sky] seen: `estimateBrightnessForColor` is a +/// relative-luminance computation (three `pow` calls), and every widget on +/// the sheet asks about the *same* `SkyLutCache.panelAmbient` value on every +/// rebuild of a drag — the answer changes once a minute, when the sky +/// re-bakes, and the memo turns the rest into one colour comparison. +/// (`SkyLutCache.panelAmbientIsLight` publishes the same verdict at the +/// source for callers that already listen there.) bool skyIsLightFrom(Color? sky, WeatherMode fallbackMode) { if (sky == null) return weatherSkyIsLight(fallbackMode); - return ThemeData.estimateBrightnessForColor(sky) == Brightness.light; + final cached = _skyIsLightMemo; + if (cached != null && cached.sky == sky) return cached.isLight; + final isLight = ThemeData.estimateBrightnessForColor(sky) == Brightness.light; + _skyIsLightMemo = (sky: sky, isLight: isLight); + return isLight; } +({Color sky, bool isLight})? _skyIsLightMemo; + /// Ink for content drawn **on** the weather sky (header, region badges). /// /// As [reveal] rises, shifts toward dark ink on a light sky (critical in dark diff --git a/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart b/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart index a27d4ba94..6a1c7fbee 100644 --- a/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart +++ b/lib/features/home/presentation/widgets/weather_sky/card_water_field.dart @@ -69,7 +69,10 @@ class CardWaterField { this.capacity = 200, int seed = 7, }) : _p = _Drops(capacity), - _random = math.Random(seed); + _random = math.Random(seed), + _transforms = Float32List(capacity * 4), + _rects = Float32List(capacity * 4), + _colors = Int32List(capacity)..fillRange(0, capacity, 0x40FFFFFF); /// Drops currently alive. int get liveCount => _live; @@ -714,44 +717,69 @@ class CardWaterField { if (_live == 0) return; final sprite = negative ? spriteNeg : spritePos; - final transforms = Float32List(_live * 4); - final rects = Float32List(_live * 4); - final colors = Int32List(_live); - final w = sprite.width.toDouble(); final h = sprite.height.toDouble(); final scale = dropSize / w; + // Every drop samples the whole sprite, so the rect is one value repeated + // — refilled only when the sprite itself is swapped (the procedural + // stand-in for the baked pair), not per pass. + if (w != _rectW || h != _rectH) { + _rectW = w; + _rectH = h; + for (var i = 0; i < capacity; i++) { + final o = i * 4; + _rects[o] = 0; + _rects[o + 1] = 0; + _rects[o + 2] = w; + _rects[o + 3] = h; + } + } + + final transforms = _transforms; for (var i = 0; i < _live; i++) { final o = i * 4; transforms[o] = scale; transforms[o + 1] = 0; transforms[o + 2] = _p.x[i] - w * scale / 2; transforms[o + 3] = _p.y[i] - h * scale / 2; - - rects[o] = 0; - rects[o + 1] = 0; - rects[o + 2] = w; - rects[o + 3] = h; - - // 0x40FFFFFF premultiplies to ×0.251 on *every* channel — exactly the - // accumulation scale. (An rgb of 0x40 here would premultiply twice.) - colors[i] = 0x40FFFFFF; } + // `drawRawAtlas` copies the arrays into the display list as it records, + // so the live prefix of one fixed buffer is handed over as a view — no + // per-pass allocation, and the next pass may overwrite it freely. canvas.drawRawAtlas( sprite, - transforms, - rects, - colors, + Float32List.sublistView(transforms, 0, _live * 4), + Float32List.sublistView(_rects, 0, _live * 4), + Int32List.sublistView(_colors, 0, _live), BlendMode.modulate, null, - Paint() - ..filterQuality = FilterQuality.medium - ..blendMode = BlendMode.plus, + _coveragePaint, ); } + /// [paintCoverage]'s atlas payload, sized to [capacity] once and reused — + /// two accumulation passes per frame per card used to allocate all three + /// fresh each time. + final Float32List _transforms; + final Float32List _rects; + + /// 0x40FFFFFF premultiplies to ×0.251 on *every* channel — exactly the + /// accumulation scale. (An rgb of 0x40 here would premultiply twice.) The + /// same value for every drop, so it is written once at construction. + final Int32List _colors; + + /// Sprite size the [_rects] buffer was last filled for. + double _rectW = -1; + double _rectH = -1; + + /// The additive accumulation paint. Immutable in practice — nothing else + /// touches it — so one instance serves every pass. + static final Paint _coveragePaint = Paint() + ..filterQuality = FilterQuality.medium + ..blendMode = BlendMode.plus; + /// Empties the field. void clear() { _live = 0; diff --git a/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart b/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart index cea01ad31..f571968eb 100644 --- a/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart +++ b/lib/features/home/presentation/widgets/weather_sky/precipitation_field.dart @@ -180,16 +180,14 @@ class PrecipitationField { _p.transforms[o + 3] = _p.y[i] - (_p.transforms[o + 1] * ax + _p.transforms[o] * ay); - // Depth picks the width variant. `particle_rain.comp` makes the quad's - // width `startSize * 0.4 * (3 - 2*depth)` against a length of - // `startSize * 3.0`, so a *distant* drop is relatively the widest — and - // the atlas is baked widest-first, so the index is depth itself. - final v = variants == 1 - ? 0 - : ((depth * (variants - 1)).round()).clamp(0, variants - 1); - _p.rects[o] = v * cell.width; + // The atlas cell this drop samples, chosen at spawn (see [_spawn]). The + // four floats still have to be written here rather than once per + // particle: the alpha cull above (`continue`) compacts the draw list, + // so output slot `n` is not particle `i`, and a rect written at spawn + // to slot `i` would be read back for the wrong drop. + _p.rects[o] = _p.atlasLeft[i]; _p.rects[o + 1] = 0; - _p.rects[o + 2] = (v + 1) * cell.width; + _p.rects[o + 2] = _p.atlasRight[i]; _p.rects[o + 3] = cell.height; // Pack the drop's colour in place of `tint.withValues(alpha: a)`: the @@ -216,6 +214,18 @@ class PrecipitationField { _p.depth[i] = depth; _p.size[i] = sizeMin + (sizeMax - sizeMin) * depth; + // Depth picks the width variant. `particle_rain.comp` makes the quad's + // width `startSize * 0.4 * (3 - 2*depth)` against a length of + // `startSize * 3.0`, so a *distant* drop is relatively the widest — and + // the atlas is baked widest-first, so the index is depth itself. Depth is + // fixed for the drop's life, so the cell edges are resolved here, once, + // instead of a `round().clamp()` (which boxes through `num`) per drop per + // frame. + final v = variants == 1 + ? 0 + : ((depth * (variants - 1)).round()).clamp(0, variants - 1); + _p.atlasLeft[i] = v * cell.width; + _p.atlasRight[i] = (v + 1) * cell.width; // The reference sets `setLife(3.0f, 3.0f)` — a constant, not a range. _p.life[i] = life; _p.age[i] = 0; @@ -248,6 +258,10 @@ class _Particle { final Float64List spin; final Float64List phase; + /// The atlas cell's left and right edge, in texels — fixed at spawn. + final Float64List atlasLeft; + final Float64List atlasRight; + final Float32List transforms; final Float32List rects; final Int32List colors; @@ -261,6 +275,8 @@ class _Particle { age = Float64List(n), spin = Float64List(n), phase = Float64List(n), + atlasLeft = Float64List(n), + atlasRight = Float64List(n), transforms = Float32List(n * 4), rects = Float32List(n * 4), colors = Int32List(n); diff --git a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart index 82e71cb6a..2bd3f7812 100644 --- a/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_card.dart @@ -121,12 +121,39 @@ class _RainOnCardState extends State /// Repaints the water without rebuilding the card beneath it. final ValueNotifier _frame = ValueNotifier(0); + /// The painter's repaint trigger: [_frame], plus the sky re-bake so the + /// water's ambient follows the backdrop it falls out of. Built once — the + /// merge is the same pair for the State's whole life, and building it in + /// `build` allocated a fresh listenable (and re-subscribed the painter) on + /// every scroll tick. + late final Listenable _repaint = Listenable.merge([ + _frame, + SkyLutCache.panelAmbient, + ]); + ui.FragmentShader? _shader; Duration _last = Duration.zero; Size _size = Size.zero; double _screenHeight = 0; Size _screenSize = Size.zero; + /// Screen-derived values, recomputed only when [_screenSize] changes — see + /// [_syncScreen]. They are read on every build and every paint, and none of + /// them moves between one frame and the next. + double _pointSize = 5.0; + double _threshold = 0.6; + // The values a zero screen produces — what [_syncScreen] would compute for + // its initial [_screenSize], so a build that never sees a real size draws + // exactly what it always did. + late Paint _blurPaint = Paint() + ..imageFilter = ui.ImageFilter.blur(sigmaX: 0, sigmaY: 0); + + /// Local minute of the cached [_night], so the wall-clock read and the + /// keyframe-ring lookup run once a minute rather than once per build (a + /// scroll rebuilds this card on every pixel). + int _nightMinuteKey = -1; + bool _night = false; + /// Wraps [RainOnCard.child] so [_capture] has something to rasterise — /// only meaningful while [RainOnCard.silhouette] is on, but cheap enough to /// hold unconditionally rather than juggling a nullable key. @@ -511,9 +538,9 @@ class _RainOnCardState extends State /// Those are **buffer** pixels, not logical ones: the particle pass renders /// into an 800-tall render texture that stands for the whole screen, so a /// drop covers `size/800` of the screen's height whatever the device. - (double, double) get _pointSizeAndThreshold { - final w = _screenSize.width; - final h = _screenSize.height; + static (double, double) _pointSizeAndThreshold(Size screen) { + final w = screen.width; + final h = screen.height; if (w <= 0 || h <= 0) return (5.0, 0.6); final scale = h / CardWaterField.bufferHeight; if (h / w >= 2.0) return (5.0 * scale, 0.6); @@ -521,6 +548,68 @@ class _RainOnCardState extends State return (3.7 * scale, 0.3); } + /// The separable 5-tap blur the reference runs between the particle pass and + /// the composite, as an equivalent gaussian. + /// + /// Taps sit at ±0.7 and ±0.35 of `uBlurBufferSize` with weights + /// .164/.217/.238, so σ is `sqrt(2·(0.164·0.7² + 0.217·0.35²))` = 0.4625 of + /// that step. The step is the load-bearing part: the reference computes it + /// as `1/((w·355)/h)` **while both fields are still 1**, and only assigns the + /// buffer's width and height afterwards — so the aspect correction is dead + /// code and *both* axes get a flat 1/355 in **UV**. + /// + /// A UV step is not a pixel step. On the 800-tall buffer that is 1.04 px + /// vertically but only `1.04·W/H` horizontally — the blur is 2.2x wider + /// down the screen than across it, and reading it as one isotropic 0.46 px + /// (as an earlier version did) leaves each drop far too sharp and far too + /// much of it above the alpha cut. + static const double _blurStepUv = 1 / 355; + static const double _blurSigmaUv = 0.46253 * _blurStepUv; + + /// Refreshes everything derived from the screen size, only when it changes. + /// + /// The blur's σ pair depends on nothing but the screen, yet the painter used + /// to build a fresh `ImageFilter.blur` (and its `Paint`) twice per frame per + /// card — one per signed accumulation pass. The filter is immutable, so one + /// instance serves every frame until the screen itself changes (a rotation, + /// a window resize). + void _syncScreen(Size screen) { + if (screen == _screenSize) return; + _screenSize = screen; + _screenHeight = screen.height; + final (pointSize, threshold) = _pointSizeAndThreshold(screen); + _pointSize = pointSize; + _threshold = threshold; + _blurPaint = Paint() + ..imageFilter = ui.ImageFilter.blur( + sigmaX: _blurSigmaUv * screen.width, + sigmaY: _blurSigmaUv * screen.height, + ); + } + + /// The reference's night flag is `hour >= 15 || hour <= 4` — and that hour + /// is the scene's position on its 24-keyframe day ring, where ~15 is dusk + /// and ~4 is dawn. DPIP has the same mapping; the theme's brightness (used + /// here before) is unrelated to the sun. + /// + /// Memoised to the local minute: the ring position is a function of the + /// wall-clock hour and minute alone, so within one minute the answer cannot + /// change, and a scroll rebuilds this card at frame rate. + bool _nightNow() { + final utc = AppTime.utc; + final minuteKey = utc.millisecondsSinceEpoch ~/ 60000; + if (minuteKey != _nightMinuteKey) { + _nightMinuteKey = minuteKey; + final wall = AppTime.taipei(utc); + final key = keyframePosition( + wall.hour + wall.minute / 60.0, + frameCount: 24, + ); + _night = key >= 15.0 || key <= 4.0; + } + return _night; + } + @override void dispose() { _ticker.dispose(); @@ -531,19 +620,8 @@ class _RainOnCardState extends State @override Widget build(BuildContext context) { - _screenSize = MediaQuery.sizeOf(context); - _screenHeight = _screenSize.height; - // The reference's night flag is `hour >= 15 || hour <= 4` — and that hour is - // the scene's position on its 24-keyframe day ring, where ~15 is dusk and - // ~4 is dawn. DPIP has the same mapping; the theme's brightness (used here - // before) is unrelated to the sun. - final wall = AppTime.utc8; - final key = keyframePosition( - wall.hour + wall.minute / 60.0, - frameCount: 24, - ); - final night = key >= 15.0 || key <= 4.0; - final (pointSize, threshold) = _pointSizeAndThreshold; + _syncScreen(MediaQuery.sizeOf(context)); + final night = _nightNow(); return LayoutBuilder( builder: (context, constraints) { _size = Size( @@ -600,17 +678,13 @@ class _RainOnCardState extends State primary: _primary, secondary: _secondary, shader: _shader, - // Also repaints when the sky re-bakes, so the water's - // ambient follows the backdrop it falls out of. - repaint: Listenable.merge([ - _frame, - SkyLutCache.panelAmbient, - ]), + repaint: _repaint, + blurPaint: _blurPaint, opacity: widget.opacity.clamp(0.0, 1.0), night: night, screenHeight: _screenHeight, - pointSize: pointSize, - threshold: threshold, + pointSize: _pointSize, + threshold: _threshold, screenWidth: _screenSize.width, gateOpen: _gateOpen, ), @@ -631,6 +705,7 @@ class _CardWaterPainter extends CustomPainter { required this.secondary, required this.shader, required Listenable repaint, + required this.blurPaint, required this.opacity, required this.night, required this.screenHeight, @@ -643,6 +718,12 @@ class _CardWaterPainter extends CustomPainter { final CardWaterField primary; final CardWaterField secondary; final ui.FragmentShader? shader; + + /// The accumulation layer's blur, owned by the State and rebuilt only when + /// the screen size changes — see `_RainOnCardState._syncScreen` for the σ + /// derivation. Held as a [Paint] rather than an `ImageFilter` so `saveLayer` + /// allocates nothing per pass either. + final Paint blurPaint; final double opacity; final bool night; final double screenHeight; @@ -654,27 +735,6 @@ class _CardWaterPainter extends CustomPainter { final double pointSize; final double threshold; - /// The separable 5-tap blur the reference runs between the particle pass and - /// the composite, as an equivalent gaussian. - /// - /// Taps sit at ±0.7 and ±0.35 of `uBlurBufferSize` with weights - /// .164/.217/.238, so σ is `sqrt(2·(0.164·0.7² + 0.217·0.35²))` = 0.4625 of - /// that step. The step is the load-bearing part: the reference computes it - /// as `1/((w·355)/h)` **while both fields are still 1**, and only assigns the - /// buffer's width and height afterwards — so the aspect correction is dead - /// code and *both* axes get a flat 1/355 in **UV**. - /// - /// A UV step is not a pixel step. On the 800-tall buffer that is 1.04 px - /// vertically but only `1.04·W/H` horizontally — the blur is 2.2x wider - /// down the screen than across it, and reading it as one isotropic 0.46 px - /// (as an earlier version did) leaves each drop far too sharp and far too - /// much of it above the alpha cut. - static const double _blurStepUv = 1 / 355; - static const double _blurSigmaUv = 0.46253 * _blurStepUv; - - double get _sigmaX => _blurSigmaUv * screenWidth; - double get _sigmaY => _blurSigmaUv * screenHeight; - /// One signed half of the accumulation — see [CardWaterField.paintCoverage]. ui.Image _accumulate( Size fieldSize, @@ -683,11 +743,7 @@ class _CardWaterPainter extends CustomPainter { }) { final recorder = ui.PictureRecorder(); final offscreen = Canvas(recorder); - offscreen.saveLayer( - Offset.zero & fieldSize, - Paint() - ..imageFilter = ui.ImageFilter.blur(sigmaX: _sigmaX, sigmaY: _sigmaY), - ); + offscreen.saveLayer(Offset.zero & fieldSize, blurPaint); offscreen.translate(0, headroom); primary.paintCoverage(offscreen, dropSize: pointSize, negative: negative); secondary.paintCoverage(offscreen, dropSize: pointSize, negative: negative); diff --git a/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart b/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart index 6a0d17ff2..9a87e8c87 100644 --- a/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart +++ b/lib/features/home/presentation/widgets/weather_sky/rain_on_glass.dart @@ -84,8 +84,16 @@ class _RainOnGlassState extends State void _syncRunning() { // No shader means there can be no visible frame. This also permanently // stops the vsync loop on Skia after the filter capability check fails. + // + // The opacity term mirrors [build]'s own short-circuit: below 0.004 the + // filter is never built, so a ticker kept alive there only rebuilds an + // `ImageFiltered(enabled: false)` sixty times a second. The header card's + // opacity reaches exactly 0 once the hero scrolls past the fold, and that + // is the state this used to spin in (`RainOnCard` gates its own ticker + // the same way). if (widget.active && widget.intensity > 0.01 && + widget.opacity > 0.004 && _shader != null && !_filterUnsupported) { if (!_clock.isRunning) _clock.start(); @@ -100,7 +108,8 @@ class _RainOnGlassState extends State void didUpdateWidget(RainOnGlass oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.active != widget.active || - (oldWidget.intensity > 0.01) != (widget.intensity > 0.01)) { + (oldWidget.intensity > 0.01) != (widget.intensity > 0.01) || + (oldWidget.opacity > 0.004) != (widget.opacity > 0.004)) { _syncRunning(); } } diff --git a/lib/features/home/presentation/widgets/weather_sky/sky_clouds.dart b/lib/features/home/presentation/widgets/weather_sky/sky_clouds.dart index 1c35ce77f..5965e557a 100644 --- a/lib/features/home/presentation/widgets/weather_sky/sky_clouds.dart +++ b/lib/features/home/presentation/widgets/weather_sky/sky_clouds.dart @@ -173,14 +173,14 @@ List placeClouds( // Loop-invariant pieces of the per-cloud math: the wind-scaled deck speed // and the depth-dependent size/opacity factors are hoisted out of the loop. final windSpeed = layout.speed * (1.0 + wind * 2.0); + // Wrap over two screen widths so a sprite is never popped into view. + const span = 2.4; for (var i = 0; i < layout.clouds.length && i < visible; i++) { final c = layout.clouds[i]; // Near clouds drift faster — the parallax the reference gets from its 3D layout. final speed = windSpeed * (1.6 - c.depth); - // Wrap over two screen widths so a sprite is never popped into view. - final span = 2.4; var x = (c.x + time * speed) % span; if (x < 0) x += span; x -= 0.7; // start off the left edge @@ -211,19 +211,21 @@ List placeClouds( /// 1 = zenith), `multi` is a gain and `intensity` scales the contribution. typedef LightConfig = (double picker, double multi, double intensity); -/// Cloud lighting for a given sun height, in the reference's five-group form. -/// -/// The engine authors these per keyframe; the ramps here follow the same -/// shape — as the sun drops, every probe slides toward the horizon, which is -/// what turns the clouds gold at dusk without any colour being named. -({ +/// The four light groups plus the warm-mix weight the cloud shader takes. +typedef CloudLighting = ({ LightConfig base, LightConfig sun, LightConfig ground, LightConfig ambient, double whitePer, -}) -cloudLighting({required double sunAngleY}) { +}); + +/// Cloud lighting for a given sun height, in the reference's five-group form. +/// +/// The engine authors these per keyframe; the ramps here follow the same +/// shape — as the sun drops, every probe slides toward the horizon, which is +/// what turns the clouds gold at dusk without any colour being named. +CloudLighting cloudLighting({required double sunAngleY}) { // 0 at night, 1 at midday. `sunAngleY` runs ~0.01 … 0.57. final day = ((sunAngleY - 0.02) / 0.42).clamp(0.0, 1.0); diff --git a/lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart b/lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart index f08ca1f02..442031f79 100644 --- a/lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart +++ b/lib/features/home/presentation/widgets/weather_sky/sky_lut_cache.dart @@ -73,10 +73,24 @@ class SkyLutCache { /// out of*, experimental overrides included. static final ValueNotifier panelAmbient = ValueNotifier(null); + /// Whether [panelAmbient] is light enough for dark ink — the same + /// [ThemeData.estimateBrightnessForColor] judgment `skyIsLightFrom` makes, + /// evaluated once per bake here rather than once per widget per frame (it + /// is three `pow` calls, and five widgets ask on every sheet-drag rebuild). + /// `null` until the first readback, exactly when [panelAmbient] is. + static final ValueNotifier panelAmbientIsLight = ValueNotifier(null); + /// Where the panel water samples the LUT: `vUv` ≈ (0.5, 0.7) — the card /// sits in the lower third of the screen. static const Offset _ambientSample = Offset(0.5, 0.7); + /// Bumped every time [_skyViewBytes] is replaced — i.e. every time a + /// readback lands. [skyAt] answers differently before and after that + /// moment for the *same* keyframe (null → real colours), so anything + /// caching a [skyAt]-derived value keys on this alongside the keyframe. + int _readbackGeneration = 0; + int get readbackGeneration => _readbackGeneration; + /// Re-bakes if [sky] differs from what is cached. Returns whether it did. bool update(ResolvedSky sky) { final previous = _baked; @@ -104,16 +118,23 @@ class SkyLutCache { .then((data) { if (data == null) return; _skyViewBytes = data.buffer.asUint8List(); + _readbackGeneration++; final bytes = _skyViewBytes!; final x = (_ambientSample.dx * (skyViewSize.width - 1)).round(); final y = (_ambientSample.dy * (skyViewSize.height - 1)).round(); final o = (y * skyViewSize.width.toInt() + x) * 4; - panelAmbient.value = Color.fromARGB( + final ambient = Color.fromARGB( 255, bytes[o], bytes[o + 1], bytes[o + 2], ); + // The brightness verdict is published *before* the colour so a + // listener on [panelAmbient] that reads [panelAmbientIsLight] in + // the same callback never sees the two disagree. + panelAmbientIsLight.value = + ThemeData.estimateBrightnessForColor(ambient) == Brightness.light; + panelAmbient.value = ambient; _bakeSkyColumn(sky); _bakeSkyGradient(sky); }) @@ -348,6 +369,8 @@ class SkyLutCache { _skyGradient = null; _skyColumn = null; _skyViewBytes = null; + // The bytes are gone, so a value derived from them is stale too. + _readbackGeneration++; _baked = null; } } diff --git a/lib/features/home/presentation/widgets/weather_sky/solar_time.dart b/lib/features/home/presentation/widgets/weather_sky/solar_time.dart index ea92ed74b..9c5f14e7b 100644 --- a/lib/features/home/presentation/widgets/weather_sky/solar_time.dart +++ b/lib/features/home/presentation/widgets/weather_sky/solar_time.dart @@ -87,6 +87,55 @@ SolarPosition solarPosition( double latitude = kTaiwanLatitude, double longitude = kTaiwanLongitude, double utcOffsetHours = 8, +}) { + // One-entry memo, keyed to the minute. Every caller feeds this "now" — + // `isNightAt(AppTime.utc)` from a header that rebuilds at frame rate while + // the sheet drags, the forecast strip's glyphs, the backdrop's day anchor — + // and the ephemeris below is ~15 trig calls that move the answer by well + // under a tenth of a second across a minute (the sun's declination shifts + // ~0.0003° in that time). The minute is the UTC one, so the key is a plain + // integer division with no `DateTime` built for it. + final minute = utc.millisecondsSinceEpoch ~/ Duration.millisecondsPerMinute; + final cached = _sunTimesMemo; + if (cached != null && + cached.minute == minute && + cached.latitude == latitude && + cached.longitude == longitude && + cached.utcOffsetHours == utcOffsetHours) { + return cached.times; + } + final times = _sunTimes( + utc, + latitude: latitude, + longitude: longitude, + utcOffsetHours: utcOffsetHours, + ); + _sunTimesMemo = ( + minute: minute, + latitude: latitude, + longitude: longitude, + utcOffsetHours: utcOffsetHours, + times: times, + ); + return times; +} + +/// [sunTimes]'s memo — see the note at the top of that function. +({ + int minute, + double latitude, + double longitude, + double utcOffsetHours, + ({double sunrise, double sunset}) times, +})? +_sunTimesMemo; + +/// The ephemeris behind [sunTimes], unmemoised. +({double sunrise, double sunset}) _sunTimes( + DateTime utc, { + required double latitude, + required double longitude, + required double utcOffsetHours, }) { final n = _julianDays(utc); diff --git a/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart b/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart index 1333690da..2acba1dd5 100644 --- a/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_background.dart @@ -438,7 +438,6 @@ class _WeatherSkyBackgroundState extends State SkyFrame _buildFrame() { final utc = AppTime.utc; - final local = AppTime.utc8; final time = _clock.elapsedMilliseconds / 1000.0; final look = _lookFor(widget.mode); @@ -450,7 +449,7 @@ class _WeatherSkyBackgroundState extends State final rain = widget.rainIntensity ?? look.rain; final snow = widget.snowIntensity ?? look.snow; - _syncSky(utc, local, frames); + _syncSky(utc, frames); return SkyFrame( time: time, @@ -476,8 +475,9 @@ class _WeatherSkyBackgroundState extends State /// that genuinely move every frame ([_lightningAt], particle time): before /// this, the ephemeris ran (and the LUT re-baked, see [_minuteKey]) on every /// tick. - void _syncSky(DateTime utc, DateTime local, List frames) { - final dayKey = utc.millisecondsSinceEpoch ~/ 86400000; + void _syncSky(DateTime utc, List frames) { + final utcMs = utc.millisecondsSinceEpoch; + final dayKey = utcMs ~/ 86400000; if (dayKey != _dayKey) { _dayKey = dayKey; // Anchor the ring to the day's real sunrise and sunset, so dawn keyframes @@ -489,9 +489,14 @@ class _WeatherSkyBackgroundState extends State ); _moon = _moonPhase(utc); } - final minuteKey = local.millisecondsSinceEpoch ~/ 60000; + // The local minute, from the UTC instant's epoch arithmetic rather than a + // second `DateTime` per vsync: `AppTime.utc8` is exactly `utc + 8 h`, so + // its epoch milliseconds are `utcMs + 28 800 000` and the wall-clock + // `DateTime` is only needed — and only built — once a minute, below. + final minuteKey = (utcMs + 8 * Duration.millisecondsPerHour) ~/ 60000; if (minuteKey != _minuteKey) { _minuteKey = minuteKey; + final local = AppTime.taipei(utc); final hour = skyTimeHour(widget.timeMode) ?? (local.hour + local.minute / 60.0); _position = keyframePosition( @@ -529,10 +534,14 @@ typedef _Look = ({ /// The keyframe tables cover all 14 of the engine's weather types; the five /// user-facing modes pick from them, so adding a mode later is a table entry /// rather than new artwork. +/// +/// Every arm is a `const` record: `_buildFrame` asks once per vsync, and a +/// non-const record literal here would be a fresh allocation every frame for +/// a value that never changes. _Look _lookFor(WeatherMode mode) => switch (mode) { // Until a live feed drives it, `auto` is Taiwan's typical humid, lightly // clouded sky. - WeatherMode.auto => ( + WeatherMode.auto => const ( keyframes: cloudyKeyframes, layout: CloudLayout.scattered, coverage: 0.55, @@ -543,7 +552,7 @@ _Look _lookFor(WeatherMode mode) => switch (mode) { wind: 0.25, lightning: false, ), - WeatherMode.clear => ( + WeatherMode.clear => const ( keyframes: sunnyKeyframes, layout: CloudLayout.fair, coverage: 0.35, @@ -554,7 +563,7 @@ _Look _lookFor(WeatherMode mode) => switch (mode) { wind: 0.18, lightning: false, ), - WeatherMode.cloudy => ( + WeatherMode.cloudy => const ( keyframes: cloudyKeyframes, layout: CloudLayout.scattered, coverage: 0.85, @@ -565,7 +574,7 @@ _Look _lookFor(WeatherMode mode) => switch (mode) { wind: 0.30, lightning: false, ), - WeatherMode.overcast => ( + WeatherMode.overcast => const ( keyframes: overcastKeyframes, layout: CloudLayout.overcast, coverage: 1.0, @@ -576,7 +585,7 @@ _Look _lookFor(WeatherMode mode) => switch (mode) { wind: 0.20, lightning: false, ), - WeatherMode.snow => ( + WeatherMode.snow => const ( keyframes: snowyMediumKeyframes, layout: CloudLayout.overcast, coverage: 0.95, @@ -589,7 +598,7 @@ _Look _lookFor(WeatherMode mode) => switch (mode) { ), // No sand shader yet — the sandy keyframes plus a heavy dust-coloured haze // stand in, so the mode is at least testable. - WeatherMode.sand => ( + WeatherMode.sand => const ( keyframes: sandyHeavyKeyframes, layout: CloudLayout.overcast, coverage: 0.65, @@ -600,7 +609,7 @@ _Look _lookFor(WeatherMode mode) => switch (mode) { wind: 0.55, lightning: false, ), - WeatherMode.rain => ( + WeatherMode.rain => const ( keyframes: rainyMediumKeyframes, layout: CloudLayout.rain, coverage: 1.0, @@ -616,7 +625,7 @@ _Look _lookFor(WeatherMode mode) => switch (mode) { wind: 0.40, lightning: false, ), - WeatherMode.fog => ( + WeatherMode.fog => const ( keyframes: foggyKeyframes, layout: CloudLayout.overcast, coverage: 0.7, @@ -627,7 +636,7 @@ _Look _lookFor(WeatherMode mode) => switch (mode) { wind: 0.10, lightning: false, ), - WeatherMode.thunderstorm => ( + WeatherMode.thunderstorm => const ( keyframes: rainyExtremeKeyframes, layout: CloudLayout.rain, coverage: 1.0, diff --git a/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart b/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart index 0e6b8ef29..1480b315d 100644 --- a/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart +++ b/lib/features/home/presentation/widgets/weather_sky/weather_sky_painter.dart @@ -174,9 +174,10 @@ class WeatherSkyPainter extends CustomPainter { _paintRainbow(canvas, size); } + // A frame is built fresh per tick, so identity alone already implies "a new + // tick" — the old `time` comparison was subsumed by it. @override - bool shouldRepaint(WeatherSkyPainter old) => - old.frame.time != frame.time || !identical(old.frame, frame); + bool shouldRepaint(WeatherSkyPainter old) => !identical(old.frame, frame); void _fill( Canvas canvas, @@ -287,7 +288,10 @@ class WeatherSkyPainter extends CustomPainter { if (shader == null || skyColumn == null || cloudSprites.isEmpty) return; if (frame.cloudCoverage < 0.02) return; - final lighting = cloudLighting(sunAngleY: frame.sky.sunAngleY); + final probes = _cloudProbes(); + final lighting = probes.lighting; + final baseSky = probes.baseSky; + final hazeSky = probes.hazeSky; final placed = placeClouds( frame.cloudLayout, width: size.width, @@ -304,21 +308,6 @@ class WeatherSkyPainter extends CustomPainter { final sunDirY = 0.25 + 0.70 * day; const sunDirZ = 0.45; - // The base and haze sky probes are pixel-independent, so their colours are - // read once per frame from the CPU LUT readback rather than fetched on - // every cloud pixel (see clouds.frag's iBaseSky / iHazeSky). The picker - // mapping mirrors the shader's `skyAt` exactly, and the base picker rides - // the same `cloudDepth` lerp the shader applies. - final lutU = frame.sky.sunAngleY.clamp(0.0, 1.0); - final fallback = SkyLutCache.panelAmbient.value ?? const Color(0xFF5C6B7E); - final baseSky = - lutCache.skyAt( - lutU, - _skyAtV(0.05 + (lighting.base.$1 - 0.05) * 0.85), - ) ?? - fallback; - final hazeSky = lutCache.skyAt(lutU, _skyAtV(0.95)) ?? fallback; - // Only five of the forty-three uniform slots differ between instances — // the sprite's size, its opacity, and the texture's own size. The rest // describe the frame, so they are written once here instead of forty @@ -398,6 +387,61 @@ class WeatherSkyPainter extends CustomPainter { } } + /// The cloud deck's keyframe-derived inputs: the five-group lighting and + /// the base/haze sky probes. + /// + /// The base and haze probes are pixel-independent, so their colours are + /// read from the CPU LUT readback rather than fetched on every cloud pixel + /// (see clouds.frag's iBaseSky / iHazeSky). The picker mapping mirrors the + /// shader's `skyAt` exactly, and the base picker rides the same + /// `cloudDepth` lerp the shader applies. + /// + /// All of it is a function of `frame.sky` — which `_syncSky` resolves once + /// a minute and hands to every frame in between by identity — plus what the + /// LUT cache has read back so far. Memoised on exactly those: the keyframe's + /// identity, the cache instance and its readback generation (before the + /// first readback `skyAt` is null and the fallback shows; the same keyframe + /// must re-resolve once it lands), and the fallback colour itself. Keying + /// on `sunAngleY` alone would be wrong the other way: two keyframes can + /// share a sun angle and still not share a bake. + ({CloudLighting lighting, Color baseSky, Color hazeSky}) _cloudProbes() { + final sky = frame.sky; + final generation = lutCache.readbackGeneration; + final fallback = SkyLutCache.panelAmbient.value ?? const Color(0xFF5C6B7E); + final cached = _probes; + if (cached != null && + identical(sky, _probeSky) && + identical(lutCache, _probeCache) && + generation == _probeGeneration && + fallback == _probeFallback) { + return cached; + } + + final lighting = cloudLighting(sunAngleY: sky.sunAngleY); + final lutU = sky.sunAngleY.clamp(0.0, 1.0); + final baseSky = + lutCache.skyAt( + lutU, + _skyAtV(0.05 + (lighting.base.$1 - 0.05) * 0.85), + ) ?? + fallback; + final hazeSky = lutCache.skyAt(lutU, _skyAtV(0.95)) ?? fallback; + + _probeSky = sky; + _probeCache = lutCache; + _probeGeneration = generation; + _probeFallback = fallback; + return _probes = (lighting: lighting, baseSky: baseSky, hazeSky: hazeSky); + } + + /// [_cloudProbes]'s memo. Static because the painter is rebuilt every + /// frame; one entry suffices — only the home sky draws clouds. + static ResolvedSky? _probeSky; + static SkyLutCache? _probeCache; + static int _probeGeneration = -1; + static Color? _probeFallback; + static ({CloudLighting lighting, Color baseSky, Color hazeSky})? _probes; + /// The cloud shader's `skyAt` picker→LUT-v mapping, mirrored on the CPU for /// the pre-baked base/haze probes — the shader's `elevation = picker·(π/2)` /// cancels to `sqrt(picker)` (see clouds.frag). @@ -566,31 +610,6 @@ class WeatherSkyPainter extends CustomPainter { final sunY = 0.5 - arcY; final golden = frame.goldenAmount; - var i = 0; - void set(double v) => shader.setFloat(i++, v); - set(size.width); - set(size.height); - set(sunX); - set(sunY); - set(1.0); - set(0.80 - 0.14 * golden); - set(0.45 - 0.22 * golden); // ray tint - set(frame.time); - set(intensity); - // The disc survives cloud better than the rays do — it is far brighter. - set((1.0 - 0.55 * frame.cloudCoverage).clamp(0.0, 1.0)); - set((0.35 + 0.5 * golden) * (1.0 - 0.7 * frame.cloudCoverage)); - // `uAnnulusAlpha` is 0.13 while the sun is up. - set(0.13 * (1.0 - frame.cloudCoverage)); - // `uCircleAlpha` 1.03, and `uCircleOffset` -0.5. - set(1.03 * (1.0 - 0.8 * frame.cloudCoverage)); - set(-0.5); - set((frame.time / 9.0).floorToDouble()); - set((0.7 + 0.3 * golden) * (1.0 - 0.5 * frame.cloudCoverage)); - shader.setImageSampler(0, sunTextures[0]); - shader.setImageSampler(1, sunTextures[1]); - shader.setImageSampler(2, sunTextures[2]); - // The reference renders the sun into a **quarter-resolution** buffer // (`new C1502b(ctx, w / 4, h / 4, true)`) and blits it back up with // The reference shader, which is just `texture(uTex, vUv) * uOpacity`. All of the @@ -603,9 +622,6 @@ class WeatherSkyPainter extends CustomPainter { (size.width / 4).ceilToDouble().clamp(1, double.infinity), (size.height / 4).ceilToDouble().clamp(1, double.infinity), ); - // The shader's own resolution uniform must match the buffer it draws into. - shader.setFloat(0, quarter.width); - shader.setFloat(1, quarter.height); // The bake is a synchronous GPU rasterisation on the UI thread, and the // sun's motion is slow (a keyframed arc + ~2 rad/s rays) — re-bake only @@ -623,6 +639,38 @@ class WeatherSkyPainter extends CustomPainter { frame.cloudCoverage, ); if (_sunFlare == null || bakeKey != _sunFlareKey) { + // The uniforms are written only on the frames that bake. Nothing reads + // them outside this branch — the blit below samples the baked image, + // not the shader — so writing all nineteen slots on every frame (as an + // earlier version did) was 19 engine calls to feed a draw that then did + // not happen. + var i = 0; + void set(double v) => shader.setFloat(i++, v); + // The shader's own resolution must match the buffer it draws into — + // the quarter buffer, not the screen. + set(quarter.width); + set(quarter.height); + set(sunX); + set(sunY); + set(1.0); + set(0.80 - 0.14 * golden); + set(0.45 - 0.22 * golden); // ray tint + set(frame.time); + set(intensity); + // The disc survives cloud better than the rays do — it is far brighter. + set((1.0 - 0.55 * frame.cloudCoverage).clamp(0.0, 1.0)); + set((0.35 + 0.5 * golden) * (1.0 - 0.7 * frame.cloudCoverage)); + // `uAnnulusAlpha` is 0.13 while the sun is up. + set(0.13 * (1.0 - frame.cloudCoverage)); + // `uCircleAlpha` 1.03, and `uCircleOffset` -0.5. + set(1.03 * (1.0 - 0.8 * frame.cloudCoverage)); + set(-0.5); + set((frame.time / 9.0).floorToDouble()); + set((0.7 + 0.3 * golden) * (1.0 - 0.5 * frame.cloudCoverage)); + shader.setImageSampler(0, sunTextures[0]); + shader.setImageSampler(1, sunTextures[1]); + shader.setImageSampler(2, sunTextures[2]); + _sunFlareKey = bakeKey; _sunFlare?.dispose(); final recorder = ui.PictureRecorder(); diff --git a/test/features/home/weather_sky/rain_on_glass_test.dart b/test/features/home/weather_sky/rain_on_glass_test.dart index 293bf1c89..7d2a946af 100644 --- a/test/features/home/weather_sky/rain_on_glass_test.dart +++ b/test/features/home/weather_sky/rain_on_glass_test.dart @@ -77,4 +77,25 @@ void main() { expect(tester.binding.transientCallbackCount, 0); }); + + testWidgets('a card faded to zero opacity schedules no frames', ( + tester, + ) async { + // `build` never constructs the filter below opacity 0.004, so the Skia + // capability check above never fires here — the only thing that can stop + // the ticker is the opacity gate itself. Before it existed, this widget + // spun a vsync loop forever on an invisible card. + await tester.pumpWidget( + const MaterialApp( + home: RainOnGlass(intensity: 0.8, opacity: 0, child: SizedBox.expand()), + ), + ); + // A bounded pump: with the bug the ticker repeats forever and + // `pumpAndSettle` would never return. + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + + expect(tester.binding.transientCallbackCount, 0); + }); } From c8741415a6ab0636a1acc2e2f46af305d2dcf21b Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 11:38:57 +0800 Subject: [PATCH 10/32] perf(home): rebuild less of the sheet while it moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 拖動或捲動首頁面板時不再重建整條逐時預報、天空背景與底部導覽列 Optimization(en-US): dragging or scrolling the home sheet no longer rebuilds the whole hourly forecast strip, the sky and the navigation bar --- lib/app/shell/main_shell.dart | 33 +++- lib/core/settings/region_store.dart | 21 +- .../widgets/home_forecast_section.dart | 187 +++++++++++++----- .../home/presentation/widgets/home_sheet.dart | 85 ++++++-- .../widgets/home_sheet_header.dart | 31 ++- 5 files changed, 282 insertions(+), 75 deletions(-) diff --git a/lib/app/shell/main_shell.dart b/lib/app/shell/main_shell.dart index c2137b1a5..45a36b973 100644 --- a/lib/app/shell/main_shell.dart +++ b/lib/app/shell/main_shell.dart @@ -59,10 +59,30 @@ class _MainShellState extends State with RouteAware { /// [shellRouteObserver]. Held so the subscription can be dropped again. ModalRoute? _shellRoute; + /// The bottom bar's dismissal, derived from [HomeSheetExtent] — not the raw + /// extent. The sheet publishes every scroll tick; the bar only moves while + /// Home is the visible branch *and* the extent crosses [HomeChrome.navDismiss]'s + /// ramp, so listening to the extent directly rebuilt the bar on every tick + /// from every tab for a value that was 0 the whole time. This is assigned + /// only when the derived value changes (see [_syncNavDismiss]). + final ValueNotifier _navDismiss = ValueNotifier(0); + late final HomeSheetExtent _sheetExtent; + @override void initState() { super.initState(); _trace(() => 'init current=${widget.navigationShell.currentIndex}'); + _sheetExtent = context.read() + ..addListener(_syncNavDismiss); + } + + /// Only Home dismisses the bar; every other tab keeps it (dismiss 0). + void _syncNavDismiss() { + final dismiss = widget.navigationShell.currentIndex == 0 + ? HomeChrome.navDismiss(_sheetExtent.value) + : 0.0; + if (dismiss == _navDismiss.value) return; + _navDismiss.value = dismiss; } @override @@ -104,6 +124,8 @@ class _MainShellState extends State with RouteAware { void dispose() { _trace(() => 'dispose'); if (_shellRoute != null) shellRouteObserver.unsubscribe(this); + _sheetExtent.removeListener(_syncNavDismiss); + _navDismiss.dispose(); _visibleTab.dispose(); super.dispose(); } @@ -149,6 +171,11 @@ class _MainShellState extends State with RouteAware { }); } _lastIndex = index; + // The branch is an input to the bar's dismissal too — pin it to 0 the + // moment another branch is on screen, regardless of where Home's sheet + // was left. Safe mid-build: the only listener is the builder below, a + // descendant that this build re-creates anyway. + _syncNavDismiss(); // Publish after the frame: pages listening to this rebuild on the edge, and // a notify during build would land mid-build for them. if (_visibleTab.value != index) { @@ -216,11 +243,9 @@ class _MainShellState extends State with RouteAware { ), ], ), - // Only Home dismisses the bar; every other tab keeps it (dismiss 0). bottomNavigationBar: ValueListenableBuilder( - valueListenable: context.read(), - builder: (context, extent, child) { - final dismiss = index == 0 ? HomeChrome.navDismiss(extent) : 0.0; + valueListenable: _navDismiss, + builder: (context, dismiss, child) { // Slide the bar down by its own height and fade it out; stop it // catching taps once it is mostly gone so the sheet behind gets them. return IgnorePointer( diff --git a/lib/core/settings/region_store.dart b/lib/core/settings/region_store.dart index f2445755b..469370754 100644 --- a/lib/core/settings/region_store.dart +++ b/lib/core/settings/region_store.dart @@ -36,11 +36,22 @@ class RegionStore extends ChangeNotifier { String? get currentCode => _currentCode; /// The ordered areas: 全國, 所在地, then each saved township. - List get areas => [ + /// + /// Built once per (current code, saved list) and handed out as the same + /// unmodifiable instance until a mutator changes one of those. [selected], + /// [selectedIndex], [selectedCode] and [count] all go through here, and a + /// header rebuilding per scroll tick reads several of them per build — a + /// fresh list of fresh [HomeArea]s each time also meant `select`-style + /// listeners could never see an unchanged value, since [HomeArea] compares + /// by identity. + List get areas => _areas ??= List.unmodifiable([ const NationwideArea(), CurrentArea(_currentCode), for (final code in _saved) SavedArea(code), - ]; + ]); + + /// The memoised [areas]; null after any change to what it is built from. + List? _areas; /// Number of areas (drop-in for the region bar/pager). int get count => areas.length; @@ -73,6 +84,7 @@ class RegionStore extends ChangeNotifier { void setCurrentCode(String? code) { if (code == _currentCode) return; _currentCode = code; + _areas = null; notifyListeners(); } @@ -84,6 +96,7 @@ class RegionStore extends ChangeNotifier { bool addSaved(String code) { if (!canSave(code)) return false; _saved = [..._saved, code]; + _areas = null; _persist(); notifyListeners(); return true; @@ -102,6 +115,8 @@ class RegionStore extends ChangeNotifier { for (final c in _saved) if (c != code) c, ]; + // Before the clamp below: [count] reads [areas]. + _areas = null; _persist(); if (removedIndex < _selectedIndex) _selectedIndex -= 1; _selectedIndex = _selectedIndex.clamp(0, count - 1); @@ -119,6 +134,7 @@ class RegionStore extends ChangeNotifier { final list = [..._saved]; list[position] = newCode; _saved = list; + _areas = null; _persist(); notifyListeners(); return true; @@ -141,6 +157,7 @@ class RegionStore extends ChangeNotifier { final list = [..._saved]; list.insert(target, list.removeAt(oldIndex)); _saved = list; + _areas = null; _persist(); // Saved areas start at index 2 (after 全國, 所在地); keep the same one active. if (selectedCode != null) { diff --git a/lib/features/home/presentation/widgets/home_forecast_section.dart b/lib/features/home/presentation/widgets/home_forecast_section.dart index 4328ebbf4..d90631d5f 100644 --- a/lib/features/home/presentation/widgets/home_forecast_section.dart +++ b/lib/features/home/presentation/widgets/home_forecast_section.dart @@ -83,6 +83,14 @@ class _HomeForecastSectionState extends State { int _sunMinute = -1; ({double sunrise, double sunset})? _sunlight; + /// The hour-chip strip last handed to the tree, with the inputs it was + /// built from (see [_chipStrip]). + _HourChipStrip? _strip; + + /// A method, not a closure built in `build`, so the strip's callback is + /// the same tear-off every time and never a reason to rebuild it. + void _select(int index) => setState(() => _selected = index); + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -242,51 +250,13 @@ class _HomeForecastSectionState extends State { ), ), SizedBox(height: AppSpacing.md * expansion), - // The strip is exactly as tall as the tallest chip wants to be, not - // a fixed height the chips are expected to fit inside. Every line in - // a chip grows with the text-size setting while the icon does not, - // so no constant is right at every step: 108 fit until 特大, where - // the chips ran 16 px over it and the rain chance was cut in half. - // The intrinsic pass costs one extra layout of a row of ~24 chips of - // three short strings each. - IntrinsicHeight( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: AppSpacing.sm, - children: [ - for (final (index, p) in points.indexed) - Builder( - builder: (context) { - final hour = _hourNumber(p.time); - final (icon, accent) = weatherVisual( - p.weather, - p.weatherCode, - colors, - // Per hour, not per row: a clear 02:00 chip must show - // a moon while the 14:00 chip beside it shows a sun. - isNight: - hour < sunlight.sunrise || - hour >= sunlight.sunset, - ); - return _HourChip( - time: l10n.chartHourLabel(hour), - icon: icon, - iconColor: accent ?? secondary, - temp: '${p.temperature.round()}°', - pop: l10n.homeForecastPop(p.pop.toString()), - selected: index == selected, - foreground: foreground, - secondary: secondary, - selectedFill: colors.primary.withValues(alpha: 0.16), - onTap: () => setState(() => _selected = index), - ); - }, - ), - ], - ), - ), + _chipStrip( + forecast: forecast, + selected: selected, + colors: colors, + foreground: foreground, + secondary: secondary, + sunlight: sunlight, ), // Snapped open, not scroll-linked like the sparkline above: this band // is text, and a fraction of a line of text is a line cut in half. @@ -323,11 +293,45 @@ class _HomeForecastSectionState extends State { ); } - /// `"14:00"` → `14` for [AppLocalizations.chartHourLabel] (`14時`). - static int _hourNumber(String time) { - final colon = time.indexOf(':'); - final raw = colon <= 0 ? time : time.substring(0, colon); - return int.tryParse(raw) ?? 0; + /// The hour-chip strip — the *same* widget instance as last time whenever + /// nothing it reads has changed. + /// + /// This section rebuilds on every scroll tick while [HomeForecastSection.expansion] + /// animates, and nothing in the strip depends on the expansion. Handing the + /// tree an identical instance lets `Element.updateChild` short-circuit the + /// whole subtree — the 24 chips, and the [IntrinsicHeight] pass over them + /// — the same trick the rain-trend chart uses. Keyed on the forecast's + /// identity rather than its point list: the freezed getter wraps the list + /// anew on every read, so the list is never the same object twice. Theme + /// and locale are read by the strip from its own context, so those still + /// reach it through the inherited-widget path. + _HourChipStrip _chipStrip({ + required WeatherForecast forecast, + required int selected, + required ColorScheme colors, + required Color foreground, + required Color secondary, + required ({double sunrise, double sunset}) sunlight, + }) { + final cached = _strip; + if (cached != null && + identical(cached.forecast, forecast) && + cached.selected == selected && + cached.colors == colors && + cached.foreground == foreground && + cached.secondary == secondary && + cached.sunlight == sunlight) { + return cached; + } + return _strip = _HourChipStrip( + forecast: forecast, + selected: selected, + colors: colors, + foreground: foreground, + secondary: secondary, + sunlight: sunlight, + onSelect: _select, + ); } _ForecastTemperatureSeries _temperatureSeries(WeatherForecast forecast) { @@ -364,6 +368,89 @@ class _HomeForecastSectionState extends State { } } +/// The horizontally scrolling row of hour chips under the sparkline. +/// +/// Hoisted out of [HomeForecastSection]'s build so the section can hand the +/// tree one instance per set of inputs (see `_chipStrip`); every field here +/// is one of those inputs. +class _HourChipStrip extends StatelessWidget { + const _HourChipStrip({ + required this.forecast, + required this.selected, + required this.colors, + required this.foreground, + required this.secondary, + required this.sunlight, + required this.onSelect, + }); + + final WeatherForecast forecast; + final int selected; + final ColorScheme colors; + final Color foreground; + final Color secondary; + final ({double sunrise, double sunset}) sunlight; + final ValueChanged onSelect; + + /// `"14:00"` → `14` for [AppLocalizations.chartHourLabel] (`14時`). + static int _hourNumber(String time) { + final colon = time.indexOf(':'); + final raw = colon <= 0 ? time : time.substring(0, colon); + return int.tryParse(raw) ?? 0; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final points = forecast.forecast; + final selectedFill = colors.primary.withValues(alpha: 0.16); + // The strip is exactly as tall as the tallest chip wants to be, not + // a fixed height the chips are expected to fit inside. Every line in + // a chip grows with the text-size setting while the icon does not, + // so no constant is right at every step: 108 fit until 特大, where + // the chips ran 16 px over it and the rain chance was cut in half. + // The intrinsic pass costs one extra layout of a row of ~24 chips of + // three short strings each. + return IntrinsicHeight( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: AppSpacing.sm, + children: [ + for (final (index, p) in points.indexed) + Builder( + builder: (context) { + final hour = _hourNumber(p.time); + final (icon, accent) = weatherVisual( + p.weather, + p.weatherCode, + colors, + // Per hour, not per row: a clear 02:00 chip must show + // a moon while the 14:00 chip beside it shows a sun. + isNight: hour < sunlight.sunrise || hour >= sunlight.sunset, + ); + return _HourChip( + time: l10n.chartHourLabel(hour), + icon: icon, + iconColor: accent ?? secondary, + temp: '${p.temperature.round()}°', + pop: l10n.homeForecastPop(p.pop.toString()), + selected: index == selected, + foreground: foreground, + secondary: secondary, + selectedFill: selectedFill, + onTap: () => onSelect(index), + ); + }, + ), + ], + ), + ), + ); + } +} + class _ForecastTemperatureSeries { const _ForecastTemperatureSeries(this.temps, this.min, this.max); diff --git a/lib/features/home/presentation/widgets/home_sheet.dart b/lib/features/home/presentation/widgets/home_sheet.dart index f290f9791..3c36cd492 100644 --- a/lib/features/home/presentation/widgets/home_sheet.dart +++ b/lib/features/home/presentation/widgets/home_sheet.dart @@ -171,21 +171,16 @@ class HomeSheet extends StatelessWidget { // stop together (see `_syncRunning`), so when the list // returns to the top the animation resumes where it left // off — no jump. - child: ListenableBuilder( - listenable: scrollController, - builder: (context, _) { - final scrolled = - scrollController.hasClients && - scrollController.offset > 0; - return WeatherSkyBackground( - mode: weatherMode, - rainIntensity: rainIntensity, - snowIntensity: snowIntensity, - humidity: humidity ?? 0.65, - timeMode: skyTimeMode, - active: HomeChrome.weatherActive(e) && !scrolled, - ); - }, + child: _ScrolledGate( + scrollController: scrollController, + builder: (context, scrolled) => WeatherSkyBackground( + mode: weatherMode, + rainIntensity: rainIntensity, + snowIntensity: snowIntensity, + humidity: humidity ?? 0.65, + timeMode: skyTimeMode, + active: HomeChrome.weatherActive(e) && !scrolled, + ), ), ), ), @@ -205,6 +200,66 @@ class HomeSheet extends StatelessWidget { ((e - _flushFrom) / (maxExtent - _flushFrom)).clamp(0.0, 1.0); } +/// Rebuilds [builder] only when the list crosses between "at the top" and +/// "scrolled" — not on every scroll pixel. +/// +/// The sky under the sheet used to sit inside a `ListenableBuilder` on the +/// scroll controller, so every pixel of a list scroll rebuilt +/// `WeatherSkyBackground` (its element, its `LayoutBuilder`, its +/// `AnimatedBuilder`) to recompute a boolean that flips exactly once per +/// gesture. The controller notifies here too, but the notifier in between +/// only fires on the edge, so the sky's subtree sees two rebuilds per gesture +/// instead of hundreds. +class _ScrolledGate extends StatefulWidget { + const _ScrolledGate({required this.scrollController, required this.builder}); + + final ScrollController scrollController; + final Widget Function(BuildContext context, bool scrolled) builder; + + @override + State<_ScrolledGate> createState() => _ScrolledGateState(); +} + +class _ScrolledGateState extends State<_ScrolledGate> { + final ValueNotifier _scrolled = ValueNotifier(false); + + bool get _isScrolled => + widget.scrollController.hasClients && widget.scrollController.offset > 0; + + @override + void initState() { + super.initState(); + widget.scrollController.addListener(_onScroll); + _scrolled.value = _isScrolled; + } + + @override + void didUpdateWidget(_ScrolledGate oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.scrollController, widget.scrollController)) { + oldWidget.scrollController.removeListener(_onScroll); + widget.scrollController.addListener(_onScroll); + _onScroll(); + } + } + + // ValueNotifier only notifies on a real change, so this is the edge filter. + void _onScroll() => _scrolled.value = _isScrolled; + + @override + void dispose() { + widget.scrollController.removeListener(_onScroll); + _scrolled.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: _scrolled, + builder: (context, scrolled, _) => widget.builder(context, scrolled), + ); +} + /// A [BackdropFilter] that reuses its [ImageFilter] instance while [sigma] /// stays on the same quantised step. /// diff --git a/lib/features/home/presentation/widgets/home_sheet_header.dart b/lib/features/home/presentation/widgets/home_sheet_header.dart index b89636842..af932fa39 100644 --- a/lib/features/home/presentation/widgets/home_sheet_header.dart +++ b/lib/features/home/presentation/widgets/home_sheet_header.dart @@ -39,7 +39,7 @@ import 'package:provider/provider.dart'; /// fetch is in flight. Ink tracks the weather sky via [inkOverWeather] — dark /// theme [ColorScheme.onSurface] is white and vanishes on a clear daylight /// backdrop without that shift. -class HomeSheetHeader extends StatelessWidget { +class HomeSheetHeader extends StatefulWidget { const HomeSheetHeader({ super.key, this.reveal = 0, @@ -67,12 +67,35 @@ class HomeSheetHeader extends StatelessWidget { static final DateFormat _clockFormat = DateFormat('HH:mm'); + @override + State createState() => _HomeSheetHeaderState(); +} + +class _HomeSheetHeaderState extends State { + /// The UTC minute [_night] was computed for, or -1 before the first build. + int _nightMinute = -1; + bool _night = false; + + /// Whether the condition glyph takes its night form, cached to the minute. + /// The sheet rebuilds this header on every scroll tick, and [isNightAt] is + /// a full solar ephemeris — sunrise moves by about a minute a day, so the + /// answer cannot change within one, and the day/night flip lands on the + /// same minute boundary it would have anyway. + bool _isNight(DateTime utc) { + final minute = utc.millisecondsSinceEpoch ~/ Duration.millisecondsPerMinute; + if (minute == _nightMinute) return _night; + _nightMinute = minute; + return _night = isNightAt(utc); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; - final skyIsLight = skyIsLightFrom(sky, weatherMode); + final reveal = widget.reveal; + final expanded = widget.expanded; + final skyIsLight = skyIsLightFrom(widget.sky, widget.weatherMode); // Directly on the weather sky — not inside a glass card. Dark theme's // onSurface is white; on clear/fog daylight that must go dark with reveal. final foreground = inkOverWeather(colors, reveal, skyIsLight: skyIsLight); @@ -122,7 +145,7 @@ class HomeSheetHeader extends StatelessWidget { data.weather, data.weatherCode, colors, - isNight: isNightAt(AppTime.utc), + isNight: _isNight(AppTime.utc), ); final conditionIcon = weather?.$1 ?? cloudy; // Not `weather?.$2` — that accent is a fixed [ColorScheme] role (amber for @@ -179,7 +202,7 @@ class HomeSheetHeader extends StatelessWidget { Text( l10n.weatherDataTime( current.station.name, - _clockFormat.format( + HomeSheetHeader._clockFormat.format( AppTime.taipei( DateTime.fromMillisecondsSinceEpoch( current.time * 1000, From a5de22a49b07d193583ee5e1e72e50889bcf41f8 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 11:39:00 +0800 Subject: [PATCH 11/32] perf(eew): stop re-sending unchanged replay boxes and stacking waves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 地震回放不再每秒重送相同的偵測格網,波前更新也不再互相堆疊 Optimization(en-US): the earthquake replay no longer re-sends an unchanged detection grid or piles up overlapping wavefront updates --- .../pages/report_replay_page.dart | 146 ++++++++++++++---- 1 file changed, 113 insertions(+), 33 deletions(-) diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 5f6492e16..c614f91a8 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -103,6 +103,18 @@ class _ReportReplayPageState extends State { /// on a real transition). The wave-front rings redraw on their own, faster /// cadence instead — see `_ReplayMapState._wavefrontTicker`. final ValueNotifier _tick = ValueNotifier(0); + + /// The replay clock's whole second, for the status bar — it shows `HH:mm:ss` + /// and nothing finer, so rebuilding it on every 5 Hz [_tick] redrew the same + /// digits four times out of five. Assigned only when the second changes. + final ValueNotifier _clockSecond = ValueNotifier(0); + + void _syncClockSecond() { + final second = _session.clock.now().millisecondsSinceEpoch ~/ 1000; + if (second == _clockSecond.value) return; + _clockSecond.value = second; + } + Timer? _ticker; /// Which active alert the single EEW card currently shows — tapping the card @@ -179,10 +191,10 @@ class _ReportReplayPageState extends State { } void _startTicker() { - _ticker ??= Timer.periodic( - const Duration(milliseconds: 200), - (_) => _tick.value++, - ); + _ticker ??= Timer.periodic(const Duration(milliseconds: 200), (_) { + _tick.value++; + _syncClockSecond(); + }); } @override @@ -256,6 +268,7 @@ class _ReportReplayPageState extends State { _session.eew.removeListener(_syncAnnouncement); _speechSettings?.removeListener(_syncAnnouncement); _announcement?.dispose(); + _clockSecond.dispose(); _session.dispose(); super.dispose(); } @@ -371,7 +384,7 @@ class _ReportReplayPageState extends State { const SizedBox(height: AppSpacing.sm), _ReplayStatusBar( clock: _session.clock, - tick: _tick, + second: _clockSecond, rts: _session.rts, eew: _session.eew, ), @@ -760,6 +773,9 @@ class _ReplayMapState extends State<_ReplayMap> { final controller = _controller; if (controller == null) return; _styleLoaded = true; + // A style (re)load recreates every source below empty — whatever the box + // source held before is gone, so the next [_updateBox] must not skip. + _boxSignature = null; try { final data = await IntensityIconRenderer.render('cross'); await controller.addImage(_crossIcon, data); @@ -1016,15 +1032,32 @@ class _ReplayMapState extends State<_ReplayMap> { final grid = _boxGrid; if (controller == null || !_ready || grid == null) return; final hasBox = widget.rts.box.isNotEmpty; + if (!hasBox) return; + final (:geoJson, :signature) = _boxGeoJson(grid); + // This runs at the page's 5 Hz tick as well as on every poll, and the + // feature set only changes when the feed does or the S-wave sweeps + // past a box — a handful of times per event. The same set was being + // re-serialised and re-uploaded a few times a second in between. + if (signature == _boxSignature) return; + // Claimed before the await, not after: two in-flight writes land on the + // platform channel in call order, so the later call's set is the one the + // source ends up holding — and it must be the one recorded here. + _boxSignature = signature; try { - if (hasBox) { - await controller.setGeoJsonSource(_boxSourceId, _boxGeoJson(grid)); - } + await controller.setGeoJsonSource(_boxSourceId, geoJson); } catch (_) { - // Source/layer not on the map yet (mid style-reload) — the next update retries. + // Source/layer not on the map yet (mid style-reload) — the next update + // retries; the claim is dropped because the write never landed. + _boxSignature = null; } } + /// The feature set [_boxSourceId] last received — the box ids that survived + /// the coverage check with their intensities, in feed order (see + /// [_boxGeoJson]). Null whenever the source has just been (re)created, so + /// the first upload after a style load always lands. + String? _boxSignature; + /// Whether [_eewSourceId] currently holds the empty collection — mirrors /// the live monitor's flag. The old blanket `alerts.isEmpty` skip made the /// one *clearing* write unreachable: once the replayed alert expired, the @@ -1032,23 +1065,36 @@ class _ReplayMapState extends State<_ReplayMap> { /// the map for the rest of the replay. bool _eewSourceEmpty = true; + /// Whether an [_updateEew] write is still on the platform channel. The + /// wave-front ticker fires every 16 ms and does not wait for the previous + /// write to land, so without this a slow frame let several ring uploads + /// queue up behind each other — each one a full polygon set the map would + /// render in turn, none of them the current one. + bool _eewUpdating = false; + Future _updateEew() async { final controller = _controller; if (controller == null || !_ready) return; - final empty = widget.eew.alerts.isEmpty; - // Nothing to draw and nothing drawn — skip the per-tick round trip. - if (empty && _eewSourceEmpty) return; + if (_eewUpdating) return; + _eewUpdating = true; try { - await controller.setGeoJsonSource( - _eewSourceId, - empty ? _emptyCollection : _eewGeoJson(), - ); - _eewSourceEmpty = empty; - } catch (_) { - // Source not on the map yet (mid style-reload) — the next update - // retries; the flag is untouched because the write never landed. + final empty = widget.eew.alerts.isEmpty; + // Nothing to draw and nothing drawn — skip the per-tick round trip. + if (empty && _eewSourceEmpty) return; + try { + await controller.setGeoJsonSource( + _eewSourceId, + empty ? _emptyCollection : _eewGeoJson(), + ); + _eewSourceEmpty = empty; + } catch (_) { + // Source not on the map yet (mid style-reload) — the next update + // retries; the flag is untouched because the write never landed. + } + await _updateAreaFill(controller); + } finally { + _eewUpdating = false; } - await _updateAreaFill(controller); } /// Tints the whole island by estimated shaking while an EEW alert is up — @@ -1198,15 +1244,29 @@ class _ReplayMapState extends State<_ReplayMap> { /// S-wave has already fully swept past (see [_isBoxFullyCovered]) so it /// stops blinking instead of blinking forever once it's no longer live /// information. - Map _boxGeoJson(RtsBoxGrid grid) { + /// + /// Also returns a [signature] of the set — every surviving box id and its + /// intensity, in order — cheap enough to build on every call and exact + /// enough that an equal signature means an identical upload: a box's + /// geometry is a function of its id alone (the static grid), so id + + /// intensity is everything the feature carries. + ({Map geoJson, String signature}) _boxGeoJson( + RtsBoxGrid grid, + ) { final table = _travelTimeTable; final now = widget.clock.now(); final features = >[]; + final signature = StringBuffer(); for (final entry in widget.rts.box.entries) { final id = int.tryParse(entry.key); final ring = id == null ? null : grid.rings[id]; if (ring == null) continue; if (table != null && _isBoxFullyCovered(ring, table, now)) continue; + signature + ..write(entry.key) + ..write(':') + ..write(entry.value) + ..write(';'); features.add({ 'type': 'Feature', 'geometry': { @@ -1216,9 +1276,18 @@ class _ReplayMapState extends State<_ReplayMap> { 'properties': {'i': entry.value}, }); } - return {'type': 'FeatureCollection', 'features': features}; + return ( + geoJson: {'type': 'FeatureCollection', 'features': features}, + signature: signature.toString(), + ); } + /// Kilometres per degree of latitude along a meridian, rounded *down* from + /// the 111.3195 km/° of [geo.LatLng.distanceTo]'s sphere. See + /// [_isBoxFullyCovered] — the rounding direction is what keeps the reject + /// exact. + static const double _kmPerDegreeLatitude = 111.3; + /// Whether every corner of [ring] is already within some active alert's /// S-wave radius — ported from the legacy monitor's `checkBoxSkip`, which /// dropped a detection box from the map (not just its blink) the instant @@ -1238,13 +1307,21 @@ class _ReplayMapState extends State<_ReplayMap> { final radiusKm = table.waveRadius(info.depth, elapsed).s; if (radiusKm <= 0) continue; final epicenter = info.latlng; - final allCornersCovered = ring - .take(4) - .every( - (point) => - epicenter.distanceTo(geo.LatLng(point[1], point[0])) / 1000 <= - radiusKm, - ); + final allCornersCovered = ring.take(4).every((point) { + // Exact bounding reject before the haversine: the great-circle + // distance is never shorter than the meridional (latitude-only) + // leg, so a corner whose latitude gap alone exceeds the radius + // cannot be inside. The multiplier is rounded below the sphere's + // true km/°, so this can only under-estimate that leg — it never + // rejects a corner the haversine would have accepted. This runs + // 4 × boxes × alerts at 5 Hz, and most corners fail here. + if ((point[1] - epicenter.latitude).abs() * _kmPerDegreeLatitude > + radiusKm) { + return false; + } + return epicenter.distanceTo(geo.LatLng(point[1], point[0])) / 1000 <= + radiusKm; + }); if (allCornersCovered) return true; } return false; @@ -1463,20 +1540,23 @@ class _EewAlertCard extends StatelessWidget { class _ReplayStatusBar extends StatelessWidget { const _ReplayStatusBar({ required this.clock, - required this.tick, + required this.second, required this.rts, required this.eew, }); final ReplayClock clock; - final ValueNotifier tick; + + /// [clock]'s whole second — the finest thing this bar displays, so it is + /// what the bar rebuilds on (see `_ReportReplayPageState._clockSecond`). + final ValueNotifier second; final RtsRealtimeController rts; final EewRealtimeController eew; @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: tick, + valueListenable: second, builder: (context, _, _) => ListenableBuilder( listenable: Listenable.merge([rts, eew]), builder: (context, _) => _buildContent(context), From 8552dd436642af2ce46f65a04a0f38de041e337a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 11:39:04 +0800 Subject: [PATCH 12/32] perf(mesh): pause the radio strip's clock while the page is hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): Meshtastic 電台狀態列切到其他頁面或背景後不再每秒喚醒 Optimization(en-US): the Meshtastic radio strip stops waking up every second while another page is on screen or the app is in the background --- .../presentation/pages/meshtastic_page.dart | 46 ++++++------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/lib/features/meshtastic/presentation/pages/meshtastic_page.dart b/lib/features/meshtastic/presentation/pages/meshtastic_page.dart index 9699a7144..e9d8d1b9b 100644 --- a/lib/features/meshtastic/presentation/pages/meshtastic_page.dart +++ b/lib/features/meshtastic/presentation/pages/meshtastic_page.dart @@ -27,6 +27,7 @@ import 'package:dpip/features/meshtastic/presentation/widgets/mesh_chart_section import 'package:dpip/features/meshtastic/presentation/widgets/mesh_ratio_chart.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/widgets/empty_view.dart'; +import 'package:dpip/shared/widgets/second_ticker.dart'; import 'package:dpip/shared/widgets/section_header.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -446,46 +447,27 @@ class _RadioStrip extends StatefulWidget { State<_RadioStrip> createState() => _RadioStripState(); } -class _RadioStripState extends State<_RadioStrip> { - Timer? _ticker; - - @override - void initState() { - super.initState(); - _syncTicker(); - } - - @override - void didUpdateWidget(_RadioStrip oldWidget) { - super.didUpdateWidget(oldWidget); - _syncTicker(); - } - - /// Runs the 1 Hz tick **only while there is a link**. +class _RadioStripState extends State<_RadioStrip> with SecondTicker { + /// Runs the 1 Hz tick **only while there is a link** and the strip can be + /// seen. /// /// The "last packet Ns ago" readout has to age on its own — without a tick it /// would freeze at whatever it said when the last packet arrived, which is /// exactly the reassurance-without-evidence this strip exists to avoid. But /// the strip renders nothing when disconnected, so ticking then would rebuild /// a hidden widget once a second forever (and never let a widget test settle). - void _syncTicker() { - final needed = widget.link.isConnected; - if (needed == (_ticker != null)) return; - if (needed) { - _ticker = Timer.periodic( - const Duration(seconds: 1), - (_) => setState(() {}), - ); - } else { - _ticker?.cancel(); - _ticker = null; - } - } + /// The [TickerMode] half is the same gate the EEW countdown uses: this page + /// stays mounted behind whichever tab the user switches to, and a bare + /// `Timer` would keep rebuilding it there; [SecondTicker] also stops it while + /// the app is backgrounded. + @override + bool get secondTickerActive => + widget.link.isConnected && TickerMode.valuesOf(context).enabled; @override - void dispose() { - _ticker?.cancel(); - super.dispose(); + void didUpdateWidget(_RadioStrip oldWidget) { + super.didUpdateWidget(oldWidget); + syncSecondTicker(); } @override From 74811541e16fa88b5087632de6a1a835977f7b54 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 11:39:07 +0800 Subject: [PATCH 13/32] perf(weather): rank stations once per data set, not per rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 天氣排行不再在每次重繪時重新排序全部測站 Optimization(en-US): the weather ranking no longer re-sorts every station on each repaint --- .../pages/weather_ranking_page.dart | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/lib/features/weather/presentation/pages/weather_ranking_page.dart b/lib/features/weather/presentation/pages/weather_ranking_page.dart index 2c0314764..430c13ac4 100644 --- a/lib/features/weather/presentation/pages/weather_ranking_page.dart +++ b/lib/features/weather/presentation/pages/weather_ranking_page.dart @@ -516,16 +516,36 @@ class _WeatherMetricPanelState extends State<_WeatherMetricPanel> { bool _ascending = false; RankingMerge _merge = RankingMerge.none; + /// The last ranking and the inputs it was computed from (see [_ranked]). + List? _rankedCache; + Map? _rankedStations; + WeatherSnapshot? _rankedSnapshot; + bool _rankedAscending = false; + RankingMerge _rankedMerge = RankingMerge.none; + void _setMerge(RankingMerge next) { setState(() { _merge = _merge == next ? RankingMerge.none : next; }); } - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final colors = Theme.of(context).colorScheme; + /// Ranks the snapshot once per (stations, snapshot, order, merge) — the + /// page rebuilds this panel on every tab swipe and pull-to-refresh frame, + /// and re-sorting several hundred stations for the same answer is what + /// made those stutter. Identity, not equality, for the two maps: a refresh + /// hands the panel new objects, and anything else hands it the same ones. + /// The accessor closures are deliberately not keyed on — the parent + /// creates them anew per build, and each panel sits in a fixed + /// [TabBarView] slot, so the same State never sees a different metric. + List _ranked() { + final cached = _rankedCache; + if (cached != null && + identical(_rankedStations, widget.stations) && + identical(_rankedSnapshot, widget.snapshot) && + _rankedAscending == _ascending && + _rankedMerge == _merge) { + return cached; + } final ranked = rankWeather( stations: widget.stations, snapshot: widget.snapshot, @@ -536,6 +556,18 @@ class _WeatherMetricPanelState extends State<_WeatherMetricPanel> { merge: _merge, requirePositive: widget.requirePositive, ); + _rankedStations = widget.stations; + _rankedSnapshot = widget.snapshot; + _rankedAscending = _ascending; + _rankedMerge = _merge; + return _rankedCache = ranked; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = Theme.of(context).colorScheme; + final ranked = _ranked(); final time = _formatSnapshotTime(widget.snapshot.time); return RefreshIndicator( From 1edf3b2d32e8cf337b4f087fc5a0d2bb9775df32 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 11:39:11 +0800 Subject: [PATCH 14/32] perf(mesh): reuse the traced route's geometry across animation frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 地圖上的 Mesh 路徑追蹤動畫每一幀不再重建路徑 Optimization(en-US): the mesh route-trace animation on the map no longer rebuilds its path every frame --- .../presentation/layers/mesh_node_layer.dart | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/lib/features/map/presentation/layers/mesh_node_layer.dart b/lib/features/map/presentation/layers/mesh_node_layer.dart index 9b298581f..c5d9d1379 100644 --- a/lib/features/map/presentation/layers/mesh_node_layer.dart +++ b/lib/features/map/presentation/layers/mesh_node_layer.dart @@ -900,6 +900,32 @@ class _RouteOverlayState extends State<_RouteOverlay> duration: const Duration(milliseconds: 900), )..repeat(); + /// The segments' path metrics, built once per projection. + /// + /// Only [_phase] changes between frames — the geometry is fixed until the + /// camera settles and the overlay is reprojected. Building the paths and + /// walking `computeMetrics` on every frame of a 60 Hz march re-derived the + /// same curves sixty times a second; the painter now only slides the dash + /// offset along metrics it was handed. + late List _metrics = _metricsFor(widget.segments); + + static List _metricsFor(List> segments) => [ + for (final segment in segments) + if (segment.length >= 2) + ...(Path() + ..moveTo(segment.first.dx, segment.first.dy) + ..addPolygon(segment.sublist(1), false)) + .computeMetrics(), + ]; + + @override + void didUpdateWidget(_RouteOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.segments, widget.segments)) { + _metrics = _metricsFor(widget.segments); + } + } + @override void dispose() { _phase.dispose(); @@ -915,6 +941,7 @@ class _RouteOverlayState extends State<_RouteOverlay> size: Size.infinite, painter: _RoutePainter( segments: widget.segments, + metrics: _metrics, color: widget.color, phase: _phase.value, ), @@ -927,11 +954,16 @@ class _RouteOverlayState extends State<_RouteOverlay> class _RoutePainter extends CustomPainter { const _RoutePainter({ required this.segments, + required this.metrics, required this.color, required this.phase, }); final List> segments; + + /// One metric per drawable segment, in [segments] order (see + /// `_RouteOverlayState._metricsFor`). + final List metrics; final Color color; /// 0..1 — where the dash pattern sits along the path; the controller @@ -949,21 +981,14 @@ class _RoutePainter extends CustomPainter { ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..color = color; - final cycle = _dash + _gap; + const cycle = _dash + _gap; final offset = phase * cycle; - for (final segment in segments) { - if (segment.length < 2) continue; - final path = Path()..moveTo(segment.first.dx, segment.first.dy); - for (final point in segment.skip(1)) { - path.lineTo(point.dx, point.dy); - } - for (final metric in path.computeMetrics()) { - var distance = offset; - while (distance < metric.length) { - final end = math.min(distance + _dash, metric.length); - canvas.drawPath(metric.extractPath(distance, end), stroke); - distance += cycle; - } + for (final metric in metrics) { + var distance = offset; + while (distance < metric.length) { + final end = math.min(distance + _dash, metric.length); + canvas.drawPath(metric.extractPath(distance, end), stroke); + distance += cycle; } } // Endpoints as dots, so the path says where it starts and where it ends @@ -978,7 +1003,7 @@ class _RoutePainter extends CustomPainter { @override bool shouldRepaint(_RoutePainter oldDelegate) => - oldDelegate.segments != segments || + oldDelegate.metrics != metrics || oldDelegate.color != color || oldDelegate.phase != phase; } From 639c2958e5c538a476a8108dcb7d34f739909740 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 11:39:16 +0800 Subject: [PATCH 15/32] refactor(map): get the Mercator tile row from one transcendental call --- lib/shared/map/xyz_tiles.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/shared/map/xyz_tiles.dart b/lib/shared/map/xyz_tiles.dart index 42e591801..5f2c664c8 100644 --- a/lib/shared/map/xyz_tiles.dart +++ b/lib/shared/map/xyz_tiles.dart @@ -18,10 +18,10 @@ int lngToTileX(double lng, int z) { int latToTileY(double lat, int z) { final n = 1 << z; final latRad = degToRad(lat.clamp(-85.05112878, 85.05112878)); + // Mercator y = ln(tan φ + sec φ), and tan φ + sec φ ≡ tan(π/4 + φ/2) — the + // same number from one transcendental call instead of three. final y = - ((1.0 - math.log(math.tan(latRad) + 1.0 / math.cos(latRad)) / math.pi) / - 2.0) * - n; + (1.0 - math.log(math.tan(math.pi / 4 + latRad / 2)) / math.pi) / 2.0 * n; return y.floor().clamp(0, n - 1); } From 9791cd91a7ab9950f17b9bf5de465bf5c0a614cb Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 11:51:34 +0800 Subject: [PATCH 16/32] perf(map): keep timeline neighbours from re-fetching on every pan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 平移雷達或衛星地圖時只重新載入正在顯示的那一幀,低階手機解碼負擔降到五分之一 Optimization(en-US): panning the radar or satellite map reloads only the frame on screen, cutting tile decoding on low-end phones to a fifth --- lib/shared/map/raster_timeline_layer.dart | 25 ++++++--- test/features/map/qpesums_layer_test.dart | 3 +- test/features/map/radar_layer_test.dart | 59 +++++++++++++------- test/features/map/satellite_layer_test.dart | 62 +++++++++++---------- 4 files changed, 90 insertions(+), 59 deletions(-) diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index 5da3ce6f0..d5872ac24 100644 --- a/lib/shared/map/raster_timeline_layer.dart +++ b/lib/shared/map/raster_timeline_layer.dart @@ -29,8 +29,8 @@ enum _IdlePreloadOutcome { ready, timeout, cancelled } /// /// ## Four tiers /// - **The ring** — frames within [ringRadius] are mounted **visible**, only -/// the current one at full [opacity] and its neighbours at -/// [preloadOpacity] (not zero — at zero MapLibre would not fetch them). During a +/// the current one at full [opacity] and its neighbours at zero (which +/// MapLibre does not fetch — see [preloadOpacity] for what does). During a /// drag, an L1-complete frame may join this set on demand; [maxResident] /// bounds the extra sources. A frame replaces the current timestamp only /// after [RasterFrameSource.frameTileReadiness] proves that one complete @@ -505,7 +505,9 @@ abstract class RasterTimelineLayer implements MapLayer { rasterOpacityTransition: _instantTransition, ); - /// What a mounted-but-not-shown frame is drawn at. + /// What a frame that must *load* while staying unseen is drawn at: the + /// settle target under the still-opaque previous frame, and an idle-preload + /// candidate. /// /// Not zero, and the difference is whether the frame loads at all. MapLibre /// Native's `RenderRasterLayer::evaluate` sets the layer's render pass to @@ -519,6 +521,12 @@ abstract class RasterTimelineLayer implements MapLayer { /// One 8-bit level is under what a pixel can show over the opaque current /// frame, and it is enough for the renderer to treat the layer as drawn — /// which is what makes it fetch. + /// + /// The ±[ringRadius] neighbours deliberately stay at **0**: at this value + /// every one of them would re-fetch and re-decode its viewport on every pan, + /// five viewports of raster work per gesture on a low-end phone instead of + /// one. They come from the L2 warm band and the idle preload instead, which + /// is where their bytes were always coming from. @visibleForTesting static const double preloadOpacity = 1 / 255; @@ -681,9 +689,7 @@ abstract class RasterTimelineLayer implements MapLayer { if (previous != null && previous != frameId) ( layerId: _layerId(previous), - properties: _ring.contains(previous) - ? _opacity(preloadOpacity) - : _hidden, + properties: _ring.contains(previous) ? _opacity(0) : _hidden, ), (layerId: _layerId(frameId), properties: _opacity(opacity)), ], skipNulls: true); @@ -726,7 +732,9 @@ abstract class RasterTimelineLayer implements MapLayer { // timestamp remains fully opaque below them. await Future.wait([ for (final id in ring) - if (id != _shownFrameId) _mount(controller, id, preloadOpacity), + if (id != _shownFrameId) + // Only the target has to fetch; see [preloadOpacity]. + _mount(controller, id, id == frameId ? preloadOpacity : 0), ]); _ring.addAll(ring); MapTileCache.trace( @@ -891,7 +899,8 @@ abstract class RasterTimelineLayer implements MapLayer { MapLibreMapController controller, String frameId, ) async { - await _mount(controller, frameId, preloadOpacity); + // Its tiles are already in L1 (the probe passed), so nothing to fetch. + await _mount(controller, frameId, 0); _ring.add(frameId); final keep = {frameId}; final shown = _shownFrameId; diff --git a/test/features/map/qpesums_layer_test.dart b/test/features/map/qpesums_layer_test.dart index 76290fafe..9baa71203 100644 --- a/test/features/map/qpesums_layer_test.dart +++ b/test/features/map/qpesums_layer_test.dart @@ -1,7 +1,6 @@ import 'package:dpip/features/map/presentation/layers/qpesums_layer.dart'; import 'package:dpip/features/weather/domain/qpesums_repository.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; -import 'package:dpip/shared/map/raster_timeline_layer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -61,7 +60,7 @@ void main() { await layer.show(controller, frames[5], scrubbing: true); expect(controller.calls, [ - 'set:qpesums-lyr-${frames[4].id}:${RasterTimelineLayer.preloadOpacity}', + 'set:qpesums-lyr-${frames[4].id}:0.0', 'set:qpesums-lyr-${frames[5].id}:0.85', ]); expect( diff --git a/test/features/map/radar_layer_test.dart b/test/features/map/radar_layer_test.dart index 2b85a6b77..b155ce1d0 100644 --- a/test/features/map/radar_layer_test.dart +++ b/test/features/map/radar_layer_test.dart @@ -142,10 +142,7 @@ void main() { 'visible', reason: 'neighbours stay visible so their tiles stay loaded', ); - expect( - controller.opacityOf('radar-lyr-${frames[i].id}'), - '${RasterTimelineLayer.preloadOpacity}', - ); + expect(controller.opacityOf('radar-lyr-${frames[i].id}'), '0.0'); } }); @@ -257,7 +254,7 @@ void main() { reason: 'one scrub frame must cross the platform channel only once', ); expect(controller.calls, [ - 'set:radar-lyr-${frames[4].id}:${RasterTimelineLayer.preloadOpacity}', + 'set:radar-lyr-${frames[4].id}:0.0', 'set:radar-lyr-${frames[5].id}:0.85', ]); expect( @@ -322,10 +319,7 @@ void main() { expect(layer.readyVisibleFrameId.value, frames[2].id); await layer.show(controller, frames[3], scrubbing: true); - expect( - controller.opacityOf('radar-lyr-${frames[2].id}'), - '${RasterTimelineLayer.preloadOpacity}', - ); + expect(controller.opacityOf('radar-lyr-${frames[2].id}'), '0.0'); expect(controller.opacityOf('radar-lyr-${frames[3].id}'), '0.85'); expect(layer.readyVisibleFrameId.value, frames[3].id); expect( @@ -335,6 +329,39 @@ void main() { ); }); + test('a cold settle target fetches; its ring neighbours do not', () async { + final source = _ControlledReadinessRadarRepository(_ids(9))..ready = false; + final layer = testRadarLayer(source); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[2]); + await layer.show(controller, frames[4]); + + expect( + controller.opacityOf('radar-lyr-${frames[4].id}'), + '${RasterTimelineLayer.preloadOpacity}', + reason: + 'at exactly 0 MapLibre never requests the tiles, and the settle ' + 'waited out its whole readiness timeout for nothing', + ); + expect( + controller.opacityOf('radar-lyr-${frames[2].id}'), + '0.85', + reason: 'the previous complete frame stays on screen meanwhile', + ); + for (final i in [3, 5, 6]) { + expect( + controller.opacityOf('radar-lyr-${frames[i].id}'), + '0.0', + reason: + 'a neighbour above 0 would re-fetch its viewport on every pan — ' + 'five viewports of raster decode per gesture on a low-end phone', + ); + } + }); + test('a late native idle completes a settle after an L1 miss', () async { final source = _ControlledReadinessRadarRepository(_ids(5))..ready = false; final layer = testRadarLayer(source); @@ -349,10 +376,7 @@ void main() { layer.onMapIdle(); await Future.delayed(const Duration(milliseconds: 80)); - expect( - controller.opacityOf('radar-lyr-${frames[2].id}'), - '${RasterTimelineLayer.preloadOpacity}', - ); + expect(controller.opacityOf('radar-lyr-${frames[2].id}'), '0.0'); expect(controller.opacityOf('radar-lyr-${frames[3].id}'), '0.85'); }); @@ -456,14 +480,11 @@ void main() { await layer.show(controller, frames[1], scrubbing: true); - expect( - controller.opacityOf('radar-lyr-${frames[4].id}'), - '${RasterTimelineLayer.preloadOpacity}', - ); + expect(controller.opacityOf('radar-lyr-${frames[4].id}'), '0.0'); expect(controller.opacityOf('radar-lyr-${frames[1].id}'), '0.85'); expect(controller.calls, [ - 'set:radar-lyr-${frames[1].id}:${RasterTimelineLayer.preloadOpacity}', - 'set:radar-lyr-${frames[4].id}:${RasterTimelineLayer.preloadOpacity}', + 'set:radar-lyr-${frames[1].id}:0.0', + 'set:radar-lyr-${frames[4].id}:0.0', 'set:radar-lyr-${frames[1].id}:0.85', ]); expect( diff --git a/test/features/map/satellite_layer_test.dart b/test/features/map/satellite_layer_test.dart index 4ec472ed5..a50e27c03 100644 --- a/test/features/map/satellite_layer_test.dart +++ b/test/features/map/satellite_layer_test.dart @@ -2,7 +2,6 @@ import 'package:dpip/features/map/presentation/layers/satellite_layer.dart'; import 'package:dpip/features/weather/domain/satellite_channel.dart'; import 'package:dpip/features/weather/domain/satellite_repository.dart'; import 'package:dpip/shared/map/map_style.dart'; -import 'package:dpip/shared/map/raster_timeline_layer.dart'; import 'package:flutter_test/flutter_test.dart'; import 'raster_timeline_harness.dart'; @@ -45,35 +44,38 @@ void main() { expect(controller.opacityOf('satellite-lyr-${frames[2].id}'), '1.0'); }); - test('scrubbing inside the ring is two opacity writes, nothing else', () async { - final layer = SatelliteMapLayer( - _FakeSatelliteRepository(_ids(9)), - channel: SatelliteChannel.irClean, - referenceOutline: testReferenceOutline(), - ); - final frames = (await layer.frames()).valueOrNull!; - final controller = RecordingMapController(); - - await layer.prepare(controller, frames); - await layer.show(controller, frames[4]); - controller.calls.clear(); - controller.sentKeys.clear(); - - await layer.show(controller, frames[3], scrubbing: true); - - expect(controller.calls, [ - 'set:satellite-lyr-${frames[4].id}:${RasterTimelineLayer.preloadOpacity}', - 'set:satellite-lyr-${frames[3].id}:1.0', - ]); - expect( - controller.sentKeys, - everyElement(equals({'raster-opacity', 'raster-opacity-transition'})), - reason: - 'the scrub path must not re-send visibility or the seven other ' - 'raster properties the layer type has — only the opacity and the ' - 'zero cross-fade that keeps a scrub a loop instead of a smear', - ); - }); + test( + 'scrubbing inside the ring is two opacity writes, nothing else', + () async { + final layer = SatelliteMapLayer( + _FakeSatelliteRepository(_ids(9)), + channel: SatelliteChannel.irClean, + referenceOutline: testReferenceOutline(), + ); + final frames = (await layer.frames()).valueOrNull!; + final controller = RecordingMapController(); + + await layer.prepare(controller, frames); + await layer.show(controller, frames[4]); + controller.calls.clear(); + controller.sentKeys.clear(); + + await layer.show(controller, frames[3], scrubbing: true); + + expect(controller.calls, [ + 'set:satellite-lyr-${frames[4].id}:0.0', + 'set:satellite-lyr-${frames[3].id}:1.0', + ]); + expect( + controller.sentKeys, + everyElement(equals({'raster-opacity', 'raster-opacity-transition'})), + reason: + 'the scrub path must not re-send visibility or the seven other ' + 'raster properties the layer type has — only the opacity and the ' + 'zero cross-fade that keeps a scrub a loop instead of a smear', + ); + }, + ); test('bright yellow county and town outlines are added once and removed on clear', () async { final layer = SatelliteMapLayer( From 59eb34445e3577aef21ea836db0105e00e92edb9 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 12:04:30 +0800 Subject: [PATCH 17/32] perf(eew): stop re-deriving the alert colour and estimate every second MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 地震速報卡片倒數時不再每秒重算顏色與本地震度估計,警報期間更省電 Optimization(en-US): earthquake alert cards stop recomputing their colour and local shaking estimate on every countdown second, saving battery during an alert --- .../earthquake/domain/eew_local_estimate.dart | 32 +++++++++++++++++++ .../widgets/home_eew_section.dart | 19 +++++++---- .../widgets/home_monitor_banner.dart | 13 +++++--- lib/shared/widgets/eew_estimate_tile.dart | 9 +++++- 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/lib/features/earthquake/domain/eew_local_estimate.dart b/lib/features/earthquake/domain/eew_local_estimate.dart index b7e1d2ff8..cf97c2fff 100644 --- a/lib/features/earthquake/domain/eew_local_estimate.dart +++ b/lib/features/earthquake/domain/eew_local_estimate.dart @@ -51,6 +51,38 @@ EewLocalEstimate estimateLocalShaking( LatLng user, { SeismicTravelTimeTable? table, }) { + // One-entry memo. Every alert card (home, monitor, list) recomputes this on + // its one-second countdown tick, and the inputs only move when a new serial + // arrives or the observer moves: two haversines, the attenuation law and a + // travel-time table scan per tick per card, for the same answer. `Eew` and + // `LatLng` are value types, so equality is the exact "same inputs" test; + // the table is compared by identity because it is a loaded asset that never + // changes in place. + final last = _last; + if (last != null && + last.eew == eew && + last.user == user && + identical(last.table, table)) { + return last.estimate; + } + final estimate = _estimate(eew, user, table); + _last = (eew: eew, user: user, table: table, estimate: estimate); + return estimate; +} + +({ + Eew eew, + LatLng user, + SeismicTravelTimeTable? table, + EewLocalEstimate estimate, +})? +_last; + +EewLocalEstimate _estimate( + Eew eew, + LatLng user, + SeismicTravelTimeTable? table, +) { final location = EewEstimator.locationInfo( mag: eew.info.magnitude, depth: eew.info.depth, diff --git a/lib/features/home/presentation/widgets/home_eew_section.dart b/lib/features/home/presentation/widgets/home_eew_section.dart index f260a1132..f25184938 100644 --- a/lib/features/home/presentation/widgets/home_eew_section.dart +++ b/lib/features/home/presentation/widgets/home_eew_section.dart @@ -48,13 +48,18 @@ class HomeEewSection extends StatelessWidget { /// [build] gates on, exposed so `HomeContent` can decide whether to /// reserve a gap after it without re-deriving (and risking drifting from) /// the same liveness condition. - static bool isActive(BuildContext context) { - final state = context.watch>>().state; - final alerts = state.data; - return state.status == RealtimeStatus.live && - alerts != null && - alerts.isNotEmpty; - } + static bool isActive(BuildContext context) => + // `select`, not `watch`: the caller (`HomeContent`'s scroll-driven panel + // builder) only needs the boolean, and a plain watch rebuilt that whole + // panel on every EEW serial update. Same answer, rebuilt only when the + // answer flips. + context.select>, bool>((controller) { + final state = controller.state; + final alerts = state.data; + return state.status == RealtimeStatus.live && + alerts != null && + alerts.isNotEmpty; + }); @override Widget build(BuildContext context) { diff --git a/lib/features/home/presentation/widgets/home_monitor_banner.dart b/lib/features/home/presentation/widgets/home_monitor_banner.dart index 7a75ee41c..a0e1ba68f 100644 --- a/lib/features/home/presentation/widgets/home_monitor_banner.dart +++ b/lib/features/home/presentation/widgets/home_monitor_banner.dart @@ -47,11 +47,14 @@ class HomeMonitorBanner extends StatelessWidget { /// exposed so a caller that needs to lay out *around* this banner (Home's /// overlap stack with the gold support bar) reads the identical condition /// instead of re-deriving it and risking the two disagreeing. - static bool isActive(BuildContext context) { - final eew = context.watch>>(); - final alerts = eew.state.data ?? const []; - return eew.state.status == RealtimeStatus.live && alerts.isNotEmpty; - } + static bool isActive(BuildContext context) => + // `select`, not `watch`: the Home overlap stack calls this to lay out + // around the banner, and a watch there rebuilt it on every EEW serial + // update. The boolean is the same; only a flip rebuilds the caller. + context.select>, bool>((eew) { + final alerts = eew.state.data ?? const []; + return eew.state.status == RealtimeStatus.live && alerts.isNotEmpty; + }); @override Widget build(BuildContext context) { diff --git a/lib/shared/widgets/eew_estimate_tile.dart b/lib/shared/widgets/eew_estimate_tile.dart index 492970511..051b74b82 100644 --- a/lib/shared/widgets/eew_estimate_tile.dart +++ b/lib/shared/widgets/eew_estimate_tile.dart @@ -29,7 +29,14 @@ class EewEstimateTile extends StatelessWidget { /// theme or arrival state — M3's dark-mode `error` role is a pale pink for /// contrast, which reads as calm rather than urgent for a safety-critical /// warning. - static Color alertRed() => AppTheme.scheme(Brightness.light).error; + static Color alertRed() => _alertRed; + + /// Resolved once. `AppTheme.scheme` runs `ColorScheme.fromSeed` — a full + /// HCT tonal-palette derivation, milliseconds on a low-end phone — and this + /// used to run it on every build of every EEW card, i.e. once a second per + /// card for the whole countdown. The seed, brightness and contrast are all + /// fixed here, so the answer never changes. + static final Color _alertRed = AppTheme.scheme(Brightness.light).error; @override Widget build(BuildContext context) { From 3df24bb1475763489599dd67000e29197155d0c9 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 12:04:37 +0800 Subject: [PATCH 18/32] perf(data): stop re-solving the moon calendar on every scrub tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 月相頁拖動時間軸時不再重算整月的月相與下次滿月、新月 Optimization(en-US): scrubbing the moon page's timeline no longer recomputes the whole month's phases and the next full and new moon --- .../data/presentation/pages/moon_page.dart | 48 +++++++++++--- test/features/data/moon_page_test.dart | 66 +++++++++++++++++++ 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/lib/features/data/presentation/pages/moon_page.dart b/lib/features/data/presentation/pages/moon_page.dart index 72ce1e7bf..0d8c42363 100644 --- a/lib/features/data/presentation/pages/moon_page.dart +++ b/lib/features/data/presentation/pages/moon_page.dart @@ -182,6 +182,40 @@ class _MoonPageState extends State { _visibleMonth = AppTime.taipei(_frames[index].time); }); + /// Calendar phase per day, keyed `yyyymmdd`. The calendar's grid is + /// `shrinkWrap`, so every one of its ~35 cells is rebuilt on every page + /// rebuild — and every timeline scrub tick is a rebuild. Each cell asked + /// for a fresh Meeus lunar series (`MoonEphemeris.at`, ~120 trig terms), so + /// a scrub frame paid for a month of ephemerides to redraw glyphs whose + /// inputs had not changed. A day's noon phase is a pure function of the + /// day, so it is computed once. Bounded by the calendar's own reach: it + /// can only page within the timeline's ±31 days, a few months at most. + final Map _dayPhase = {}; + + double _phaseOfDay(DateTime day) => _dayPhase.putIfAbsent( + day.year * 10000 + day.month * 100 + day.day, + () => MoonPhase.angleAt( + DateTime.utc(day.year, day.month, day.day, 12).subtract(_taiwanOffset), + ), + ); + + /// The next full / new moon after the selection. Each is a five-pass + /// settle over the ephemeris, and both depend only on the selected frame — + /// but the page also rebuilds when the calendar pages a month, where the + /// selection has not moved. Remembered for the frame they were solved for. + int? _upcomingFor; + late DateTime _nextFull; + late DateTime _nextNew; + + (DateTime full, DateTime newMoon) get _upcoming { + if (_upcomingFor != _selectedIndex) { + _upcomingFor = _selectedIndex; + _nextFull = MoonPhase.nextFullMoon(_selected); + _nextNew = MoonPhase.nextNewMoon(_selected); + } + return (_nextFull, _nextNew); + } + /// Jumps to [day] (Taipei wall time) keeping the time of day, so stepping /// through the calendar compares like with like. void _selectDay(DateTime day) { @@ -200,6 +234,7 @@ class _MoonPageState extends State { final l10n = AppLocalizations.of(context); final phase = MoonPhase.at(_selected); final libration = MoonPhase.librationAt(_selected); + final (nextFull, nextNew) = _upcoming; final town = observerTown(context); final local = _selectedLocal; final riseSet = town == null @@ -330,12 +365,12 @@ class _MoonPageState extends State { ( Icons.brightness_1_outlined, l10n.moonNextFullMoon, - _stamp(MoonPhase.nextFullMoon(_selected)), + _stamp(nextFull), ), ( Icons.brightness_3_outlined, l10n.moonNextNewMoon, - _stamp(MoonPhase.nextNewMoon(_selected)), + _stamp(nextNew), ), ], ), @@ -355,14 +390,7 @@ class _MoonPageState extends State { lastDay: AppTime.taipei(_frames.last.time), onMonthChanged: (month) => setState(() => _visibleMonth = month), onDaySelected: _selectDay, - phaseAt: (day) => MoonPhase.angleAt( - DateTime.utc( - day.year, - day.month, - day.day, - 12, - ).subtract(_taiwanOffset), - ), + phaseAt: _phaseOfDay, ), ), ], diff --git a/test/features/data/moon_page_test.dart b/test/features/data/moon_page_test.dart index cd4d3eb4c..e5e6fc278 100644 --- a/test/features/data/moon_page_test.dart +++ b/test/features/data/moon_page_test.dart @@ -7,15 +7,19 @@ /// the one it actually computed for. library; +import 'package:dpip/core/astro/moon_phase.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/settings/settings_store.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/features/data/presentation/pages/moon_page.dart'; import 'package:dpip/features/data/presentation/widgets/moon_calendar.dart'; +import 'package:dpip/features/data/presentation/widgets/moon_glyph.dart'; +import 'package:dpip/shared/map/map_timeline.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; final _directory = TownDirectory.fromJson({ @@ -136,4 +140,66 @@ void main() { // that passes for three weeks and fails in the fourth, on nobody's change. expect(find.textContaining(RegExp(r'^\d\d\d,\d\d\d km$')), findsOneWidget); }); + + // The page memoises its astronomy — the calendar's per-day phase and the + // two "next" searches — so a scrub tick stops re-solving a month of + // ephemerides. Memoised must still mean *correct*: a glyph must carry its + // own day's phase, and the readouts must follow the selection rather than + // the first answer they cached. + testWidgets('a calendar glyph carries its own day\'s noon phase', ( + tester, + ) async { + await _pumpPage(tester, await _regions(currentCode: '100')); + + final today = AppTime.utc8; + // A day the page did not build first (the selected one), so a cache that + // handed every cell the same value would be caught. + final day = today.day == 15 ? 16 : 15; + final cell = find.ancestor( + of: find.descendant( + of: find.byType(MoonCalendar), + matching: find.text('$day'), + ), + matching: find.byType(Column), + ); + final glyph = tester.widget( + find.descendant(of: cell.first, matching: find.byType(MoonGlyph)), + ); + // Noon Taipei of that day, as the page defines a day's phase. + final noon = DateTime.utc( + today.year, + today.month, + day, + 12, + ).subtract(const Duration(hours: 8)); + expect(glyph.angle, MoonPhase.angleAt(noon)); + }); + + testWidgets('the next full moon follows the timeline selection', ( + tester, + ) async { + await _pumpPage(tester, await _regions(currentCode: '100')); + + final timeline = tester.widget(find.byType(MapTimeline)); + String stamp(DateTime utc) { + final local = AppTime.taipei(utc); + return '${DateFormat('M/d').format(local)} ' + '${DateFormat('HH:mm').format(local)}'; + } + + final before = stamp( + MoonPhase.nextFullMoon(timeline.frames[timeline.selectedIndex].time), + ); + expect(find.text(before), findsOneWidget); + + // Thirty days on — past one synodic month, so the answer must change. + final target = timeline.selectedIndex + 30 * 12; + timeline.onSelected(target); + await tester.pump(); + + final after = stamp(MoonPhase.nextFullMoon(timeline.frames[target].time)); + expect(after, isNot(before)); + expect(find.text(after), findsOneWidget); + expect(find.text(before), findsNothing); + }); } From d5002e0b637b768741476935496649a465419c8d Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 12:04:45 +0800 Subject: [PATCH 19/32] perf: compile patterns and formatters once instead of per rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 回報清單、伺服器狀態、更新日誌與地震報告頁不再在每次重繪時重新編譯字串規則 Optimization(en-US): the bug list, server status, changelog and report pages stop recompiling their text patterns on every repaint --- lib/core/a11y/color_vision.dart | 11 +++++++--- .../presentation/pages/bug_list_page.dart | 22 ++++++++++++++----- .../pages/version_notes_page.dart | 14 +++++++++--- .../pages/report_detail_page.dart | 10 +++++++-- .../more/presentation/pages/more_page.dart | 9 ++++++-- .../pages/server_status_page.dart | 16 ++++++++++---- 6 files changed, 62 insertions(+), 20 deletions(-) diff --git a/lib/core/a11y/color_vision.dart b/lib/core/a11y/color_vision.dart index 919188db8..8780fc82f 100644 --- a/lib/core/a11y/color_vision.dart +++ b/lib/core/a11y/color_vision.dart @@ -178,6 +178,13 @@ abstract final class ColorVisionFilter { ? c * 12.92 : 1.055 * math.pow(c, 1 / 2.4).toDouble() - 0.055; + /// `rgba(r, g, b, a)` / `rgb(r, g, b)`. Compiled once: [transformHex] runs + /// per paint value each time a map layer's style is built, and an inline + /// `RegExp(...)` compiles a fresh pattern on every call. + static final RegExp _rgbaFunctional = RegExp( + r'^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$', + ); + /// `(r, g, b, a, wasFunctional)` in 0–255 / 0–1, or null if unrecognised. static (int, int, int, double, bool)? _parseRgba(String value) { final text = value.trim(); @@ -201,9 +208,7 @@ abstract final class ColorVisionFilter { return null; } } - final match = RegExp( - r'^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$', - ).firstMatch(text); + final match = _rgbaFunctional.firstMatch(text); if (match == null) return null; try { return ( diff --git a/lib/features/bug_tracker/presentation/pages/bug_list_page.dart b/lib/features/bug_tracker/presentation/pages/bug_list_page.dart index 48b765c9e..d3adf69af 100644 --- a/lib/features/bug_tracker/presentation/pages/bug_list_page.dart +++ b/lib/features/bug_tracker/presentation/pages/bug_list_page.dart @@ -317,15 +317,25 @@ class _DiscordReportButton extends StatelessWidget { /// stripped here and the result flows as ordinary text. Images vanish, link /// labels survive, headings lose their `#`, emphasis markers come off. String _bugPreview(String body) => body - .replaceAllMapped(RegExp(r'!\[([^\]]*)\]\([^)]*\)'), (_) => '') - .replaceAllMapped(RegExp(r'\[([^\]]+)\]\([^)]*\)'), (m) => m.group(1)!) - .replaceAll(RegExp(r'```[a-zA-Z]*'), ' ') - .replaceAll(RegExp(r'^#{1,6}\s*', multiLine: true), '') - .replaceAll(RegExp(r'^\s*[-+*]\s+', multiLine: true), '• ') - .replaceAll(RegExp(r'[*_~`]'), '') + .replaceAllMapped(_mdImage, (_) => '') + .replaceAllMapped(_mdLink, (m) => m.group(1)!) + .replaceAll(_mdFence, ' ') + .replaceAll(_mdHeading, '') + .replaceAll(_mdBullet, '• ') + .replaceAll(_mdEmphasis, '') .replaceAll('\n', ' ') .trim(); +// Compiled once. `_bugPreview` runs for every card on every list rebuild — +// each sort or tag-filter toggle — and an inline `RegExp(...)` compiles a +// fresh pattern per call, so six patterns × every visible card × every toggle. +final RegExp _mdImage = RegExp(r'!\[([^\]]*)\]\([^)]*\)'); +final RegExp _mdLink = RegExp(r'\[([^\]]+)\]\([^)]*\)'); +final RegExp _mdFence = RegExp(r'```[a-zA-Z]*'); +final RegExp _mdHeading = RegExp(r'^#{1,6}\s*', multiLine: true); +final RegExp _mdBullet = RegExp(r'^\s*[-+*]\s+', multiLine: true); +final RegExp _mdEmphasis = RegExp(r'[*_~`]'); + /// One thread row — title, tag badges, body preview, author and reply count. /// The dot between two facts in a card's meta row. /// diff --git a/lib/features/changelog/presentation/pages/version_notes_page.dart b/lib/features/changelog/presentation/pages/version_notes_page.dart index 836524fd5..2986d8a42 100644 --- a/lib/features/changelog/presentation/pages/version_notes_page.dart +++ b/lib/features/changelog/presentation/pages/version_notes_page.dart @@ -42,17 +42,25 @@ class VersionNotesPage extends StatelessWidget { /// page's match (tag or name, `v` stripped) so both pages agree on which /// entry is "current" without sharing state. static bool _isCurrent(ReleaseNote note, String label) { - final tag = note.tagName.replaceFirst(RegExp(r'^v'), ''); - final name = note.name.replaceFirst(RegExp(r'^v'), ''); + final tag = note.tagName.replaceFirst(_vPrefix, ''); + final name = note.name.replaceFirst(_vPrefix, ''); return tag == label || name == label; } + /// Compiled once — `_isCurrent` runs per fetched note on every build, and + /// every inline `RegExp(...)` compiles a fresh pattern. + static final RegExp _vPrefix = RegExp(r'^v'); + + /// A release label is a plain `major.minor`; compiled once for the same + /// reason as [_vPrefix]. + static final RegExp _releaseLabel = RegExp(r'^\d+\.\d+$'); + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final repo = context.read(); final label = AppBuild.label; - final stable = RegExp(r'^\d+\.\d+$').hasMatch(label); + final stable = _releaseLabel.hasMatch(label); final typeColor = stable ? _stableColor : _snapshotColor; final refresh = RefreshSignal(); return Scaffold( diff --git a/lib/features/earthquake/presentation/pages/report_detail_page.dart b/lib/features/earthquake/presentation/pages/report_detail_page.dart index 5885c4624..95eac6e44 100644 --- a/lib/features/earthquake/presentation/pages/report_detail_page.dart +++ b/lib/features/earthquake/presentation/pages/report_detail_page.dart @@ -168,6 +168,12 @@ class _ReportDetailPageState extends State { } } +/// `yyyy/MM/dd HH:mm:ss` for the origin time — the peek summary and the info +/// card both print it. Numeric only, so no locale symbol data is needed, and +/// one parsed pattern instead of one per build: `DateFormat(...)` parses its +/// pattern on construction, which the peek summary re-ran on every rebuild. +final DateFormat _originTimeFormat = DateFormat('yyyy/MM/dd HH:mm:ss'); + /// The report's epicentre + station bounds, in the map library's coordinate /// type — computed here (not on the domain model) so the domain layer stays /// free of a `maplibre_gl` dependency. @@ -833,7 +839,7 @@ class _ReportPeekSummary extends StatelessWidget { report.originTimeUtc, ); final taipei = AppTime.taipei(report.originTimeUtc); - final time = DateFormat('yyyy/MM/dd HH:mm:ss').format(taipei); + final time = _originTimeFormat.format(taipei); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1047,7 +1053,7 @@ class _ReportInfoCard extends StatelessWidget { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).colorScheme; final taipei = AppTime.taipei(report.originTimeUtc); - final originTime = DateFormat('yyyy/MM/dd HH:mm:ss').format(taipei); + final originTime = _originTimeFormat.format(taipei); final coordinates = '${report.latitude.toStringAsFixed(2)}°N・' '${report.longitude.toStringAsFixed(2)}°E'; diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 0234a5e06..f09a699aa 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -1351,11 +1351,16 @@ class _VersionCardState extends State<_VersionCard> { /// notes page's match (tag or name, `v` stripped) so both pages agree on /// which entry is "current" without sharing state. static bool _isCurrent(ReleaseNote note, String label) { - final tag = note.tagName.replaceFirst(RegExp(r'^v'), ''); - final name = note.name.replaceFirst(RegExp(r'^v'), ''); + final tag = note.tagName.replaceFirst(_vPrefix, ''); + final name = note.name.replaceFirst(_vPrefix, ''); return tag == label || name == label; } + /// Compiled once — `_isCurrent` runs per fetched note, and every inline + /// `RegExp(...)` compiles a fresh pattern (same reasoning as the changelog + /// page's copy). + static final RegExp _vPrefix = RegExp(r'^v'); + /// The number's gradient, derived from the version string itself so every /// build wears its own colours — 26w34a is one pair, 26w34b another — and /// any one build stays stable across reloads. Two hues off the golden diff --git a/lib/features/status/presentation/pages/server_status_page.dart b/lib/features/status/presentation/pages/server_status_page.dart index 183e031e8..f6506af26 100644 --- a/lib/features/status/presentation/pages/server_status_page.dart +++ b/lib/features/status/presentation/pages/server_status_page.dart @@ -1137,17 +1137,25 @@ String _compactPercent(num value) { var text = ''; for (var decimals = 2; decimals >= 0; decimals--) { text = value.toStringAsFixed(decimals); - final digits = text.replaceAll(RegExp(r'[^0-9]'), ''); + final digits = text.replaceAll(_nonDigits, ''); // 前導零不算位數(`0.02` 只有「2」一位)。 - if (digits.replaceFirst(RegExp(r'^0+(?=.)'), '').length <= 3) break; + if (digits.replaceFirst(_leadingZeros, '').length <= 3) break; } if (text.contains('.')) { - text = text.replaceFirst(RegExp(r'0+$'), ''); - text = text.replaceFirst(RegExp(r'\.$'), ''); + text = text.replaceFirst(_trailingZeros, ''); + text = text.replaceFirst(_trailingDot, ''); } return text; } +// Compiled once. `_compactPercent` runs on every rebuild of the status grid, +// and an inline `RegExp(...)` compiles a fresh pattern on every call — up to +// eight compilations per build for a string a few characters long. +final RegExp _nonDigits = RegExp(r'[^0-9]'); +final RegExp _leadingZeros = RegExp(r'^0+(?=.)'); +final RegExp _trailingZeros = RegExp(r'0+$'); +final RegExp _trailingDot = RegExp(r'\.$'); + Color _threeTone(BuildContext context, num value, double warn, double bad) { final colors = context.colorScheme; if (value >= bad) return colors.error; From 82ca715793a1daee4fb22857c23ee602f44a722e Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 12:04:50 +0800 Subject: [PATCH 20/32] perf(home): look the region label up once per badge, not per pixel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 首頁切換地區的滑動動畫不再每一格重查地區名稱 Optimization(en-US): the home area-switch slide no longer looks the area name up again for every pixel of the animation --- lib/shared/widgets/region_bar.dart | 42 +++++++++++++++++++----------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/lib/shared/widgets/region_bar.dart b/lib/shared/widgets/region_bar.dart index 2593ca1ba..031b96d20 100644 --- a/lib/shared/widgets/region_bar.dart +++ b/lib/shared/widgets/region_bar.dart @@ -2,7 +2,6 @@ import 'package:dpip/app/theme/app_glass.dart'; import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/geo/town_directory.dart'; -import 'package:dpip/core/settings/home_area.dart'; import 'package:dpip/core/settings/region_store.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/widgets/area_page_sync.dart'; @@ -63,6 +62,8 @@ class _RegionBarState extends State with AreaPageSyncMixin { final blend = widget.blend; final dismiss = widget.dismiss; final colors = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context); + final directory = context.read(); // Slide up by the bar's own height and fade out once the sheet invades it; // release taps to the sheet behind as soon as it is mostly gone. return IgnorePointer( @@ -81,16 +82,27 @@ class _RegionBarState extends State with AreaPageSyncMixin { controller: areaPageController, physics: const NeverScrollableScrollPhysics(), itemCount: areas.length, - itemBuilder: (context, index) => AnimatedBuilder( - animation: areaPageController, - builder: (context, _) => _RegionBadge( - index: index, - area: areas[index], - distance: (areaPage - index).abs(), - blend: blend, - skyIsLight: widget.skyIsLight, - ), - ), + itemBuilder: (context, index) { + // Resolved here, once per item, not inside the builder + // below: the label is a directory lookup that does not + // depend on the page position, and the builder runs for + // every visible badge on every pixel of the slide. + final label = regionAreaLabel( + l10n, + directory, + areas[index], + ); + return AnimatedBuilder( + animation: areaPageController, + builder: (context, _) => _RegionBadge( + index: index, + label: label, + distance: (areaPage - index).abs(), + blend: blend, + skyIsLight: widget.skyIsLight, + ), + ); + }, ), ), ), @@ -105,24 +117,24 @@ class _RegionBarState extends State with AreaPageSyncMixin { class _RegionBadge extends StatelessWidget { const _RegionBadge({ required this.index, - required this.area, + required this.label, required this.distance, required this.blend, required this.skyIsLight, }); final int index; - final HomeArea area; + + /// The area's display name, resolved by the bar (see its `itemBuilder`). + final String label; final double distance; final double blend; final bool skyIsLight; @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final colors = theme.colorScheme; - final label = regionAreaLabel(l10n, context.read(), area); // 1 at the centre → 0 one step away: badge fill and text emphasis. final fill = (1 - distance).clamp(0.0, 1.0); From a12c9b57d629f722d7696703f5df7dfc4ee6fc61 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 12:06:09 +0800 Subject: [PATCH 21/32] perf(map): parse each tile directory once instead of every URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 雷達與衛星時間軸在背景暖身快取時少做上萬次字串解析,手機更不發熱 Optimization(en-US): warming the radar and satellite caches no longer parses tens of thousands of URLs one by one, so the phone runs cooler --- .../weather/data/frame_tile_repository.dart | 29 ++- lib/shared/map/map_tile_cache.dart | 28 ++- lib/shared/map/map_tile_warmer.dart | 40 +++- lib/shared/map/tile_url.dart | 134 ++++++++++++ test/shared/map/tile_url_test.dart | 190 ++++++++++++++++++ 5 files changed, 399 insertions(+), 22 deletions(-) create mode 100644 lib/shared/map/tile_url.dart create mode 100644 test/shared/map/tile_url_test.dart diff --git a/lib/features/weather/data/frame_tile_repository.dart b/lib/features/weather/data/frame_tile_repository.dart index 7099bfe90..7de5b724f 100644 --- a/lib/features/weather/data/frame_tile_repository.dart +++ b/lib/features/weather/data/frame_tile_repository.dart @@ -15,6 +15,7 @@ import 'package:dpip/features/weather/domain/wind_field.dart'; import 'package:dpip/features/weather/domain/wind_forecast_repository.dart'; import 'package:dpip/shared/map/map_tile_warmer.dart'; import 'package:dpip/shared/map/raster_frame_source.dart'; +import 'package:dpip/shared/map/tile_url.dart'; import 'package:dpip/shared/map/xyz_tiles.dart'; import 'package:flutter/foundation.dart'; @@ -76,12 +77,20 @@ abstract base class FrameTileRepository implements RasterFrameSource { // its fallback and prefetch levels. Warming only cameraZoom.floor() missed // the z+1 requests MapLibre makes for 256 px tiles on Retina displays, // which made an apparently full L1 useless during scrubs. + // + // One [TileUrlTemplate] per frame, not a `replaceFirst` chain per tile. It + // is compiled from `tileUrl(frame)` rather than a frame id because + // building the URL resolves the host list and the query string; the three + // placeholder scans on top of that were then repeated for every tile of + // every frame — 36k scans and 36k intermediate strings on a 12k-URL fill. + // The expansion is the same string (see [TileUrlTemplate.expand]); the + // readiness probe below shares the template for the same reason. final urls = []; for (final frame in frames) { - final template = tileUrl(frame); + final template = TileUrlTemplate(tileUrl(frame)); for (final group in groups) { for (final tile in group) { - urls.add(_expand(template, tile)); + urls.add(template.expand(tile)); } } } @@ -117,14 +126,14 @@ abstract base class FrameTileRepository implements RasterFrameSource { if (tileGroups.isEmpty) { return (ready: false, resident: 0, required: 0); } - final template = tileUrl(frame); + final template = TileUrlTemplate(tileUrl(frame)); // Parent tiles are a useful visual fallback, but revealing on a complete // parent lets MapLibre replace it child-by-child a moment later — exactly // the patchwork flash this gate exists to prevent. The first group is the // highest display level native is expected to request, so only that whole // group makes the timestamp display-ready. final display = [ - for (final tile in tileGroups.first) _expand(template, tile), + for (final tile in tileGroups.first) template.expand(tile), ]; // A warm probe also *fills*, so it is handed every level — the fallback and @@ -138,7 +147,7 @@ abstract base class FrameTileRepository implements RasterFrameSource { final all = { ...display, for (var i = 1; i < tileGroups.length; i++) - for (final tile in tileGroups[i]) _expand(template, tile), + for (final tile in tileGroups[i]) template.expand(tile), }; resident = (await warmer.prepareUrls(all)).resident; } else { @@ -213,16 +222,6 @@ abstract base class FrameTileRepository implements RasterFrameSource { return groups; } - /// Fills one `tileUrl(frame)` template in for [tile]. - /// - /// The template is a parameter rather than a frame id because building it - /// resolves the host list and the query string: doing that per tile made a - /// viewport's worth of identical strings for every frame warmed or probed. - static String _expand(String template, XyzTile tile) => template - .replaceFirst('{z}', '${tile.z}') - .replaceFirst('{x}', '${tile.x}') - .replaceFirst('{y}', '${tile.y}'); - @override void cancelTileWarm() => warmer.cancel(); diff --git a/lib/shared/map/map_tile_cache.dart b/lib/shared/map/map_tile_cache.dart index fe34be79f..645e08ba8 100644 --- a/lib/shared/map/map_tile_cache.dart +++ b/lib/shared/map/map_tile_cache.dart @@ -10,7 +10,8 @@ import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/etag_interceptor.dart'; import 'package:dpip/core/network/network_usage_store.dart'; -import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:dpip/shared/map/tile_url.dart'; +import 'package:flutter/foundation.dart' show kDebugMode, visibleForTesting; import 'package:maplibre_gl/maplibre_gl.dart'; /// What one L1 warm actually accomplished. @@ -725,8 +726,31 @@ class MapTileCache { return sample.isEmpty ? '-' : sample.join(','); } - static bool _isTile(String url) { + /// Memoised per frame directory: [warm] and [residentUrls] gate every URL + /// they are handed through this, and a settled fill hands over 12k+ — each + /// paid a `Uri.tryParse`, a `toString` and ten substring searches for an + /// answer its `z/x/y` siblings had already established. + /// + /// Exact, because a plain `z/x/y.ext` tail cannot change the answer: the + /// parse succeeds or fails on the scheme and authority, which the tail is + /// not part of, and every marker in [EtagInterceptor.immutableAssetMarkers] + /// ends in a letter followed by `/` — something a run of decimal digits can + /// neither contain nor complete. Both halves are pinned by + /// `tile_url_test.dart`. + static final TileUrlMemo _isTileMemo = TileUrlMemo(_parseIsTile); + + static bool _isTile(String url) => _isTileMemo(url); + + static bool _parseIsTile(String url) { final uri = Uri.tryParse(url); return uri != null && EtagInterceptor.isImmutableTile(uri); } + + /// [_isTile] without the memo — the reference the memo must match. + @visibleForTesting + static bool parseIsTile(String url) => _parseIsTile(url); + + /// The memoised path, for the equivalence test. + @visibleForTesting + static bool isTileUrl(String url) => _isTile(url); } diff --git a/lib/shared/map/map_tile_warmer.dart b/lib/shared/map/map_tile_warmer.dart index b1e66a452..e33abc5bb 100644 --- a/lib/shared/map/map_tile_warmer.dart +++ b/lib/shared/map/map_tile_warmer.dart @@ -8,7 +8,9 @@ import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/network/api_region.dart'; import 'package:dpip/shared/map/map_tile_cache.dart'; +import 'package:dpip/shared/map/tile_url.dart'; import 'package:dpip/shared/map/xyz_tiles.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; /// Shared warm-up spine for radar / satellite / DPM / basemap. /// @@ -274,8 +276,11 @@ class MapTileWarmer { final direct = {}; final framesByFamily = >{}; for (final url in stale) { + // The family is the frame prefix minus its last segment, so it is cut + // from the prefix already in hand — deriving it from the URL again + // parsed every stale URL twice. final prefix = _framePrefix(url); - final family = _frameFamilyPrefix(url); + final family = prefix == null ? null : _familyOfFrame(prefix); if (prefix == null || family == null) { direct.add(url); } else if (wantedFrames.contains(prefix)) { @@ -319,7 +324,32 @@ class MapTileWarmer { return patterns.toList(growable: false); } - static String? _framePrefix(String url) { + /// Memoised per frame directory: every `z/x/y` sibling of one frame shares + /// its prefix, and a fill names thousands of siblings. `Uri.tryParse` plus + /// the regex ran for each of them — with the working-set diff asking twice + /// per stale URL — which was the single largest Dart cost of a settled fill. + /// + /// Exact, because the answer cannot depend on a plain `z/x/y.ext` tail: the + /// regex requires exactly three segments after the frame directory and + /// captures everything before them, so any plain tail yields the same + /// group; the origin and the parse's success come from the scheme and + /// authority, which the tail never touches. Pinned by + /// `tile_url_test.dart`. + static final TileUrlMemo _framePrefixMemo = TileUrlMemo( + _parseFramePrefix, + ); + + static String? _framePrefix(String url) => _framePrefixMemo(url); + + /// [_framePrefix] without the memo — the reference the memo must match. + @visibleForTesting + static String? parseFramePrefix(String url) => _parseFramePrefix(url); + + /// The memoised path, for the equivalence test. + @visibleForTesting + static String? framePrefixOf(String url) => _framePrefix(url); + + static String? _parseFramePrefix(String url) { final uri = Uri.tryParse(url); if (uri == null || !uri.hasScheme || uri.host.isEmpty) return null; final match = _frameTilePath.firstMatch(uri.path); @@ -328,9 +358,9 @@ class MapTileWarmer { return '${uri.origin}$path'; } - static String? _frameFamilyPrefix(String url) { - final frame = _framePrefix(url); - if (frame == null) return null; + /// The raster family a [_framePrefix] belongs to — the prefix with its + /// trailing `/` segment removed. + static String? _familyOfFrame(String frame) { final slash = frame.lastIndexOf('/', frame.length - 2); if (slash < 0) return null; return frame.substring(0, slash + 1); diff --git a/lib/shared/map/tile_url.dart b/lib/shared/map/tile_url.dart new file mode 100644 index 000000000..96cd971e5 --- /dev/null +++ b/lib/shared/map/tile_url.dart @@ -0,0 +1,134 @@ +/// String-level helpers for the XYZ tile URLs the map warms by the thousand. +/// +/// A settled radar fill names 12k+ tile URLs, and everything that used to be +/// derived from one of them — is it a cacheable tile, which frame directory +/// does it belong to, what does its template expand to — was derived again +/// for every sibling `z/x/y` of the same frame. The helpers here make that +/// once-per-frame instead of once-per-tile without changing a single answer. +library; + +import 'package:dpip/shared/map/xyz_tiles.dart'; + +/// Index just past the `/` that precedes a plain `z/x/y.ext` tail, or `-1`. +/// +/// A *plain* tail is exactly three decimal runs and an ASCII-alphanumeric +/// extension — `7/108/56.webp` — never one with a query or fragment +/// (`…/56.webp?style=jma`), a non-numeric coordinate, or fewer than three +/// segments. The restriction is what makes [TileUrlMemo] exact: a plain tail +/// contains nothing `Uri.parse` would reject, escape or treat as a delimiter, +/// so it can neither change whether the URL parses nor how the part before it +/// is normalised. +int tileUrlDirectoryEnd(String url) { + var i = url.length - 1; + // Extension: one or more `[A-Za-z0-9]`, then the dot. + final extEnd = i; + while (i >= 0 && _isAlphanumeric(url.codeUnitAt(i))) { + i--; + } + if (i == extEnd || i < 0 || url.codeUnitAt(i) != _dot) return -1; + i--; + // Three decimal runs, `/`-separated, each preceded by a `/`. + for (var run = 0; run < 3; run++) { + final runEnd = i; + while (i >= 0 && _isDigit(url.codeUnitAt(i))) { + i--; + } + if (i == runEnd || i < 0 || url.codeUnitAt(i) != _slash) return -1; + if (run < 2) i--; + } + return i + 1; +} + +const int _dot = 0x2E; +const int _slash = 0x2F; + +bool _isDigit(int unit) => unit >= 0x30 && unit <= 0x39; + +bool _isAlphanumeric(int unit) => + _isDigit(unit) || + (unit >= 0x41 && unit <= 0x5A) || + (unit >= 0x61 && unit <= 0x7A); + +/// Memoises a per-URL derivation on the URL's directory. +/// +/// [derive] must give the same answer for every URL that shares a directory +/// and differs only in a plain `z/x/y.ext` tail (see [tileUrlDirectoryEnd]); +/// each call site states why its function does. A URL whose tail is not +/// plain is derived directly and never cached, so the memo can only ever +/// return what [derive] would have. +/// +/// Bounded: the table is cleared once it reaches [capacity] entries. The keys +/// are frame directories — a few hundred per timeline — so it is cleared +/// rarely and refilled at one derivation per frame. +class TileUrlMemo { + TileUrlMemo(this._derive, {this.capacity = 512}); + + final T Function(String url) _derive; + final int capacity; + final Map _byDirectory = {}; + + T call(String url) { + final end = tileUrlDirectoryEnd(url); + if (end < 0) return _derive(url); + final directory = url.substring(0, end); + // `containsKey` rather than a null check: `T` may itself be nullable. + if (_byDirectory.containsKey(directory)) { + return _byDirectory[directory] as T; + } + if (_byDirectory.length >= capacity) _byDirectory.clear(); + return _byDirectory[directory] = _derive(url); + } +} + +/// A tile URL template split once around its `{z}` / `{x}` / `{y}` slots. +/// +/// [expand] is byte-for-byte what +/// `template.replaceFirst('{z}', z).replaceFirst('{x}', x).replaceFirst('{y}', y)` +/// produces, at one string build per tile instead of three scans and three +/// intermediate strings. The two agree because the substituted values are +/// decimal digits: they can never create, destroy or shift a later `{…}` +/// occurrence, so the first occurrence of each token in the original template +/// is the one every `replaceFirst` would have found. A token the template does +/// not contain is left out, exactly as `replaceFirst` leaves it. +final class TileUrlTemplate { + factory TileUrlTemplate(String template) { + final slots = <(int, int)>[ + for (final (axis, token) in const [(0, '{z}'), (1, '{x}'), (2, '{y}')]) + if (template.indexOf(token) case final at when at >= 0) (at, axis), + ]..sort((a, b) => a.$1.compareTo(b.$1)); + final pieces = []; + final axes = []; + var from = 0; + for (final (at, axis) in slots) { + pieces.add(template.substring(from, at)); + axes.add(axis); + from = at + 3; + } + pieces.add(template.substring(from)); + return TileUrlTemplate._( + List.unmodifiable(pieces), + List.unmodifiable(axes), + ); + } + + const TileUrlTemplate._(this._pieces, this._axes); + + /// Literal text between slots — always one more than [_axes]. + final List _pieces; + + /// Which coordinate each slot takes: 0 = z, 1 = x, 2 = y, in template order. + final List _axes; + + String expand(XyzTile tile) { + final out = StringBuffer(_pieces[0]); + for (var i = 0; i < _axes.length; i++) { + out.write(switch (_axes[i]) { + 0 => tile.z, + 1 => tile.x, + _ => tile.y, + }); + out.write(_pieces[i + 1]); + } + return out.toString(); + } +} diff --git a/test/shared/map/tile_url_test.dart b/test/shared/map/tile_url_test.dart new file mode 100644 index 000000000..e04ec7ab3 --- /dev/null +++ b/test/shared/map/tile_url_test.dart @@ -0,0 +1,190 @@ +/// The tile-URL helpers must be invisible: every memoised or precompiled +/// answer has to equal the one the plain string/URI code gave before. +library; + +import 'package:dpip/core/network/etag_interceptor.dart'; +import 'package:dpip/shared/map/map_tile_cache.dart'; +import 'package:dpip/shared/map/map_tile_warmer.dart'; +import 'package:dpip/shared/map/tile_url.dart'; +import 'package:dpip/shared/map/xyz_tiles.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A spread of URLs the memo may see: real tiles of several families, the +/// same frame at many coordinates, and every shape that must fall through to +/// the direct derivation (queries, non-numeric frames or coordinates, no +/// scheme, too few segments, an uppercase host the parser normalises). +const List _urls = [ + 'https://static.exptech.dev/api/v2/tiles/radar/1787236800/7/106/55.webp', + 'https://static.exptech.dev/api/v2/tiles/radar/1787236800/7/107/55.webp', + 'https://static.exptech.dev/api/v2/tiles/radar/1787236800/6/53/27.webp', + 'https://static.exptech.dev/api/v2/tiles/radar/1787237400/7/106/55.webp', + 'https://static.exptech.dev/api/v2/tiles/satellite/13/normal/1787236800/' + '7/106/55.webp', + 'https://static.exptech.dev/api/v2/tiles/wind/gfs/1787239200/1787236800/' + '5/26/13.webp', + 'https://static.exptech.dev/api/v2/tiles/dpm/aed/12/3421/1740.mvt', + 'https://static.lb.exptech.dev/api/v1/map/tiles/7/1/1.pbf', + 'https://static.lb.exptech.dev/api/v1/map/terrain/7/107/55.png', + 'https://static.lb.exptech.dev/api/v1/map/gsi/14/13700/7000.pbf', + 'https://cdn.jsdelivr.net/gh/exptechtw/map-assets/Noto/0-255.pbf', + 'https://static.exptech.dev/api/v2/tiles/radar/1787236800/7/106/55.webp' + '?style=jma', + 'https://static.exptech.dev/api/v2/tiles/radar/1787236800/7/106/55.webp' + '#frag', + 'https://static.exptech.dev/api/v2/tiles/radar/old/2/3/4.webp', + 'https://static.exptech.dev/api/v2/tiles/radar/1787236800/7/x/55.webp', + 'https://static.exptech.dev/api/v2/tiles/radar/1787236800/7/106/55', + 'https://static.exptech.dev/api/v2/tiles/radar/1787236800/106/55.webp', + 'https://STATIC.EXPTECH.DEV/api/v2/tiles/radar/1787236800/7/106/55.webp', + 'https://example.com/other/1787236800/7/106/55.webp', + '//static.exptech.dev/api/v2/tiles/radar/1787236800/7/106/55.webp', + '/api/v2/tiles/radar/1787236800/7/106/55.webp', + '1/2/3.png', + '', +]; + +void main() { + group('tileUrlDirectoryEnd', () { + test('finds the directory of a plain z/x/y.ext tail', () { + const url = 'https://h/api/v2/tiles/radar/1787236800/7/106/55.webp'; + final end = tileUrlDirectoryEnd(url); + expect(url.substring(0, end), 'https://h/api/v2/tiles/radar/1787236800/'); + }); + + test( + 'refuses anything that is not three decimal runs and an extension', + () { + for (final url in const [ + 'https://h/a/7/106/55.webp?style=jma', + 'https://h/a/7/106/55.webp#f', + 'https://h/a/7/x/55.webp', + 'https://h/a/7/106/55', + 'https://h/a/106/55.webp', + 'https://h/a/7/106/55.', + 'https://h/a/7/106/.webp', + 'https://h/a7/106/55.webp', + '1/2/3.png', + '', + ]) { + expect(tileUrlDirectoryEnd(url), -1, reason: url); + } + }, + ); + }); + + group('TileUrlMemo', () { + test('derives once per directory and never caches a non-plain tail', () { + final seen = []; + final memo = TileUrlMemo((url) { + seen.add(url); + return url.length; + }); + const a = 'https://h/f/1/2/3.png'; + const b = 'https://h/f/1/2/4.png'; + const q = 'https://h/f/1/2/4.png?x'; + expect(memo(a), a.length); + expect(memo(b), b.length); + expect(memo(q), q.length); + expect(memo(q), q.length); + expect(seen, [a, q, q], reason: 'siblings share; queries never cache'); + }); + + test('clears at capacity instead of growing without bound', () { + var derived = 0; + final memo = TileUrlMemo((_) => ++derived, capacity: 2); + memo('https://h/a/1/2/3.png'); + memo('https://h/b/1/2/3.png'); + memo('https://h/c/1/2/3.png'); + expect(memo('https://h/a/1/2/3.png'), 4, reason: 'a was evicted'); + }); + }); + + group('frame prefix memo', () { + test('agrees with the direct parse for every URL shape', () { + for (final url in _urls) { + expect( + MapTileWarmer.framePrefixOf(url), + MapTileWarmer.parseFramePrefix(url), + reason: url, + ); + } + }); + + test('a sibling served from the memo still gets the parsed answer', () { + const frame = 'https://h/api/v2/tiles/radar/1787236800/'; + const first = '${frame}7/106/55.webp'; + const sibling = '${frame}9/423/221.webp'; + expect(MapTileWarmer.framePrefixOf(first), frame); + expect( + MapTileWarmer.framePrefixOf(sibling), + MapTileWarmer.parseFramePrefix(sibling), + ); + // A query string is not a plain tail, so it goes through the parser + // itself — and there the path still matches, exactly as it always did. + expect(MapTileWarmer.framePrefixOf('$first?style=jma'), frame); + }); + }); + + group('tile gate memo', () { + test('agrees with the direct parse for every URL shape', () { + for (final url in _urls) { + expect( + MapTileCache.isTileUrl(url), + MapTileCache.parseIsTile(url), + reason: url, + ); + } + }); + + test('no immutable marker can be matched by a plain z/x/y tail', () { + // The memo's exactness rests on this: a marker ending in a letter then + // `/` can neither sit inside a run of digits nor be completed by one, + // so swapping one plain tail for another never changes the answer. A + // marker like `/api/v1/` would break that — it would end in `1/`. + for (final marker in EtagInterceptor.immutableAssetMarkers) { + expect( + RegExp(r'[A-Za-z]/$').hasMatch(marker), + isTrue, + reason: '$marker must end in a letter followed by "/"', + ); + } + }); + }); + + group('TileUrlTemplate', () { + String reference(String template, XyzTile tile) => template + .replaceFirst('{z}', '${tile.z}') + .replaceFirst('{x}', '${tile.x}') + .replaceFirst('{y}', '${tile.y}'); + + const tiles = [ + (z: 0, x: 0, y: 0), + (z: 7, x: 106, y: 55), + (z: 12, x: 3421, y: 1740), + ]; + + test('expands exactly as the replaceFirst chain did', () { + for (final template in const [ + 'https://h/api/v2/tiles/radar/1787236800/{z}/{x}/{y}.webp', + 'https://h/api/v2/tiles/satellite/13/jma/1787236800/{z}/{x}/{y}.webp' + '?style=jma', + 'https://h/{y}/{x}/{z}.png', + 'https://h/{z}/{z}/{x}/{y}.png', + 'https://h/{x}/{y}.png', + 'https://h/{z}{x}{y}', + '{z}', + 'https://h/no/placeholders.png', + '', + ]) { + final compiled = TileUrlTemplate(template); + for (final tile in tiles) { + expect( + compiled.expand(tile), + reference(template, tile), + reason: '$template @ $tile', + ); + } + } + }); + }); +} From 34dc850e92115cfc54c7ebdb6424f8a863d763c8 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 12:06:15 +0800 Subject: [PATCH 22/32] perf(map): stop rebuilding formatters and era colours per tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 拖動時間軸、監視器狀態列與測站圖表每次重繪時不再重建時間格式與色彩 Optimization(en-US): the timeline, monitor status strip and station chart stop rebuilding their time formats and colours on every repaint --- .../widgets/rts_monitor_panel.dart | 7 ++++++- .../presentation/widgets/station_sheet.dart | 12 ++++++++--- lib/shared/map/map_timeline.dart | 20 +++++++++++++++---- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/lib/features/map/presentation/widgets/rts_monitor_panel.dart b/lib/features/map/presentation/widgets/rts_monitor_panel.dart index 35937d47f..c4bc7ae19 100644 --- a/lib/features/map/presentation/widgets/rts_monitor_panel.dart +++ b/lib/features/map/presentation/widgets/rts_monitor_panel.dart @@ -333,6 +333,11 @@ class _StatusBar extends StatelessWidget { final RealtimeState state; final RealtimeNotifier> eew; + /// The snapshot clock. One instance: this strip rebuilds on every ~1 Hz RTS + /// poll, and each build re-parsed the pattern into a fresh formatter. The + /// pattern is numeric-only, so no locale symbol data is involved. + static final DateFormat _clock = DateFormat('HH:mm:ss'); + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -350,7 +355,7 @@ class _StatusBar extends StatelessWidget { // corrected clock — both on the server clock, so the lag is device-skew // immune. Latency floored at 0 against sub-sync jitter. final dataTime = hasData - ? DateFormat('HH:mm:ss').format( + ? _clock.format( AppTime.taipei( DateTime.fromMillisecondsSinceEpoch(time, isUtc: true), ), diff --git a/lib/features/map/presentation/widgets/station_sheet.dart b/lib/features/map/presentation/widgets/station_sheet.dart index 2a2515904..9bc147361 100644 --- a/lib/features/map/presentation/widgets/station_sheet.dart +++ b/lib/features/map/presentation/widgets/station_sheet.dart @@ -681,6 +681,12 @@ class _TrendChart extends StatelessWidget { /// uses a line. final bool bars; + /// The 7-day axis date. One instance: fl_chart asks `getTitlesWidget` for + /// every tick on every layout, and each ask re-parsed this pattern into a + /// fresh formatter. Numeric-only, so no locale symbol data is needed — the + /// same reasoning as the timeline's `HH:mm` / `yyyy/MM/dd` statics. + static final DateFormat _monthDay = DateFormat('M/d'); + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); @@ -752,11 +758,11 @@ class _TrendChart extends StatelessWidget { ); final hourMark = l10n.chartHourLabel(t.hour); // Daily-or-wider: date only. Sub-daily 7d: date + hour. 24h: compact hour. - if (labelStep >= 24 * hourSec) return DateFormat('M/d').format(t); + if (labelStep >= 24 * hourSec) return _monthDay.format(t); if (range == '7d') { return t.hour == 0 - ? DateFormat('M/d').format(t) - : '${DateFormat('M/d').format(t)} $hourMark'; + ? _monthDay.format(t) + : '${_monthDay.format(t)} $hourMark'; } return hourMark; } diff --git a/lib/shared/map/map_timeline.dart b/lib/shared/map/map_timeline.dart index f20b9a40f..5c294aa24 100644 --- a/lib/shared/map/map_timeline.dart +++ b/lib/shared/map/map_timeline.dart @@ -538,6 +538,17 @@ class _MapTimelineState extends State { final nowIndex = nowFrameIndex(widget.frames, now: AppTime.utc); final era = _eraOf(_liveIndex, nowIndex); final labelStep = (48 / widget.itemExtent).ceil(); + // The three era colours once per build, not once per tick: `_eraColor` + // runs the colour-vision transform (a linear-light round trip when a + // correction is on), and the ruler lays out dozens of ticks on every frame + // the finger crosses. Same pure function of the same inputs, so each tick + // gets exactly the colour it computed for itself before. + final brightness = theme.brightness; + final eraColors = ( + past: _eraColor(TimelineEra.past, brightness), + now: _eraColor(TimelineEra.now, brightness), + future: _eraColor(TimelineEra.future, brightness), + ); return Column( mainAxisSize: MainAxisSize.min, @@ -615,10 +626,11 @@ class _MapTimelineState extends State { itemBuilder: (context, i) => _Tick( width: widget.itemExtent, label: i % labelStep == 0 ? _times[i] : null, - labelColor: _eraColor( - _eraOf(i, nowIndex), - theme.brightness, - ), + labelColor: switch (_eraOf(i, nowIndex)) { + TimelineEra.past => eraColors.past, + TimelineEra.now => eraColors.now, + TimelineEra.future => eraColors.future, + }, emphasised: i == _liveIndex, colors: colors, textStyle: theme.textTheme.labelSmall, From fd4cd0c750e56b1fabb70fb3987e87a940290e02 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 12:07:39 +0800 Subject: [PATCH 23/32] perf(eew): decode the seismic feed from its bytes without a copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 強震監視器每秒的測站資料少複製一次、少解析一次,長時間開著更省電 Optimization(en-US): the monitor's once-a-second station feed is copied and parsed one time fewer, so leaving it open costs less battery --- lib/core/network/sse_client.dart | 23 ++++---- lib/core/realtime/sse_realtime_source.dart | 30 ++++++++-- .../earthquake/data/rts_realtime_source.dart | 13 +++++ test/core/network/sse_client_test.dart | 9 +++ .../realtime/sse_realtime_source_test.dart | 58 ++++++++++++++++--- .../earthquake/rts_realtime_source_test.dart | 17 ++++++ 6 files changed, 127 insertions(+), 23 deletions(-) diff --git a/lib/core/network/sse_client.dart b/lib/core/network/sse_client.dart index e58e5bf03..c7d7838b5 100644 --- a/lib/core/network/sse_client.dart +++ b/lib/core/network/sse_client.dart @@ -71,6 +71,7 @@ class HttpSseClient implements SseClient { static Stream parse(Stream> bytes) async* { String? name; final data = StringBuffer(); + var dataLines = 0; Duration? retry; var dirty = false; @@ -78,14 +79,11 @@ class HttpSseClient implements SseClient { await for (final line in lines) { if (line.isEmpty) { if (dirty) { - yield SseEvent( - name: name, - data: _stripTrailingNewline(data.toString()), - retry: retry, - ); + yield SseEvent(name: name, data: data.toString(), retry: retry); } name = null; data.clear(); + dataLines = 0; retry = null; dirty = false; continue; @@ -100,9 +98,15 @@ class HttpSseClient implements SseClient { case 'event': name = value; case 'data': - data - ..write(value) - ..write('\n'); + // The separator goes *between* lines, never after the last one, so + // the buffer already holds the spec's joined form and [toString] + // is the frame: the trailing-newline strip that used to follow it + // copied every payload once more — at 1 Hz on RTS, a 20 KB string + // per second for nothing. Same output: `a`,`b` → `a\nb`; an empty + // `data:` line still contributes its empty string. + if (dataLines > 0) data.write('\n'); + data.write(value); + dataLines++; case 'retry': final ms = int.tryParse(value); if (ms != null) retry = Duration(milliseconds: ms); @@ -111,7 +115,4 @@ class HttpSseClient implements SseClient { } } } - - static String _stripTrailingNewline(String s) => - s.endsWith('\n') ? s.substring(0, s.length - 1) : s; } diff --git a/lib/core/realtime/sse_realtime_source.dart b/lib/core/realtime/sse_realtime_source.dart index 8d95ca897..995364302 100644 --- a/lib/core/realtime/sse_realtime_source.dart +++ b/lib/core/realtime/sse_realtime_source.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/error/result.dart'; @@ -106,6 +107,18 @@ abstract class SseRealtimeSource extends RealtimeSource { /// JSON the one-shot GET returns, so this mirrors the repository's mapping. T decode(String data); + /// Decodes an inflated `compress=1` payload — the same JSON as [decode]'s + /// argument, still as UTF-8 bytes. + /// + /// Default: materialise the string and hand it to [decode], which is what + /// every source did before this hook existed. A source whose payload is + /// large and continuous (RTS, ~1000 stations at 1 Hz) overrides it to parse + /// the bytes directly with `Utf8Decoder.fuse(JsonDecoder)`: `dart:convert` + /// then walks the UTF-8 once, instead of decoding it into a 60 KB `String` + /// only to tokenise that string a second time. Same object graph out, one + /// full copy of every frame fewer — on the UI isolate, every second. + T decodeBytes(Uint8List utf8Json) => decode(utf8.decode(utf8Json)); + @override Future> fetch() async { if (_disposed) { @@ -192,11 +205,18 @@ abstract class SseRealtimeSource extends RealtimeSource { // decompressed here at the application layer. if (event.name == _compressedEvent || event.isDefault) { try { - final json = event.name == _compressedEvent - ? utf8.decode(gzip.decode(base64.decode(event.data.trim()))) - : event.data; - if (json.isEmpty) return; // metadata-only frame, not a payload - _latest = decode(json); + // Metadata-only frames carry no payload: skipped before decoding on + // either path, exactly as the empty-string check did. + if (event.name == _compressedEvent) { + final bytes = gzip.decode(base64.decode(event.data.trim())); + if (bytes.isEmpty) return; + _latest = decodeBytes( + bytes is Uint8List ? bytes : Uint8List.fromList(bytes), + ); + } else { + if (event.data.isEmpty) return; + _latest = decode(event.data); + } _hasSnapshot = true; _lastEventMark = _elapsed.elapsed; } catch (error, stackTrace) { diff --git a/lib/features/earthquake/data/rts_realtime_source.dart b/lib/features/earthquake/data/rts_realtime_source.dart index 80c631295..67d4415e4 100644 --- a/lib/features/earthquake/data/rts_realtime_source.dart +++ b/lib/features/earthquake/data/rts_realtime_source.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:dpip/core/network/sse_event.dart'; import 'package:dpip/core/realtime/sse_realtime_source.dart'; @@ -30,6 +31,18 @@ class RtsRealtimeSource extends SseRealtimeSource { Rts decode(String data) => Rts.fromJson(jsonDecode(data) as Map); + /// The live path. Parses the inflated UTF-8 directly — the fused decoder + /// is the pair `jsonDecode` itself uses on a byte input, so the map handed + /// to [Rts.fromJson] is shape-for-shape what [decode] builds from a string; + /// it just never builds the string. See [SseRealtimeSource.decodeBytes]. + @override + Rts decodeBytes(Uint8List utf8Json) => + Rts.fromJson(_utf8Json.convert(utf8Json) as Map); + + /// Fused once; `fuse` builds a new converter object per call. + static final Converter, Object?> _utf8Json = const Utf8Decoder() + .fuse(const JsonDecoder()); + /// Null: freshness is event-recency (above), not payload age — so clock skew /// on the snapshot's `time` can't reclassify a live feed. @override diff --git a/test/core/network/sse_client_test.dart b/test/core/network/sse_client_test.dart index 1cce706c6..0b8ac7668 100644 --- a/test/core/network/sse_client_test.dart +++ b/test/core/network/sse_client_test.dart @@ -45,6 +45,15 @@ void main() { }, ); + test('empty data lines still contribute their newline', () async { + // The join used to be "append a newline after every line, strip one at + // the end"; it is now "a newline between lines". Both give these. + final events = await HttpSseClient.parse( + _bytes(['data:\ndata:\n\n', 'data: a\ndata:\n\n', 'data:\n\n']), + ).toList(); + expect(events.map((e) => e.data), ['\n', 'a\n', '']); + }); + test('skips comment / heartbeat lines', () async { final events = await HttpSseClient.parse( _bytes([': keep-alive\ndata: x\n\n']), diff --git a/test/core/realtime/sse_realtime_source_test.dart b/test/core/realtime/sse_realtime_source_test.dart index a623b8298..3f423893b 100644 --- a/test/core/realtime/sse_realtime_source_test.dart +++ b/test/core/realtime/sse_realtime_source_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'dart:convert'; import 'dart:io'; @@ -38,14 +39,35 @@ class _TestSseSource extends SseRealtimeSource { /// Drives a [SseRealtimeSource] with a controllable connection factory, a /// manually-completed reconnect delay, and a fake monotonic clock. +/// A source that overrides the byte hook, so a test can tell which path a +/// compressed frame took: the default routes bytes through [decode]. +class _BytesSseSource extends _TestSseSource { + _BytesSseSource({ + required super.connect, + super.liveness, + super.elapsed, + super.delay, + }); + + @override + String decodeBytes(Uint8List utf8Json) => 'bytes:${utf8.decode(utf8Json)}'; +} + class _Harness { - _Harness({SseLiveness? liveness}) { - source = _TestSseSource( - connect: _connect, - liveness: liveness, - elapsed: elapsed, - delay: _delay, - ); + _Harness({SseLiveness? liveness, bool bytesSource = false}) { + source = bytesSource + ? _BytesSseSource( + connect: _connect, + liveness: liveness, + elapsed: elapsed, + delay: _delay, + ) + : _TestSseSource( + connect: _connect, + liveness: liveness, + elapsed: elapsed, + delay: _delay, + ); } final connects = >[]; @@ -102,6 +124,28 @@ void main() { expect(h.connects, hasLength(1), reason: 'no reconnect churn while open'); }); + test('a compressed frame reaches decodeBytes, never decode', () async { + final h = _Harness(bytesSource: true); + await h.source.fetch(); + final packed = base64.encode(gzip.encode(utf8.encode('shaken'))); + h.current.add(SseEvent(name: 'g', data: packed)); + await Future.delayed(Duration.zero); + final result = await h.source.fetch(); + expect(result.valueOrNull, 'bytes:shaken'); + }); + + test( + 'an empty compressed frame is metadata, not an empty snapshot', + () async { + final h = _Harness(bytesSource: true); + await h.source.fetch(); + final packed = base64.encode(gzip.encode(utf8.encode(''))); + h.current.add(SseEvent(name: 'g', data: packed)); + await Future.delayed(Duration.zero); + expect((await h.source.fetch()).isOk, isFalse); + }, + ); + test('decompresses a compressed payload (event: g, base64 gzip)', () async { final h = _Harness(); await h.source.fetch(); diff --git a/test/features/earthquake/rts_realtime_source_test.dart b/test/features/earthquake/rts_realtime_source_test.dart index 5c046c3d9..ea240391d 100644 --- a/test/features/earthquake/rts_realtime_source_test.dart +++ b/test/features/earthquake/rts_realtime_source_test.dart @@ -26,6 +26,23 @@ void main() { expect(rts.station['2012144']!.intensity, -3.0); }); + test('decodeBytes (the compress=1 path) builds the same Rts as decode', () { + final data = jsonEncode({ + 'station': { + '2012144': {'pga': 2.79, 'pgv': 0.52, 'i': -2.9, 'I': -3, 'alert': 1}, + '11339996': {'pga': 0.1, 'pgv': 0.01, 'i': -3.1, 'I': -3.2}, + }, + 'box': {'1': 2}, + 'int': [ + {'code': 400, 'i': 3}, + ], + 'time': 1783968266383, + }); + final source = _source(); + // Freezed deep equality: every station, box and intensity compared. + expect(source.decodeBytes(utf8.encode(data)), source.decode(data)); + }); + test('timestampOf is null → event-recency freshness, not payload age', () { final rts = _source().decode(jsonEncode({'time': 1783968266383})); expect(_source().timestampOf(rts), isNull); From 87139b0b4776697ea56ca4b55f395e49d5da173c Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 12:07:46 +0800 Subject: [PATCH 24/32] perf: stop re-parsing URLs and copying bodies on the cache path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 每一次快取讀寫少做多次網址解析與整段內容複製,載入地圖圖磚與資料更省 CPU Optimization(en-US): every cache read and write skips repeated URL parsing and whole-body copies, so loading tiles and data costs less CPU --- lib/core/network/etag_cache_store.dart | 34 ++++++++--- lib/core/network/etag_interceptor.dart | 81 +++++++++++++++++++------ test/core/network/utf8_length_test.dart | 47 ++++++++++++++ 3 files changed, 132 insertions(+), 30 deletions(-) create mode 100644 test/core/network/utf8_length_test.dart diff --git a/lib/core/network/etag_cache_store.dart b/lib/core/network/etag_cache_store.dart index 727359898..dc5ddccd8 100644 --- a/lib/core/network/etag_cache_store.dart +++ b/lib/core/network/etag_cache_store.dart @@ -185,6 +185,12 @@ class EtagCacheStore { /// Compressed bytes above which a batch inflate is worth an isolate hop. static const _isolateThreshold = 64 * 1024; + /// JSON body length (code units, so ~bytes for the ASCII these are) above + /// which the write-side gzip is worth an isolate hop — the same 16 KB line + /// [readJson] draws for the inflate. Below it a spawn (plus copying the + /// body across) costs more than the deflate it moves. + static const _jsonIsolateThreshold = 16 * 1024; + /// Running `SUM(LENGTH(body))`, seeded by the first trim. Replacements are /// counted as pure additions between sweeps, so this only ever over-estimates /// — which triggers a sweep early rather than letting the store overrun. @@ -335,9 +341,11 @@ class EtagCacheStore { if (kind != kindBinary && kind != kindBinaryGzip) return null; if (touch) _scheduleTouch(url); final blob = row['body'] as Uint8List; - final bytes = kind == kindBinaryGzip - ? await _gunzip(blob) - : Uint8List.fromList(blob); + // The raw blob is served as-is: sqlite_async already hands over a + // Uint8List this isolate owns (it crossed from the database isolate), + // nothing else holds it, and no caller writes into it — so the copy + // that used to sit here doubled every WebP tile for no reader. + final bytes = kind == kindBinaryGzip ? await _gunzip(blob) : blob; final entry = CachedBytes( etag: row['etag'] as String, bytes: bytes, @@ -403,9 +411,10 @@ class EtagCacheStore { final kind = row['kind'] as int; if (kind != kindBinary && kind != kindBinaryGzip) continue; final key = row['key'] as String; + // Same as [readBytes]: the raw blob is already this isolate's own. final bytes = kind == kindBinaryGzip ? inflated[key] - : Uint8List.fromList(row['body'] as Uint8List); + : row['body'] as Uint8List; if (bytes == null) continue; final entry = CachedBytes( etag: row['etag'] as String, @@ -457,12 +466,14 @@ class EtagCacheStore { int size = 0, }) async { try { - // Light gzip on a worker isolate so large JSON writes don't jank the UI. - final blob = await Isolate.run(() { - return Uint8List.fromList( - GZipCodec(level: 1).encode(utf8.encode(body)), - ); - }); + // Light gzip on a worker isolate so large JSON writes don't jank the UI + // — but only when the body is big enough to be worth the hop. Most JSON + // responses are a few KB (an EEW list, a report page); spawning an + // isolate for those cost more than deflating them inline, and the bytes + // written are identical either way. + final blob = body.length > _jsonIsolateThreshold + ? await Isolate.run(() => _gzipJson(body)) + : _gzipJson(body); await _insert( url, etag: etag, @@ -709,6 +720,9 @@ class EtagCacheStore { return Map.of(row); } + static Uint8List _gzipJson(String body) => + Uint8List.fromList(GZipCodec(level: 1).encode(utf8.encode(body))); + /// JSON bodies are stored gzip-1; inflate off the UI isolate when large. static Future _decodeJsonBody(Uint8List blob) async { if (blob.length >= 2 && blob[0] == 0x1f && blob[1] == 0x8b) { diff --git a/lib/core/network/etag_interceptor.dart b/lib/core/network/etag_interceptor.dart index d8f35e5b4..f79c841f4 100644 --- a/lib/core/network/etag_interceptor.dart +++ b/lib/core/network/etag_interceptor.dart @@ -6,6 +6,7 @@ import 'package:dio/dio.dart'; import 'package:dpip/core/network/api_paths.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/network_usage_store.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; /// Dio interceptor implementing HTTP ETag revalidation against an /// [EtagCacheStore]. @@ -47,13 +48,20 @@ class EtagInterceptor extends Interceptor { /// GET is the default cacheable verb; POST is only cached for status-exptech /// dashboards, whose query body is a constant baked into the client and whose /// URL therefore pins the result — content-addressed, like an immutable tile. - static bool _cacheable(RequestOptions o) { - if (o.method.toUpperCase() == 'GET') { + /// + /// [uri] is the request's resolved URI, parsed **once** by the caller. + /// `RequestOptions.uri` is a getter that re-runs `Uri.parse` (plus a regex + /// and `normalizePath`) on every read, and each hook below used to read it + /// three or four times — per tile, in a viewport of dozens. Threading one + /// parsed value through is the same URI every time. + static bool _cacheable(RequestOptions o, Uri uri) { + final method = o.method.toUpperCase(); + if (method == 'GET') { return o.responseType != ResponseType.stream && - !isUncacheablePath(o.uri.path); + !isUncacheablePath(uri.path); } - if (o.method.toUpperCase() == 'POST') { - return o.uri.host == 'status.exptech.dev' && + if (method == 'POST') { + return uri.host == 'status.exptech.dev' && o.responseType != ResponseType.stream; } return false; @@ -138,11 +146,42 @@ class EtagInterceptor extends Interceptor { response.headers.value(Headers.contentLengthHeader) ?? '', ); if (length != null && length > 0) return length; - if (encoded != null) return utf8.encode(encoded).length; + if (encoded != null) return utf8Length(encoded); final data = response.data; if (data == null) return 0; if (data is List) return data.length; - return utf8.encode(data is String ? data : jsonEncode(data)).length; + return utf8Length(data is String ? data : jsonEncode(data)); + } + + /// `utf8.encode(s).length` without materialising the encoding. + /// + /// The metering fallback runs on almost every JSON miss (the platform strips + /// `Content-Length` when it gunzips), and `utf8.encode` allocated and filled + /// a full byte copy of the body — 130 KB for a station catalogue — on the UI + /// isolate only to read its `.length`. Counting is the same number: + /// 1 byte below U+0080, 2 below U+0800, 4 for a surrogate *pair*, and 3 for + /// everything else — including a lone surrogate, which `Utf8Encoder` + /// replaces with U+FFFD (three bytes). Pinned by a test against the encoder. + @visibleForTesting + static int utf8Length(String s) { + var bytes = 0; + final length = s.length; + for (var i = 0; i < length; i++) { + final unit = s.codeUnitAt(i); + if (unit < 0x80) { + bytes += 1; + } else if (unit < 0x800) { + bytes += 2; + } else if ((unit & 0xFC00) == 0xD800 && + i + 1 < length && + (s.codeUnitAt(i + 1) & 0xFC00) == 0xDC00) { + bytes += 4; + i++; + } else { + bytes += 3; + } + } + return bytes; } static Uint8List _asBytes(Object? data) { @@ -156,15 +195,16 @@ class EtagInterceptor extends Interceptor { RequestOptions options, RequestInterceptorHandler handler, ) async { - if (!_cacheable(options)) { + final uri = options.uri; + if (!_cacheable(options, uri)) { handler.next(options); return; } - final url = options.uri.toString(); + final url = uri.toString(); // Immutable tiles: URL is the key — serve SQLite hits locally. Never send // If-None-Match (content is pinned by the URL; revalidation is pointless). - if (_isBytes(options) && isImmutableTile(options.uri)) { + if (_isBytes(options) && isImmutableTile(uri)) { final cached = await _store.readBytes(url); if (cached != null) { // Hit metering lives in [EtagCacheStore.readBytes]. @@ -200,8 +240,9 @@ class EtagInterceptor extends Interceptor { ResponseInterceptorHandler handler, ) async { final options = response.requestOptions; - if (_cacheable(options)) { - final url = options.uri.toString(); + final uri = options.uri; + if (_cacheable(options, uri)) { + final url = uri.toString(); final binary = _isBytes(options); final post = options.method.toUpperCase() == 'POST'; if (response.statusCode == 304) { @@ -264,14 +305,13 @@ class EtagInterceptor extends Interceptor { ? _downBytes(response, encoded: jsonBody) : _downBytes(response); final immutable = - post || - (binary && response.data != null && isImmutableTile(options.uri)); + post || (binary && response.data != null && isImmutableTile(uri)); var etag = response.headers.value('etag'); if (immutable) { // POST (a dashboard query whose body is a constant) and URL-pinned // tiles both carry their content in the URL — ignore any server ETag // and always store under the URL hash. - etag = etagFromUrl(options.uri); + etag = etagFromUrl(uri); response.headers.set('etag', etag); } // Non-immutable: ETag only — no ETag ⇒ no store. Immutable responses @@ -316,13 +356,14 @@ class EtagInterceptor extends Interceptor { ErrorInterceptorHandler handler, ) async { final options = err.requestOptions; + final uri = options.uri; final status = err.response?.statusCode; // Basemap PBF only — ocean / uncovered z/x/y is stable. Not radar/sat/DPM. - if (_cacheable(options) && + if (_cacheable(options, uri) && _isBytes(options) && status == 404 && - isBasemapPbf(options.uri)) { - final url = options.uri.toString(); + isBasemapPbf(uri)) { + final url = uri.toString(); await _store.writeBytes( url, etag: negativeTileEtag, @@ -344,8 +385,8 @@ class EtagInterceptor extends Interceptor { if (!_isBytes(options) && options.method.toUpperCase() == 'POST' && status == null && - options.uri.host == 'status.exptech.dev') { - final cached = await _store.readJson(options.uri.toString()); + uri.host == 'status.exptech.dev') { + final cached = await _store.readJson(uri.toString()); if (cached != null) { handler.resolve( Response( diff --git a/test/core/network/utf8_length_test.dart b/test/core/network/utf8_length_test.dart new file mode 100644 index 000000000..49c2e456e --- /dev/null +++ b/test/core/network/utf8_length_test.dart @@ -0,0 +1,47 @@ +/// Pins [EtagInterceptor.utf8Length] to `utf8.encode(s).length` — the number +/// the traffic meter used to obtain by encoding the whole body. +library; + +import 'dart:convert'; + +import 'package:dpip/core/network/etag_interceptor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('utf8Length matches the encoder byte for byte', () { + const cases = [ + '', + 'plain ascii {"a":1}', + 'é ß ÿ', // 2-byte + '台北 地震 速報', // 3-byte CJK + '\u{1F600}\u{1F30F}', // 4-byte surrogate pairs + 'mixed 台北 \u{1F600} é end', + // The boundaries: last 1-byte, first 2-byte, last 2-byte, first 3-byte, + // last BMP code point. + '\u007F\u0080\u07FF\u0800\uFFFF', + ]; + for (final s in cases) { + expect( + EtagInterceptor.utf8Length(s), + utf8.encode(s).length, + reason: 'for ${jsonEncode(s)}', + ); + } + }); + + test( + 'a lone surrogate counts as U+FFFD, exactly as the encoder writes it', + () { + final lone = [ + String.fromCharCode(0xD83D), // lead with nothing after + '${String.fromCharCode(0xD83D)}x', // lead followed by a non-trail + String.fromCharCode(0xDE00), // trail alone + // Reversed pair: neither half completes the other. + 'a${String.fromCharCode(0xDE00)}${String.fromCharCode(0xD83D)}', + ]; + for (final s in lone) { + expect(EtagInterceptor.utf8Length(s), utf8.encode(s).length); + } + }, + ); +} From 9bc859762b1b5eac35c9e57244c34efaeed52bee Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 12:07:52 +0800 Subject: [PATCH 25/32] perf: cap the log table without materialising thousands of ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 日誌寫入時的清理查詢不再每三秒建立一張五千列的暫存表 Optimization(en-US): the log's housekeeping query no longer builds a five-thousand-row temporary table every few seconds --- lib/core/logging/log_store.dart | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/lib/core/logging/log_store.dart b/lib/core/logging/log_store.dart index a1f3a5900..2194aae46 100644 --- a/lib/core/logging/log_store.dart +++ b/lib/core/logging/log_store.dart @@ -88,6 +88,20 @@ class LogStore { final _pending = []; Timer? _timer; + + /// The row ceiling, as one statement both [flush] and [prune] run. + /// + /// "Everything but the newest N" is *every id below the N-th newest*, and + /// that one id is a single `OFFSET N-1` read down the primary-key index. It + /// used to be `id NOT IN (SELECT id … LIMIT N)`, which materialises all N + /// ids into a temporary table and probes it per row — on every flush, + /// i.e. every three seconds while anything logs. Same rows deleted: with + /// fewer than N rows the subquery is NULL, `id < NULL` matches nothing, and + /// with more, the rows below the N-th newest are exactly the ones outside + /// the old `IN` list. + static const String _capSql = + 'DELETE FROM $logTable WHERE id < (' + 'SELECT id FROM $logTable ORDER BY id DESC LIMIT 1 OFFSET ?)'; Future _databaseTail = Future.value(); /// Preserves the order in which persistence operations were requested. @@ -149,11 +163,7 @@ class LogStore { _now().toUtc().subtract(logRetention).millisecondsSinceEpoch, ]); // See [logMaxRows]: the newest lines survive whatever the clock says. - await tx.execute( - 'DELETE FROM $logTable WHERE id NOT IN (' - 'SELECT id FROM $logTable ORDER BY id DESC LIMIT ?)', - [logMaxRows], - ); + await tx.execute(_capSql, [logMaxRows - 1]); }); } on Object { // Reporting a logging failure through the logger is how a write loop @@ -196,11 +206,7 @@ class LogStore { // primary key and monotonic: a clock that steps backwards would // otherwise make the newest rows look like the oldest and delete // them. - await tx.execute( - 'DELETE FROM $logTable WHERE id NOT IN (' - 'SELECT id FROM $logTable ORDER BY id DESC LIMIT ?)', - [logMaxRows], - ); + await tx.execute(_capSql, [logMaxRows - 1]); }); } on Object { // Deliberately silent: reporting a logging failure through the logger From 46dd89ce0e95059cb929e21a0a8261343eb156e3 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 12:07:59 +0800 Subject: [PATCH 26/32] perf: compile patterns and prepare SQL once instead of per call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 流量統計、伺服器狀態與語言設定不再每次呼叫都重建規則與查詢字串 Optimization(en-US): traffic accounting, endpoint health and the language setting stop rebuilding their patterns and queries on every call --- lib/core/network/endpoint_health.dart | 7 +++++- lib/core/network/network_usage_store.dart | 29 ++++++++++++----------- lib/core/settings/locale_controller.dart | 5 +++- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/lib/core/network/endpoint_health.dart b/lib/core/network/endpoint_health.dart index deef9b8f9..6e6c6fee2 100644 --- a/lib/core/network/endpoint_health.dart +++ b/lib/core/network/endpoint_health.dart @@ -119,10 +119,15 @@ class EndpointHealth { /// `TPE1`. Also covers the static hosts (`static.core-tnn1…`) and legacy /// `api-1` (no region → the host's own last segment). String get regionCode { - final core = RegExp(r'-(tpe1|khh1|tyo1|tnn1)\.').firstMatch(host); + final core = _regionInHost.firstMatch(host); if (core != null) return core.group(1)!.toUpperCase(); return host.split('.').first.toUpperCase(); } + + /// Compiled once: the status table reads this getter for every cell on + /// every rebuild, and a `RegExp(...)` literal inside it re-compiled the + /// pattern each time. + static final RegExp _regionInHost = RegExp(r'-(tpe1|khh1|tyo1|tnn1)\.'); } /// Tracks per-service-host request outcomes so the UI can show which region is diff --git a/lib/core/network/network_usage_store.dart b/lib/core/network/network_usage_store.dart index aae89e9d3..b4ab12359 100644 --- a/lib/core/network/network_usage_store.dart +++ b/lib/core/network/network_usage_store.dart @@ -348,27 +348,28 @@ class NetworkUsageStore { int hour, _Pending add, ) async { - final sets = _columns.map((c) => '$c = $c + ?').join(', '); final values = [add.down, add.saved, add.hits, add.misses]; - final result = await tx.execute( - 'UPDATE $_buckets SET $sets WHERE hour = ?', - [...values, hour], - ); + final result = await tx.execute(_updateSql, [...values, hour]); if (result.isEmpty) { - await tx.execute( - 'INSERT INTO $_buckets (hour, ${_columns.join(', ')}) ' - 'VALUES (?, ?, ?, ?, ?)', - [hour, ...values], - ); + await tx.execute(_insertSql, [hour, ...values]); } } + // The statements are assembled once. They were rebuilt from [_columns] by + // map/join on every flush and every stats read — the same string each time. + static final String _updateSql = + 'UPDATE $_buckets SET ' + '${_columns.map((c) => '$c = $c + ?').join(', ')} WHERE hour = ?'; + static final String _insertSql = + 'INSERT INTO $_buckets (hour, ${_columns.join(', ')}) ' + 'VALUES (?, ?, ?, ?, ?)'; + static final String _sumSql = + 'SELECT ${_columns.map((c) => 'COALESCE(SUM($c), 0) AS $c').join(', ')} ' + 'FROM $_buckets WHERE hour >= ?'; + /// Sums every counter over one trailing window in a single query. Future<_Pending> _sumSince(int sinceHour) async { - final sums = _columns.map((c) => 'COALESCE(SUM($c), 0) AS $c').join(', '); - final row = await _db.get('SELECT $sums FROM $_buckets WHERE hour >= ?', [ - sinceHour, - ]); + final row = await _db.get(_sumSql, [sinceHour]); return _Pending() ..down = (row['down'] as num).toInt() ..saved = (row['saved'] as num).toInt() diff --git a/lib/core/settings/locale_controller.dart b/lib/core/settings/locale_controller.dart index 14f1dfd91..c98d9ddbf 100644 --- a/lib/core/settings/locale_controller.dart +++ b/lib/core/settings/locale_controller.dart @@ -38,11 +38,14 @@ class LocaleController extends ChangeNotifier { notifyListeners(); } + /// `-` or the legacy `_` — compiled once rather than per parse. + static final RegExp _separator = RegExp('[-_]'); + /// Parses a BCP-47 tag (`zh-Hant-HK`) back into a [Locale], recognising the /// 4-letter script subtag so it survives the round-trip. Also tolerates the /// legacy `_` separator from earlier builds. static Locale _parseTag(String tag) { - final parts = tag.split(RegExp('[-_]')); + final parts = tag.split(_separator); String? script; String? country; for (final part in parts.skip(1)) { From 704e2a0eee1ebaaa2c3c572a9b8052051794f1ef Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 12:08:06 +0800 Subject: [PATCH 27/32] perf(data): propagate each satellite step once when finding passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimization(zh-Hant): 衛星過境預測的軌道計算量減半,開啟更快 Optimization(en-US): satellite pass prediction does half the orbital propagation, so the page opens faster --- lib/core/astro/satellite.dart | 60 ++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/lib/core/astro/satellite.dart b/lib/core/astro/satellite.dart index 3469f574f..0c68a1720 100644 --- a/lib/core/astro/satellite.dart +++ b/lib/core/astro/satellite.dart @@ -95,7 +95,7 @@ class TleSet { final exponent = field.substring(6).trim(); if (mantissa.isEmpty || mantissa == '00000') return 0; final sign = mantissa.startsWith('-') ? -1 : 1; - final digits = mantissa.replaceAll(RegExp('[+-]'), ''); + final digits = mantissa.replaceAll(_signChars, ''); return sign * double.parse('0.$digits') * math.pow(10, int.parse(exponent)).toDouble(); @@ -128,6 +128,10 @@ class TleSet { ); } + /// Compiled once: [parseAll] runs [parse] per set in a catalogue file, and + /// a `RegExp` literal inside [parse] re-compiled it for each. + static final RegExp _signChars = RegExp('[+-]'); + /// Every element set in a standard TLE file. static List parseAll(String text) { final lines = text @@ -500,37 +504,39 @@ class Sgp4 { /// /// TEME is an inertial frame, so the Earth is rotated under it by the /// sidereal angle before the observer's position is subtracted. + /// + /// [state] is the satellite's TEME state at [utc] when the caller already + /// has it; otherwise it is propagated here. The pass search hands it in so + /// each step propagates once — it used to propagate here *and* again in the + /// sunlit test for the same instant, and SGP4 is the whole cost of a step. Horizontal lookFrom( DateTime utc, { required double latitude, required double longitude, + SatelliteState? state, }) { - final state = at(utc); + state ??= at(utc); final gmst = greenwichSiderealTime(utc); final localSidereal = gmst + longitude * degrees; - // The observer, in the same rotating-into-inertial sense. + // The observer, in the same rotating-into-inertial sense. One sine and + // one cosine of the latitude, reused below — they were each evaluated + // two or three times for the same angle. final phi = latitude * degrees; + final sinPhi = math.sin(phi); + final cosPhi = math.cos(phi); const flattening = 1 / 298.26; // WGS-72, to match SGP4's Earth. final c = - 1 / - math.sqrt( - 1 + flattening * (flattening - 2) * math.pow(math.sin(phi), 2), - ); - final observerX = - _earthRadiusKm * c * math.cos(phi) * math.cos(localSidereal); - final observerY = - _earthRadiusKm * c * math.cos(phi) * math.sin(localSidereal); - final observerZ = - _earthRadiusKm * c * math.pow(1 - flattening, 2) * math.sin(phi); + 1 / math.sqrt(1 + flattening * (flattening - 2) * math.pow(sinPhi, 2)); + final observerX = _earthRadiusKm * c * cosPhi * math.cos(localSidereal); + final observerY = _earthRadiusKm * c * cosPhi * math.sin(localSidereal); + final observerZ = _earthRadiusKm * c * math.pow(1 - flattening, 2) * sinPhi; final rx = state.position.$1 - observerX; final ry = state.position.$2 - observerY; final rz = state.position.$3 - observerZ.toDouble(); // Rotate the range vector into the observer's south-east-zenith frame. - final sinPhi = math.sin(phi); - final cosPhi = math.cos(phi); final sinTheta = math.sin(localSidereal); final cosTheta = math.cos(localSidereal); final south = sinPhi * cosTheta * rx + sinPhi * sinTheta * ry - cosPhi * rz; @@ -586,20 +592,26 @@ abstract final class SatellitePasses { }) { final passes = []; const step = Duration(seconds: 30); + final end = from.add(window); + // Immutable, so one for the whole search rather than one per step. + final observer = Observer(latitude: latitude, longitude: longitude); DateTime? rose; var best = -math.pi; var bestAt = from; var bestAzimuth = 0.0; - for (var at = from; at.isBefore(from.add(window)); at = at.add(step)) { + for (var at = from; at.isBefore(end); at = at.add(step)) { + // Propagated once per step and shared with the sunlit test below; the + // look and the shadow check are two views of this same state. + final state = satellite.at(at); final look = satellite.lookFrom( at, latitude: latitude, longitude: longitude, + state: state, ); final visible = - look.altitude > 0 && - (!sunlitOnly || _isSunlit(satellite, at, latitude, longitude)); + look.altitude > 0 && (!sunlitOnly || _isSunlit(state, at, observer)); if (visible) { rose ??= at; if (look.altitude > best) { @@ -628,14 +640,10 @@ abstract final class SatellitePasses { /// Whether the satellite is in sunlight while the ground is dark — the /// condition that makes a pass actually visible to the eye. - static bool _isSunlit( - Sgp4 satellite, - DateTime at, - double latitude, - double longitude, - ) { + /// + /// [state] is the satellite at [at], already propagated by the caller. + static bool _isSunlit(SatelliteState state, DateTime at, Observer observer) { // The ground must be at least in civil twilight, or the sky outshines it. - final observer = Observer(latitude: latitude, longitude: longitude); final sunAltitude = observer .lookAt(SunEphemeris.at(at).equatorial, at) .altitude; @@ -643,7 +651,7 @@ abstract final class SatellitePasses { final sun = _sunTeme(at); // And the satellite must be outside the Earth's shadow cylinder. - final position = satellite.at(at).position; + final position = state.position; final dot = position.$1 * sun.$1 + position.$2 * sun.$2 + position.$3 * sun.$3; if (dot > 0) return true; From 4982c3c24e0ca29eb08c22ef82d5ca4f0a8edbe2 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 11 Sep 2026 16:12:10 +0800 Subject: [PATCH 28/32] fix(notify): register the notification catalogue once per isolate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Fix(zh-Hant): 修正地震速報連續推播時 app 會卡住沒有反應 Fix(en-US): fix the app freezing while a burst of earthquake alerts arrives --- .../notifications/notification_service.dart | 66 +++++++++++++-- .../awesome_initialize_once_test.dart | 81 +++++++++++++++++++ 2 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 test/core/notifications/awesome_initialize_once_test.dart diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index 106427fbe..3dc788d46 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -363,6 +363,13 @@ class NotificationService { } } + // The plugin is up in *this* isolate now, by whichever branch. Record it so + // a foreground push — which runs [onFcmSilentData] here, on the main + // isolate — does not rewrite the whole channel catalogue through the + // platform main thread while the user is looking at the screen (see + // [ensureAwesomeInitialized]). + _awesomeReady ??= Future.value(); + // Android freezes a created channel's behaviour — sound included — so a // changed definition cannot simply be pushed. awesome's own update path // makes it worse: a forced update deletes the channel under its plain key @@ -728,6 +735,59 @@ int? _asNotificationId(Object? value) { return parsed; } +/// The one `initialize` handshake this isolate performs, if it has done one. +/// +/// `initialize` is not the cheap idempotent handshake its name suggests. The +/// native side takes it on the **platform main thread** +/// (`channelMethodInitialize` → `AwesomeNotifications.initialize`) and writes +/// every one of [NotificationChannels.channels] through the plugin's own +/// SQLite (`me.carda.awesome_notifications.core.databases.SQLitePrimitivesDB`). +/// +/// [onFcmSilentData] used to call it per message. The channels are process-wide +/// native state that [NotificationService.init] has already registered, so +/// every push after the first rewrote the whole catalogue for nothing — on the +/// main thread, during exactly the burst of pushes an earthquake produces. +/// Play's ANR reports name that database and say "I/O on main thread". +/// +/// What a background isolate genuinely needs is its own one-time handshake, +/// because plugin state does not cross an isolate. So: once per isolate, which +/// is what the plugin asks for. A failure is not remembered, so the next +/// message retries rather than drawing nothing for the rest of the isolate's +/// life. +Future? _awesomeReady; + +/// Performs that handshake, at most once per isolate and once concurrently. +/// +/// Single-flight rather than a bare flag: two pushes can arrive together, and +/// both would otherwise start their own full registration. +/// +/// [initializer] is the seam a test counts — the plugin resolves to a no-op +/// implementation off-device, so the platform channel cannot show whether the +/// handshake was skipped. Production callers pass nothing. +@visibleForTesting +Future ensureAwesomeInitialized({Future Function()? initializer}) { + final pending = _awesomeReady; + if (pending != null) return pending; + final future = (initializer ?? _initializeAwesome)(); + _awesomeReady = future; + return future.catchError((Object error, StackTrace stackTrace) { + if (identical(_awesomeReady, future)) _awesomeReady = null; + Error.throwWithStackTrace(error, stackTrace); + }); +} + +Future _initializeAwesome() => AwesomeNotifications() + .initialize( + NotificationChannels.icon, + NotificationChannels.channels, + channelGroups: NotificationChannels.groups, + ) + .then((_) {}); + +/// Forgets this isolate's handshake — for a test that needs a cold start. +@visibleForTesting +void resetAwesomeInitializedForTest() => _awesomeReady = null; + /// Fires when awesome accepts a notification, before it is shown. @pragma('vm:entry-point') Future onNotificationCreated(ReceivedNotification notification) async { @@ -762,11 +822,7 @@ Future onFcmSilentData(FcmSilentData silentData) async { final data = silentData.data; if (data == null || data.isEmpty) return; - await AwesomeNotifications().initialize( - NotificationChannels.icon, - NotificationChannels.channels, - channelGroups: NotificationChannels.groups, - ); + await ensureAwesomeInitialized(); if (silentData.createdLifeCycle == NotificationLifeCycle.Terminated) { await AwesomeNotifications().createNotificationFromJsonData( diff --git a/test/core/notifications/awesome_initialize_once_test.dart b/test/core/notifications/awesome_initialize_once_test.dart new file mode 100644 index 000000000..308e3c320 --- /dev/null +++ b/test/core/notifications/awesome_initialize_once_test.dart @@ -0,0 +1,81 @@ +/// Pins how often the push path re-registers the notification catalogue. +/// +/// `AwesomeNotifications().initialize` is taken on the **platform main thread** +/// natively, and it writes every channel through the plugin's own SQLite +/// (`SQLitePrimitivesDB`). The FCM handler used to call it per message, so an +/// earthquake — a burst of pushes seconds apart — rewrote the whole catalogue +/// on the main thread once per push. That is what Play's ANR reports name, with +/// "I/O on main thread" against that database. +/// +/// The channels are process-wide native state; what an isolate needs is its own +/// one-time handshake. These tests pin that: once per isolate, retried only if +/// it failed. +library; + +import 'package:dpip/core/notifications/notification_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late int handshakes; + late bool failNext; + + /// Stands in for the plugin call — see [ensureAwesomeInitialized]'s seam. + Future initializer() async { + handshakes++; + if (failNext) throw Exception('channel rejected'); + } + + setUp(() { + handshakes = 0; + failNext = false; + resetAwesomeInitializedForTest(); + }); + + tearDown(resetAwesomeInitializedForTest); + + test('a second call does not re-register the catalogue', () async { + await ensureAwesomeInitialized(initializer: initializer); + await ensureAwesomeInitialized(initializer: initializer); + await ensureAwesomeInitialized(initializer: initializer); + + expect( + handshakes, + 1, + reason: + 'every extra initialize is a whole-catalogue SQLite rewrite on the ' + 'platform main thread', + ); + }); + + test('two pushes arriving together still handshake once', () async { + await Future.wait([ + ensureAwesomeInitialized(initializer: initializer), + ensureAwesomeInitialized(initializer: initializer), + ]); + + expect( + handshakes, + 1, + reason: 'the guard is single-flight, not just a flag set after the await', + ); + }); + + test('a failed handshake is retried, not remembered as done', () async { + failNext = true; + await expectLater( + ensureAwesomeInitialized(initializer: initializer), + throwsA(isA()), + ); + + failNext = false; + await ensureAwesomeInitialized(initializer: initializer); + + expect( + handshakes, + 2, + reason: + 'remembering a failure would leave this isolate unable to draw any ' + 'notification for the rest of its life', + ); + }); +} From 54fdb14fa4588345f07b2666dc0607362b742521 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Thu, 17 Sep 2026 21:57:46 +0800 Subject: [PATCH 29/32] feat(more): list Thinktron and A-Liang's embedded blog as partners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New(zh-Hant): 合作夥伴新增興創知能股份有限公司與阿良的嵌入式系統技術學習區 New(en-US): Thinktron and Jimsun Embedded are now listed among DPIP's partners --- README.md | 2 ++ .../more/presentation/pages/more_page.dart | 12 ++++++++++ lib/l10n/app_en.arb | 2 ++ lib/l10n/app_fil.arb | 2 ++ lib/l10n/app_id.arb | 2 ++ lib/l10n/app_ja.arb | 2 ++ lib/l10n/app_ko.arb | 2 ++ lib/l10n/app_th.arb | 2 ++ lib/l10n/app_vi.arb | 2 ++ lib/l10n/app_yue.arb | 2 ++ lib/l10n/app_zh.arb | 2 ++ lib/l10n/app_zh_Hans.arb | 2 ++ lib/l10n/app_zh_Hant_HK.arb | 2 ++ lib/l10n/app_zh_TW.arb | 2 ++ lib/l10n/gen/app_localizations.dart | 12 ++++++++++ lib/l10n/gen/app_localizations_en.dart | 6 +++++ lib/l10n/gen/app_localizations_fil.dart | 6 +++++ lib/l10n/gen/app_localizations_id.dart | 6 +++++ lib/l10n/gen/app_localizations_ja.dart | 6 +++++ lib/l10n/gen/app_localizations_ko.dart | 6 +++++ lib/l10n/gen/app_localizations_th.dart | 6 +++++ lib/l10n/gen/app_localizations_vi.dart | 6 +++++ lib/l10n/gen/app_localizations_yue.dart | 6 +++++ lib/l10n/gen/app_localizations_zh.dart | 24 +++++++++++++++++++ test/features/more/more_page_test.dart | 8 +++++++ 25 files changed, 130 insertions(+) diff --git a/README.md b/README.md index dc50fe152..4aebc13c9 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,8 @@ tool/dev/build.sh ios # iOS(不含簽章) |---|---| | [巨科資訊有限公司](https://www.geoscience.com.tw/) | [巨科資訊有限公司](https://www.geoscience.com.tw/) 提供開發與測試所需的設備 | | [台灣數位串流有限公司](https://www.twds.com.tw/) | [台灣數位串流有限公司](https://www.twds.com.tw/) 提供雲端運算資源、網路頻寬與技術諮詢 | +| [興創知能股份有限公司](https://www.thinktronltd.com/) | [興創知能股份有限公司](https://www.thinktronltd.com/) 提供開發與測試所需的設備 | +| | [阿良的嵌入式系統技術學習區](https://jimsun-embedded.blogspot.com/?m=1) 提供開發與測試所需的設備 | ## 授權 diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index f09a699aa..2e4e2a854 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -321,6 +321,18 @@ class MorePage extends StatelessWidget { host: 'twds.com.tw', url: 'https://www.twds.com.tw/', ), + _MoreLinkTile( + icon: Icons.public_outlined, + title: l10n.morePartnerThinktron, + host: 'thinktronltd.com', + url: 'https://www.thinktronltd.com/', + ), + _MoreLinkTile( + icon: Icons.memory_outlined, + title: l10n.morePartnerJimsun, + host: 'jimsun-embedded.blogspot.com', + url: 'https://jimsun-embedded.blogspot.com/?m=1', + ), ], ), SectionHeader(l10n.moreSectionAbout), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ff2717cbc..2bca9cc9a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1233,6 +1233,8 @@ "morePartnersNote": "Listed in order of partnership. Thank you to the individuals and companies whose contributions to disaster preparedness made DPIP possible.", "morePartnerGeoscience": "Geoscience", "morePartnerTwds": "TWDS", + "morePartnerThinktron": "Thinktron", + "morePartnerJimsun": "Jimsun Embedded", "reportFilterIntensityInfoLegacyBody": "Only levels 0–7. No 5− / 5+ / 6− / 6+ split.", "@notifyTsunami": { "description": "Notify channel title" diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index e7c2fbe45..4dd8644c1 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -444,6 +444,8 @@ "morePartnersNote": "Nakaayos ayon sa tamang panahon ng pakikipagtulungan. Salamat sa mga indibidwal at kompanyang nag-ambag sa paghahanda sa kalamidad; ang kanilang kontribusyon ang nagbigay-daan sa DPIP.", "morePartnerGeoscience": "Geoscience", "morePartnerTwds": "TWDS", + "morePartnerThinktron": "Thinktron", + "morePartnerJimsun": "Jimsun Embedded", "reportFilterIntensityInfoLegacyBody": "Antas 0–7 lang; walang 5−/5+/6−/6+.", "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", "qpesumsOverlayMenuTooltip": "Mga opsyon sa layer ng pagtataya ng pag-ulan", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 1891faa8f..b74df6cca 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -444,6 +444,8 @@ "morePartnersNote": "Urut sesuai waktu kemitraan. Terima kasih kepada para individu dan perusahaan yang berkontribusi pada penanggulangan bencana; kontribusi mereka membuat DPIP menjadi mungkin.", "morePartnerGeoscience": "Geoscience", "morePartnerTwds": "TWDS", + "morePartnerThinktron": "Thinktron", + "morePartnerJimsun": "Jimsun Embedded", "reportFilterIntensityInfoLegacyBody": "Hanya tingkat 0–7, tanpa pemisahan 5−/5+/6−/6+.", "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", "qpesumsOverlayMenuTooltip": "Opsi lapisan prakiraan curah hujan", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 76e6c4087..b97879faa 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -444,6 +444,8 @@ "morePartnersNote": "提携順に表示しています。防災への貢献で DPIP を支えてくださった個人・企業の皆様に感謝します。", "morePartnerGeoscience": "Geoscience", "morePartnerTwds": "TWDS", + "morePartnerThinktron": "Thinktron", + "morePartnerJimsun": "Jimsun Embedded", "reportFilterIntensityInfoLegacyBody": "震度は 0–7 のみ。5弱/5強/6弱/6強の区分はありません。", "mapLayerSatelliteSst": "ひまわり 海面水温", "qpesumsOverlayMenuTooltip": "定量降水予報レイヤー設定", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 041452fc1..2d48cd9ea 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -444,6 +444,8 @@ "morePartnersNote": "파트너십 순서대로 표시됩니다. 재난 예방에 기여한 개인과 기업에 감사드립니다. 그들의 기여 더봉에 DPIP가 가능했습니다.", "morePartnerGeoscience": "Geoscience", "morePartnerTwds": "TWDS", + "morePartnerThinktron": "Thinktron", + "morePartnerJimsun": "Jimsun Embedded", "reportFilterIntensityInfoLegacyBody": "진도는 0–7만 있으며 5약/5강/6약/6강 구분이 없습니다.", "mapLayerSatelliteSst": "히마와리 해수면 온도", "qpesumsOverlayMenuTooltip": "정량 강수 예보 레이어 옵션", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 282ddcd74..d264769e0 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -444,6 +444,8 @@ "morePartnersNote": "เรียงตามลำดับคู่ความร่วมมือ ขอบคุณบุคคลและบริษัทที่มีส่วนร่วมในการป้องกันภัยพิบัติ การสนับสนุนของพวกเขาทำให้ DPIP เกิดขึ้นได้", "morePartnerGeoscience": "Geoscience", "morePartnerTwds": "TWDS", + "morePartnerThinktron": "Thinktron", + "morePartnerJimsun": "Jimsun Embedded", "reportFilterIntensityInfoLegacyBody": "มีระดับ 0–7 เท่านั้น ไม่แยก 5−/5+/6−/6+", "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", "qpesumsOverlayMenuTooltip": "ตัวเลือกชั้นพยากรณ์น้ำฝน", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index af85c0d6e..a1c1d1ec8 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -444,6 +444,8 @@ "morePartnersNote": "Theo thứ tự hợp tác. Xin cảm ơn các cá nhân và công ty đã đóng góp cho công tác phòng chống thiên tai, nhờ đó DPIP mới có thể ra đời.", "morePartnerGeoscience": "Geoscience", "morePartnerTwds": "TWDS", + "morePartnerThinktron": "Thinktron", + "morePartnerJimsun": "Jimsun Embedded", "reportFilterIntensityInfoLegacyBody": "Chỉ có mức 0–7, không tách 5−/5+/6−/6+.", "mapLayerSatelliteSst": "Himawari Sea Surface Temperature", "qpesumsOverlayMenuTooltip": "Tùy chọn lớp dự báo mưa định lượng", diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index c9f5d563a..e5edc8f2c 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -446,6 +446,8 @@ "morePartnersNote": "依合作時間先後排列。感謝呢些個人同公司對防災嘅貢獻,佢哋讓 DPIP 成為可能。", "morePartnerGeoscience": "巨科資訊有限公司", "morePartnerTwds": "台灣數位串流有限公司", + "morePartnerThinktron": "興創知能股份有限公司", + "morePartnerJimsun": "阿良的嵌入式系統技術學習區", "reportFilterIntensityInfoLegacyBody": "震度僅 0–7,冇 5弱/5強/6弱/6強。", "mapLayerSatelliteSst": "ひまわり 海表溫度", "qpesumsOverlayMenuTooltip": "定量降水預報圖層選項", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 71f40bb98..ffdefa3d8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -444,6 +444,8 @@ "morePartnersNote": "依合作時間先後排列。感謝這些個人與公司對防災的貢獻,他們讓 DPIP 成為可能。", "morePartnerGeoscience": "巨科資訊有限公司", "morePartnerTwds": "台灣數位串流有限公司", + "morePartnerThinktron": "興創知能股份有限公司", + "morePartnerJimsun": "阿良的嵌入式系統技術學習區", "reportFilterIntensityInfoLegacyBody": "震度僅 0–7,沒有 5弱/5強/6弱/6強。", "mapLayerSatelliteSst": "ひまわり 海表溫度", "qpesumsOverlayMenuTooltip": "定量降水預報圖層選項", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index d5c2a6fa4..ae09bc0bd 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -444,6 +444,8 @@ "morePartnersNote": "按合作時間先後排列。感謝這些個人與公司對防災的貢獻,他們讓 DPIP 成為可能。", "morePartnerGeoscience": "巨科资讯有限公司", "morePartnerTwds": "台湾数位串流有限公司", + "morePartnerThinktron": "兴创知能股份有限公司", + "morePartnerJimsun": "阿良的嵌入式系统技术学习区", "reportFilterIntensityInfoLegacyBody": "震度仅 0–7,没有 5弱/5强/6弱/6强。", "mapLayerSatelliteSst": "ひまわり 海表温度", "qpesumsOverlayMenuTooltip": "定量降水预报图层选项", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 8301f622f..ed4230d59 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -444,6 +444,8 @@ "morePartnersNote": "依合作時間先後排列。感謝這些個人與公司對防災的貢獻,他們讓 DPIP 成為可能。", "morePartnerGeoscience": "巨科資訊有限公司", "morePartnerTwds": "台灣數位串流有限公司", + "morePartnerThinktron": "興創知能股份有限公司", + "morePartnerJimsun": "阿良的嵌入式系統技術學習區", "reportFilterIntensityInfoLegacyBody": "震度僅 0–7,沒有 5弱/5強/6弱/6強。", "mapLayerSatelliteSst": "ひまわり 海表溫度", "qpesumsOverlayMenuTooltip": "定量降水預報圖層選項", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 7e29d7870..8bb3406f4 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -444,6 +444,8 @@ "morePartnersNote": "依合作時間先後排列。感謝這些個人與公司對防災的貢獻,他們讓 DPIP 成為可能。", "morePartnerGeoscience": "巨科資訊有限公司", "morePartnerTwds": "台灣數位串流有限公司", + "morePartnerThinktron": "興創知能股份有限公司", + "morePartnerJimsun": "阿良的嵌入式系統技術學習區", "reportFilterIntensityInfoLegacyBody": "震度僅 0–7,沒有 5弱/5強/6弱/6強。", "mapLayerSatelliteSst": "ひまわり 海表溫度", "qpesumsOverlayMenuTooltip": "定量降水預報圖層選項", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 38693660f..4bddd0ac8 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1853,6 +1853,18 @@ abstract class AppLocalizations { /// **'TWDS'** String get morePartnerTwds; + /// No description provided for @morePartnerThinktron. + /// + /// In en, this message translates to: + /// **'Thinktron'** + String get morePartnerThinktron; + + /// No description provided for @morePartnerJimsun. + /// + /// In en, this message translates to: + /// **'Jimsun Embedded'** + String get morePartnerJimsun; + /// No description provided for @reportFilterIntensityInfoLegacyBody. /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 1b4639eae..c3d0a0cd8 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -945,6 +945,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get morePartnerTwds => 'TWDS'; + @override + String get morePartnerThinktron => 'Thinktron'; + + @override + String get morePartnerJimsun => 'Jimsun Embedded'; + @override String get reportFilterIntensityInfoLegacyBody => 'Only levels 0–7. No 5− / 5+ / 6− / 6+ split.'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 88a3af940..ae4e1ced7 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -951,6 +951,12 @@ class AppLocalizationsFil extends AppLocalizations { @override String get morePartnerTwds => 'TWDS'; + @override + String get morePartnerThinktron => 'Thinktron'; + + @override + String get morePartnerJimsun => 'Jimsun Embedded'; + @override String get reportFilterIntensityInfoLegacyBody => 'Antas 0–7 lang; walang 5−/5+/6−/6+.'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index ceca3a619..03de16cbf 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -948,6 +948,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get morePartnerTwds => 'TWDS'; + @override + String get morePartnerThinktron => 'Thinktron'; + + @override + String get morePartnerJimsun => 'Jimsun Embedded'; + @override String get reportFilterIntensityInfoLegacyBody => 'Hanya tingkat 0–7, tanpa pemisahan 5−/5+/6−/6+.'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index b49906d41..ab50d6433 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -928,6 +928,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get morePartnerTwds => 'TWDS'; + @override + String get morePartnerThinktron => 'Thinktron'; + + @override + String get morePartnerJimsun => 'Jimsun Embedded'; + @override String get reportFilterIntensityInfoLegacyBody => '震度は 0–7 のみ。5弱/5強/6弱/6強の区分はありません。'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 3ea52d93b..f17fff947 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -928,6 +928,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get morePartnerTwds => 'TWDS'; + @override + String get morePartnerThinktron => 'Thinktron'; + + @override + String get morePartnerJimsun => 'Jimsun Embedded'; + @override String get reportFilterIntensityInfoLegacyBody => '진도는 0–7만 있으며 5약/5강/6약/6강 구분이 없습니다.'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 353918bae..0da0c91b7 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -943,6 +943,12 @@ class AppLocalizationsTh extends AppLocalizations { @override String get morePartnerTwds => 'TWDS'; + @override + String get morePartnerThinktron => 'Thinktron'; + + @override + String get morePartnerJimsun => 'Jimsun Embedded'; + @override String get reportFilterIntensityInfoLegacyBody => 'มีระดับ 0–7 เท่านั้น ไม่แยก 5−/5+/6−/6+'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 8a1f80401..5f7c2ceed 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -945,6 +945,12 @@ class AppLocalizationsVi extends AppLocalizations { @override String get morePartnerTwds => 'TWDS'; + @override + String get morePartnerThinktron => 'Thinktron'; + + @override + String get morePartnerJimsun => 'Jimsun Embedded'; + @override String get reportFilterIntensityInfoLegacyBody => 'Chỉ có mức 0–7, không tách 5−/5+/6−/6+.'; diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 2642117f0..4bdca7328 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -922,6 +922,12 @@ class AppLocalizationsYue extends AppLocalizations { @override String get morePartnerTwds => '台灣數位串流有限公司'; + @override + String get morePartnerThinktron => '興創知能股份有限公司'; + + @override + String get morePartnerJimsun => '阿良的嵌入式系統技術學習區'; + @override String get reportFilterIntensityInfoLegacyBody => '震度僅 0–7,冇 5弱/5強/6弱/6強。'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 1aee120d0..eadfe6ae8 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -922,6 +922,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get morePartnerTwds => '台灣數位串流有限公司'; + @override + String get morePartnerThinktron => '興創知能股份有限公司'; + + @override + String get morePartnerJimsun => '阿良的嵌入式系統技術學習區'; + @override String get reportFilterIntensityInfoLegacyBody => '震度僅 0–7,沒有 5弱/5強/6弱/6強。'; @@ -4194,6 +4200,12 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get morePartnerTwds => '台湾数位串流有限公司'; + @override + String get morePartnerThinktron => '兴创知能股份有限公司'; + + @override + String get morePartnerJimsun => '阿良的嵌入式系统技术学习区'; + @override String get reportFilterIntensityInfoLegacyBody => '震度仅 0–7,没有 5弱/5强/6弱/6强。'; @@ -7466,6 +7478,12 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get morePartnerTwds => '台灣數位串流有限公司'; + @override + String get morePartnerThinktron => '興創知能股份有限公司'; + + @override + String get morePartnerJimsun => '阿良的嵌入式系統技術學習區'; + @override String get reportFilterIntensityInfoLegacyBody => '震度僅 0–7,沒有 5弱/5強/6弱/6強。'; @@ -10738,6 +10756,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get morePartnerTwds => '台灣數位串流有限公司'; + @override + String get morePartnerThinktron => '興創知能股份有限公司'; + + @override + String get morePartnerJimsun => '阿良的嵌入式系統技術學習區'; + @override String get reportFilterIntensityInfoLegacyBody => '震度僅 0–7,沒有 5弱/5強/6弱/6強。'; diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index a921fb175..4a4913faa 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -297,6 +297,14 @@ void main() { findsOneWidget, ); expect(find.widgetWithText(ListTile, l10n.morePartnerTwds), findsOneWidget); + expect( + find.widgetWithText(ListTile, l10n.morePartnerThinktron), + findsOneWidget, + ); + expect( + find.widgetWithText(ListTile, l10n.morePartnerJimsun), + findsOneWidget, + ); // And they land below the store rows, in the 取得 App order. final play = tester.getTopLeft( find.widgetWithText(ListTile, 'Google Play'), From 3a1c78a80555572f40079bdab59f6e8fe8f06b7a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Thu, 17 Sep 2026 22:39:36 +0800 Subject: [PATCH 30/32] fix(storage): raise the SQLite pool floors past a memory-corrupting race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix(zh-Hant): 修正 app 同時讀寫本機資料時偶爾會閃退 Fix(en-US): fix an occasional crash while the app reads and writes its local data at the same time --- pubspec.lock | 10 +++++----- pubspec.yaml | 14 +++++++++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index e00d86104..70c96c092 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1101,13 +1101,13 @@ packages: source: hosted version: "3.5.2" sqlite3_connection_pool: - dependency: transitive + dependency: "direct main" description: name: sqlite3_connection_pool - sha256: "8f2df36dc9f0f51ec04506b90848769b5a4538a9f937472147a274910a92e7ee" + sha256: b01024fd67922680e5512abf3201974dab2d98d0664d145ec741740b448fdc5f url: "https://pub.dev" source: hosted - version: "0.2.9" + version: "0.2.11" sqlite3_web: dependency: transitive description: @@ -1120,10 +1120,10 @@ packages: dependency: "direct main" description: name: sqlite_async - sha256: "83aff15d2bb3b296d35e15419a85c3207df3baca62b64b778958a59eb291502d" + sha256: "40c7d6f660322e4a2b4367ff75b9d5111389441aaa87e44f417420b56e0b0c97" url: "https://pub.dev" source: hosted - version: "0.14.4" + version: "0.14.5" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8ab1989bf..6eb783b65 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -112,7 +112,19 @@ dependencies: # sqflite: every operation runs on a background isolate through sqlite3 FFI, # so a cold-start lock contention window no longer fails opens on the UI # thread, and WAL + busy_timeout (lockTimeout) ship as sane defaults. - sqlite_async: ^0.14.4 + # + # 0.14.5 is the floor because `close()` before it returned while connections + # were still open. + sqlite_async: ^0.14.5 + # Not used directly — sqlite_async's pool. Declared only to hold a floor + # sqlite_async itself does not (it accepts ^0.2.7). Up to 0.2.9 every lease, + # readers included, was treated as the writer, so the update notification + # sent after each read could run against the writer's update and commit hooks + # on another thread and corrupt memory (simolus3/sqlite3.dart@00634a8). With + # connections opened without a mutex, that surfaced as native crashes inside + # SQLite itself: a garbage expression pointer in the parser on iOS, and + # `vdbeCommit` on Android. Do not lower this below 0.2.10. + sqlite3_connection_pool: ^0.2.10 # The synchronous SQLite API. Not used to open anything in app code — it is # here for two types: the in-memory test helper wraps one handle with # SqliteDatabase.singleConnection, and the location track's read-only open From 903ed329501d913b512e64e11bb9979efa4cc6e3 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Thu, 17 Sep 2026 23:40:41 +0800 Subject: [PATCH 31/32] fix(notify): stop initialising the notification plugin for every push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Fix(zh-Hant): 修正收到推播時 app 可能短暫卡住沒有反應 Fix(en-US): fix the app briefly freezing when a push notification arrives --- .../notifications/notification_service.dart | 86 +++++-------------- .../awesome_initialize_once_test.dart | 81 ----------------- 2 files changed, 21 insertions(+), 146 deletions(-) delete mode 100644 test/core/notifications/awesome_initialize_once_test.dart diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index 3dc788d46..c72c189a5 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -363,13 +363,6 @@ class NotificationService { } } - // The plugin is up in *this* isolate now, by whichever branch. Record it so - // a foreground push — which runs [onFcmSilentData] here, on the main - // isolate — does not rewrite the whole channel catalogue through the - // platform main thread while the user is looking at the screen (see - // [ensureAwesomeInitialized]). - _awesomeReady ??= Future.value(); - // Android freezes a created channel's behaviour — sound included — so a // changed definition cannot simply be pushed. awesome's own update path // makes it worse: a forced update deletes the channel under its plain key @@ -735,59 +728,6 @@ int? _asNotificationId(Object? value) { return parsed; } -/// The one `initialize` handshake this isolate performs, if it has done one. -/// -/// `initialize` is not the cheap idempotent handshake its name suggests. The -/// native side takes it on the **platform main thread** -/// (`channelMethodInitialize` → `AwesomeNotifications.initialize`) and writes -/// every one of [NotificationChannels.channels] through the plugin's own -/// SQLite (`me.carda.awesome_notifications.core.databases.SQLitePrimitivesDB`). -/// -/// [onFcmSilentData] used to call it per message. The channels are process-wide -/// native state that [NotificationService.init] has already registered, so -/// every push after the first rewrote the whole catalogue for nothing — on the -/// main thread, during exactly the burst of pushes an earthquake produces. -/// Play's ANR reports name that database and say "I/O on main thread". -/// -/// What a background isolate genuinely needs is its own one-time handshake, -/// because plugin state does not cross an isolate. So: once per isolate, which -/// is what the plugin asks for. A failure is not remembered, so the next -/// message retries rather than drawing nothing for the rest of the isolate's -/// life. -Future? _awesomeReady; - -/// Performs that handshake, at most once per isolate and once concurrently. -/// -/// Single-flight rather than a bare flag: two pushes can arrive together, and -/// both would otherwise start their own full registration. -/// -/// [initializer] is the seam a test counts — the plugin resolves to a no-op -/// implementation off-device, so the platform channel cannot show whether the -/// handshake was skipped. Production callers pass nothing. -@visibleForTesting -Future ensureAwesomeInitialized({Future Function()? initializer}) { - final pending = _awesomeReady; - if (pending != null) return pending; - final future = (initializer ?? _initializeAwesome)(); - _awesomeReady = future; - return future.catchError((Object error, StackTrace stackTrace) { - if (identical(_awesomeReady, future)) _awesomeReady = null; - Error.throwWithStackTrace(error, stackTrace); - }); -} - -Future _initializeAwesome() => AwesomeNotifications() - .initialize( - NotificationChannels.icon, - NotificationChannels.channels, - channelGroups: NotificationChannels.groups, - ) - .then((_) {}); - -/// Forgets this isolate's handshake — for a test that needs a cold start. -@visibleForTesting -void resetAwesomeInitializedForTest() => _awesomeReady = null; - /// Fires when awesome accepts a notification, before it is shown. @pragma('vm:entry-point') Future onNotificationCreated(ReceivedNotification notification) async { @@ -808,9 +748,27 @@ Future onNotificationDisplayed(ReceivedNotification notification) async { /// Draws a push that arrived through awesome_notifications_fcm. /// -/// Runs on a background isolate when the app is not in the foreground, so -/// awesome has to be initialized here before it can be used — the isolate does -/// not inherit the one `init()` set up. +/// **Deliberately does not call `AwesomeNotifications().initialize`.** It used +/// to, once per push, and that is what Play's ANR reports were: the native side +/// takes `initialize` on the platform main thread and rewrites its defaults +/// through `SQLitePrimitivesDB` (`getReadableDatabase`, `remove`) plus every +/// channel, however little changed. Nothing here needs it, checked against the +/// 0.12.1 sources on both platforms: +/// +/// - the background entry point (`silentPushBackgroundMain`) already calls +/// `WidgetsFlutterBinding.ensureInitialized()`, so platform channels work; +/// - the native plugin instance is created when the engine attaches, not by +/// `initialize`, and `createNotification` checks nothing but notification +/// permission (Android) or nothing at all (iOS); +/// - channels live in the OS and in awesome's persisted store, and its +/// `DefaultsManager` restores the default icon and callback handles from disk +/// when a fresh process first touches it; +/// - the time-zone identifiers `initialize` fetches are read only by scheduled +/// notifications, and this app schedules none. +/// +/// The official awesome_notifications_fcm example does not call it in its +/// silent handler either. If the plugin is upgraded, re-run the device check: +/// a push in the foreground, in the background, and with the process killed. /// /// The terminated case goes through `createNotificationFromJsonData` rather /// than a hand-built [NotificationContent]: at that point there is no engine @@ -822,8 +780,6 @@ Future onFcmSilentData(FcmSilentData silentData) async { final data = silentData.data; if (data == null || data.isEmpty) return; - await ensureAwesomeInitialized(); - if (silentData.createdLifeCycle == NotificationLifeCycle.Terminated) { await AwesomeNotifications().createNotificationFromJsonData( data.cast(), diff --git a/test/core/notifications/awesome_initialize_once_test.dart b/test/core/notifications/awesome_initialize_once_test.dart deleted file mode 100644 index 308e3c320..000000000 --- a/test/core/notifications/awesome_initialize_once_test.dart +++ /dev/null @@ -1,81 +0,0 @@ -/// Pins how often the push path re-registers the notification catalogue. -/// -/// `AwesomeNotifications().initialize` is taken on the **platform main thread** -/// natively, and it writes every channel through the plugin's own SQLite -/// (`SQLitePrimitivesDB`). The FCM handler used to call it per message, so an -/// earthquake — a burst of pushes seconds apart — rewrote the whole catalogue -/// on the main thread once per push. That is what Play's ANR reports name, with -/// "I/O on main thread" against that database. -/// -/// The channels are process-wide native state; what an isolate needs is its own -/// one-time handshake. These tests pin that: once per isolate, retried only if -/// it failed. -library; - -import 'package:dpip/core/notifications/notification_service.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - late int handshakes; - late bool failNext; - - /// Stands in for the plugin call — see [ensureAwesomeInitialized]'s seam. - Future initializer() async { - handshakes++; - if (failNext) throw Exception('channel rejected'); - } - - setUp(() { - handshakes = 0; - failNext = false; - resetAwesomeInitializedForTest(); - }); - - tearDown(resetAwesomeInitializedForTest); - - test('a second call does not re-register the catalogue', () async { - await ensureAwesomeInitialized(initializer: initializer); - await ensureAwesomeInitialized(initializer: initializer); - await ensureAwesomeInitialized(initializer: initializer); - - expect( - handshakes, - 1, - reason: - 'every extra initialize is a whole-catalogue SQLite rewrite on the ' - 'platform main thread', - ); - }); - - test('two pushes arriving together still handshake once', () async { - await Future.wait([ - ensureAwesomeInitialized(initializer: initializer), - ensureAwesomeInitialized(initializer: initializer), - ]); - - expect( - handshakes, - 1, - reason: 'the guard is single-flight, not just a flag set after the await', - ); - }); - - test('a failed handshake is retried, not remembered as done', () async { - failNext = true; - await expectLater( - ensureAwesomeInitialized(initializer: initializer), - throwsA(isA()), - ); - - failNext = false; - await ensureAwesomeInitialized(initializer: initializer); - - expect( - handshakes, - 2, - reason: - 'remembering a failure would leave this isolate unable to draw any ' - 'notification for the rest of its life', - ); - }); -} From 27d808b40c186cbb3b72c4b9bd39f418fa05b06d Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Thu, 17 Sep 2026 23:41:30 +0800 Subject: [PATCH 32/32] fix(app): keep one DPIP card in Recents after opening an alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Fix(zh-Hant): 修正點開通知後,最近使用的應用程式清單可能出現兩個 DPIP Fix(en-US): fix opening a notification sometimes leaving two DPIP cards in the recent apps list --- android/app/src/main/AndroidManifest.xml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index eb40eec1c..46d44e6f3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -87,11 +87,20 @@ +