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/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/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 @@
+
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/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/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/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;
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
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/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/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/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/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart
index 106427fbe..c72c189a5 100644
--- a/lib/core/notifications/notification_service.dart
+++ b/lib/core/notifications/notification_service.dart
@@ -748,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
@@ -762,12 +780,6 @@ Future onFcmSilentData(FcmSilentData silentData) async {
final data = silentData.data;
if (data == null || data.isEmpty) return;
- await AwesomeNotifications().initialize(
- NotificationChannels.icon,
- NotificationChannels.channels,
- channelGroups: NotificationChannels.groups,
- );
-
if (silentData.createdLifeCycle == NotificationLifeCycle.Terminated) {
await AwesomeNotifications().createNotificationFromJsonData(
data.cast(),
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/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)) {
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/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/bug_tracker/presentation/pages/bug_list_page.dart b/lib/features/bug_tracker/presentation/pages/bug_list_page.dart
index 3f53788a6..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.
///
@@ -354,11 +364,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/changelog/presentation/pages/version_notes_page.dart b/lib/features/changelog/presentation/pages/version_notes_page.dart
index 65c465240..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(
@@ -94,11 +102,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 +214,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 +260,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/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/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/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/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/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/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 =