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/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/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/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 94a469fae..559d81124 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -95,6 +95,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 @@ -147,16 +159,17 @@ 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 void dispose() { _ticker?.cancel(); _tick.dispose(); + _clockSecond.dispose(); _session.dispose(); super.dispose(); } @@ -261,7 +274,7 @@ class _ReportReplayPageState extends State { const SizedBox(height: AppSpacing.sm), _ReplayStatusBar( clock: _session.clock, - tick: _tick, + second: _clockSecond, rts: _session.rts, eew: _session.eew, ), @@ -650,6 +663,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); @@ -906,15 +922,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 @@ -922,23 +955,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 — @@ -1084,15 +1130,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': { @@ -1102,9 +1162,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 @@ -1124,13 +1193,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; @@ -1349,20 +1426,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), 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/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 302a6cff7..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'; @@ -69,7 +71,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,15 +228,29 @@ 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!, - enabled: t > 0, + // [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. + // + // 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 && mapChromeBlursBackdrop, child: ColoredBox( color: Colors.black.withValues(alpha: dim), ), 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_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_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/features/home/presentation/widgets/home_sheet.dart b/lib/features/home/presentation/widgets/home_sheet.dart index a1c16b254..3c36cd492 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'; @@ -169,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, + ), ), ), ), @@ -203,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. /// @@ -244,9 +301,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/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, 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/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/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; } diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index a52aa32b9..095a96b85 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; @@ -922,6 +963,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/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); diff --git a/lib/features/map/presentation/widgets/rts_monitor_panel.dart b/lib/features/map/presentation/widgets/rts_monitor_panel.dart index 46bf8ff99..cf63fde26 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/features/map/presentation/widgets/typhoon_forecast_callouts.dart b/lib/features/map/presentation/widgets/typhoon_forecast_callouts.dart index b63785a9b..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), @@ -603,6 +613,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 +626,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/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 diff --git a/lib/features/more/presentation/pages/more_page.dart b/lib/features/more/presentation/pages/more_page.dart index 4771dc6de..ffc2aba35 100644 --- a/lib/features/more/presentation/pages/more_page.dart +++ b/lib/features/more/presentation/pages/more_page.dart @@ -144,7 +144,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), ), @@ -155,7 +156,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, @@ -171,11 +174,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), ), @@ -421,10 +428,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, @@ -943,32 +954,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) { @@ -979,7 +976,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. @@ -1366,11 +1363,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/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/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..f6506af26 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( @@ -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; 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/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( 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_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/map_timeline.dart b/lib/shared/map/map_timeline.dart index daf6d4d91..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, @@ -601,16 +612,25 @@ 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, 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, diff --git a/lib/shared/map/raster_timeline_layer.dart b/lib/shared/map/raster_timeline_layer.dart index f6ec5369f..d5872ac24 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 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 @@ -504,6 +505,31 @@ abstract class RasterTimelineLayer implements MapLayer { rasterOpacityTransition: _instantTransition, ); + /// 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 + /// *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. + /// + /// 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; + static const RasterLayerProperties _hidden = RasterLayerProperties( visibility: 'none', rasterOpacity: 0, @@ -706,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, 0), + if (id != _shownFrameId) + // Only the target has to fetch; see [preloadOpacity]. + _mount(controller, id, id == frameId ? preloadOpacity : 0), ]); _ring.addAll(ring); MapTileCache.trace( @@ -871,6 +899,7 @@ abstract class RasterTimelineLayer implements MapLayer { MapLibreMapController controller, String frameId, ) async { + // Its tiles are already in L1 (the probe passed), so nothing to fetch. await _mount(controller, frameId, 0); _ring.add(frameId); final keep = {frameId}; @@ -1120,7 +1149,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); }); @@ -1645,8 +1674,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 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/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); } 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) { 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/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); 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/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/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); + } + }, + ); +} 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/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/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); + }); } 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); 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); + }); } 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); +} diff --git a/test/features/map/radar_layer_test.dart b/test/features/map/radar_layer_test.dart index 0c55bf146..9103098f6 100644 --- a/test/features/map/radar_layer_test.dart +++ b/test/features/map/radar_layer_test.dart @@ -10,6 +10,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'; @@ -323,6 +324,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); diff --git a/test/features/more/more_page_test.dart b/test/features/more/more_page_test.dart index 3163f754c..1bc7e8b00 100644 --- a/test/features/more/more_page_test.dart +++ b/test/features/more/more_page_test.dart @@ -250,7 +250,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', @@ -355,9 +355,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]); }); } @@ -527,9 +525,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]); }); 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', ( 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', + ); + } + } + }); + }); +} 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', + ); + }); +}