diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fe0e8ec41..08a450ad5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -154,6 +154,13 @@ and converts transport/decode errors to a typed `Failure` UI. `strict-casts`/`strict-raw-types` are on. Run `build_runner` after model changes; add a `fromJson` round-trip test. +A list that is still true when old (past reports, event history) may opt into +**stale-if-error**: its data source calls `getOrSaved` (an `ApiClient` +extension in `core/network/stale_if_error.dart`), and when every region host +fails the ETag cache's last body comes back as an `Ok` with `stale` set — why, +and since when the server last vouched for it. Never for an alert feed, and +never shown without that marker: `AsyncView` and `FreshnessBanner` render it. + ## Realtime feeds live feeds (EEW now, RTS later) flow through @@ -220,9 +227,10 @@ resume). Read corrected time anywhere via the global `AppTime` facade render async/realtime state through the shared views in `shared/widgets/`, never a hand-rolled `FutureBuilder`/`Consumer` that drops the error or stale case into a blank screen. `AsyncView` maps a -one-shot `Future>` to loading/data/empty/error (with retry); +one-shot `Future>` to loading/data/empty/error (with retry), and a +saved copy (`Ok.stale`) to data under a `FreshnessBanner` with its age; `RealtimeView` maps a `RealtimeState`, adding connecting/stale/offline and -a freshness banner so aged safety data is never shown as current. Building +the same banner so aged safety data is never shown as current. Building blocks: `LoadingView` / `ErrorView` / `EmptyView`. Template consumer: `features/earthquake/presentation/pages/earthquake_page.dart`. diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 7ddc969d1..2fa4a740b 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -1,4 +1,5 @@ import 'package:dpip/app/shell/main_shell.dart'; +import 'package:dpip/app/router/route_params.dart'; import 'package:dpip/app/router/widget_location_routes.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; import 'package:dpip/features/data/presentation/pages/data_page.dart'; @@ -119,17 +120,20 @@ final GoRouter appRouter = GoRouter( GoRoute( path: AppRoutes.earthquakeReportPath, name: AppRoutes.earthquakeReport, + redirect: reportRedirect, builder: (_, state) => ReportDetailPage( - reportId: state.pathParameters['id']!, + reportId: state.pathParameters['id'] ?? '', ), routes: [ GoRoute( path: AppRoutes.earthquakeReplayPath, name: AppRoutes.earthquakeReplay, + redirect: replayRedirect, builder: (_, state) => ReportReplayPage( - replayTimestamp: int.parse( - state.uri.queryParameters['t']!, - ), + // [replayRedirect] has already turned a link + // without a usable `t` away; this only keeps the + // builder total. + replayTimestamp: replayTimestampOf(state) ?? 0, ), ), ], @@ -269,8 +273,9 @@ final GoRouter appRouter = GoRouter( GoRoute( path: AppRoutes.regionSelectCityPath, name: AppRoutes.regionSelectCity, + redirect: regionCityRedirect, builder: (_, state) => RegionCityPage( - city: state.pathParameters['city']!, + city: state.pathParameters['city'] ?? '', replaceCode: state.uri.queryParameters['replace'], returnToMore: state.uri.queryParameters['returnToMore'] == '1', ), @@ -312,8 +317,8 @@ final GoRouter appRouter = GoRouter( GoRoute( path: AppRoutes.bugThreadPath, name: AppRoutes.bugThread, - builder: (_, state) => - BugThreadPage(id: int.tryParse(state.pathParameters['id']!) ?? 0), + redirect: bugThreadRedirect, + builder: (_, state) => BugThreadPage(id: bugThreadIdOf(state) ?? 0), ), ], ), diff --git a/lib/app/router/route_params.dart b/lib/app/router/route_params.dart new file mode 100644 index 000000000..5e059aa3d --- /dev/null +++ b/lib/app/router/route_params.dart @@ -0,0 +1,74 @@ +/// Route parameters read the way a link from outside the app has to be read: +/// as untrusted text that may be missing, empty or not a number. +/// +/// A route builder is not a place to throw. Every route here can be reached by +/// a deep link, a notification payload or a URL typed by hand, and a builder +/// that `int.parse`s a query parameter turns a malformed one into a red error +/// screen (or, in release, a grey one) with no way back but the system back +/// gesture. So each parameterised route validates in a route-level `redirect` +/// — which sends a link that cannot be honoured to the nearest page that can +/// — and its builder reads the same parameter through the same non-throwing +/// parser, so it stays total even when called directly. +library; + +import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; + +/// [raw] as a positive integer, or null when it is missing, blank, not an +/// integer, or zero / negative. Surrounding whitespace is ignored. +int? positiveIntParam(String? raw) { + final value = int.tryParse(raw?.trim() ?? ''); + return value != null && value > 0 ? value : null; +} + +/// [raw] when it holds something other than whitespace, else null. +String? nonBlankParam(String? raw) => + raw == null || raw.trim().isEmpty ? null : raw; + +/// The replay start instant (`?t=`, Unix ms) of an +/// [AppRoutes.earthquakeReplay] location, or null when it cannot be replayed. +int? replayTimestampOf(GoRouterState state) => + positiveIntParam(state.uri.queryParameters['t']); + +/// The tracker thread id (`:id`) of an [AppRoutes.bugThread] location. +int? bugThreadIdOf(GoRouterState state) => + positiveIntParam(state.pathParameters['id']); + +/// A replay without a usable start instant opens the report it hangs off +/// instead — the page that has the 重播 chip, so the user is one tap from a +/// working replay rather than on a crash screen. +String? replayRedirect(BuildContext context, GoRouterState state) { + if (replayTimestampOf(state) != null) return null; + final id = nonBlankParam(state.pathParameters['id']); + return id == null + ? state.namedLocation(AppRoutes.earthquake) + : state.namedLocation( + AppRoutes.earthquakeReport, + pathParameters: {'id': id}, + ); +} + +/// A report link without an id opens the catalogue. +String? reportRedirect(BuildContext context, GoRouterState state) => + nonBlankParam(state.pathParameters['id']) == null + ? state.namedLocation(AppRoutes.earthquake) + : null; + +/// A thread link whose id is not a thread number opens the tracker's list, +/// rather than asking the server for thread 0. +String? bugThreadRedirect(BuildContext context, GoRouterState state) => + bugThreadIdOf(state) == null + ? state.namedLocation(AppRoutes.bugTracker) + : null; + +/// A city link without a city opens the city list, keeping the picker's own +/// query parameters (`replace`, `returnToMore`) so the flow it was part of +/// still completes. +String? regionCityRedirect(BuildContext context, GoRouterState state) => + nonBlankParam(state.pathParameters['city']) == null + ? state.namedLocation( + AppRoutes.regionSelect, + queryParameters: state.uri.queryParameters, + ) + : null; diff --git a/lib/core/error/result.dart b/lib/core/error/result.dart index 7e66d2910..0f5874d60 100644 --- a/lib/core/error/result.dart +++ b/lib/core/error/result.dart @@ -33,19 +33,41 @@ sealed class Result { Err(:final failure) => err(failure), }; - /// Maps the success value, preserving a failure. + /// Maps the success value, preserving a failure — and a [Ok.stale] marker, + /// since a transformed saved copy is still a saved copy. Result map(R Function(T value) transform) => switch (this) { - Ok(:final value) => Ok(transform(value)), + Ok(:final value, :final stale) => Ok(transform(value), stale: stale), Err(:final failure) => Err(failure), }; } /// A successful [Result] carrying [value]. +/// +/// [stale] is set when [value] is not a fresh answer: the source could not be +/// reached, and what came back is the copy saved from the last time it could +/// (an HTTP stale-if-error, see `StaleIfError`). Such a value is worth showing +/// — an old list beats an error screen — but never as current, so a consumer +/// that renders it says so (`AsyncView` and `FreshnessBanner` do). final class Ok extends Result { - const Ok(this.value); + const Ok(this.value, {this.stale}); /// The success value. final T value; + + /// Why and since when [value] is a saved copy; null when it is current. + final Staleness? stale; +} + +/// What makes an [Ok] value a saved copy rather than a current answer. +final class Staleness { + const Staleness({required this.failure, this.since}); + + /// Why the current answer is missing. + final Failure failure; + + /// When the source last confirmed the copy current — server-calibrated + /// time, comparable with `AppTime.utc`. Null when that was not recorded. + final DateTime? since; } /// A failed [Result] carrying [failure]. @@ -55,3 +77,8 @@ final class Err extends Result { /// The failure. final Failure failure; } + +/// A fetched value and, when it is a saved copy rather than the source's +/// current answer, why and since when — what a data source hands its +/// repository so the marker can reach [Ok.stale]. +typedef Fetched = ({T data, Staleness? stale}); diff --git a/lib/core/geo/place_search.dart b/lib/core/geo/place_search.dart new file mode 100644 index 000000000..96e121113 --- /dev/null +++ b/lib/core/geo/place_search.dart @@ -0,0 +1,121 @@ +/// Search over the township directory the way people actually type place +/// names: `台北` for `臺北市`, a township without its city, a full-width digit +/// from a CJK keyboard. +library; + +import 'package:dpip/core/geo/town.dart'; +import 'package:dpip/core/geo/town_directory.dart'; + +/// Folds [input] to the form both sides of a place-name match are compared in. +/// +/// - **臺 → 台.** The directory spells every city `臺北市` / `臺中市` / `臺東縣`, +/// while almost everyone types the everyday `台`. Without the fold `台北` +/// matched nothing at all. +/// - **Whitespace removed**, not just trimmed — `臺北市 中正區` is how +/// [Town.fullName] reads, and `台北 中正` / `台北中正` are both what someone +/// means. +/// - **Full-width ASCII → ASCII** (U+FF01–U+FF5E, plus the ideographic space): +/// the part of Unicode NFKC that a CJK keyboard actually produces. Dart has +/// no normaliser built in, and the full table would fold nothing a place +/// name here contains. +/// - **Lower case**, for the Latin-script names a translated directory or a +/// code search would carry. +/// +/// Pure and total: any string in, a string out, never throws. +String foldPlaceName(String input) { + final out = StringBuffer(); + for (final rune in input.runes) { + final folded = switch (rune) { + _taiTraditional => _taiCommon, + _ideographicSpace => null, + >= _fullWidthFirst && <= _fullWidthLast => rune - _fullWidthOffset, + _ => rune, + }; + if (folded == null || _isWhitespace(folded)) continue; + out.writeCharCode(folded); + } + return out.toString().toLowerCase(); +} + +const int _taiTraditional = 0x81FA; // 臺 +const int _taiCommon = 0x53F0; // 台 +const int _ideographicSpace = 0x3000; +const int _fullWidthFirst = 0xFF01; +const int _fullWidthLast = 0xFF5E; +const int _fullWidthOffset = 0xFEE0; + +bool _isWhitespace(int rune) => + rune == 0x20 || + (rune >= 0x09 && rune <= 0x0D) || + rune == 0x85 || + rune == 0xA0 || + rune == 0x1680 || + (rune >= 0x2000 && rune <= 0x200A) || + rune == 0x2028 || + rune == 0x2029 || + rune == 0x202F || + rune == 0x205F || + rune == 0xFEFF; + +/// One query's matches: the cities to drill into, and the townships that can +/// be picked straight away. +typedef PlaceSearchResult = ({List cities, List towns}); + +/// The township directory pre-folded for [search]. +/// +/// Built once per picker page, not per keystroke: folding every name is the +/// whole cost of a search, and the directory never changes while the app runs. +class PlaceSearchIndex { + PlaceSearchIndex(TownDirectory directory) + : this._(directory.cities, directory.all); + + PlaceSearchIndex._(List cities, Iterable towns) + : _cities = [for (final city in cities) (city, foldPlaceName(city))], + _towns = [ + for (final town in towns) + ( + town: town, + name: foldPlaceName(town.townName), + city: foldPlaceName(town.cityName), + // `台北市中正區` and `台北中正` both name this township. + withCity: [ + foldPlaceName('${town.cityName}${town.townName}'), + foldPlaceName('${town.city}${town.town}'), + ], + ), + ]; + + final List<(String, String)> _cities; + final List<({Town town, String name, String city, List withCity})> + _towns; + + /// Cities whose name contains [query], and townships it names. + /// + /// A township matches on its own name (`板橋` → 新北市 板橋區, `中正` → every + /// 中正區), or on its name with the city in front (`台北中正`). It does **not** + /// match on the city part alone: `台北` is a question about the city, which + /// is already in [PlaceSearchResult.cities], and listing all twelve of its + /// districts underneath would bury every other answer. + /// + /// An empty (or all-whitespace) query matches every city and no township — + /// the picker's resting state. + PlaceSearchResult search(String query) { + final needle = foldPlaceName(query); + if (needle.isEmpty) { + return (cities: [for (final (city, _) in _cities) city], towns: const []); + } + return ( + cities: [ + for (final (city, folded) in _cities) + if (folded.contains(needle)) city, + ], + towns: [ + for (final entry in _towns) + if (entry.name.contains(needle) || + (!entry.city.contains(needle) && + entry.withCity.any((name) => name.contains(needle)))) + entry.town, + ], + ); + } +} diff --git a/lib/core/network/api_exception.dart b/lib/core/network/api_exception.dart index 83ef63c80..ef6b97c6f 100644 --- a/lib/core/network/api_exception.dart +++ b/lib/core/network/api_exception.dart @@ -19,9 +19,21 @@ import 'package:dpip/core/logging/log.dart'; Future> guardResult( Future Function() body, { bool Function(Failure failure)? shouldLog, +}) => guardFetched( + () async => (data: await body(), stale: null), + shouldLog: shouldLog, +); + +/// [guardResult] for a body that may answer with a saved copy: its +/// [Fetched.stale] marker is carried onto the [Ok], so the repository above it +/// cannot drop it on the way to the screen. +Future> guardFetched( + Future> Function() body, { + bool Function(Failure failure)? shouldLog, }) async { try { - return Ok(await body()); + final fetched = await body(); + return Ok(fetched.data, stale: fetched.stale); } catch (error, stackTrace) { // Silent failures are the worst kind: the UI shows its error state and // nothing else records WHY. One line per failure, here at the single diff --git a/lib/core/network/etag_cache_store.dart b/lib/core/network/etag_cache_store.dart index 5a2761c34..6763d19e0 100644 --- a/lib/core/network/etag_cache_store.dart +++ b/lib/core/network/etag_cache_store.dart @@ -49,6 +49,7 @@ class CachedJson { required this.etag, required this.data, required this.size, + this.fetchedAt, }); final String etag; @@ -56,6 +57,11 @@ class CachedJson { /// Wire bytes the original download cost — what a 304 reports as saved. final int size; + + /// When the server last confirmed this body (a `200`, or a `304` for a + /// request that asked to track it) in calibrated time; null for an entry + /// stored before that was recorded. See [EtagCacheStore.markFetched]. + final DateTime? fetchedAt; } class CachedResponse { @@ -247,8 +253,19 @@ class EtagCacheStore { return; } await _createV2Schema(db); + // Added to v2 in place rather than by a drop: the column is optional, and + // throwing away a 350 MB tile cache to gain it would be a poor trade. + if (columns.isNotEmpty && !columns.contains(_fetchedColumn)) { + await db.execute( + 'ALTER TABLE $_table ADD COLUMN $_fetchedColumn INTEGER', + ); + } } + /// When the server last confirmed a JSON body (ms epoch, calibrated time); + /// NULL for binary rows and for rows written before it existed. + static const String _fetchedColumn = 'fetched'; + static Future _createV2Schema(SqliteDatabase db) async { await db.executeMultiple( 'CREATE TABLE IF NOT EXISTS $_table (' @@ -258,7 +275,8 @@ class EtagCacheStore { 'kind INTEGER NOT NULL, ' 'body BLOB NOT NULL, ' 'size INTEGER NOT NULL, ' - 'time INTEGER NOT NULL);' + 'time INTEGER NOT NULL, ' + '$_fetchedColumn INTEGER);' 'CREATE INDEX IF NOT EXISTS ${_table}_time ON $_table(time)', ); } @@ -340,10 +358,14 @@ class EtagCacheStore { } else { data = const Utf8Decoder().fuse(const JsonDecoder()).convert(blob); } + final fetched = (row[_fetchedColumn] as num?)?.toInt(); return CachedJson( etag: row['etag'] as String, data: data, size: (row['size'] as num).toInt(), + fetchedAt: fetched == null + ? null + : DateTime.fromMillisecondsSinceEpoch(fetched, isUtc: true), ); } catch (_) { // A corrupt entry reads as a miss: the interceptor then rejects the @@ -596,13 +618,32 @@ class EtagCacheStore { return _flushTouches(); } + /// Records that the server has just confirmed the JSON entry for [url] + /// unchanged (a `304`) at [at], calibrated time. Best-effort; a miss is a + /// no-op. + /// + /// Separate from the LRU `time`, which a mere read bumps too: a saved copy + /// shown offline is labelled with how old it is, and that age runs from the + /// last time the server vouched for it, not from the last time anyone asked. + Future markFetched(String url, DateTime at) async { + try { + await _db.execute( + 'UPDATE $_table SET $_fetchedColumn = ? WHERE key = ?', + [at.millisecondsSinceEpoch, url], + ); + } catch (_) {} + } + /// Stores a JSON [body] for [url] under [etag]. Best-effort. + /// + /// [fetchedAt] is when the server sent it, calibrated — see [markFetched]. Future write( String url, { required String etag, required String body, String? contentType, int size = 0, + DateTime? fetchedAt, }) async { try { // Light gzip on a worker isolate so large JSON writes don't jank the UI @@ -620,6 +661,7 @@ class EtagCacheStore { kind: kindJson, body: blob, size: size, + fetchedAt: fetchedAt, ); } catch (_) {} } @@ -698,12 +740,22 @@ class EtagCacheStore { required int kind, required Uint8List body, required int size, + DateTime? fetchedAt, }) async { await _db.execute( 'INSERT OR REPLACE INTO $_table ' - '(key, etag, content_type, kind, body, size, time) ' - 'VALUES (?, ?, ?, ?, ?, ?, ?)', - [url, etag, contentType, kind, body, size, _lruStamp()], + '(key, etag, content_type, kind, body, size, time, $_fetchedColumn) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [ + url, + etag, + contentType, + kind, + body, + size, + _lruStamp(), + fetchedAt?.millisecondsSinceEpoch, + ], ); await _noteWrite(body.length, 1); } @@ -851,8 +903,8 @@ class EtagCacheStore { Future?> _queryRow(String url) async { final row = await _db.getOptional( - 'SELECT key, etag, content_type, kind, body, size FROM $_table ' - 'WHERE key = ? LIMIT 1', + 'SELECT key, etag, content_type, kind, body, size, $_fetchedColumn ' + 'FROM $_table WHERE key = ? LIMIT 1', [url], ); if (row == null) return null; diff --git a/lib/core/network/etag_interceptor.dart b/lib/core/network/etag_interceptor.dart index 819027b8e..f82cdb817 100644 --- a/lib/core/network/etag_interceptor.dart +++ b/lib/core/network/etag_interceptor.dart @@ -6,6 +6,8 @@ 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:dpip/core/network/stale_if_error.dart'; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:flutter/foundation.dart' show visibleForTesting; /// Dio interceptor implementing HTTP ETag revalidation against an @@ -289,6 +291,12 @@ class EtagInterceptor extends Interceptor { if (usage != null) { unawaited(usage.record(down: 0, hit: true, saved: cached.size)); } + // The server has just vouched for the copy: a stale-if-error + // request's offline label counts its age from here. Only those + // pay the write — nothing else reads it. + if (StaleIfError.of(options) != null) { + unawaited(_store.markFetched(url, AppTime.utc)); + } handler.next(response); return; } @@ -355,6 +363,7 @@ class EtagInterceptor extends Interceptor { body: jsonBody!, contentType: response.headers.value(Headers.contentTypeHeader), size: down, + fetchedAt: AppTime.utc, ), ); } @@ -419,6 +428,22 @@ class EtagInterceptor extends Interceptor { return; } } + + // Stale-if-error, for a GET that asked for it: offer the saved body, but + // let the failure through — the region client may yet reach another + // host, and only the caller knows when every host has failed (see + // [StaleIfError]). + final staleIfError = StaleIfError.of(options); + if (staleIfError != null && + !_isBytes(options) && + options.method.toUpperCase() == 'GET' && + _cacheable(options, uri) && + StaleIfError.covers(err)) { + final cached = await _store.readJson(uri.toString()); + if (cached != null) { + staleIfError.offer((data: cached.data, fetchedAt: cached.fetchedAt)); + } + } handler.next(err); } } diff --git a/lib/core/network/stale_if_error.dart b/lib/core/network/stale_if_error.dart new file mode 100644 index 000000000..5ba1c2315 --- /dev/null +++ b/lib/core/network/stale_if_error.dart @@ -0,0 +1,111 @@ +/// HTTP stale-if-error for the GETs that may opt into it: when the network +/// fails, answer with the body the ETag cache saved last time, marked as a +/// saved copy — never as current. +library; + +import 'package:dio/dio.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_exception.dart'; +import 'package:dpip/core/network/api_region.dart'; + +/// One saved body the ETag cache could fall back to. +typedef SavedBody = ({Object? data, DateTime? fetchedAt}); + +/// The opt-in, carried on a request's `extra`, and the slot the +/// `EtagInterceptor` fills when that request fails. +/// +/// Why a slot on the request rather than a cached response in its place: the +/// region client fails over between hosts, and an interceptor answering the +/// first host's failure with the saved copy would stop it trying a second +/// host that is up. So the interceptor only *offers* the copy here and lets +/// the failure run its course; only once every host has failed does +/// [ApiClientStaleIfError.getOrSaved] take what was offered. +/// +/// Each host has its own URL and so its own cache entry; the offer from the +/// host that served most recently wins. +final class StaleIfError { + /// The `RequestOptions.extra` key the opt-in travels under. + static const String extraKey = 'dpip.staleIfError'; + + SavedBody? _saved; + + /// The best saved copy offered so far, or null. + SavedBody? get saved => _saved; + + /// Request options carrying this opt-in. + Options get options => Options(extra: {extraKey: this}); + + /// The opt-in on [options], or null for a request that did not ask. + static StaleIfError? of(RequestOptions options) => + switch (options.extra[extraKey]) { + final StaleIfError opt => opt, + _ => null, + }; + + /// Whether a request that failed with [error] may be answered from the + /// cache: the server could not be reached, or failed on its side (5xx). + /// + /// Not a 4xx — the resource is gone or the request is wrong, and a saved copy + /// would hide that — and not a cancellation or a certificate failure. + static bool covers(DioException error) => switch (error.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout || + DioExceptionType.transformTimeout || + DioExceptionType.connectionError || + DioExceptionType.unknown => true, + DioExceptionType.badResponse => (error.response?.statusCode ?? 0) >= 500, + DioExceptionType.cancel || DioExceptionType.badCertificate => false, + }; + + /// Records [body] as a fallback, keeping the most recently confirmed one. + void offer(SavedBody body) { + final current = _saved; + final at = body.fetchedAt; + if (current == null || + (at != null && (current.fetchedAt?.isBefore(at) ?? true))) { + _saved = body; + } + } +} + +/// GETs that fall back to the saved copy. +extension ApiClientStaleIfError on ApiClient { + /// GET [path] on [tier] like [ApiClient.get], except that when every host + /// fails in a way [StaleIfError.covers], the body the cache saved from the + /// last success is returned instead of the error — with [Fetched.stale] set, + /// so it can never pass for current. With nothing saved, the error is + /// rethrown as before. + /// + /// Only for data that is still worth seeing when old and that a reader can + /// judge by its age — a list of past reports, not an alert feed. + Future> getOrSaved( + ApiTier tier, + String path, { + Map? query, + }) async { + final fallback = StaleIfError(); + try { + final response = await request( + tier, + path, + query: query, + options: fallback.options, + ); + return (data: response.data, stale: null); + } on DioException catch (error) { + final saved = fallback.saved; + if (saved == null || !StaleIfError.covers(error)) rethrow; + Log.warning( + 'ApiClient: $path unreachable (${error.type.name}); serving the copy ' + 'saved at ${saved.fetchedAt?.toIso8601String() ?? 'an unknown time'}', + ); + return ( + data: saved.data, + stale: Staleness(failure: mapException(error), since: saved.fetchedAt), + ); + } + } +} diff --git a/lib/core/platform/render_tier.dart b/lib/core/platform/render_tier.dart index 2d9e6725b..89fb92869 100644 --- a/lib/core/platform/render_tier.dart +++ b/lib/core/platform/render_tier.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/platform/device_info.dart'; /// How much GPU/CPU a device can spend on decorative animation. @@ -33,3 +34,22 @@ RenderTier renderTierFor(DeviceDetails device, {bool? isAndroid}) { } return totalMb < 4096 ? RenderTier.low : RenderTier.high; } + +/// This device's [RenderTier], probed once per process and shared. +/// +/// The probe is a platform call, and more than one surface wants its answer — +/// asking again per map, per replay, would pay the round trip each time for a +/// value that cannot change while the app runs. A probe that fails answers +/// [RenderTier.high], for the reason [renderTierFor] gives. +Future deviceRenderTier() => _deviceTier ??= _probeDeviceTier(); + +Future? _deviceTier; + +Future _probeDeviceTier() async { + try { + return renderTierFor(await DeviceInfoService.load()); + } catch (error) { + Log.warning('Device tier probe failed, keeping full quality: $error'); + return RenderTier.high; + } +} diff --git a/lib/core/realtime/server_clock.dart b/lib/core/realtime/server_clock.dart index f33a6ce57..b69694518 100644 --- a/lib/core/realtime/server_clock.dart +++ b/lib/core/realtime/server_clock.dart @@ -15,6 +15,22 @@ import 'package:dpip/core/realtime/server_time_source.dart'; /// /// [sync] is best-effort: on failure it keeps the last anchor (device time until /// the first success) and stays usable, so flaky NTP can never block the app. +/// +/// **The anchor is a trimmed mean, not the last sample.** Each sync measures +/// where the monotonic clock's zero falls in server time; the clock keeps the +/// last [sampleWindow] of those measurements and anchors on their mean with +/// the highest and lowest dropped. A single sample is only as good as its +/// round trip — SNTP is within milliseconds, but the HTTP fallback is ±RTT/2, +/// easily a few hundred on a congested mobile link — and re-anchoring on each +/// one made calibrated time jump back and forth by that much every minute, +/// which every countdown and wavefront measured against it showed. One wild +/// sample among five is simply dropped. +/// +/// A real change is not noise, though: when a sample disagrees with **every** +/// sample in the window by more than [stepThreshold] the window restarts from +/// it, so the clock follows at once. The monotonic clock not running while the +/// device sleeps is the everyday case — the server time at its zero really has +/// moved, by however long the device slept. class ServerClock implements Clock { ServerClock(this._device, this._elapsed, this._source); @@ -22,11 +38,19 @@ class ServerClock implements Clock { final Elapsed _elapsed; final ServerTimeSource _source; - /// Server time (ms epoch, UTC) captured at the last successful sync. - int? _anchorServerMs; + /// How many recent syncs the anchor is averaged over. + static const int sampleWindow = 5; + + /// A sample this far from every sample in the window is a step, not noise. + static const Duration stepThreshold = Duration(seconds: 2); - /// Monotonic reading captured at that same sync. - Duration? _anchorMono; + /// Recent measurements, oldest first: server time (ms epoch, UTC) at the + /// monotonic clock's zero, i.e. server time minus the monotonic reading taken + /// at the same sync. + final List _samples = []; + + /// The trimmed mean of [_samples] — what [now] adds monotonic time to. + int? _originMs; bool _isSynced = false; @@ -35,25 +59,53 @@ class ServerClock implements Clock { @override DateTime now() { - final anchorMs = _anchorServerMs; - final anchorMono = _anchorMono; - if (anchorMs == null || anchorMono == null) { + final originMs = _originMs; + if (originMs == null) { return _device.now().toUtc(); // best-effort until the first sync } - final elapsedSinceAnchor = _elapsed.elapsed - anchorMono; return DateTime.fromMillisecondsSinceEpoch( - anchorMs + elapsedSinceAnchor.inMilliseconds, + originMs + _elapsed.elapsed.inMilliseconds, isUtc: true, ); } + /// Adds one measurement and re-derives the anchor from the window. + void _addSample(int sampleMs) { + final step = stepThreshold.inMilliseconds; + if (_samples.isNotEmpty && + _samples.every((s) => (sampleMs - s).abs() > step)) { + Log.info( + 'ServerClock: step of ${sampleMs - _originMs!} ms — ' + 'restarting the sample window', + ); + _samples.clear(); + } + _samples.add(sampleMs); + if (_samples.length > sampleWindow) _samples.removeAt(0); + _originMs = trimmedMean(_samples); + } + + /// The mean of [samples] without its single highest and lowest value (once + /// there are at least three to drop them from), rounded to the millisecond. + static int trimmedMean(List samples) { + final sorted = [...samples]..sort(); + final kept = sorted.length >= 3 + ? sorted.sublist(1, sorted.length - 1) + : sorted; + var sum = 0; + for (final value in kept) { + sum += value; + } + return (sum / kept.length).round(); + } + /// The current device→server correction (calibrated now minus device now), /// for diagnostics; zero before the first sync. Duration get offset => _isSynced ? now().difference(_device.now().toUtc()) : Duration.zero; - /// Re-anchors calibrated time from the server, bounded by [timeout]. Keeps - /// the last anchor (and logs) on failure. + /// Measures the server's time, bounded by [timeout], and folds it into the + /// anchor (see the class doc). Keeps the last anchor (and logs) on failure. /// /// The default has to cover the whole chain, not one request: SNTP's /// primary→backup fallback is 2 hosts × 3s, and the HTTP `/ntp` stage behind @@ -70,8 +122,7 @@ class ServerClock implements Clock { ); return; } - _anchorServerMs = serverMs; - _anchorMono = _elapsed.elapsed; + _addSample(serverMs - _elapsed.elapsed.inMilliseconds); _isSynced = true; } catch (error, stackTrace) { Log.handle(error, stackTrace, 'ServerClock sync'); diff --git a/lib/features/earthquake/data/earthquake_api.dart b/lib/features/earthquake/data/earthquake_api.dart index f5a982c5e..663a5081f 100644 --- a/lib/features/earthquake/data/earthquake_api.dart +++ b/lib/features/earthquake/data/earthquake_api.dart @@ -1,8 +1,10 @@ +import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/network/api_paths.dart'; import 'package:dpip/core/network/api_region.dart'; import 'package:dpip/core/network/sse_client.dart'; import 'package:dpip/core/network/sse_event.dart'; +import 'package:dpip/core/network/stale_if_error.dart'; /// Earthquake endpoints on the region-aware [ApiClient]. /// @@ -84,7 +86,12 @@ class EarthquakeApi { /// /// [startTime] / [endTime] are `YYYY-MM-DD` (Asia/Taipei). Defaults and /// unknown keys are stripped server-side (302 to a canonical query). - Future> getReportList({ + /// + /// Stale-if-error ([ApiClientStaleIfError.getOrSaved]): offline, the page + /// saved last time comes back marked as a saved copy. Past reports do not + /// change, so an old page is still a true one — it may only be missing the + /// newest, which the marker tells the reader. + Future>> getReportList({ int limit = 50, int page = 1, int? minIntensity, @@ -120,11 +127,12 @@ class EarthquakeApi { 'cityMinInt': ?cityMinInt, 'cityMaxInt': ?cityMaxInt, }; - return (await _client.get( + final fetched = await _client.getOrSaved( ApiTier.coreApi, '/api/v2/eq/report', query: query, - )) as List; + ); + return (data: fetched.data as List, stale: fetched.stale); } /// Full earthquake report by [reportId] (includes area `list`). diff --git a/lib/features/earthquake/data/report_repository_impl.dart b/lib/features/earthquake/data/report_repository_impl.dart index bbc66b02f..f0c0997fd 100644 --- a/lib/features/earthquake/data/report_repository_impl.dart +++ b/lib/features/earthquake/data/report_repository_impl.dart @@ -20,7 +20,7 @@ class ReportRepositoryImpl implements ReportRepository { int limit = 30, int page = 1, ReportListQuery query = ReportListQuery.empty, - }) => guardResult(() async { + }) => guardFetched(() async { final raw = await _api.getReportList( limit: limit, page: page, @@ -38,7 +38,7 @@ class ReportRepositoryImpl implements ReportRepository { cityMinInt: query.cityMinInt, cityMaxInt: query.cityMaxInt, ); - return parseReportList(raw); + return (data: parseReportList(raw.data), stale: raw.stale); }); @override diff --git a/lib/features/earthquake/presentation/pages/report_list_page.dart b/lib/features/earthquake/presentation/pages/report_list_page.dart index f2a86b9d2..6520071eb 100644 --- a/lib/features/earthquake/presentation/pages/report_list_page.dart +++ b/lib/features/earthquake/presentation/pages/report_list_page.dart @@ -16,6 +16,7 @@ import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/seismic/report_colors.dart'; import 'package:dpip/shared/widgets/empty_view.dart'; import 'package:dpip/shared/widgets/error_view.dart'; +import 'package:dpip/shared/widgets/freshness_banner.dart'; import 'package:dpip/shared/widgets/intensity_badge.dart'; import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:flutter/material.dart'; @@ -150,7 +151,7 @@ class _ReportListPageState extends State { final sections = groupReportsByTaipeiDay(c.items); final extra = c.loadingMore || !c.hasMore ? 1 : 0; - return ListView.builder( + final list = ListView.builder( controller: _scroll, physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.fromLTRB( @@ -191,6 +192,16 @@ class _ReportListPageState extends State { ); }, ); + // Rows that are not a current answer — a saved copy, or what is left + // after a failed refresh — say so above the list. + final staleness = c.staleness; + if (staleness == null) return list; + return Column( + children: [ + FreshnessBanner.saved(staleness: staleness), + Expanded(child: list), + ], + ); } } diff --git a/lib/features/earthquake/presentation/pages/report_replay_page.dart b/lib/features/earthquake/presentation/pages/report_replay_page.dart index 441c13a22..972535548 100644 --- a/lib/features/earthquake/presentation/pages/report_replay_page.dart +++ b/lib/features/earthquake/presentation/pages/report_replay_page.dart @@ -18,6 +18,7 @@ import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/logging/log.dart'; import 'package:dpip/core/models/lat_lng.dart' as geo; +import 'package:dpip/core/platform/render_tier.dart'; import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/settings/eew_cwa_only_settings.dart'; import 'package:dpip/core/settings/setting_keys.dart'; @@ -59,6 +60,7 @@ import 'package:dpip/shared/map/camera_fit.dart'; import 'package:dpip/shared/map/geo_circle.dart'; import 'package:dpip/shared/map/map_compass.dart'; import 'package:dpip/shared/map/map_gsi_overlay.dart'; +import 'package:dpip/shared/map/wavefront_cadence.dart'; import 'package:dpip/shared/map/map_town_labels.dart'; import 'package:dpip/shared/map/map_style.dart' show MapColors, countyFillLayerId, townFillLayerId, townLabelLayerId; @@ -540,9 +542,13 @@ class _ReplayMapState extends State<_ReplayMap> { /// expansion and a visibly stepped one. Runs only while there is something /// to draw or to clear ([_syncWavefrontTicker]): most of a replay has no /// alert up, and sixty idle wakeups a second kept a phone's CPU from ever - /// settling for the whole of it. + /// settling for the whole of it. A low-end phone runs it slower — see + /// [wavefrontTick]. Timer? _wavefrontTicker; + /// [_wavefrontTicker]'s interval: full rate until the device tier is known. + Duration _wavefrontTick = wavefrontTick(RenderTier.high); + /// Feeds the Flutter [MapCompass] needle — camera heading, ° clockwise from /// north, kept in sync from [BaseMap.onCameraMove]. final ValueNotifier _bearing = ValueNotifier(0); @@ -584,6 +590,15 @@ class _ReplayMapState extends State<_ReplayMap> { unawaited(_prepareModel()); widget.eew.addListener(_onEewChange); widget.tick.addListener(_onTick); + // An injected tier is a test's; the app probes the device. + (context.read?>() ?? deviceRenderTier()).then((tier) { + final tick = wavefrontTick(tier); + if (!mounted || tick == _wavefrontTick) return; + _wavefrontTick = tick; + _wavefrontTicker?.cancel(); + _wavefrontTicker = null; + _syncWavefrontTicker(); + }); widget.travelTimeTable.then((table) { if (!mounted) return; _travelTimeTable = table; @@ -657,7 +672,7 @@ class _ReplayMapState extends State<_ReplayMap> { _wavefrontTicker = null; return; } - _wavefrontTicker ??= Timer.periodic(const Duration(milliseconds: 16), (_) { + _wavefrontTicker ??= Timer.periodic(_wavefrontTick, (_) { if (!_wavefrontWanted) { _wavefrontTicker?.cancel(); _wavefrontTicker = null; diff --git a/lib/features/earthquake/presentation/report_list_controller.dart b/lib/features/earthquake/presentation/report_list_controller.dart index 1d097e548..6a9c1db57 100644 --- a/lib/features/earthquake/presentation/report_list_controller.dart +++ b/lib/features/earthquake/presentation/report_list_controller.dart @@ -2,7 +2,9 @@ library; import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/features/earthquake/domain/partial_earthquake_report.dart'; import 'package:dpip/features/earthquake/domain/report_list_query.dart'; import 'package:dpip/features/earthquake/domain/report_repository.dart'; @@ -38,6 +40,17 @@ class ReportListController extends ChangeNotifier { bool get loading => _loading; bool get loadingMore => _loadingMore; Failure? get failure => _failure; + + /// Set while the rows in [items] are not a current answer, so the page can + /// say so instead of presenting them as current: the server could not be + /// reached and they are the copy saved last time, or a refresh failed and + /// they are what the previous load returned. Null while they are current. + Staleness? get staleness => _staleness; + Staleness? _staleness; + + /// When the first page in [items] was last current (calibrated time) — the + /// age a failed refresh reports for the rows it leaves on screen. + DateTime? _currentAt; bool get isEmpty => !_loading && _failure == null && items.isEmpty; /// Draft differs from the last successful / in-flight query. @@ -72,20 +85,24 @@ class ReportListController extends ChangeNotifier { page: 1, query: this.query, ); - result.when( - ok: (page) { + switch (result) { + case Ok(value: final page, :final stale): items ..clear() ..addAll(page); _page = 1; _hasMore = page.length >= pageSize; _failure = null; - }, - err: (failure) { + _staleness = stale; + _currentAt = stale == null ? AppTime.utc : stale.since; + case Err(:final failure): _failure = failure; + // The rows stay (see [reload]), but they are no longer current. + if (items.isNotEmpty) { + _staleness = Staleness(failure: failure, since: _currentAt); + } Log.warning('Report list reload failed: ${failure.message}'); - }, - ); + } _loading = false; notifyListeners(); } @@ -102,18 +119,18 @@ class ReportListController extends ChangeNotifier { page: next, query: query, ); - result.when( - ok: (page) { + switch (result) { + case Ok(value: final page, :final stale): items.addAll(page); _page = next; _hasMore = page.length >= pageSize; _failure = null; - }, - err: (failure) { + // A saved later page makes the list as a whole a saved copy. + if (stale != null) _staleness ??= stale; + case Err(:final failure): // Keep existing rows; surface via log — footer can retry loadMore. Log.warning('Report list loadMore failed: ${failure.message}'); - }, - ); + } _loadingMore = false; notifyListeners(); } diff --git a/lib/features/events/data/event_api.dart b/lib/features/events/data/event_api.dart index 7cadc594e..a7b96fe94 100644 --- a/lib/features/events/data/event_api.dart +++ b/lib/features/events/data/event_api.dart @@ -1,5 +1,7 @@ +import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/core/network/stale_if_error.dart'; /// DPIP disaster-event (事件) endpoints — **API v1**, still on the legacy host. /// @@ -23,17 +25,24 @@ class EventApi { /// Nationwide event history, newest first. /// /// `https://api-1.exptech.dev/api/v1/dpip/history/list` - Future> getHistoryList() async => - (await _client.get(_tier, '/api/v1/dpip/history/list') as List?) ?? - const []; + /// + /// History is stale-if-error ([ApiClientStaleIfError.getOrSaved]): offline, + /// the list saved last time comes back marked as a saved copy. Past notices + /// stay true; the marker tells the reader newer ones may be missing. + Future>> getHistoryList() => + _history('/api/v1/dpip/history/list'); /// Event history affecting the township [region] (a 3-digit code), newest - /// first. + /// first. Stale-if-error, like [getHistoryList]. /// /// `https://api-1.exptech.dev/api/v1/dpip/history/{region}` - Future> getHistoryRegion(String region) async => - (await _client.get(_tier, '/api/v1/dpip/history/$region') as List?) ?? - const []; + Future>> getHistoryRegion(String region) => + _history('/api/v1/dpip/history/$region'); + + Future>> _history(String path) async { + final fetched = await _client.getOrSaved(_tier, path); + return (data: fetched.data as List? ?? const [], stale: fetched.stale); + } /// Nationwide *active* events, newest first. /// diff --git a/lib/features/events/data/event_repository_impl.dart b/lib/features/events/data/event_repository_impl.dart index dbe208517..477c19ce1 100644 --- a/lib/features/events/data/event_repository_impl.dart +++ b/lib/features/events/data/event_repository_impl.dart @@ -13,11 +13,14 @@ class EventRepositoryImpl implements EventRepository { @override Future>> events({String? regionCode}) => - guardResult(() async { + guardFetched(() async { final raw = regionCode == null ? await _api.getHistoryList() : await _api.getHistoryRegion(regionCode); - return parseEvents(raw, regionCode: regionCode); + return ( + data: parseEvents(raw.data, regionCode: regionCode), + stale: raw.stale, + ); }); @override diff --git a/lib/features/location/presentation/pages/region_city_page.dart b/lib/features/location/presentation/pages/region_city_page.dart index 5baf5c0c1..0a462abe3 100644 --- a/lib/features/location/presentation/pages/region_city_page.dart +++ b/lib/features/location/presentation/pages/region_city_page.dart @@ -3,11 +3,12 @@ library; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/geo/place_search.dart'; import 'package:dpip/core/geo/town.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/features/location/presentation/widgets/region_town_tile.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; -import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/widgets/empty_view.dart'; import 'package:dpip/shared/widgets/section_header.dart'; import 'package:flutter/material.dart'; @@ -44,13 +45,17 @@ 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, - ); + /// The townships of [RegionCityPage.city], each with its name folded for + /// the filter ([foldPlaceName]). The directory is one immutable instance for + /// the app's life, so scan and fold 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<(Town, String)> _towns = _scan(widget.city); + + List<(Town, String)> _scan(String city) => [ + for (final town in context.read().townsInCity(city)) + (town, foldPlaceName(town.townName)), + ]; @override void didUpdateWidget(covariant RegionCityPage oldWidget) { @@ -61,7 +66,7 @@ class _RegionCityPageState extends State { // 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); + _towns = _scan(widget.city); } } @@ -79,12 +84,15 @@ class _RegionCityPageState extends State { final savedCodes = store.savedCodes; final query = GoRouterState.of(context).uri.queryParameters; final effectiveReplaceCode = widget.replaceCode ?? query['replace']; + final effectiveReturnToMore = + widget.returnToMore == true || query['returnToMore'] == '1'; - final needle = _searchController.text.trim().toLowerCase(); + // Folded like the names, so `台東` finds 臺東市 and a stray space or a + // full-width character from a CJK keyboard does not empty the list. + final needle = foldPlaceName(_searchController.text); final shown = [ - for (final town in _towns) - if (needle.isEmpty || town.townName.toLowerCase().contains(needle)) - town, + for (final (town, name) in _towns) + if (needle.isEmpty || name.contains(needle)) town, ]; return Scaffold( @@ -130,92 +138,15 @@ class _RegionCityPageState extends State { ) else for (final town in shown) - _TownTile( + RegionTownTile( town: town, - saved: savedCodes.contains(town.code), - enabled: - effectiveReplaceCode == null || - town.code == effectiveReplaceCode || - !savedCodes.contains(town.code), - canAdd: - effectiveReplaceCode != null || - store.canSave(town.code) || - savedCodes.contains(town.code), - onToggle: () => _toggle(context, store, town), + store: store, + savedCodes: savedCodes, + replaceCode: effectiveReplaceCode, + returnToMore: effectiveReturnToMore, ), ], ), ); } - - void _toggle(BuildContext context, RegionStore store, Town town) { - final query = GoRouterState.of(context).uri.queryParameters; - final replace = widget.replaceCode ?? query['replace']; - final shouldReturnToMore = - widget.returnToMore == true || query['returnToMore'] == '1'; - var changed = false; - if (replace != null) { - if (store.savedCodes.contains(town.code) && town.code != replace) return; - changed = store.replaceSaved(replace, town.code); - } else if (store.savedCodes.contains(town.code)) { - store.removeSaved(town.code); - changed = true; - } else if (!store.addSaved(town.code)) { - // At the cap — nothing added; explain the limit. - final l10n = AppLocalizations.of(context); - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar(content: Text(l10n.regionSelectFull(RegionStore.maxSaved))), - ); - } else { - changed = true; - } - if (changed && shouldReturnToMore) { - context.goNamed(AppRoutes.more); - } - } -} - -/// One township row — name, code + centroid coordinates, and a star that -/// reflects and toggles its saved state. -class _TownTile extends StatelessWidget { - const _TownTile({ - required this.town, - required this.saved, - required this.enabled, - required this.canAdd, - required this.onToggle, - }); - - final Town town; - final bool saved; - final bool enabled; - final bool canAdd; - final VoidCallback onToggle; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - // Saved → primary star; addable → muted outline; at the cap → dimmed. - final starColor = !enabled - ? colors.onSurface.withValues(alpha: 0.3) - : saved - ? colors.primary - : canAdd - ? colors.onSurfaceVariant - : colors.onSurface.withValues(alpha: 0.3); - - return ListTile( - enabled: enabled, - leading: const Icon(Icons.location_on_outlined), - title: Text(town.townName), - subtitle: Text( - '${town.code}・${town.lng.toStringAsFixed(2)}°E・' - '${town.lat.toStringAsFixed(2)}°N', - ), - trailing: Icon(saved ? Icons.star : Icons.star_border, color: starColor), - onTap: enabled ? onToggle : null, - ); - } } diff --git a/lib/features/location/presentation/pages/region_select_page.dart b/lib/features/location/presentation/pages/region_select_page.dart index f2415839a..9548b4b53 100644 --- a/lib/features/location/presentation/pages/region_select_page.dart +++ b/lib/features/location/presentation/pages/region_select_page.dart @@ -1,10 +1,12 @@ -/// The first level of the region picker: a search field over the counties and -/// cities, then the full city list to drill into. +/// The first level of the region picker: a search field over the counties, +/// cities and townships, then the full city list to drill into. library; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/geo/place_search.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/features/location/presentation/widgets/region_town_tile.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/widgets/empty_view.dart'; @@ -13,12 +15,14 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; -/// The city level of the region picker, with a filter on top. +/// The city level of the region picker, with a search on top. /// /// The query narrows the city list in place — it never leaves this page: a /// matching city keeps its star marker and drill-down, a non-matching one -/// disappears, and no match at all shows an empty view. Townships are reached -/// by drilling in, as before. +/// disappears. Townships the query names are listed under the cities and pick +/// straight from here, so `板橋` does not first need the user to know it is in +/// 新北市. Both sides are compared folded ([foldPlaceName]), so the everyday +/// `台北` finds the directory's `臺北市`. No match at all shows an empty view. class RegionSelectPage extends StatefulWidget { const RegionSelectPage({super.key, this.replaceCode, this.returnToMore}); @@ -35,14 +39,16 @@ class RegionSelectPage extends StatefulWidget { class _RegionSelectPageState extends State { final _searchController = TextEditingController(); - /// The city list, read once for the page's lifetime. + /// The city and township names, folded 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; + /// mutated, so neither the names nor their folded forms can differ between + /// keystrokes — each search scans this index instead of rebuilding it. + late final PlaceSearchIndex _index = PlaceSearchIndex( + context.read(), + ); @override void dispose() { @@ -61,80 +67,108 @@ class _RegionSelectPageState extends State { final effectiveReturnToMore = widget.returnToMore == true || query['returnToMore'] == '1'; + // Read once (the getter allocates a fresh list each call). + final savedCodes = store.savedCodes; final savedCities = { - for (final code in store.savedCodes) directory.byCode(code)?.cityName, + for (final code in savedCodes) directory.byCode(code)?.cityName, }; - final needle = _searchController.text.trim().toLowerCase(); - final cities = [ - for (final city in _cities) - if (needle.isEmpty || city.toLowerCase().contains(needle)) city, - ]; + final searching = foldPlaceName(_searchController.text).isNotEmpty; + final (:cities, :towns) = _index.search(_searchController.text); + Widget cityTile(String city) => ListTile( + leading: const Icon(Icons.location_city_outlined), + title: Text(city), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (savedCities.contains(city)) + Icon(Icons.star, size: 18, color: colors.primary), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => context.pushNamed( + AppRoutes.regionSelectCity, + pathParameters: {'city': city}, + queryParameters: { + ...?(effectiveReplaceCode == null + ? null + : {'replace': effectiveReplaceCode}), + if (effectiveReturnToMore) 'returnToMore': '1', + }, + ), + ); + + // Slivers built on demand: a one-character query such as `區` names well + // over a hundred townships, and this rebuilds on every keystroke. return Scaffold( appBar: AppBar(title: Text(l10n.regionSelectTitle)), - body: ListView( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.sm, - AppSpacing.md, - AppSpacing.sm, - ), - child: TextField( - controller: _searchController, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - hintText: l10n.regionSearchHint, - prefixIcon: const Icon(Icons.search), - suffixIcon: needle.isEmpty - ? null - : IconButton( - icon: const Icon(Icons.clear), - tooltip: l10n.commonClose, - onPressed: () { - _searchController.clear(); - setState(() {}); - }, - ), - isDense: true, - border: const OutlineInputBorder(), + body: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + child: TextField( + controller: _searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: l10n.regionSearchHint, + prefixIcon: const Icon(Icons.search), + suffixIcon: !searching + ? null + : IconButton( + icon: const Icon(Icons.clear), + tooltip: l10n.commonClose, + onPressed: () { + _searchController.clear(); + setState(() {}); + }, + ), + isDense: true, + border: const OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), ), - onChanged: (_) => setState(() {}), ), ), - SectionHeader( - l10n.regionSelectCount( - store.savedCodes.length, - RegionStore.maxSaved, + SliverToBoxAdapter( + child: SectionHeader( + l10n.regionSelectCount(savedCodes.length, RegionStore.maxSaved), ), ), - if (cities.isEmpty) - EmptyView(icon: Icons.search_off, message: l10n.regionSearchEmpty) - else - for (final city in cities) - ListTile( - leading: const Icon(Icons.location_city_outlined), - title: Text(city), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (savedCities.contains(city)) - Icon(Icons.star, size: 18, color: colors.primary), - const Icon(Icons.chevron_right), - ], - ), - onTap: () => context.pushNamed( - AppRoutes.regionSelectCity, - pathParameters: {'city': city}, - queryParameters: { - ...?(effectiveReplaceCode == null - ? null - : {'replace': effectiveReplaceCode}), - if (effectiveReturnToMore) 'returnToMore': '1', - }, + if (cities.isEmpty && towns.isEmpty) + SliverToBoxAdapter( + child: EmptyView( + icon: Icons.search_off, + message: l10n.regionSearchEmpty, + ), + ) + else ...[ + SliverList.builder( + itemCount: cities.length, + itemBuilder: (context, i) => cityTile(cities[i]), + ), + if (towns.isNotEmpty) ...[ + SliverToBoxAdapter( + child: SectionHeader(l10n.regionSearchTownsHeader), + ), + SliverList.builder( + itemCount: towns.length, + itemBuilder: (context, i) => RegionTownTile( + town: towns[i], + store: store, + savedCodes: savedCodes, + replaceCode: effectiveReplaceCode, + returnToMore: effectiveReturnToMore, + showCity: true, ), ), + ], + ], ], ), ); diff --git a/lib/features/location/presentation/widgets/region_town_tile.dart b/lib/features/location/presentation/widgets/region_town_tile.dart new file mode 100644 index 000000000..e585101be --- /dev/null +++ b/lib/features/location/presentation/widgets/region_town_tile.dart @@ -0,0 +1,100 @@ +/// One pickable township row of the region picker, and what tapping it does — +/// shared by the city list's search results and the township list inside a +/// city, so a township chosen from either place behaves the same. +library; + +import 'package:dpip/core/geo/town.dart'; +import 'package:dpip/core/settings/region_store.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// A township row: name, code + centroid coordinates, and a star that +/// reflects and toggles its saved state in [store]. +/// +/// [showCity] titles the row with the full `縣市 鄉鎮市區` name — for a search +/// result, where townships from different cities share a name (two 中正區, two +/// 東區) and the row is the only place the user can tell them apart. +/// +/// [replaceCode] is the saved township this pick replaces (the manage page's +/// "change" flow), and [returnToMore] goes back to the More tab after a change +/// that landed — both as the picker received them. +class RegionTownTile extends StatelessWidget { + const RegionTownTile({ + super.key, + required this.town, + required this.store, + required this.savedCodes, + this.replaceCode, + this.returnToMore = false, + this.showCity = false, + }); + + final Town town; + final RegionStore store; + + /// [RegionStore.savedCodes], read once by the list rather than once per row + /// (the getter allocates a fresh list each call). + final List savedCodes; + final String? replaceCode; + final bool returnToMore; + final bool showCity; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final saved = savedCodes.contains(town.code); + // Replacing: another already-saved township cannot be the replacement. + final enabled = replaceCode == null || town.code == replaceCode || !saved; + final canAdd = replaceCode != null || store.canSave(town.code) || saved; + // Saved → primary star; addable → muted outline; at the cap → dimmed. + final starColor = !enabled + ? colors.onSurface.withValues(alpha: 0.3) + : saved + ? colors.primary + : canAdd + ? colors.onSurfaceVariant + : colors.onSurface.withValues(alpha: 0.3); + + return ListTile( + enabled: enabled, + leading: const Icon(Icons.location_on_outlined), + title: Text(showCity ? town.fullName : town.townName), + subtitle: Text( + '${town.code}・${town.lng.toStringAsFixed(2)}°E・' + '${town.lat.toStringAsFixed(2)}°N', + ), + trailing: Icon(saved ? Icons.star : Icons.star_border, color: starColor), + onTap: enabled ? () => _toggle(context) : null, + ); + } + + /// A saved row removes on tap; an unsaved row adds (when under the cap) or, + /// once full, leaves the selection unchanged and explains the limit. With a + /// [replaceCode] the tap swaps that saved township for this one instead. + void _toggle(BuildContext context) { + var changed = false; + final replace = replaceCode; + if (replace != null) { + if (store.savedCodes.contains(town.code) && town.code != replace) return; + changed = store.replaceSaved(replace, town.code); + } else if (store.savedCodes.contains(town.code)) { + store.removeSaved(town.code); + changed = true; + } else if (!store.addSaved(town.code)) { + // At the cap — nothing added; explain the limit. + final l10n = AppLocalizations.of(context); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(l10n.regionSelectFull(RegionStore.maxSaved))), + ); + } else { + changed = true; + } + if (changed && returnToMore) { + context.goNamed(AppRoutes.more); + } + } +} diff --git a/lib/features/map/presentation/layers/rts_layer.dart b/lib/features/map/presentation/layers/rts_layer.dart index 7772becc2..762ad45ca 100644 --- a/lib/features/map/presentation/layers/rts_layer.dart +++ b/lib/features/map/presentation/layers/rts_layer.dart @@ -8,6 +8,7 @@ import 'dart:math' as math; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/models/lat_lng.dart' as geo; +import 'package:dpip/core/platform/render_tier.dart'; import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; @@ -30,6 +31,7 @@ import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_style.dart' show MapColors, countyFillLayerId, townFillLayerId; import 'package:dpip/shared/map/monitor_map_stack.dart'; +import 'package:dpip/shared/map/wavefront_cadence.dart'; import 'package:dpip/shared/seismic/intensity_circle_renderer.dart'; import 'package:dpip/shared/seismic/intensity_colors.dart'; import 'package:dpip/shared/seismic/intensity_icon_renderer.dart'; @@ -120,6 +122,7 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { RtsLiveDemand? liveDemand, MlIntensityEstimator? mlIntensity, RtsStationEncoder? stationEncoder, + Future? renderTier, // Not initializing formals: Dart has no private *named* parameter, and // these fields must stay private. // ignore: prefer_initializing_formals @@ -135,7 +138,19 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { // ignore: prefer_initializing_formals _stationEncoder = stationEncoder, // ignore: prefer_initializing_formals - _townDirectory = townDirectory; + _townDirectory = townDirectory { + // A low-end phone redraws the rings at a lower rate (see + // [wavefrontTick]). Until the probe answers — and when no probe is + // given — the rings run at full rate, never degraded on a guess. + renderTier?.then((tier) { + final tick = wavefrontTick(tier); + if (tick == _eewTick) return; + _eewTick = tick; + _eewTicker?.cancel(); + _eewTicker = null; + _syncEewTicker(); + }); + } final RealtimeNotifier _feed; final TremStationRepository _stationRepository; @@ -283,8 +298,8 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { /// a silky-smooth expansion and a visibly stepped one. Each push is small /// (two ~64-point rings), so the extra platform-channel traffic is cheap — /// and it only runs at all while an alert is actually live - /// ([_syncEewTicker]). - static const Duration _eewTick = Duration(milliseconds: 16); + /// ([_syncEewTicker]). A low-end phone runs it slower — see [wavefrontTick]. + Duration _eewTick = wavefrontTick(RenderTier.high); static const int _maxStationRetries = 8; static const double _liveOpacity = 1.0; static const double _staleOpacity = 0.35; @@ -403,16 +418,58 @@ class RtsMapLayer with MapLayerDefaults implements MapLayer { } _eewTicker ??= Timer.periodic(_eewTick, (_) { if (!_eewActive) return; - unawaited(_pushEew()); - // A box's S-wave coverage (see [_isBoxFullyCovered]) grows every - // tick even between RTS polls, so it has to be re-evaluated here - // too — not just on [_onFeed] — or a box stops blinking only - // whenever the next poll happens to land, well after the wavefront - // actually crossed it. - unawaited(_pushBox()); + if (_eewTickInFlight && _eewTicksSkipped < _eewTickMaxSkips) { + _eewTicksSkipped++; + return; + } + final generation = ++_eewTickGeneration; + _eewTickInFlight = true; + _eewTicksSkipped = 0; + unawaited( + Future.wait([ + _pushEew(), + // A box's S-wave coverage (see [_isBoxFullyCovered]) grows every + // tick even between RTS polls, so it has to be re-evaluated here + // too — not just on [_onFeed] — or a box stops blinking only + // whenever the next poll happens to land, well after the wavefront + // actually crossed it. + _pushBox(), + ]).whenComplete(() { + // A write abandoned by [_eewTickMaxWait] must not clear the flag + // of the one that replaced it. + if (generation == _eewTickGeneration) _eewTickInFlight = false; + }), + ); }); } + /// Whether the last wavefront tick's writes are still on the platform + /// channel. + /// + /// The ticker does not wait for a write to land, and the map plugin queues + /// GeoJSON writes per source in call order without ever dropping a stale + /// one. On a phone that needs longer than a tick to apply a ring set, every + /// tick used to add another full set behind the last — each one older than + /// the clock by the time the map drew it, and the backlog growing for as + /// long as the alert lasted. Skipping the tick instead means the next write + /// is computed from the clock when it is actually sent, so the newest ring + /// is always the next one drawn. A phone that keeps up never sees a skip. + bool _eewTickInFlight = false; + + /// Ticks skipped in a row behind the in-flight write. + int _eewTicksSkipped = 0; + + /// Bumped per tick that writes, so a late completion is recognised as late. + int _eewTickGeneration = 0; + + /// About a second of ticks: past this the in-flight write is treated as lost + /// rather than slow. A platform call that never answers — a map torn down + /// mid-write — would otherwise hold the flag forever and freeze the rings + /// for the rest of an alert, which is far worse than the backlog the flag + /// exists to prevent. + int get _eewTickMaxSkips => + const Duration(seconds: 1).inMicroseconds ~/ _eewTick.inMicroseconds; + void _onFeed() { _alerts.track(_feed.state); _rankingTick.value++; diff --git a/lib/features/map/presentation/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart index bffa982f0..15217c494 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -22,6 +22,7 @@ import 'package:dpip/features/map/presentation/layers/disaster_map_layer.dart'; import 'package:dpip/features/map/presentation/layers/humidity_layer.dart'; import 'package:dpip/core/meshtastic/domain/meshtastic_service.dart'; import 'package:dpip/core/meshtastic/mesh_node_store.dart'; +import 'package:dpip/core/platform/render_tier.dart'; import 'package:dpip/features/map/presentation/layers/lightning_layer.dart'; import 'package:dpip/features/map/presentation/layers/location_track_layer.dart'; import 'package:dpip/features/map/presentation/layers/mesh_node_layer.dart'; @@ -130,6 +131,7 @@ class _MapPageState extends State { liveDemand: context.read(), mlIntensity: context.read(), stationEncoder: context.read(), + renderTier: deviceRenderTier(), ), TemperatureMapLayer(context.read()), HumidityMapLayer(context.read()), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8a0d24232..f9a66e84b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -847,6 +847,46 @@ }, "moreSectionLinks": "Links", "feedOffline": "Connection lost", + "freshnessSavedCopy": "Can't connect · showing data from {age} ago", + "freshnessSavedCopyUnknownAge": "Can't connect · showing saved data", + "durationMinutes": "{count, plural, =1{1 minute} other{{count} minutes}}", + "durationHours": "{count, plural, =1{1 hour} other{{count} hours}}", + "durationDays": "{count, plural, =1{1 day} other{{count} days}}", + "@durationDays": { + "description": "A duration in whole days, used inside other sentences (e.g. how old saved data is)", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@durationHours": { + "description": "A duration in whole hours, used inside other sentences (e.g. how old saved data is)", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@durationMinutes": { + "description": "A duration in whole minutes, used inside other sentences (e.g. how old saved data is)", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@freshnessSavedCopyUnknownAge": { + "description": "Banner over a list shown from a saved copy whose age is unknown, because the server could not be reached" + }, + "@freshnessSavedCopy": { + "description": "Banner over a list shown from the copy saved last time because the server could not be reached; {age} is a duration such as '5 minutes' (durationMinutes/Hours/Days)", + "placeholders": { + "age": { + "type": "String" + } + } + }, "feedReplaying": "Replaying", "mapLayerStyleBd": "Dvorak BD", "@mapLayerSatelliteB09": { @@ -2498,6 +2538,25 @@ "description": "More-menu link to the ExpTech announcements website" }, "mapTimelineDataTime": "Data {time}", + "mapTimelineSemanticsLabel": "Timeline", + "@mapTimelineSemanticsLabel": { + "description": "Screen-reader label of the map timeline scrubber, which is announced as an adjustable slider" + }, + "mapTimelineSemanticsValue": "{era} {time}, {date}", + "@mapTimelineSemanticsValue": { + "description": "Screen-reader value of the map timeline slider for one frame: its era word (Past/Now/Future), its time label and its date, e.g. Past 07:10, 2026/07/13", + "placeholders": { + "era": { + "type": "String" + }, + "time": { + "type": "String" + }, + "date": { + "type": "String" + } + } + }, "radarScanRange": "Show scan range", "@mapLayerQpesums": { "description": "Name of the QPESUMS next-1-hour precipitation forecast layer in the layer picker" @@ -2572,10 +2631,14 @@ }, "mapLayerSatelliteWatervapor": "Himawari Water Vapour", "regionAddButton": "Add a region", - "regionSearchHint": "Search counties and cities", - "regionSearchEmpty": "No matching counties or cities", + "regionSearchHint": "Search counties, cities and townships", + "regionSearchEmpty": "No matching counties, cities or townships", "regionSearchTownHint": "Search townships", "regionSearchTownEmpty": "No matching townships", + "regionSearchTownsHeader": "Townships", + "@regionSearchTownsHeader": { + "description": "Section header above the township matches listed under the county/city matches when searching on the first page of the region picker" + }, "displaySettings": "Display", "restroomGradePoor": "Below standard", "@moreSectionNotify": { diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 2379e4815..d4122bbd3 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -288,6 +288,11 @@ "reportListYesterday": "Kahapon", "moreSectionLinks": "Mga Link", "feedOffline": "Nawala ang koneksyon", + "freshnessSavedCopy": "Hindi makakonekta · ipinapakita ang datos mula {age} na ang nakalipas", + "freshnessSavedCopyUnknownAge": "Hindi makakonekta · ipinapakita ang naka-save na datos", + "durationMinutes": "{count, plural, =1{1 minuto} other{{count} minuto}}", + "durationHours": "{count, plural, =1{1 oras} other{{count} oras}}", + "durationDays": "{count, plural, =1{1 araw} other{{count} araw}}", "feedReplaying": "Nire-replay", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "Display", @@ -901,6 +906,8 @@ }, "reportFilterSortDepth": "Lalim", "mapTimelineDataTime": "Oras ng data {time}", + "mapTimelineSemanticsLabel": "Timeline", + "mapTimelineSemanticsValue": "{era} {time}, {date}", "radarScanRange": "Ipakita ang saklaw ng pag-scan", "meshtasticHopLimit": "Hop limit", "@meshtasticUptime": { @@ -937,10 +944,11 @@ "typhoonPickerTd": "Tropical depression TD {no}", "mapLayerSatelliteWatervapor": "Himawari Water Vapour", "regionAddButton": "Magdagdag ng rehiyon", - "regionSearchHint": "Maghanap ng mga lalawigan at lungsod", - "regionSearchEmpty": "Walang tumugmang lalawigan o lungsod", + "regionSearchHint": "Maghanap ng lalawigan, lungsod o bayan", + "regionSearchEmpty": "Walang tumugmang lalawigan, lungsod o bayan", "regionSearchTownHint": "Maghanap ng mga bayan", "regionSearchTownEmpty": "Walang tumugmang bayan", + "regionSearchTownsHeader": "Mga bayan", "displaySettings": "Pagpapakita", "restroomGradePoor": "Mas mababa sa pamantayan", "restroomCategoryTourist": "Lugar para sa turista", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 2a4e59f5f..10507b645 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -288,6 +288,11 @@ "reportListYesterday": "Kemarin", "moreSectionLinks": "Tautan", "feedOffline": "Koneksi terputus", + "freshnessSavedCopy": "Tidak dapat terhubung · menampilkan data dari {age} yang lalu", + "freshnessSavedCopyUnknownAge": "Tidak dapat terhubung · menampilkan data tersimpan", + "durationMinutes": "{count, plural, other{{count} menit}}", + "durationHours": "{count, plural, other{{count} jam}}", + "durationDays": "{count, plural, other{{count} hari}}", "feedReplaying": "Memutar ulang", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "Tampilan", @@ -901,6 +906,8 @@ }, "reportFilterSortDepth": "Kedalaman", "mapTimelineDataTime": "Waktu data {time}", + "mapTimelineSemanticsLabel": "Linimasa", + "mapTimelineSemanticsValue": "{era} {time}, {date}", "radarScanRange": "Tampilkan jangkauan pindai", "meshtasticHopLimit": "Batas lompatan", "@meshtasticUptime": { @@ -937,10 +944,11 @@ "typhoonPickerTd": "Depresi tropis TD {no}", "mapLayerSatelliteWatervapor": "Himawari Water Vapour", "regionAddButton": "Tambah wilayah", - "regionSearchHint": "Cari kabupaten dan kota", - "regionSearchEmpty": "Tidak ada kabupaten/kota yang cocok", + "regionSearchHint": "Cari kabupaten, kota, atau kecamatan", + "regionSearchEmpty": "Tidak ada kabupaten/kota atau kecamatan yang cocok", "regionSearchTownHint": "Cari kecamatan", "regionSearchTownEmpty": "Tidak ada kecamatan yang cocok", + "regionSearchTownsHeader": "Kecamatan", "displaySettings": "Tampilan", "restroomGradePoor": "Di bawah standar", "restroomCategoryTourist": "Kawasan wisata", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 0b3e7dc64..16da7ed47 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -288,6 +288,11 @@ "reportListYesterday": "昨日", "moreSectionLinks": "関連リンク", "feedOffline": "接続が切断されました", + "freshnessSavedCopy": "接続できません・{age}前のデータを表示中", + "freshnessSavedCopyUnknownAge": "接続できません・保存済みのデータを表示中", + "durationMinutes": "{count, plural, other{{count}分}}", + "durationHours": "{count, plural, other{{count}時間}}", + "durationDays": "{count, plural, other{{count}日}}", "feedReplaying": "再生中", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "表示", @@ -901,6 +906,8 @@ }, "reportFilterSortDepth": "深さ", "mapTimelineDataTime": "データ時刻 {time}", + "mapTimelineSemanticsLabel": "タイムライン", + "mapTimelineSemanticsValue": "{era} {time}、{date}", "radarScanRange": "走査範囲を表示", "meshtasticHopLimit": "ホップ数上限", "@meshtasticUptime": { @@ -937,10 +944,11 @@ "typhoonPickerTd": "熱帯低気圧 TD {no}", "mapLayerSatelliteWatervapor": "ひまわり 水蒸気", "regionAddButton": "地域を追加", - "regionSearchHint": "都道府県・市区を検索", + "regionSearchHint": "県市・町村を検索", "regionSearchEmpty": "一致する地域がありません", "regionSearchTownHint": "町村を検索", "regionSearchTownEmpty": "該当する町村がありません", + "regionSearchTownsHeader": "町村", "displaySettings": "表示", "restroomGradePoor": "不合格", "restroomCategoryTourist": "観光地・景勝地", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index a5413a28d..1ab9cef8b 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -288,6 +288,11 @@ "reportListYesterday": "어제", "moreSectionLinks": "링크", "feedOffline": "연결이 끊어졌습니다", + "freshnessSavedCopy": "연결할 수 없음 · {age} 전 데이터 표시 중", + "freshnessSavedCopyUnknownAge": "연결할 수 없음 · 저장된 데이터 표시 중", + "durationMinutes": "{count, plural, other{{count}분}}", + "durationHours": "{count, plural, other{{count}시간}}", + "durationDays": "{count, plural, other{{count}일}}", "feedReplaying": "재생 중", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "표시", @@ -901,6 +906,8 @@ }, "reportFilterSortDepth": "깊이", "mapTimelineDataTime": "데이터 시간 {time}", + "mapTimelineSemanticsLabel": "타임라인", + "mapTimelineSemanticsValue": "{era} {time}, {date}", "radarScanRange": "스캔 범위 표시", "meshtasticHopLimit": "홉 제한", "@meshtasticUptime": { @@ -937,10 +944,11 @@ "typhoonPickerTd": "열대 저기압 TD {no}", "mapLayerSatelliteWatervapor": "히마와리 수증기", "regionAddButton": "지역 추가", - "regionSearchHint": "시·도 검색", - "regionSearchEmpty": "일치하는 시·도가 없습니다", + "regionSearchHint": "시·도 또는 읍·면·동 검색", + "regionSearchEmpty": "일치하는 지역이 없습니다", "regionSearchTownHint": "읍·면·동 검색", "regionSearchTownEmpty": "일치하는 읍·면·동이 없습니다", + "regionSearchTownsHeader": "읍·면·동", "displaySettings": "화면", "restroomGradePoor": "불합격", "restroomCategoryTourist": "관광 지역·경치 구역", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index b22b11a28..06ddb9ab2 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -288,6 +288,11 @@ "reportListYesterday": "เมื่อวาน", "moreSectionLinks": "ลิงก์ที่เกี่ยวข้อง", "feedOffline": "การเชื่อมต่อขาดหาย", + "freshnessSavedCopy": "เชื่อมต่อไม่ได้ · แสดงข้อมูลเมื่อ {age}ที่แล้ว", + "freshnessSavedCopyUnknownAge": "เชื่อมต่อไม่ได้ · แสดงข้อมูลที่บันทึกไว้", + "durationMinutes": "{count, plural, other{{count} นาที}}", + "durationHours": "{count, plural, other{{count} ชั่วโมง}}", + "durationDays": "{count, plural, other{{count} วัน}}", "feedReplaying": "กำลังเล่นซ้ำ", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "การแสดงผล", @@ -901,6 +906,8 @@ }, "reportFilterSortDepth": "ความลึก", "mapTimelineDataTime": "เวลาข้อมูล {time}", + "mapTimelineSemanticsLabel": "ไทม์ไลน์", + "mapTimelineSemanticsValue": "{era} {time}, {date}", "radarScanRange": "แสดงขอบเขตการสแกน", "meshtasticHopLimit": "จำนวนฮอปสูงสุด", "@meshtasticUptime": { @@ -937,10 +944,11 @@ "typhoonPickerTd": "ดีเปรสชันเขตร้อน TD {no}", "mapLayerSatelliteWatervapor": "Himawari Water Vapour", "regionAddButton": "เพิ่มพื้นที่", - "regionSearchHint": "ค้นหาจังหวัดและเมือง", - "regionSearchEmpty": "ไม่พบจังหวัดหรือเมืองที่ตรงกัน", + "regionSearchHint": "ค้นหาจังหวัด เมือง หรือตำบล", + "regionSearchEmpty": "ไม่พบจังหวัด เมือง หรือตำบลที่ตรงกัน", "regionSearchTownHint": "ค้นหาตำบล", "regionSearchTownEmpty": "ไม่พบตำบลที่ตรงกัน", + "regionSearchTownsHeader": "ตำบล", "displaySettings": "การแสดงผล", "restroomGradePoor": "ต่ำกว่ามาตรฐาน", "restroomCategoryTourist": "แหล่งท่องเที่ยว", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 0fad033e1..ff1f12830 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -288,6 +288,11 @@ "reportListYesterday": "Hôm qua", "moreSectionLinks": "Liên kết", "feedOffline": "Mất kết nối", + "freshnessSavedCopy": "Không thể kết nối · đang hiển thị dữ liệu từ {age} trước", + "freshnessSavedCopyUnknownAge": "Không thể kết nối · đang hiển thị dữ liệu đã lưu", + "durationMinutes": "{count, plural, other{{count} phút}}", + "durationHours": "{count, plural, other{{count} giờ}}", + "durationDays": "{count, plural, other{{count} ngày}}", "feedReplaying": "Đang phát lại", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "Hiển thị", @@ -901,6 +906,8 @@ }, "reportFilterSortDepth": "Độ sâu", "mapTimelineDataTime": "Thời gian dữ liệu {time}", + "mapTimelineSemanticsLabel": "Dòng thời gian", + "mapTimelineSemanticsValue": "{era} {time}, {date}", "radarScanRange": "Hiện phạm vi quét", "meshtasticHopLimit": "Giới hạn hop", "@meshtasticUptime": { @@ -937,10 +944,11 @@ "typhoonPickerTd": "Áp thấp nhiệt đới TD {no}", "mapLayerSatelliteWatervapor": "Himawari Water Vapour", "regionAddButton": "Thêm khu vực", - "regionSearchHint": "Tìm kiếm tỉnh và thành phố", - "regionSearchEmpty": "Không tìm thấy tỉnh/thành phố phù hợp", + "regionSearchHint": "Tìm tỉnh, thành phố hoặc xã", + "regionSearchEmpty": "Không tìm thấy tỉnh/thành phố hoặc xã phù hợp", "regionSearchTownHint": "Tìm kiếm xã", "regionSearchTownEmpty": "Không tìm thấy xã phù hợp", + "regionSearchTownsHeader": "Xã", "displaySettings": "Hiển thị", "restroomGradePoor": "Dưới chuẩn", "restroomCategoryTourist": "Khu du lịch thắng cảnh", diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index f1fdd6cdd..a6abdaf87 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -290,6 +290,11 @@ "reportListYesterday": "昨天", "moreSectionLinks": "相關連結", "feedOffline": "連接中斷", + "freshnessSavedCopy": "連唔到網絡・顯示緊 {age}前嘅資料", + "freshnessSavedCopyUnknownAge": "連唔到網絡・顯示緊之前儲存嘅資料", + "durationMinutes": "{count, plural, other{{count} 分鐘}}", + "durationHours": "{count, plural, other{{count} 個鐘}}", + "durationDays": "{count, plural, other{{count} 日}}", "feedReplaying": "重播緊", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "顯示", @@ -901,6 +906,8 @@ }, "reportFilterSortDepth": "深度", "mapTimelineDataTime": "資料時間 {time}", + "mapTimelineSemanticsLabel": "時間軸", + "mapTimelineSemanticsValue": "{era} {time},{date}", "radarScanRange": "顯示掃描範圍", "meshtasticHopLimit": "跳數上限", "@meshtasticUptime": { @@ -937,10 +944,11 @@ "typhoonPickerTd": "熱帶性低氣壓 TD {no}", "mapLayerSatelliteWatervapor": "ひまわり 水氣", "regionAddButton": "新增地區", - "regionSearchHint": "搜尋縣市", - "regionSearchEmpty": "搵唔到符合嘅縣市", + "regionSearchHint": "搜尋縣市或鄉鎮", + "regionSearchEmpty": "搵唔到符合嘅縣市或鄉鎮", "regionSearchTownHint": "搜尋鄉鎮", "regionSearchTownEmpty": "搵唔到符合嘅鄉鎮", + "regionSearchTownsHeader": "鄉鎮", "displaySettings": "顯示設定", "restroomGradePoor": "唔合格", "restroomCategoryTourist": "觀光地區及風景區", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 89f1c8976..d8cda19a3 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -288,6 +288,11 @@ "reportListYesterday": "昨天", "moreSectionLinks": "相關連結", "feedOffline": "連線中斷", + "freshnessSavedCopy": "無法連線・顯示 {age}前的資料", + "freshnessSavedCopyUnknownAge": "無法連線・顯示先前儲存的資料", + "durationMinutes": "{count, plural, other{{count} 分鐘}}", + "durationHours": "{count, plural, other{{count} 小時}}", + "durationDays": "{count, plural, other{{count} 天}}", "feedReplaying": "重播中", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "顯示", @@ -901,6 +906,8 @@ }, "reportFilterSortDepth": "深度", "mapTimelineDataTime": "資料時間 {time}", + "mapTimelineSemanticsLabel": "時間軸", + "mapTimelineSemanticsValue": "{era} {time},{date}", "radarScanRange": "顯示掃描範圍", "meshtasticHopLimit": "跳數上限", "@meshtasticUptime": { @@ -937,10 +944,11 @@ "typhoonPickerTd": "熱帶性低氣壓 TD {no}", "mapLayerSatelliteWatervapor": "ひまわり 水氣", "regionAddButton": "新增地區", - "regionSearchHint": "搜尋縣市", - "regionSearchEmpty": "找不到符合的縣市", + "regionSearchHint": "搜尋縣市或鄉鎮市區", + "regionSearchEmpty": "找不到符合的縣市或鄉鎮市區", "regionSearchTownHint": "搜尋鄉鎮市區", "regionSearchTownEmpty": "找不到符合的鄉鎮市區", + "regionSearchTownsHeader": "鄉鎮市區", "displaySettings": "顯示設定", "restroomGradePoor": "不合格", "restroomCategoryTourist": "觀光地區及風景區", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index fdbf8405d..5df33f9d1 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -288,6 +288,11 @@ "reportListYesterday": "昨天", "moreSectionLinks": "相关链接", "feedOffline": "连接中断", + "freshnessSavedCopy": "无法连接・显示 {age}前的数据", + "freshnessSavedCopyUnknownAge": "无法连接・显示先前保存的数据", + "durationMinutes": "{count, plural, other{{count} 分钟}}", + "durationHours": "{count, plural, other{{count} 小时}}", + "durationDays": "{count, plural, other{{count} 天}}", "feedReplaying": "重播中", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "显示", @@ -901,6 +906,8 @@ }, "reportFilterSortDepth": "深度", "mapTimelineDataTime": "资料时间 {time}", + "mapTimelineSemanticsLabel": "时间轴", + "mapTimelineSemanticsValue": "{era} {time},{date}", "radarScanRange": "显示扫描范围", "meshtasticHopLimit": "跳数上限", "@meshtasticUptime": { @@ -937,10 +944,11 @@ "typhoonPickerTd": "热带性低气压 TD {no}", "mapLayerSatelliteWatervapor": "ひまわり 水气", "regionAddButton": "添加地区", - "regionSearchHint": "搜索县市", - "regionSearchEmpty": "找不到符合的县市", + "regionSearchHint": "搜索县市或乡镇市区", + "regionSearchEmpty": "找不到符合的县市或乡镇市区", "regionSearchTownHint": "搜索乡镇市区", "regionSearchTownEmpty": "找不到符合的乡镇市区", + "regionSearchTownsHeader": "乡镇市区", "displaySettings": "显示设置", "restroomGradePoor": "不合格", "restroomCategoryTourist": "观光地区及风景区", diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 0224dea6d..9f2ae0379 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -288,6 +288,11 @@ "reportListYesterday": "昨天", "moreSectionLinks": "相關連結", "feedOffline": "連接中斷", + "freshnessSavedCopy": "連唔到網絡・顯示緊 {age}前嘅資料", + "freshnessSavedCopyUnknownAge": "連唔到網絡・顯示緊之前儲存嘅資料", + "durationMinutes": "{count, plural, other{{count} 分鐘}}", + "durationHours": "{count, plural, other{{count} 個鐘}}", + "durationDays": "{count, plural, other{{count} 日}}", "feedReplaying": "重播中", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "顯示", @@ -901,6 +906,8 @@ }, "reportFilterSortDepth": "深度", "mapTimelineDataTime": "資料時間 {time}", + "mapTimelineSemanticsLabel": "時間軸", + "mapTimelineSemanticsValue": "{era} {time},{date}", "radarScanRange": "顯示掃描範圍", "meshtasticHopLimit": "跳數上限", "@meshtasticUptime": { @@ -937,10 +944,11 @@ "typhoonPickerTd": "熱帶性低氣壓 TD {no}", "mapLayerSatelliteWatervapor": "ひまわり 水氣", "regionAddButton": "新增地區", - "regionSearchHint": "搜尋縣市", - "regionSearchEmpty": "搵唔到符合嘅縣市", + "regionSearchHint": "搜尋縣市或鄉鎮", + "regionSearchEmpty": "搵唔到符合嘅縣市或鄉鎮", "regionSearchTownHint": "搜尋鄉鎮", "regionSearchTownEmpty": "搵唔到符合嘅鄉鎮", + "regionSearchTownsHeader": "鄉鎮", "displaySettings": "顯示設定", "restroomGradePoor": "不合格", "restroomCategoryTourist": "觀光地區及風景區", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index d0cfe63a6..01d92a72b 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -288,6 +288,11 @@ "reportListYesterday": "昨天", "moreSectionLinks": "相關連結", "feedOffline": "連線中斷", + "freshnessSavedCopy": "無法連線・顯示 {age}前的資料", + "freshnessSavedCopyUnknownAge": "無法連線・顯示先前儲存的資料", + "durationMinutes": "{count, plural, other{{count} 分鐘}}", + "durationHours": "{count, plural, other{{count} 小時}}", + "durationDays": "{count, plural, other{{count} 天}}", "feedReplaying": "重播中", "mapLayerStyleBd": "Dvorak BD", "moreSectionDisplay": "顯示", @@ -901,6 +906,8 @@ }, "reportFilterSortDepth": "深度", "mapTimelineDataTime": "資料時間 {time}", + "mapTimelineSemanticsLabel": "時間軸", + "mapTimelineSemanticsValue": "{era} {time},{date}", "radarScanRange": "顯示掃描範圍", "meshtasticHopLimit": "跳數上限", "@meshtasticUptime": { @@ -937,10 +944,11 @@ "typhoonPickerTd": "熱帶性低氣壓 TD {no}", "mapLayerSatelliteWatervapor": "ひまわり 水氣", "regionAddButton": "新增地區", - "regionSearchHint": "搜尋縣市", - "regionSearchEmpty": "找不到符合的縣市", + "regionSearchHint": "搜尋縣市或鄉鎮市區", + "regionSearchEmpty": "找不到符合的縣市或鄉鎮市區", "regionSearchTownHint": "搜尋鄉鎮市區", "regionSearchTownEmpty": "找不到符合的鄉鎮市區", + "regionSearchTownsHeader": "鄉鎮市區", "displaySettings": "顯示設定", "restroomGradePoor": "不合格", "restroomCategoryTourist": "觀光地區及風景區", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 99e0f25d7..9b63f7f12 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1290,6 +1290,36 @@ abstract class AppLocalizations { /// **'Connection lost'** String get feedOffline; + /// Banner over a list shown from the copy saved last time because the server could not be reached; {age} is a duration such as '5 minutes' (durationMinutes/Hours/Days) + /// + /// In en, this message translates to: + /// **'Can\'t connect · showing data from {age} ago'** + String freshnessSavedCopy(String age); + + /// Banner over a list shown from a saved copy whose age is unknown, because the server could not be reached + /// + /// In en, this message translates to: + /// **'Can\'t connect · showing saved data'** + String get freshnessSavedCopyUnknownAge; + + /// A duration in whole minutes, used inside other sentences (e.g. how old saved data is) + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 minute} other{{count} minutes}}'** + String durationMinutes(int count); + + /// A duration in whole hours, used inside other sentences (e.g. how old saved data is) + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 hour} other{{count} hours}}'** + String durationHours(int count); + + /// A duration in whole days, used inside other sentences (e.g. how old saved data is) + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 day} other{{count} days}}'** + String durationDays(int count); + /// Status word on the replay page when the replayed instant is older than the RTS retention window: the replay is running, the server just has no shaking snapshot that far back. Never 'offline' — the feed is not broken /// /// In en, this message translates to: @@ -3534,6 +3564,18 @@ abstract class AppLocalizations { /// **'Data {time}'** String mapTimelineDataTime(String time); + /// Screen-reader label of the map timeline scrubber, which is announced as an adjustable slider + /// + /// In en, this message translates to: + /// **'Timeline'** + String get mapTimelineSemanticsLabel; + + /// Screen-reader value of the map timeline slider for one frame: its era word (Past/Now/Future), its time label and its date, e.g. Past 07:10, 2026/07/13 + /// + /// In en, this message translates to: + /// **'{era} {time}, {date}'** + String mapTimelineSemanticsValue(String era, String time, String date); + /// Radar scan-range overlay toggle in the map's radar overlay menu. /// /// In en, this message translates to: @@ -3657,13 +3699,13 @@ abstract class AppLocalizations { /// No description provided for @regionSearchHint. /// /// In en, this message translates to: - /// **'Search counties and cities'** + /// **'Search counties, cities and townships'** String get regionSearchHint; /// No description provided for @regionSearchEmpty. /// /// In en, this message translates to: - /// **'No matching counties or cities'** + /// **'No matching counties, cities or townships'** String get regionSearchEmpty; /// No description provided for @regionSearchTownHint. @@ -3678,6 +3720,12 @@ abstract class AppLocalizations { /// **'No matching townships'** String get regionSearchTownEmpty; + /// Section header above the township matches listed under the county/city matches when searching on the first page of the region picker + /// + /// In en, this message translates to: + /// **'Townships'** + String get regionSearchTownsHeader; + /// Display-settings menu entry and page title (theme mode) /// /// In en, this message translates to: diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 132954333..0f81b86ce 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -645,6 +645,48 @@ class AppLocalizationsEn extends AppLocalizations { @override String get feedOffline => 'Connection lost'; + @override + String freshnessSavedCopy(String age) { + return 'Can\'t connect · showing data from $age ago'; + } + + @override + String get freshnessSavedCopyUnknownAge => + 'Can\'t connect · showing saved data'; + + @override + String durationMinutes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count minutes', + one: '1 minute', + ); + return '$_temp0'; + } + + @override + String durationHours(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hours', + one: '1 hour', + ); + return '$_temp0'; + } + + @override + String durationDays(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count days', + one: '1 day', + ); + return '$_temp0'; + } + @override String get feedReplaying => 'Replaying'; @@ -1845,6 +1887,14 @@ class AppLocalizationsEn extends AppLocalizations { return 'Data $time'; } + @override + String get mapTimelineSemanticsLabel => 'Timeline'; + + @override + String mapTimelineSemanticsValue(String era, String time, String date) { + return '$era $time, $date'; + } + @override String get radarScanRange => 'Show scan range'; @@ -1916,10 +1966,10 @@ class AppLocalizationsEn extends AppLocalizations { String get regionAddButton => 'Add a region'; @override - String get regionSearchHint => 'Search counties and cities'; + String get regionSearchHint => 'Search counties, cities and townships'; @override - String get regionSearchEmpty => 'No matching counties or cities'; + String get regionSearchEmpty => 'No matching counties, cities or townships'; @override String get regionSearchTownHint => 'Search townships'; @@ -1927,6 +1977,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get regionSearchTownEmpty => 'No matching townships'; + @override + String get regionSearchTownsHeader => 'Townships'; + @override String get displaySettings => 'Display'; diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 54b38d553..199fb8250 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -648,6 +648,48 @@ class AppLocalizationsFil extends AppLocalizations { @override String get feedOffline => 'Nawala ang koneksyon'; + @override + String freshnessSavedCopy(String age) { + return 'Hindi makakonekta · ipinapakita ang datos mula $age na ang nakalipas'; + } + + @override + String get freshnessSavedCopyUnknownAge => + 'Hindi makakonekta · ipinapakita ang naka-save na datos'; + + @override + String durationMinutes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count minuto', + one: '1 minuto', + ); + return '$_temp0'; + } + + @override + String durationHours(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count oras', + one: '1 oras', + ); + return '$_temp0'; + } + + @override + String durationDays(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count araw', + one: '1 araw', + ); + return '$_temp0'; + } + @override String get feedReplaying => 'Nire-replay'; @@ -1856,6 +1898,14 @@ class AppLocalizationsFil extends AppLocalizations { return 'Oras ng data $time'; } + @override + String get mapTimelineSemanticsLabel => 'Timeline'; + + @override + String mapTimelineSemanticsValue(String era, String time, String date) { + return '$era $time, $date'; + } + @override String get radarScanRange => 'Ipakita ang saklaw ng pag-scan'; @@ -1927,10 +1977,10 @@ class AppLocalizationsFil extends AppLocalizations { String get regionAddButton => 'Magdagdag ng rehiyon'; @override - String get regionSearchHint => 'Maghanap ng mga lalawigan at lungsod'; + String get regionSearchHint => 'Maghanap ng lalawigan, lungsod o bayan'; @override - String get regionSearchEmpty => 'Walang tumugmang lalawigan o lungsod'; + String get regionSearchEmpty => 'Walang tumugmang lalawigan, lungsod o bayan'; @override String get regionSearchTownHint => 'Maghanap ng mga bayan'; @@ -1938,6 +1988,9 @@ class AppLocalizationsFil extends AppLocalizations { @override String get regionSearchTownEmpty => 'Walang tumugmang bayan'; + @override + String get regionSearchTownsHeader => 'Mga bayan'; + @override String get displaySettings => 'Pagpapakita'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 2bbfd5a02..0c0977fe3 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -646,6 +646,45 @@ class AppLocalizationsId extends AppLocalizations { @override String get feedOffline => 'Koneksi terputus'; + @override + String freshnessSavedCopy(String age) { + return 'Tidak dapat terhubung · menampilkan data dari $age yang lalu'; + } + + @override + String get freshnessSavedCopyUnknownAge => + 'Tidak dapat terhubung · menampilkan data tersimpan'; + + @override + String durationMinutes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count menit', + ); + return '$_temp0'; + } + + @override + String durationHours(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count jam', + ); + return '$_temp0'; + } + + @override + String durationDays(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hari', + ); + return '$_temp0'; + } + @override String get feedReplaying => 'Memutar ulang'; @@ -1850,6 +1889,14 @@ class AppLocalizationsId extends AppLocalizations { return 'Waktu data $time'; } + @override + String get mapTimelineSemanticsLabel => 'Linimasa'; + + @override + String mapTimelineSemanticsValue(String era, String time, String date) { + return '$era $time, $date'; + } + @override String get radarScanRange => 'Tampilkan jangkauan pindai'; @@ -1921,10 +1968,11 @@ class AppLocalizationsId extends AppLocalizations { String get regionAddButton => 'Tambah wilayah'; @override - String get regionSearchHint => 'Cari kabupaten dan kota'; + String get regionSearchHint => 'Cari kabupaten, kota, atau kecamatan'; @override - String get regionSearchEmpty => 'Tidak ada kabupaten/kota yang cocok'; + String get regionSearchEmpty => + 'Tidak ada kabupaten/kota atau kecamatan yang cocok'; @override String get regionSearchTownHint => 'Cari kecamatan'; @@ -1932,6 +1980,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get regionSearchTownEmpty => 'Tidak ada kecamatan yang cocok'; + @override + String get regionSearchTownsHeader => 'Kecamatan'; + @override String get displaySettings => 'Tampilan'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 53e3aaa9d..375fbb167 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -637,6 +637,44 @@ class AppLocalizationsJa extends AppLocalizations { @override String get feedOffline => '接続が切断されました'; + @override + String freshnessSavedCopy(String age) { + return '接続できません・$age前のデータを表示中'; + } + + @override + String get freshnessSavedCopyUnknownAge => '接続できません・保存済みのデータを表示中'; + + @override + String durationMinutes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count分', + ); + return '$_temp0'; + } + + @override + String durationHours(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count時間', + ); + return '$_temp0'; + } + + @override + String durationDays(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count日', + ); + return '$_temp0'; + } + @override String get feedReplaying => '再生中'; @@ -1815,6 +1853,14 @@ class AppLocalizationsJa extends AppLocalizations { return 'データ時刻 $time'; } + @override + String get mapTimelineSemanticsLabel => 'タイムライン'; + + @override + String mapTimelineSemanticsValue(String era, String time, String date) { + return '$era $time、$date'; + } + @override String get radarScanRange => '走査範囲を表示'; @@ -1884,7 +1930,7 @@ class AppLocalizationsJa extends AppLocalizations { String get regionAddButton => '地域を追加'; @override - String get regionSearchHint => '都道府県・市区を検索'; + String get regionSearchHint => '県市・町村を検索'; @override String get regionSearchEmpty => '一致する地域がありません'; @@ -1895,6 +1941,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get regionSearchTownEmpty => '該当する町村がありません'; + @override + String get regionSearchTownsHeader => '町村'; + @override String get displaySettings => '表示'; diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 66dcb8998..97221f89a 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -636,6 +636,44 @@ class AppLocalizationsKo extends AppLocalizations { @override String get feedOffline => '연결이 끊어졌습니다'; + @override + String freshnessSavedCopy(String age) { + return '연결할 수 없음 · $age 전 데이터 표시 중'; + } + + @override + String get freshnessSavedCopyUnknownAge => '연결할 수 없음 · 저장된 데이터 표시 중'; + + @override + String durationMinutes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count분', + ); + return '$_temp0'; + } + + @override + String durationHours(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count시간', + ); + return '$_temp0'; + } + + @override + String durationDays(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count일', + ); + return '$_temp0'; + } + @override String get feedReplaying => '재생 중'; @@ -1815,6 +1853,14 @@ class AppLocalizationsKo extends AppLocalizations { return '데이터 시간 $time'; } + @override + String get mapTimelineSemanticsLabel => '타임라인'; + + @override + String mapTimelineSemanticsValue(String era, String time, String date) { + return '$era $time, $date'; + } + @override String get radarScanRange => '스캔 범위 표시'; @@ -1884,10 +1930,10 @@ class AppLocalizationsKo extends AppLocalizations { String get regionAddButton => '지역 추가'; @override - String get regionSearchHint => '시·도 검색'; + String get regionSearchHint => '시·도 또는 읍·면·동 검색'; @override - String get regionSearchEmpty => '일치하는 시·도가 없습니다'; + String get regionSearchEmpty => '일치하는 지역이 없습니다'; @override String get regionSearchTownHint => '읍·면·동 검색'; @@ -1895,6 +1941,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get regionSearchTownEmpty => '일치하는 읍·면·동이 없습니다'; + @override + String get regionSearchTownsHeader => '읍·면·동'; + @override String get displaySettings => '화면'; diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 7f991bfc6..9b68d9135 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -643,6 +643,45 @@ class AppLocalizationsTh extends AppLocalizations { @override String get feedOffline => 'การเชื่อมต่อขาดหาย'; + @override + String freshnessSavedCopy(String age) { + return 'เชื่อมต่อไม่ได้ · แสดงข้อมูลเมื่อ $ageที่แล้ว'; + } + + @override + String get freshnessSavedCopyUnknownAge => + 'เชื่อมต่อไม่ได้ · แสดงข้อมูลที่บันทึกไว้'; + + @override + String durationMinutes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count นาที', + ); + return '$_temp0'; + } + + @override + String durationHours(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count ชั่วโมง', + ); + return '$_temp0'; + } + + @override + String durationDays(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count วัน', + ); + return '$_temp0'; + } + @override String get feedReplaying => 'กำลังเล่นซ้ำ'; @@ -1841,6 +1880,14 @@ class AppLocalizationsTh extends AppLocalizations { return 'เวลาข้อมูล $time'; } + @override + String get mapTimelineSemanticsLabel => 'ไทม์ไลน์'; + + @override + String mapTimelineSemanticsValue(String era, String time, String date) { + return '$era $time, $date'; + } + @override String get radarScanRange => 'แสดงขอบเขตการสแกน'; @@ -1912,10 +1959,10 @@ class AppLocalizationsTh extends AppLocalizations { String get regionAddButton => 'เพิ่มพื้นที่'; @override - String get regionSearchHint => 'ค้นหาจังหวัดและเมือง'; + String get regionSearchHint => 'ค้นหาจังหวัด เมือง หรือตำบล'; @override - String get regionSearchEmpty => 'ไม่พบจังหวัดหรือเมืองที่ตรงกัน'; + String get regionSearchEmpty => 'ไม่พบจังหวัด เมือง หรือตำบลที่ตรงกัน'; @override String get regionSearchTownHint => 'ค้นหาตำบล'; @@ -1923,6 +1970,9 @@ class AppLocalizationsTh extends AppLocalizations { @override String get regionSearchTownEmpty => 'ไม่พบตำบลที่ตรงกัน'; + @override + String get regionSearchTownsHeader => 'ตำบล'; + @override String get displaySettings => 'การแสดงผล'; diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 55580275b..6bd54c51e 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -643,6 +643,45 @@ class AppLocalizationsVi extends AppLocalizations { @override String get feedOffline => 'Mất kết nối'; + @override + String freshnessSavedCopy(String age) { + return 'Không thể kết nối · đang hiển thị dữ liệu từ $age trước'; + } + + @override + String get freshnessSavedCopyUnknownAge => + 'Không thể kết nối · đang hiển thị dữ liệu đã lưu'; + + @override + String durationMinutes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count phút', + ); + return '$_temp0'; + } + + @override + String durationHours(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count giờ', + ); + return '$_temp0'; + } + + @override + String durationDays(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count ngày', + ); + return '$_temp0'; + } + @override String get feedReplaying => 'Đang phát lại'; @@ -1846,6 +1885,14 @@ class AppLocalizationsVi extends AppLocalizations { return 'Thời gian dữ liệu $time'; } + @override + String get mapTimelineSemanticsLabel => 'Dòng thời gian'; + + @override + String mapTimelineSemanticsValue(String era, String time, String date) { + return '$era $time, $date'; + } + @override String get radarScanRange => 'Hiện phạm vi quét'; @@ -1917,10 +1964,11 @@ class AppLocalizationsVi extends AppLocalizations { String get regionAddButton => 'Thêm khu vực'; @override - String get regionSearchHint => 'Tìm kiếm tỉnh và thành phố'; + String get regionSearchHint => 'Tìm tỉnh, thành phố hoặc xã'; @override - String get regionSearchEmpty => 'Không tìm thấy tỉnh/thành phố phù hợp'; + String get regionSearchEmpty => + 'Không tìm thấy tỉnh/thành phố hoặc xã phù hợp'; @override String get regionSearchTownHint => 'Tìm kiếm xã'; @@ -1928,6 +1976,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get regionSearchTownEmpty => 'Không tìm thấy xã phù hợp'; + @override + String get regionSearchTownsHeader => 'Xã'; + @override String get displaySettings => 'Hiển thị'; diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 36ac9bf80..90bee844e 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -633,6 +633,44 @@ class AppLocalizationsYue extends AppLocalizations { @override String get feedOffline => '連接中斷'; + @override + String freshnessSavedCopy(String age) { + return '連唔到網絡・顯示緊 $age前嘅資料'; + } + + @override + String get freshnessSavedCopyUnknownAge => '連唔到網絡・顯示緊之前儲存嘅資料'; + + @override + String durationMinutes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 分鐘', + ); + return '$_temp0'; + } + + @override + String durationHours(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 個鐘', + ); + return '$_temp0'; + } + + @override + String durationDays(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 日', + ); + return '$_temp0'; + } + @override String get feedReplaying => '重播緊'; @@ -1803,6 +1841,14 @@ class AppLocalizationsYue extends AppLocalizations { return '資料時間 $time'; } + @override + String get mapTimelineSemanticsLabel => '時間軸'; + + @override + String mapTimelineSemanticsValue(String era, String time, String date) { + return '$era $time,$date'; + } + @override String get radarScanRange => '顯示掃描範圍'; @@ -1872,10 +1918,10 @@ class AppLocalizationsYue extends AppLocalizations { String get regionAddButton => '新增地區'; @override - String get regionSearchHint => '搜尋縣市'; + String get regionSearchHint => '搜尋縣市或鄉鎮'; @override - String get regionSearchEmpty => '搵唔到符合嘅縣市'; + String get regionSearchEmpty => '搵唔到符合嘅縣市或鄉鎮'; @override String get regionSearchTownHint => '搜尋鄉鎮'; @@ -1883,6 +1929,9 @@ class AppLocalizationsYue extends AppLocalizations { @override String get regionSearchTownEmpty => '搵唔到符合嘅鄉鎮'; + @override + String get regionSearchTownsHeader => '鄉鎮'; + @override String get displaySettings => '顯示設定'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index f49dcc2d9..1aa4fcda5 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -633,6 +633,44 @@ class AppLocalizationsZh extends AppLocalizations { @override String get feedOffline => '連線中斷'; + @override + String freshnessSavedCopy(String age) { + return '無法連線・顯示 $age前的資料'; + } + + @override + String get freshnessSavedCopyUnknownAge => '無法連線・顯示先前儲存的資料'; + + @override + String durationMinutes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 分鐘', + ); + return '$_temp0'; + } + + @override + String durationHours(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 小時', + ); + return '$_temp0'; + } + + @override + String durationDays(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 天', + ); + return '$_temp0'; + } + @override String get feedReplaying => '重播中'; @@ -1803,6 +1841,14 @@ class AppLocalizationsZh extends AppLocalizations { return '資料時間 $time'; } + @override + String get mapTimelineSemanticsLabel => '時間軸'; + + @override + String mapTimelineSemanticsValue(String era, String time, String date) { + return '$era $time,$date'; + } + @override String get radarScanRange => '顯示掃描範圍'; @@ -1872,10 +1918,10 @@ class AppLocalizationsZh extends AppLocalizations { String get regionAddButton => '新增地區'; @override - String get regionSearchHint => '搜尋縣市'; + String get regionSearchHint => '搜尋縣市或鄉鎮市區'; @override - String get regionSearchEmpty => '找不到符合的縣市'; + String get regionSearchEmpty => '找不到符合的縣市或鄉鎮市區'; @override String get regionSearchTownHint => '搜尋鄉鎮市區'; @@ -1883,6 +1929,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get regionSearchTownEmpty => '找不到符合的鄉鎮市區'; + @override + String get regionSearchTownsHeader => '鄉鎮市區'; + @override String get displaySettings => '顯示設定'; @@ -4121,6 +4170,44 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get feedOffline => '连接中断'; + @override + String freshnessSavedCopy(String age) { + return '无法连接・显示 $age前的数据'; + } + + @override + String get freshnessSavedCopyUnknownAge => '无法连接・显示先前保存的数据'; + + @override + String durationMinutes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 分钟', + ); + return '$_temp0'; + } + + @override + String durationHours(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 小时', + ); + return '$_temp0'; + } + + @override + String durationDays(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 天', + ); + return '$_temp0'; + } + @override String get feedReplaying => '重播中'; @@ -5291,6 +5378,14 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { return '资料时间 $time'; } + @override + String get mapTimelineSemanticsLabel => '时间轴'; + + @override + String mapTimelineSemanticsValue(String era, String time, String date) { + return '$era $time,$date'; + } + @override String get radarScanRange => '显示扫描范围'; @@ -5360,10 +5455,10 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get regionAddButton => '添加地区'; @override - String get regionSearchHint => '搜索县市'; + String get regionSearchHint => '搜索县市或乡镇市区'; @override - String get regionSearchEmpty => '找不到符合的县市'; + String get regionSearchEmpty => '找不到符合的县市或乡镇市区'; @override String get regionSearchTownHint => '搜索乡镇市区'; @@ -5371,6 +5466,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get regionSearchTownEmpty => '找不到符合的乡镇市区'; + @override + String get regionSearchTownsHeader => '乡镇市区'; + @override String get displaySettings => '显示设置'; @@ -7608,6 +7706,44 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get feedOffline => '連接中斷'; + @override + String freshnessSavedCopy(String age) { + return '連唔到網絡・顯示緊 $age前嘅資料'; + } + + @override + String get freshnessSavedCopyUnknownAge => '連唔到網絡・顯示緊之前儲存嘅資料'; + + @override + String durationMinutes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 分鐘', + ); + return '$_temp0'; + } + + @override + String durationHours(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 個鐘', + ); + return '$_temp0'; + } + + @override + String durationDays(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 日', + ); + return '$_temp0'; + } + @override String get feedReplaying => '重播中'; @@ -8778,6 +8914,14 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { return '資料時間 $time'; } + @override + String get mapTimelineSemanticsLabel => '時間軸'; + + @override + String mapTimelineSemanticsValue(String era, String time, String date) { + return '$era $time,$date'; + } + @override String get radarScanRange => '顯示掃描範圍'; @@ -8847,10 +8991,10 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get regionAddButton => '新增地區'; @override - String get regionSearchHint => '搜尋縣市'; + String get regionSearchHint => '搜尋縣市或鄉鎮'; @override - String get regionSearchEmpty => '搵唔到符合嘅縣市'; + String get regionSearchEmpty => '搵唔到符合嘅縣市或鄉鎮'; @override String get regionSearchTownHint => '搜尋鄉鎮'; @@ -8858,6 +9002,9 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get regionSearchTownEmpty => '搵唔到符合嘅鄉鎮'; + @override + String get regionSearchTownsHeader => '鄉鎮'; + @override String get displaySettings => '顯示設定'; @@ -11096,6 +11243,44 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get feedOffline => '連線中斷'; + @override + String freshnessSavedCopy(String age) { + return '無法連線・顯示 $age前的資料'; + } + + @override + String get freshnessSavedCopyUnknownAge => '無法連線・顯示先前儲存的資料'; + + @override + String durationMinutes(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 分鐘', + ); + return '$_temp0'; + } + + @override + String durationHours(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 小時', + ); + return '$_temp0'; + } + + @override + String durationDays(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count 天', + ); + return '$_temp0'; + } + @override String get feedReplaying => '重播中'; @@ -12266,6 +12451,14 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '資料時間 $time'; } + @override + String get mapTimelineSemanticsLabel => '時間軸'; + + @override + String mapTimelineSemanticsValue(String era, String time, String date) { + return '$era $time,$date'; + } + @override String get radarScanRange => '顯示掃描範圍'; @@ -12335,10 +12528,10 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get regionAddButton => '新增地區'; @override - String get regionSearchHint => '搜尋縣市'; + String get regionSearchHint => '搜尋縣市或鄉鎮市區'; @override - String get regionSearchEmpty => '找不到符合的縣市'; + String get regionSearchEmpty => '找不到符合的縣市或鄉鎮市區'; @override String get regionSearchTownHint => '搜尋鄉鎮市區'; @@ -12346,6 +12539,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get regionSearchTownEmpty => '找不到符合的鄉鎮市區'; + @override + String get regionSearchTownsHeader => '鄉鎮市區'; + @override String get displaySettings => '顯示設定'; diff --git a/lib/shared/map/map_timeline.dart b/lib/shared/map/map_timeline.dart index 2d28844e6..e40e983b8 100644 --- a/lib/shared/map/map_timeline.dart +++ b/lib/shared/map/map_timeline.dart @@ -301,16 +301,51 @@ class _MapTimelineState extends State { /// The big time label: the selected instant, or — when the layer's frames /// each cover a period — the range that instant starts, so "21:00" is read /// as "21:00–22:00" rather than a point measurement. - String get _bigLabel { - final start = _times[_liveIndex]; + String get _bigLabel => _labelFor(_liveIndex); + + String _labelFor(int index) { + final start = _times[index]; final period = widget.framePeriod; if (period == null) return start; - final end = _time.format( - widget.frames[_liveIndex].time.toLocal().add(period), - ); + final end = _time.format(widget.frames[index].time.toLocal().add(period)); return '$start – $end'; } + /// What a screen reader announces for frame [index]: its era, the same time + /// label the header shows, and its date — "Past 07:10, 2026/07/13". + String _semanticValue(AppLocalizations l10n, int index, int nowIndex) => + l10n.mapTimelineSemanticsValue( + _eraWord(l10n, _eraOf(index, nowIndex)), + _labelFor(index), + _dates[index], + ); + + static String _eraWord(AppLocalizations l10n, TimelineEra era) => + switch (era) { + TimelineEra.past => l10n.mapTimelinePast, + TimelineEra.now => l10n.mapTimelineNow, + TimelineEra.future => l10n.mapTimelineFuture, + }; + + /// Moves the selection one frame by [delta] — the slider's increase and + /// decrease actions, the only way a screen-reader user can scrub, since the + /// ruler itself is a drag target with nothing to focus. + /// + /// Goes through the same scroll a finger would: the jump emits the usual + /// start / update / end notifications, so [_onScroll] reports the frame and + /// the scrub settles through its quiet window exactly as after a short + /// fling. Only when the ruler has not laid out yet is the frame reported + /// directly. + void _step(int delta) { + final target = (_liveIndex + delta).clamp(0, widget.frames.length - 1); + if (target == _liveIndex) return; + _centreOn(target, animate: false); + if (_liveIndex != target) { + _publishLiveIndex(target); + widget.onSelected(target); + } + } + @override void initState() { super.initState(); @@ -504,15 +539,10 @@ class _MapTimelineState extends State { crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ - Text(switch (era) { - TimelineEra.past => AppLocalizations.of( - context, - ).mapTimelinePast, - TimelineEra.now => AppLocalizations.of(context).mapTimelineNow, - TimelineEra.future => AppLocalizations.of( - context, - ).mapTimelineFuture, - }, style: theme.textTheme.titleMedium?.copyWith(color: color)), + Text( + _eraWord(AppLocalizations.of(context), era), + style: theme.textTheme.titleMedium?.copyWith(color: color), + ), const SizedBox(width: AppSpacing.sm), Text( _bigLabel, @@ -630,68 +660,103 @@ class _MapTimelineState extends State { }, ), const SizedBox(height: AppSpacing.xs), - SizedBox( - height: _rulerHeight, - child: Stack( - alignment: Alignment.center, - children: [ - LayoutBuilder( - builder: (context, constraints) { - final pad = (constraints.maxWidth - widget.itemExtent) / 2; - // ListView.builder (not a Row) so a week of frames only ever - // builds the ~dozens of ticks on screen. itemExtent keeps the - // centring math: offset `i * itemExtent` centres frame `i`. - return _TickEmphasis( - live: _live, - child: NotificationListener( - onNotification: _onScroll, - child: ListView.builder( - 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: switch (_eraOf(i, nowIndex)) { - TimelineEra.past => eraColors.past, - TimelineEra.now => eraColors.now, - TimelineEra.future => eraColors.future, - }, - emphasised: _TickEmphasis.of(context, i), - colors: colors, - textStyle: theme.textTheme.labelSmall, - ), - ), + // One adjustable node in place of a strip of tick labels: a screen + // reader focuses "timeline, Past 07:10", and swiping up / down steps a + // frame. The ruler's own semantics — dozens of time labels and a + // horizontal scroll — would leave nothing to adjust. + ValueListenableBuilder( + valueListenable: _live, + builder: (context, liveIndex, ruler) { + final last = widget.frames.length - 1; + return Semantics( + container: true, + slider: true, + label: l10n.mapTimelineSemanticsLabel, + value: _semanticValue(l10n, liveIndex, nowIndex), + increasedValue: liveIndex < last + ? _semanticValue(l10n, liveIndex + 1, nowIndex) + : null, + decreasedValue: liveIndex > 0 + ? _semanticValue(l10n, liveIndex - 1, nowIndex) + : null, + onIncrease: liveIndex < last ? () => _step(1) : null, + onDecrease: liveIndex > 0 ? () => _step(-1) : null, + child: ExcludeSemantics(child: ruler), + ); + }, + child: _buildRuler(theme, colors, nowIndex, labelStep, eraColors), + ), + ], + ); + } + + Widget _buildRuler( + ThemeData theme, + ColorScheme colors, + int nowIndex, + int labelStep, + ({Color past, Color now, Color future}) eraColors, + ) { + return SizedBox( + height: _rulerHeight, + child: Stack( + alignment: Alignment.center, + children: [ + LayoutBuilder( + builder: (context, constraints) { + final pad = (constraints.maxWidth - widget.itemExtent) / 2; + // ListView.builder (not a Row) so a week of frames only ever + // builds the ~dozens of ticks on screen. itemExtent keeps the + // centring math: offset `i * itemExtent` centres frame `i`. + return _TickEmphasis( + live: _live, + child: NotificationListener( + onNotification: _onScroll, + child: ListView.builder( + 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: switch (_eraOf(i, nowIndex)) { + TimelineEra.past => eraColors.past, + TimelineEra.now => eraColors.now, + TimelineEra.future => eraColors.future, + }, + emphasised: _TickEmphasis.of(context, i), + colors: colors, + textStyle: theme.textTheme.labelSmall, ), - ); - }, - ), - // Fixed centre scrubber. - IgnorePointer( - child: Container( - width: 2.5, - height: 28, - decoration: BoxDecoration( - color: colors.primary, - borderRadius: BorderRadius.circular(2), ), ), + ); + }, + ), + // Fixed centre scrubber. + IgnorePointer( + child: Container( + width: 2.5, + height: 28, + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(2), ), - ], + ), ), - ), - ], + ], + ), ); } } diff --git a/lib/shared/map/wavefront_cadence.dart b/lib/shared/map/wavefront_cadence.dart new file mode 100644 index 000000000..497c11040 --- /dev/null +++ b/lib/shared/map/wavefront_cadence.dart @@ -0,0 +1,28 @@ +/// How often the EEW wave-front rings are redrawn, by device class — shared by +/// the live monitor and the report replay so both surfaces animate alike. +library; + +import 'package:dpip/core/platform/render_tier.dart'; + +/// The redraw interval for the P/S wave-front rings on a [tier] device. +/// +/// The rings are real polygon geometry, not a paint property MapLibre can +/// tween, so this interval *is* the animation's frame rate — and every frame +/// is a GeoJSON serialisation, a platform-channel hop and a re-tessellation on +/// the map's own thread. +/// +/// - [RenderTier.high]: 16 ms, about display rate, for an expansion that reads +/// as continuous motion. +/// - [RenderTier.low]: 200 ms (5 Hz). On a 2–4 GB phone sixty ring uploads a +/// second compete with the map's rendering and the alert UI for the same +/// few cores, during the minute it matters most that both stay responsive. +/// The cost is smoothness, not accuracy: each redraw is computed from the +/// calibrated clock when it is sent, so a ring is never more than 200 ms +/// behind the model — the S wave moves about 0.7 km and the P wave about +/// 1.2 km in that time, roughly a pixel at island zoom and well inside the +/// travel-time model's own uncertainty. Zoomed in to a city, the expansion +/// visibly steps rather than glides. +Duration wavefrontTick(RenderTier tier) => switch (tier) { + RenderTier.high => const Duration(milliseconds: 16), + RenderTier.low => const Duration(milliseconds: 200), +}; diff --git a/lib/shared/widgets/async_view.dart b/lib/shared/widgets/async_view.dart index 366307737..bee28f9fb 100644 --- a/lib/shared/widgets/async_view.dart +++ b/lib/shared/widgets/async_view.dart @@ -3,12 +3,16 @@ import 'package:dpip/core/error/result.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/widgets/empty_view.dart'; import 'package:dpip/shared/widgets/error_view.dart'; +import 'package:dpip/shared/widgets/freshness_banner.dart'; import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:flutter/material.dart'; /// Renders a one-shot `Future>` to the async-state contract: /// waiting → loading, [Ok] → [builder] (or [empty] when [isEmpty]), [Err] → /// error with "couldn't load data, please try again" and a **retry** button. +/// An [Ok] that is a saved copy ([Ok.stale] — the source was unreachable) +/// renders like any other, under a [FreshnessBanner] saying how old it is: +/// shown, but never as current. /// Pairs with the repository `Result` contract so a feature never hand-rolls a /// `FutureBuilder` that drops the error case into a blank screen. /// @@ -73,6 +77,10 @@ class _AsyncViewState extends State> { /// news you already had. T? _loaded; + /// Whether [_loaded] is a saved copy, so it keeps its banner while a + /// refresh is in flight. + Staleness? _loadedStale; + @override void initState() { super.initState(); @@ -112,16 +120,31 @@ class _AsyncViewState extends State> { /// The success UI for [value] — the empty state when [AsyncView.isEmpty] says /// so, else the caller's builder. - Widget _content(BuildContext context, T value) { + Widget _content(BuildContext context, T value, Staleness? stale) { _loaded = value; - if (widget.isEmpty?.call(value) ?? false) { - return widget.empty?.call(context) ?? - EmptyView( - icon: Icons.inbox_outlined, - message: AppLocalizations.of(context).commonEmpty, - ); - } - return widget.builder(context, value); + _loadedStale = stale; + final content = (widget.isEmpty?.call(value) ?? false) + ? widget.empty?.call(context) ?? + EmptyView( + icon: Icons.inbox_outlined, + message: AppLocalizations.of(context).commonEmpty, + ) + : widget.builder(context, value); + if (stale == null) return content; + final banner = FreshnessBanner.saved(staleness: stale); + // Most hosts give this view a bounded box and a scrollable builder, which + // needs the rest of the height; one laid out in a column of its own sizes + // to its content instead. + return LayoutBuilder( + builder: (context, constraints) => constraints.hasBoundedHeight + ? Column( + children: [ + banner, + Expanded(child: content), + ], + ) + : Column(mainAxisSize: MainAxisSize.min, children: [banner, content]), + ); } @override @@ -135,7 +158,9 @@ class _AsyncViewState extends State> { builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { final previous = _loaded; - if (previous != null) return _content(context, previous); + if (previous != null) { + return _content(context, previous, _loadedStale); + } return widget.loading?.call(context) ?? const LoadingView(); } // Repositories return Result and don't throw; a raw error here is @@ -144,7 +169,7 @@ class _AsyncViewState extends State> { return ErrorView(headline: l10n.commonFetchFailed, onRetry: _retry); } return switch (snapshot.data!) { - Ok(:final value) => _content(context, value), + Ok(:final value, :final stale) => _content(context, value, stale), Err(:final failure) => widget.error?.call(context, failure, _retry) ?? ErrorView(headline: l10n.commonFetchFailed, onRetry: _retry), diff --git a/lib/shared/widgets/freshness_banner.dart b/lib/shared/widgets/freshness_banner.dart new file mode 100644 index 000000000..77e73a1a6 --- /dev/null +++ b/lib/shared/widgets/freshness_banner.dart @@ -0,0 +1,109 @@ +/// The "still showing data, but it is not current" strip — one look for every +/// surface that can put aged data on screen, realtime feed or one-shot list. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/core/realtime/realtime_state.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; + +/// A thin banner flagging that the data below it is not current. +/// +/// - [FreshnessBanner.feed]: a realtime feed has aged (`stale`) or dropped +/// (`offline`) — what [RealtimeView] puts over its last snapshot. +/// - [FreshnessBanner.saved]: a one-shot fetch could not reach its source and +/// the screen shows the copy saved last time ([Staleness]), with how old +/// that copy is when it is known. +class FreshnessBanner extends StatelessWidget { + /// For a realtime feed in [status] — `stale` or `offline`. + const FreshnessBanner.feed({super.key, required RealtimeStatus this.status}) + : staleness = null; + + /// For a saved copy shown in place of a current answer. + const FreshnessBanner.saved({super.key, required Staleness this.staleness}) + : status = null; + + /// The feed's status, for [FreshnessBanner.feed]. + final RealtimeStatus? status; + + /// Why and since when the data is a saved copy, for [FreshnessBanner.saved]. + final Staleness? staleness; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final colors = Theme.of(context).colorScheme; + final saved = staleness; + + final ( + Color background, + Color foreground, + IconData icon, + String label, + ) = saved != null + ? ( + colors.errorContainer, + colors.onErrorContainer, + Icons.cloud_off_outlined, + switch (saved.since) { + final since? => l10n.freshnessSavedCopy( + ageLabel(l10n, AppTime.utc.difference(since)), + ), + null => l10n.freshnessSavedCopyUnknownAge, + }, + ) + : switch (status) { + RealtimeStatus.offline => ( + colors.errorContainer, + colors.onErrorContainer, + Icons.cloud_off_outlined, + l10n.feedOffline, + ), + _ => ( + colors.tertiaryContainer, + colors.onTertiaryContainer, + Icons.schedule, + l10n.feedStale, + ), + }; + + return Material( + color: background, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.sm, + ), + child: Row( + children: [ + Icon(icon, size: 18, color: foreground), + const SizedBox(width: AppSpacing.sm), + // Expanded: the saved-copy line carries an age and runs longer + // than one line in some languages. + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(color: foreground), + ), + ), + ], + ), + ), + ); + } + + /// [age] as the coarsest whole unit that still says something: minutes + /// under an hour, hours under two days, days beyond. Never "0 minutes" — + /// a copy is at least a minute old by the time anyone reads the label — + /// and a negative age (a clock that moved) reads as the minimum too. + static String ageLabel(AppLocalizations l10n, Duration age) { + if (age < const Duration(hours: 1)) { + return l10n.durationMinutes(age.inMinutes < 1 ? 1 : age.inMinutes); + } + if (age < const Duration(days: 2)) return l10n.durationHours(age.inHours); + return l10n.durationDays(age.inDays); + } +} diff --git a/lib/shared/widgets/realtime_view.dart b/lib/shared/widgets/realtime_view.dart index 6eefcd571..b835d74e9 100644 --- a/lib/shared/widgets/realtime_view.dart +++ b/lib/shared/widgets/realtime_view.dart @@ -1,8 +1,8 @@ -import 'package:dpip/app/theme/app_spacing.dart'; import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/widgets/error_view.dart'; +import 'package:dpip/shared/widgets/freshness_banner.dart'; import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:flutter/material.dart'; @@ -71,64 +71,9 @@ class RealtimeView extends StatelessWidget { return Column( children: [ - _FreshnessBanner(status: state.status), + FreshnessBanner.feed(status: state.status), Expanded(child: content), ], ); } } - -/// A thin banner flagging that shown data has aged (stale) or the feed dropped -/// (offline) — the "still showing last known data, but…" affordance. -class _FreshnessBanner extends StatelessWidget { - const _FreshnessBanner({required this.status}); - - final RealtimeStatus status; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final colors = Theme.of(context).colorScheme; - - final ( - Color background, - Color foreground, - IconData icon, - String label, - ) = switch (status) { - RealtimeStatus.offline => ( - colors.errorContainer, - colors.onErrorContainer, - Icons.cloud_off_outlined, - l10n.feedOffline, - ), - _ => ( - colors.tertiaryContainer, - colors.onTertiaryContainer, - Icons.schedule, - l10n.feedStale, - ), - }; - - return Material( - color: background, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.sm, - ), - child: Row( - children: [ - Icon(icon, size: 18, color: foreground), - const SizedBox(width: AppSpacing.sm), - Text( - label, - style: Theme.of(context).textTheme.labelLarge - ?.copyWith(color: foreground), - ), - ], - ), - ), - ); - } -} diff --git a/test/app/router/app_router_test.dart b/test/app/router/app_router_test.dart index d9312f567..014f7ef2e 100644 --- a/test/app/router/app_router_test.dart +++ b/test/app/router/app_router_test.dart @@ -75,6 +75,7 @@ import 'package:dpip/features/onboarding/presentation/pages/onboarding_page.dart import 'package:dpip/features/weather/domain/radar_repository.dart'; import 'package:dpip/features/weather/weather_providers.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/map/raster_frame_source.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -99,6 +100,74 @@ void main() { expect(find.byType(SizedBox), findsOneWidget); }); + testWidgets('a malformed link reaches no builder that throws', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + final malformed = [ + _state('/', uri: '/x/abc?t=abc&tab=%20', id: 'abc', city: ' '), + _state('/', uri: '/x', id: null, city: null), + ]; + for (final state in malformed) { + final built = []; + _walk(appRouter.configuration.routes, context, built, state: state); + expect(built, isNotEmpty); + } + expect(tester.takeException(), isNull); + }); + + test('every parameterised route sends a link it cannot honour elsewhere', () { + final checked = []; + void visit(List routes) { + for (final route in routes) { + switch (route) { + case GoRoute(): + final params = RegExp(r':(\w+)').allMatches(route.path); + final takesT = route.name == AppRoutes.earthquakeReplay; + if (params.isNotEmpty || takesT) { + expect( + route.redirect, + isNotNull, + reason: '${route.name} reads parameters a link controls', + ); + final state = _state( + route.path, + uri: '/x/abc?t=abc', + id: 'abc', + city: ' ', + ); + final target = route.redirect!(_NoContext(), state); + // A report id is free-form text, so `abc` is a fine id; every + // other route here must refuse the malformed value. + if (route.name != AppRoutes.earthquakeReport) { + expect(target, isNotNull, reason: route.name); + } + checked.add(route.name!); + } + visit(route.routes); + case StatefulShellRoute(): + for (final branch in route.branches) { + visit(branch.routes); + } + default: + break; + } + } + } + + visit(appRouter.configuration.routes); + expect( + checked, + containsAll([ + AppRoutes.earthquakeReport, + AppRoutes.earthquakeReplay, + AppRoutes.regionSelectCity, + AppRoutes.bugThread, + ]), + ); + }); + testWidgets('an unfinished welcome stays put until the refresh fires', ( tester, ) async { @@ -375,18 +444,23 @@ class _WidgetRain implements RainHourTrendRepository { Ok(RainHourTrend(startSecond: 0, mm: List.filled(60, 0))); } -void _walk(List routes, BuildContext context, List built) { +void _walk( + List routes, + BuildContext context, + List built, { + GoRouterState? state, +}) { for (final route in routes) { switch (route) { case GoRoute(): final builder = route.builder; if (builder != null) { - built.add(builder(context, _state(route.path))); + built.add(builder(context, state ?? _state(route.path))); } - _walk(route.routes, context, built); + _walk(route.routes, context, built, state: state); case StatefulShellRoute(): for (final branch in route.branches) { - _walk(branch.routes, context, built); + _walk(branch.routes, context, built, state: state); } default: break; @@ -394,17 +468,25 @@ void _walk(List routes, BuildContext context, List built) { } } -GoRouterState _state(String path) => GoRouterState( +GoRouterState _state( + String path, { + String uri = '/x/42?t=1700000000&tab=temp&replace=6300100&returnToMore=1', + String? id = '42', + String? city = 'Taipei', +}) => GoRouterState( appRouter.configuration, - uri: Uri.parse('/x/42?t=1700000000&tab=temp&replace=6300100&returnToMore=1'), + uri: Uri.parse(uri), matchedLocation: '/x/42', name: path, path: path, fullPath: path, - pathParameters: const {'id': '42', 'city': 'Taipei'}, + pathParameters: {'id': ?id, 'city': ?city}, pageKey: ValueKey(path), ); +/// The route-level redirects read only the state; none touches its context. +class _NoContext extends Fake implements BuildContext {} + ChangeNotifierProvider>> _eew() => ChangeNotifierProvider>>( create: (_) => RealtimeNotifier>( diff --git a/test/app/router/route_params_test.dart b/test/app/router/route_params_test.dart new file mode 100644 index 000000000..33bde391f --- /dev/null +++ b/test/app/router/route_params_test.dart @@ -0,0 +1,154 @@ +/// Malformed deep links: every parameterised route turns a link it cannot +/// honour into the nearest page that can, instead of throwing from its builder. +/// +/// The router here mirrors the shape of `appRouter`'s parameterised routes — +/// same names, same paths, the same redirect functions — with stand-in pages, +/// so a test can follow a real navigation without bootstrapping every feature. +/// `app_router_test.dart` separately runs the real table's builders and +/// redirects against malformed parameters. +library; + +import 'package:dpip/app/router/route_params.dart'; +import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +GoRouter _router(String initialLocation) => GoRouter( + initialLocation: initialLocation, + routes: [ + GoRoute( + path: AppRoutes.dataPath, + name: AppRoutes.data, + builder: (_, _) => const Text('data'), + routes: [ + GoRoute( + path: AppRoutes.earthquakePath, + name: AppRoutes.earthquake, + builder: (_, _) => const Text('reports'), + routes: [ + GoRoute( + path: AppRoutes.earthquakeReportPath, + name: AppRoutes.earthquakeReport, + redirect: reportRedirect, + builder: (_, state) => + Text('report ${state.pathParameters['id']}'), + routes: [ + GoRoute( + path: AppRoutes.earthquakeReplayPath, + name: AppRoutes.earthquakeReplay, + redirect: replayRedirect, + builder: (_, state) => + Text('replay ${replayTimestampOf(state)}'), + ), + ], + ), + ], + ), + ], + ), + GoRoute( + path: AppRoutes.regionSelectPath, + name: AppRoutes.regionSelect, + builder: (_, state) => Text('cities ${state.uri.query}'), + routes: [ + GoRoute( + path: AppRoutes.regionSelectCityPath, + name: AppRoutes.regionSelectCity, + redirect: regionCityRedirect, + builder: (_, state) => Text('city ${state.pathParameters['city']}'), + ), + ], + ), + GoRoute( + path: AppRoutes.bugTrackerPath, + name: AppRoutes.bugTracker, + builder: (_, _) => const Text('threads'), + routes: [ + GoRoute( + path: AppRoutes.bugThreadPath, + name: AppRoutes.bugThread, + redirect: bugThreadRedirect, + builder: (_, state) => Text('thread ${bugThreadIdOf(state)}'), + ), + ], + ), + ], +); + +Future _open(WidgetTester tester, String location) async { + await tester.pumpWidget(MaterialApp.router(routerConfig: _router(location))); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); +} + +void main() { + group('positiveIntParam', () { + test('reads a positive integer, ignoring surrounding space', () { + expect(positiveIntParam('1700000000000'), 1700000000000); + expect(positiveIntParam(' 42 '), 42); + }); + + test('is null for anything that is not one', () { + for (final raw in [null, '', ' ', 'abc', '12abc', '1.5', '0', '-3']) { + expect(positiveIntParam(raw), isNull, reason: '"$raw"'); + } + // Beyond 64 bits: tryParse refuses it rather than wrapping around. + expect(positiveIntParam('99999999999999999999999'), isNull); + }); + }); + + test('nonBlankParam keeps text and drops blanks', () { + expect(nonBlankParam('臺北市'), '臺北市'); + expect(nonBlankParam(null), isNull); + expect(nonBlankParam(''), isNull); + expect(nonBlankParam(' \t'), isNull); + }); + + group('the replay link', () { + testWidgets('with a start instant opens the replay', (tester) async { + await _open(tester, '/data/earthquake/114001/replay?t=1700000000000'); + expect(find.text('replay 1700000000000'), findsOneWidget); + }); + + for (final query in ['', '?t=', '?t=abc', '?t=-5', '?t=0', '?t=1.5']) { + testWidgets('without a usable t ("$query") opens its report', ( + tester, + ) async { + await _open(tester, '/data/earthquake/114001/replay$query'); + expect(find.text('report 114001'), findsOneWidget); + }); + } + }); + + testWidgets('a blank report id opens the catalogue', (tester) async { + await _open(tester, '/data/earthquake/%20'); + expect(find.text('reports'), findsOneWidget); + }); + + group('the bug thread link', () { + testWidgets('with a thread number opens the thread', (tester) async { + await _open(tester, '/bug-tracker/thread/17'); + expect(find.text('thread 17'), findsOneWidget); + }); + + for (final id in ['abc', '0', '-1', '%20']) { + testWidgets('with "$id" opens the thread list', (tester) async { + await _open(tester, '/bug-tracker/thread/$id'); + expect(find.text('threads'), findsOneWidget); + }); + } + }); + + testWidgets('a blank city opens the city list and keeps the flow', ( + tester, + ) async { + await _open(tester, '/region-select/%20?replace=100&returnToMore=1'); + expect(find.text('cities replace=100&returnToMore=1'), findsOneWidget); + }); + + testWidgets('a city opens its townships', (tester) async { + await _open(tester, '/region-select/${Uri.encodeComponent('臺北市')}'); + expect(find.text('city 臺北市'), findsOneWidget); + }); +} diff --git a/test/core/geo/place_search_test.dart b/test/core/geo/place_search_test.dart new file mode 100644 index 000000000..b35caab35 --- /dev/null +++ b/test/core/geo/place_search_test.dart @@ -0,0 +1,97 @@ +import 'package:dpip/core/geo/place_search.dart'; +import 'package:dpip/core/geo/town.dart'; +import 'package:dpip/core/geo/town_directory.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Town _town(String code, String city, String cityLevel, String town) => Town( + code: code, + city: city, + town: town, + lat: 25, + lng: 121, + cityLevel: cityLevel, + townLevel: '區', +); + +/// Spelled the way the bundled directory spells it: 臺, never 台. +final _directory = TownDirectory({ + '100': _town('100', '臺北', '市', '中正'), + '103': _town('103', '臺北', '市', '大同'), + '200': _town('200', '基隆', '市', '中正'), + '220': _town('220', '新北', '市', '板橋'), + '950': _town('950', '臺東', '縣', '臺東'), +}); + +List _codes(List towns) => [for (final t in towns) t.code]; + +void main() { + group('foldPlaceName', () { + test('folds 臺 to 台, so both spellings compare equal', () { + expect(foldPlaceName('臺北市'), foldPlaceName('台北市')); + expect(foldPlaceName('臺北市'), '台北市'); + }); + + test('drops every kind of whitespace, not just the ends', () { + expect(foldPlaceName(' 臺北市 中正區 '), '台北市中正區'); + expect(foldPlaceName('台北 中正'), '台北中正'); + expect(foldPlaceName('台\t北\n'), '台北'); + }); + + test('folds full-width ASCII to half-width, and case', () { + expect(foldPlaceName('ABC123'), 'abc123'); + expect(foldPlaceName('Taipei'), 'taipei'); + }); + + test('is total on the empty and all-blank string', () { + expect(foldPlaceName(''), ''); + expect(foldPlaceName('   '), ''); + }); + }); + + group('PlaceSearchIndex', () { + final index = PlaceSearchIndex(_directory); + + test('an empty query lists every city and no township', () { + final result = index.search(' '); + expect(result.cities, ['臺北市', '基隆市', '新北市', '臺東縣']); + expect(result.towns, isEmpty); + }); + + test('台北 finds 臺北市 without listing all its districts', () { + final result = index.search('台北'); + expect(result.cities, ['臺北市']); + expect(result.towns, isEmpty); + }); + + test('a township name alone finds it on the first page', () { + final result = index.search('板橋'); + expect(result.cities, isEmpty); + expect(_codes(result.towns), ['220']); + }); + + test('a shared township name finds it in every city', () { + expect(_codes(index.search('中正').towns), ['100', '200']); + }); + + test( + 'city and township together narrow to one, with or without levels', + () { + expect(_codes(index.search('台北中正').towns), ['100']); + expect(_codes(index.search('臺北市 中正區').towns), ['100']); + expect(_codes(index.search('台北市中正').towns), ['100']); + }, + ); + + test('a township named like its city is still found by its own name', () { + final result = index.search('台東'); + expect(result.cities, ['臺東縣']); + expect(_codes(result.towns), ['950']); + }); + + test('no match is empty on both sides', () { + final result = index.search('東京'); + expect(result.cities, isEmpty); + expect(result.towns, isEmpty); + }); + }); +} diff --git a/test/core/network/etag_cache_store_test.dart b/test/core/network/etag_cache_store_test.dart index 86686ca2b..66becd74a 100644 --- a/test/core/network/etag_cache_store_test.dart +++ b/test/core/network/etag_cache_store_test.dart @@ -54,6 +54,7 @@ void main() { 'body', 'size', 'time', + 'fetched', }); expect( (await legacy.get('SELECT COUNT(*) AS n FROM http_cache'))['n'], @@ -66,6 +67,65 @@ void main() { expect((await migrated.read('https://x/v2'))?.body, '{"ok":true}'); }); + test( + 'an existing v2 table gains fetched in place, keeping its rows', + () async { + final v2 = openMemoryDb(); + addTearDown(v2.close); + await v2.executeMultiple( + 'CREATE TABLE http_cache (' + 'key TEXT PRIMARY KEY, etag TEXT NOT NULL, content_type TEXT, ' + 'kind INTEGER NOT NULL, body BLOB NOT NULL, size INTEGER NOT NULL, ' + 'time INTEGER NOT NULL);' + 'CREATE INDEX http_cache_time ON http_cache(time)', + ); + final before = EtagCacheStore(v2); + // Written by the pre-column store shape: no fetched value to give. + await v2.execute( + 'INSERT INTO http_cache (key, etag, kind, body, size, time) ' + 'VALUES (?, ?, ?, ?, ?, ?)', + [ + 'https://x/old', + '1', + EtagCacheStore.kindJson, + utf8.encode('[1]'), + 3, + 1, + ], + ); + + await EtagCacheStore.createSchema(v2); + + final columns = { + for (final row in await v2.getAll('PRAGMA table_info(http_cache)')) + row['name'], + }; + expect(columns, contains('fetched')); + final old = await before.readJson('https://x/old'); + expect(old?.data, [1], reason: 'a column, not a drop'); + expect(old?.fetchedAt, isNull, reason: 'its age was never recorded'); + // Idempotent: a second open does not try to add it again. + await EtagCacheStore.createSchema(v2); + }, + ); + + test( + 'a JSON write records when the server sent it; a 304 moves it', + () async { + final sent = DateTime.utc(2026, 10, 16, 1); + await store.write('https://x/r', etag: '1', body: '[]', fetchedAt: sent); + expect((await store.readJson('https://x/r'))?.fetchedAt, sent); + + final confirmed = sent.add(const Duration(hours: 3)); + await store.markFetched('https://x/r', confirmed); + expect((await store.readJson('https://x/r'))?.fetchedAt, confirmed); + + // A miss is a no-op, not a throw. + await store.markFetched('https://x/none', confirmed); + expect(await store.readJson('https://x/none'), isNull); + }, + ); + Future setTime(String url, int time) => db.execute('UPDATE http_cache SET time = ? WHERE key = ?', [time, url]); diff --git a/test/core/network/etag_interceptor_test.dart b/test/core/network/etag_interceptor_test.dart index 2ead94aca..cec7442b9 100644 --- a/test/core/network/etag_interceptor_test.dart +++ b/test/core/network/etag_interceptor_test.dart @@ -4,6 +4,7 @@ import 'package:dio/dio.dart'; import 'package:dpip/core/network/dio_client.dart'; import 'package:dpip/core/network/etag_cache_store.dart'; import 'package:dpip/core/network/etag_interceptor.dart'; +import 'package:dpip/core/network/stale_if_error.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sqlite_async/sqlite_async.dart'; @@ -217,6 +218,72 @@ void main() { ); }); + group('stale-if-error', () { + const url = 'https://api.core-tnn1.exptech.dev/api/v2/eq/report?limit=30'; + + Future prime() async { + await dioWith(_FakeAdapter(body: '[{"id":"a"}]', etag: 'v1')) + .get(url); + await settle(); + } + + test('an opted-in GET is offered the saved body and still fails', () async { + await prime(); + final optIn = StaleIfError(); + + await expectLater( + dioWith(_NetworkDownAdapter()) + .get(url, options: optIn.options), + throwsA(isA()), + reason: 'the failure must reach the region client, which may fail over', + ); + expect(optIn.saved?.data, [ + {'id': 'a'}, + ]); + expect(optIn.saved?.fetchedAt, isNotNull); + }); + + test('a GET that did not opt in is never answered from cache', () async { + await prime(); + await expectLater( + dioWith(_NetworkDownAdapter()).get(url), + throwsA(isA()), + ); + }); + + test('a 5xx is covered, a 404 is not', () async { + await prime(); + final serverError = StaleIfError(); + await expectLater( + dioWith(_StatusAdapter(503)) + .get(url, options: serverError.options), + throwsA(isA()), + ); + expect(serverError.saved, isNotNull); + + final gone = StaleIfError(); + await expectLater( + dioWith(_StatusAdapter(404)).get(url, options: gone.options), + throwsA(isA()), + ); + expect(gone.saved, isNull, reason: 'a 404 is an answer, not an outage'); + }); + + test( + 'an opted-in 304 records that the server vouched for the copy', + () async { + final old = DateTime.utc(2000); + await store.write(url, etag: 'v1', body: '[]', fetchedAt: old); + + await dioWith(_FakeAdapter(body: '[]', etag: 'v1')) + .get(url, options: StaleIfError().options); + await settle(); + + expect((await store.readJson(url))?.fetchedAt?.isAfter(old), isTrue); + }, + ); + }); + test( 'a failing POST on a non-dashboard host is not served from cache', () async { diff --git a/test/core/network/stale_if_error_test.dart b/test/core/network/stale_if_error_test.dart new file mode 100644 index 000000000..227584ec8 --- /dev/null +++ b/test/core/network/stale_if_error_test.dart @@ -0,0 +1,140 @@ +/// [ApiClientStaleIfError.getOrSaved] end to end: a real region client, the +/// real ETag interceptor over an in-memory cache, and a fake network per host. +/// +/// What is pinned is the order of things. The region client must still fail +/// over to a host that is up — a saved copy is the answer only once every +/// host has failed — and the copy that comes back must carry its marker, so +/// nothing above can show it as current. +library; + +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/core/network/dio_client.dart'; +import 'package:dpip/core/network/etag_cache_store.dart'; +import 'package:dpip/core/network/region_selection.dart'; +import 'package:dpip/core/network/stale_if_error.dart'; +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +import '../storage/memory_db.dart'; + +/// Answers per host: a body, a status, or a dropped connection. +class _Hosts implements HttpClientAdapter { + final Map byHost = {}; + final List asked = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + asked.add(options.uri.host); + final answer = byHost[options.uri.host]; + if (answer is String) { + return ResponseBody.fromString( + answer, + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + 'etag': ['"${answer.hashCode}"'], + }, + ); + } + if (answer is int) { + return ResponseBody.fromBytes(Uint8List(0), answer, headers: {}); + } + throw DioException.connectionError( + requestOptions: options, + reason: 'unreachable', + ); + } + + @override + void close({bool force = false}) {} +} + +void main() { + const path = '/api/v2/eq/report'; + const first = 'api.core-tnn1.exptech.dev'; + const second = 'api.core-tyo1.exptech.dev'; + late SqliteDatabase db; + late _Hosts hosts; + late ApiClient client; + + setUp(() async { + db = openMemoryDb(); + await EtagCacheStore.createSchema(db); + hosts = _Hosts(); + final dio = createDio(etagCache: EtagCacheStore(db)) + ..httpClientAdapter = hosts; + client = ApiClient(dio, RegionSelection(SettingsStore.inMemory({}))); + }); + + tearDown(() async => db.close()); + + Future saveFromFirstHost() async { + hosts.byHost[first] = '[{"id":"saved"}]'; + final fresh = await client.getOrSaved(ApiTier.coreApi, path); + expect(fresh.stale, isNull); + // The interceptor's write is fire-and-forget. + await Future.delayed(const Duration(milliseconds: 150)); + } + + test('every host down: the saved copy, marked as such', () async { + await saveFromFirstHost(); + hosts.byHost.clear(); + + final result = await client.getOrSaved(ApiTier.coreApi, path); + + expect(result.data, [ + {'id': 'saved'}, + ]); + expect(result.stale, isNotNull); + expect(result.stale!.failure, isA()); + expect(result.stale!.since, isNotNull); + expect(hosts.asked, [ + first, + first, + second, + ], reason: 'the save, then both hosts tried before the copy is used'); + }); + + test('a host that is up still wins over the saved copy', () async { + await saveFromFirstHost(); + hosts.byHost + ..clear() + ..[second] = '[{"id":"fresh"}]'; + + final result = await client.getOrSaved(ApiTier.coreApi, path); + + expect(result.data, [ + {'id': 'fresh'}, + ]); + expect(result.stale, isNull); + }); + + test('nothing saved: the failure comes back as before', () async { + await expectLater( + client.getOrSaved(ApiTier.coreApi, path), + throwsA(isA()), + ); + }); + + test('a 404 is never covered by a saved copy', () async { + await saveFromFirstHost(); + hosts.byHost + ..clear() + ..[first] = 404; + + await expectLater( + client.getOrSaved(ApiTier.coreApi, path), + throwsA(isA()), + ); + }); +} diff --git a/test/core/realtime/app_time_test.dart b/test/core/realtime/app_time_test.dart index 808a399aa..e1736f234 100644 --- a/test/core/realtime/app_time_test.dart +++ b/test/core/realtime/app_time_test.dart @@ -93,12 +93,15 @@ void main() { final next = AppTime.sync(); expect(source.requests, hasLength(2)); + // A step well past ServerClock.stepThreshold, so the clock adopts it as + // is rather than averaging it with the first — what is pinned here is + // only that the second sync really ran. source.requests.last.complete( - Ok(device.add(const Duration(seconds: 7)).millisecondsSinceEpoch), + Ok(device.add(const Duration(seconds: 60)).millisecondsSinceEpoch), ); await next; - expect(AppTime.calibratedTimeOffset, const Duration(seconds: 7)); + expect(AppTime.calibratedTimeOffset, const Duration(seconds: 60)); }); test('allows retry after a failed sync attempt', () async { diff --git a/test/core/realtime/server_clock_test.dart b/test/core/realtime/server_clock_test.dart index 54fd88bdf..e6aac612d 100644 --- a/test/core/realtime/server_clock_test.dart +++ b/test/core/realtime/server_clock_test.dart @@ -17,6 +17,7 @@ class _FakeClock implements Clock { class _FakeElapsed implements Elapsed { Duration value = Duration.zero; + void advance(Duration d) => value += d; @override Duration get elapsed => value; } @@ -106,4 +107,82 @@ void main() { expect(clock.now(), device); }); }); + + group('ServerClock sample window', () { + /// A clock whose server answers [offsets] (ms, relative to the device) in + /// turn, one per sync, with monotonic time advancing a minute per sync. + Future<(ServerClock, _FakeElapsed)> synced(List offsets) async { + final elapsed = _FakeElapsed(); + final source = _FakeServerTimeSource(); + final clock = ServerClock(_FakeClock(device), elapsed, source); + for (final offset in offsets) { + source.result = Ok( + device.millisecondsSinceEpoch + elapsed.value.inMilliseconds + offset, + ); + await clock.sync(); + elapsed.advance(const Duration(minutes: 1)); + } + return (clock, elapsed); + } + + /// Calibrated time minus where a perfect server would put it. + int errorMs(ServerClock clock, _FakeElapsed elapsed) => + clock.now().difference(device.add(elapsed.value)).inMilliseconds; + + test('averages the window without its highest and lowest sample', () async { + final (clock, elapsed) = await synced([100, -300, 50, 400, 0]); + // Sorted -300, 0, 50, 100, 400 → mean of 0, 50, 100. + expect(errorMs(clock, elapsed), 50); + }); + + test( + 'one noisy sample no longer moves the clock by its full error', + () async { + final (clock, elapsed) = await synced([0, 0, 0, 0, 900]); + expect( + errorMs(clock, elapsed), + 0, + reason: 'the outlier is the dropped maximum', + ); + }, + ); + + test('keeps only the last five samples', () async { + final (clock, elapsed) = await synced([1500, 1500, 0, 0, 0, 0, 0]); + expect(errorMs(clock, elapsed), 0); + }); + + test('follows a genuine step at once', () async { + final (clock, elapsed) = await synced([0, 10, -10, 5, 30000]); + expect( + errorMs(clock, elapsed), + 30000, + reason: '30 s away from every sample is a step, not noise', + ); + }); + + test('a sample near any one in the window is not a step', () async { + // 1.9 s from the latest but within 2 s of it: averaged, not adopted. + final (clock, elapsed) = await synced([0, 0, 0, 1900, 1900]); + // Sorted 0, 0, 0, 1900, 1900 → mean of 0, 0, 1900. + expect(errorMs(clock, elapsed), 633); + }); + + test('a failed sync leaves the window alone', () async { + final elapsed = _FakeElapsed(); + final source = _FakeServerTimeSource() + ..result = Ok(device.millisecondsSinceEpoch + 200); + final clock = ServerClock(_FakeClock(device), elapsed, source); + await clock.sync(); + source.result = const Err(TimeoutFailure('down')); + await clock.sync(); + expect(clock.offset, const Duration(milliseconds: 200)); + }); + + test('trimmedMean drops nothing below three samples', () { + expect(ServerClock.trimmedMean([10]), 10); + expect(ServerClock.trimmedMean([10, 21]), 16); + expect(ServerClock.trimmedMean([1, 10, 1000]), 10); + }); + }); } diff --git a/test/features/earthquake/data/earthquake_api_test.dart b/test/features/earthquake/data/earthquake_api_test.dart index 96331643e..0babdded0 100644 --- a/test/features/earthquake/data/earthquake_api_test.dart +++ b/test/features/earthquake/data/earthquake_api_test.dart @@ -104,7 +104,15 @@ class _RecordingApiClient implements ApiClient { Map? query, Options? options, CancelToken? cancelToken, - }) => throw UnimplementedError(); + }) async { + getCalls.add((tier, path, query)); + if (getError != null) throw getError!; + return Response( + requestOptions: RequestOptions(path: path), + statusCode: 200, + data: nextGetResult, + ); + } @override List hostsFor(ApiTier tier) => throw UnimplementedError(); diff --git a/test/features/earthquake/data/eew_replay_source_test.dart b/test/features/earthquake/data/eew_replay_source_test.dart index 4c51229e8..06d57804e 100644 --- a/test/features/earthquake/data/eew_replay_source_test.dart +++ b/test/features/earthquake/data/eew_replay_source_test.dart @@ -7,6 +7,7 @@ library; import 'package:dio/dio.dart'; +import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/network/sse_event.dart'; import 'package:dpip/core/realtime/elapsed.dart'; @@ -49,7 +50,7 @@ class _FakeEarthquakeApi implements EarthquakeApi { Stream openEewSse() => throw UnimplementedError(); @override - Future> getReportList({ + Future>> getReportList({ int limit = 50, int page = 1, int? minIntensity, diff --git a/test/features/earthquake/data/eew_repository_impl_test.dart b/test/features/earthquake/data/eew_repository_impl_test.dart index 7fdd0332d..ed54507e4 100644 --- a/test/features/earthquake/data/eew_repository_impl_test.dart +++ b/test/features/earthquake/data/eew_repository_impl_test.dart @@ -6,6 +6,7 @@ library; import 'package:dio/dio.dart'; +import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/network/sse_event.dart'; import 'package:dpip/features/earthquake/data/earthquake_api.dart'; @@ -37,7 +38,7 @@ class _FakeEarthquakeApi implements EarthquakeApi { Future> getEewAt(int seconds) => throw UnimplementedError(); @override - Future> getReportList({ + Future>> getReportList({ int limit = 50, int page = 1, int? minIntensity, diff --git a/test/features/earthquake/data/report_repository_impl_test.dart b/test/features/earthquake/data/report_repository_impl_test.dart index c1fea6455..30063872b 100644 --- a/test/features/earthquake/data/report_repository_impl_test.dart +++ b/test/features/earthquake/data/report_repository_impl_test.dart @@ -4,6 +4,7 @@ library; import 'package:dio/dio.dart'; +import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/network/sse_event.dart'; import 'package:dpip/features/earthquake/data/earthquake_api.dart'; @@ -18,9 +19,10 @@ class _Api implements EarthquakeApi { final dynamic reportBody; ReportListQuery? seen; Object? error; + Staleness? stale; @override - Future> getReportList({ + Future>> getReportList({ int limit = 50, int page = 1, int? minIntensity, @@ -53,7 +55,7 @@ class _Api implements EarthquakeApi { cityMinInt: cityMinInt, cityMaxInt: cityMaxInt, ); - return listBody; + return (data: listBody, stale: stale); } @override @@ -157,4 +159,15 @@ void main() { expect((await repo.get('115032')).failureOrNull, isA()); }, ); + + test('a saved page keeps its marker on the way up', () async { + final since = DateTime.utc(2026, 10, 16, 1); + final api = _Api([_row('115032')], null) + ..stale = Staleness(failure: const NetworkFailure('down'), since: since); + + final result = await ReportRepositoryImpl(api).list(); + + expect(result.valueOrNull!.single.id, '115032'); + expect((result as Ok).stale?.since, since); + }); } diff --git a/test/features/earthquake/data/rts_replay_source_test.dart b/test/features/earthquake/data/rts_replay_source_test.dart index ec7e16014..372d032a3 100644 --- a/test/features/earthquake/data/rts_replay_source_test.dart +++ b/test/features/earthquake/data/rts_replay_source_test.dart @@ -8,6 +8,7 @@ library; import 'package:dio/dio.dart'; +import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/network/sse_event.dart'; import 'package:dpip/core/realtime/elapsed.dart'; @@ -49,7 +50,7 @@ class _FakeEarthquakeApi implements EarthquakeApi { Future> getEewAt(int seconds) => throw UnimplementedError(); @override - Future> getReportList({ + Future>> getReportList({ int limit = 50, int page = 1, int? minIntensity, diff --git a/test/features/earthquake/presentation/pages/report_list_page_test.dart b/test/features/earthquake/presentation/pages/report_list_page_test.dart index 6640b51c2..103bfea33 100644 --- a/test/features/earthquake/presentation/pages/report_list_page_test.dart +++ b/test/features/earthquake/presentation/pages/report_list_page_test.dart @@ -27,6 +27,7 @@ import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/app_routes.dart'; import 'package:dpip/shared/widgets/empty_view.dart'; import 'package:dpip/shared/widgets/error_view.dart'; +import 'package:dpip/shared/widgets/freshness_banner.dart'; import 'package:dpip/shared/widgets/loading_view.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -64,6 +65,7 @@ class _FixedSource implements ServerTimeSource { class _FakeReportRepository implements ReportRepository { final List<({int limit, int page, ReportListQuery query})> calls = []; Failure? nextError; + Staleness? nextStale; List nextPage = const []; /// When set, the first page never arrives — the state the list is in between @@ -81,7 +83,7 @@ class _FakeReportRepository implements ReportRepository { if (gate != null) return gate.future; final error = nextError; if (error != null) return Err(error); - return Ok(page == 1 ? nextPage : const []); + return Ok(page == 1 ? nextPage : const [], stale: nextStale); } @override @@ -350,4 +352,33 @@ void main() { expect(find.text(l10n.reportListEmpty), findsNothing); }); }); + + group('offline', () { + testWidgets('a saved copy is listed under a banner with its age', ( + tester, + ) async { + final repository = _FakeReportRepository() + ..nextPage = [_report('a', originUtc: _todayMorning)] + ..nextStale = Staleness( + failure: const NetworkFailure('No connection'), + since: _now.subtract(const Duration(hours: 3)), + ); + await _pump(tester, repository); + + expect( + find.text("Can't connect · showing data from 3 hours ago"), + findsOneWidget, + ); + expect(find.byType(ErrorView), findsNothing); + expect(find.textContaining('花蓮縣'), findsWidgets); + }); + + testWidgets('fresh rows carry no banner', (tester) async { + final repository = _FakeReportRepository() + ..nextPage = [_report('a', originUtc: _todayMorning)]; + await _pump(tester, repository); + + expect(find.byType(FreshnessBanner), findsNothing); + }); + }); } diff --git a/test/features/earthquake/presentation/pages/report_replay_page_test.dart b/test/features/earthquake/presentation/pages/report_replay_page_test.dart index af8e83967..dda79b943 100644 --- a/test/features/earthquake/presentation/pages/report_replay_page_test.dart +++ b/test/features/earthquake/presentation/pages/report_replay_page_test.dart @@ -21,6 +21,7 @@ import 'package:dpip/core/network/api_client.dart'; import 'package:dpip/core/network/api_paths.dart'; import 'package:dpip/core/network/api_region.dart'; import 'package:dpip/core/network/region_selection.dart'; +import 'package:dpip/core/platform/render_tier.dart'; import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/realtime/clock.dart'; import 'package:dpip/core/realtime/elapsed.dart'; @@ -78,6 +79,9 @@ class _MapPlatform extends MapLibrePlatform { var holdEew = true; int moves = 0; + /// Wave-front writes — one per redraw of the rings. + int eewWrites = 0; + /// The map state completes a one-shot future the first time the platform /// view is created. A later rebuild of the same map must not call it again. bool _viewCreated = false; @@ -125,6 +129,7 @@ class _MapPlatform extends MapLibrePlatform { throw StateError('box'); } this.geojson[sourceId] = geojson; + if (sourceId.contains('eew')) eewWrites++; if (holdEew && sourceId.contains('eew')) { holdEew = false; await Future.delayed(const Duration(milliseconds: 40)); @@ -704,6 +709,115 @@ void main() { } }); + for (final (tier, low, high) in [ + (RenderTier.high, 40, 70), + (RenderTier.low, 4, 6), + ]) { + testWidgets( + 'a ${tier.name}-tier phone redraws the rings $low–$high a second', + (tester) async { + final settings = SettingsStore.inMemory(); + final archive = _Archive(RegionSelection(settings)) + ..eews = [ + _eew( + agency: 'cwa', + id: 'cwa-1', + serial: 1, + location: '花蓮縣', + timeMs: _origin.millisecondsSinceEpoch, + ), + ]; + final cwaOnly = EewCwaOnlySettings(settings); + final spoken = EewSpokenAnnouncementSettings(settings); + final realtime = RealtimeService( + ServerClock(_FixedClock(), _ZeroElapsed(), _FixedSource()), + ); + platform.holdEew = false; + useSurface(tester); + final router = GoRouter( + initialLocation: '/replay', + routes: [ + GoRoute( + path: '/replay', + builder: (_, _) => ReportReplayPage( + replayTimestamp: _origin.millisecondsSinceEpoch, + ), + ), + ], + ); + try { + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: archive), + Provider.value(value: realtime), + ChangeNotifierProvider.value( + value: cwaOnly, + ), + ChangeNotifierProvider.value( + value: spoken, + ), + Provider.value( + value: _Stations(const {}), + ), + Provider>.value( + value: Future.value( + const SeismicTravelTimeTable({ + 10: [(p: 2, r: 1, s: 2), (p: 100, r: 2000, s: 100)], + }), + ), + ), + Provider>.value( + value: Future.value(const RtsBoxGrid({})), + ), + Provider.value(value: const TownDirectory({})), + Provider.value(value: settings), + ChangeNotifierProvider.value( + value: RegionStore(settings), + ), + Provider.value(value: _Speech()), + Provider.value( + value: LocationService( + const TownDirectory({}), + isAvailable: () async => false, + fix: () async => null, + lastKnown: () async => null, + status: () async => LocationStatus.denied, + ), + ), + Provider.value(value: _Model()), + Provider?>.value(value: Future.value(tier)), + ], + child: MaterialApp.router( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ), + ); + await tester.pump(); + await tester.pump(); + await icons(tester); + expect(find.text('花蓮縣'), findsOneWidget); + + final before = platform.eewWrites; + for (var i = 0; i < 50; i++) { + await tester.pump(const Duration(milliseconds: 20)); + } + final perSecond = platform.eewWrites - before; + expect(perSecond, inInclusiveRange(low, high)); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 50)); + cwaOnly.dispose(); + spoken.dispose(); + realtime.dispose(); + } + }, + ); + } + testWidgets('a hidden tab pauses the replay, and a dead feed goes stale', ( tester, ) async { diff --git a/test/features/earthquake/presentation/report_list_controller_test.dart b/test/features/earthquake/presentation/report_list_controller_test.dart index e78e25784..ba0c38ff1 100644 --- a/test/features/earthquake/presentation/report_list_controller_test.dart +++ b/test/features/earthquake/presentation/report_list_controller_test.dart @@ -19,6 +19,7 @@ import 'package:flutter_test/flutter_test.dart'; class _FakeReportRepository implements ReportRepository { final List<({int limit, int page, ReportListQuery query})> calls = []; Failure? nextError; + Staleness? nextStale; List nextPage = const []; @override @@ -30,7 +31,7 @@ class _FakeReportRepository implements ReportRepository { calls.add((limit: limit, page: page, query: query)); final error = nextError; if (error != null) return Err(error); - return Ok(nextPage); + return Ok(nextPage, stale: nextStale); } @override @@ -189,4 +190,68 @@ void main() { expect(repository.calls.length, callsDuringLoad); await reloadFuture; }); + + group('staleness', () { + final since = DateTime.utc(2026, 10, 16, 1); + final saved = Staleness( + failure: const NetworkFailure('down'), + since: since, + ); + + test('a fresh page is current', () async { + repository.nextPage = _page(3); + await controller.reload(); + expect(controller.staleness, isNull); + }); + + test('a saved first page is shown, and marked', () async { + repository + ..nextPage = _page(3) + ..nextStale = saved; + await controller.reload(); + expect(controller.items, hasLength(3)); + expect(controller.failure, isNull); + expect(controller.staleness?.since, since); + }); + + test( + 'a failed refresh leaves the rows, marked since they loaded', + () async { + repository.nextPage = _page(3); + await controller.reload(); + repository.nextError = const NetworkFailure('down'); + await controller.reload(); + + expect(controller.items, hasLength(3), reason: 'rows stay on screen'); + expect(controller.staleness, isNotNull); + expect(controller.staleness!.failure, isA()); + expect(controller.staleness!.since, isNotNull); + }, + ); + + test('the next fresh reload clears the mark', () async { + repository + ..nextPage = _page(3) + ..nextStale = saved; + await controller.reload(); + repository.nextStale = null; + await controller.reload(); + expect(controller.staleness, isNull); + }); + + test('a saved later page marks the whole list', () async { + repository.nextPage = _page(ReportListController.pageSize); + await controller.reload(); + repository.nextStale = saved; + await controller.loadMore(); + expect(controller.staleness?.since, since); + }); + + test('a failure with nothing on screen is an error, not a mark', () async { + repository.nextError = const NetworkFailure('down'); + await controller.reload(); + expect(controller.failure, isNotNull); + expect(controller.staleness, isNull); + }); + }); } diff --git a/test/features/events/data/event_api_test.dart b/test/features/events/data/event_api_test.dart index 0fadbd600..0cb929eea 100644 --- a/test/features/events/data/event_api_test.dart +++ b/test/features/events/data/event_api_test.dart @@ -54,11 +54,11 @@ ResponseBody _json(String body, int status) => ResponseBody.fromString( /// four near-identical test bodies. final Map> Function(EventApi), String)> _calls = { 'getHistoryList': ( - (api) => api.getHistoryList(), + (api) async => (await api.getHistoryList()).data, '/api/v1/dpip/history/list', ), 'getHistoryRegion': ( - (api) => api.getHistoryRegion('063'), + (api) async => (await api.getHistoryRegion('063')).data, '/api/v1/dpip/history/063', ), 'getRealtimeList': ( diff --git a/test/features/events/data/event_repository_impl_test.dart b/test/features/events/data/event_repository_impl_test.dart index 26f378394..ba91d0b15 100644 --- a/test/features/events/data/event_repository_impl_test.dart +++ b/test/features/events/data/event_repository_impl_test.dart @@ -3,6 +3,8 @@ /// does. library; +import 'package:dpip/core/error/failure.dart'; +import 'package:dpip/core/error/result.dart'; import 'package:dpip/features/events/data/event_api.dart'; import 'package:dpip/features/events/data/event_repository_impl.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -13,16 +15,18 @@ class _Api implements EventApi { var historyAll = false; var realtimeAll = false; + Staleness? stale; + @override - Future> getHistoryList() async { + Future>> getHistoryList() async { historyAll = true; - return [_event('h-all', 20), 'skip']; + return (data: [_event('h-all', 20), 'skip'], stale: stale); } @override - Future> getHistoryRegion(String region) async { + Future>> getHistoryRegion(String region) async { historyRegion = region; - return [_event('h-town', 10)]; + return (data: [_event('h-town', 10)], stale: stale); } @override @@ -74,4 +78,23 @@ void main() { expect(townActive.valueOrNull!.single.id, 'r-town'); }, ); + + test( + 'saved history keeps its marker; active events never have one', + () async { + final since = DateTime.utc(2026, 10, 16, 1); + final api = _Api() + ..stale = Staleness( + failure: const NetworkFailure('down'), + since: since, + ); + final repo = EventRepositoryImpl(api); + + final history = await repo.events(regionCode: '710'); + final active = await repo.activeEvents(); + + expect((history as Ok).stale?.since, since); + expect((active as Ok).stale, isNull); + }, + ); } diff --git a/test/features/events/presentation/widgets/event_timeline_test.dart b/test/features/events/presentation/widgets/event_timeline_test.dart index 054b02649..a163fcb81 100644 --- a/test/features/events/presentation/widgets/event_timeline_test.dart +++ b/test/features/events/presentation/widgets/event_timeline_test.dart @@ -63,6 +63,23 @@ void main() { expect(find.text('03:04'), findsNWidgets(2)); }); + testWidgets('saved history is listed under a banner, never as current', ( + tester, + ) async { + await _pump( + tester, + _Repo( + Ok([ + _event('1', 'Quake'), + ], stale: const Staleness(failure: NetworkFailure('offline'))), + ), + ); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text('Quake'), findsOneWidget); + expect(find.text(l10n.freshnessSavedCopyUnknownAge), findsOneWidget); + expect(find.text(l10n.commonFetchFailed), findsNothing); + }); + testWidgets('an empty feed is not an error', (tester) async { await _pump(tester, _Repo(const Ok([]))); expect(find.byIcon(Icons.inbox_outlined), findsOneWidget); diff --git a/test/features/location/presentation/pages/region_city_page_test.dart b/test/features/location/presentation/pages/region_city_page_test.dart index c46402952..c6bb837d1 100644 --- a/test/features/location/presentation/pages/region_city_page_test.dart +++ b/test/features/location/presentation/pages/region_city_page_test.dart @@ -122,6 +122,18 @@ void main() { expect(find.text(l10n.regionSearchTownEmpty), findsOneWidget); }); + testWidgets('the township filter folds spacing and full-width input', ( + tester, + ) async { + await tester.pumpWidget(_wrap(await _store())); + + await tester.enterText(find.byType(TextField), ' 松\u3000山 '); + await tester.pump(); + + expect(find.text('松山區'), findsOneWidget); + expect(find.text('中正區'), findsNothing); + }); + testWidgets('replace swaps the named township and can return to More', ( tester, ) async { diff --git a/test/features/location/presentation/pages/region_select_page_test.dart b/test/features/location/presentation/pages/region_select_page_test.dart index 895308a7d..8bd3978f4 100644 --- a/test/features/location/presentation/pages/region_select_page_test.dart +++ b/test/features/location/presentation/pages/region_select_page_test.dart @@ -41,6 +41,7 @@ final _directory = TownDirectory({ '103': _town('103', '臺北', '市', '大同'), '801': _town('801', '高雄', '市', '新興'), '973': _town('973', '花蓮', '縣', '吉安'), + '220': _town('220', '新北', '市', '板橋'), }); Widget _wrap( @@ -106,6 +107,7 @@ void main() { expect(find.text('臺北市'), findsOneWidget); expect(find.text('高雄市'), findsOneWidget); expect(find.text('花蓮縣'), findsOneWidget); + expect(find.text('新北市'), findsOneWidget); }); testWidgets('typing in the search field narrows the city list', ( @@ -199,6 +201,88 @@ void main() { expect(find.text('city:臺北市|replace:-|returnToMore:-'), findsOneWidget); }); + testWidgets('台北 finds the directory\'s 臺北市', (tester) async { + await tester.pumpWidget(_wrap(await _store())); + + await tester.enterText(find.byType(TextField), ' 台北 '); + await tester.pump(); + + expect(find.text('臺北市'), findsOneWidget); + expect(find.text('高雄市'), findsNothing); + // A city query is answered by the city; its districts are one tap away. + expect(find.text('臺北市 中正區'), findsNothing); + }); + + testWidgets('a township is found on the first page and saves on tap', ( + tester, + ) async { + final store = await _store(); + await tester.pumpWidget(_wrap(store)); + final l10n = AppLocalizations.of(tester.element(find.byType(Scaffold))); + + await tester.enterText(find.byType(TextField), '板橋'); + await tester.pump(); + + expect(find.text(l10n.regionSearchEmpty), findsNothing); + expect(find.text(l10n.regionSearchTownsHeader), findsOneWidget); + // Titled with its city: two cities can share a township name. + expect(find.text('新北市 板橋區'), findsOneWidget); + + await tester.tap(find.text('新北市 板橋區')); + await tester.pump(); + + expect(store.savedCodes, ['220']); + expect( + find.descendant( + of: find.widgetWithText(ListTile, '新北市 板橋區'), + matching: find.byIcon(Icons.star), + ), + findsOneWidget, + ); + }); + + testWidgets('a township hit honours replace and returns to More', ( + tester, + ) async { + final store = await _store(['100']); + final router = GoRouter( + initialLocation: '/?replace=100&returnToMore=1', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: store), + Provider.value(value: _directory), + ], + child: const RegionSelectPage(), + ), + ), + GoRoute( + name: AppRoutes.more, + path: '/more', + builder: (_, _) => const Text('more'), + ), + ], + ); + await tester.pumpWidget( + MaterialApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('zh', 'TW'), + routerConfig: router, + ), + ); + + await tester.enterText(find.byType(TextField), '吉安'); + await tester.pump(); + await tester.tap(find.text('花蓮縣 吉安區')); + await tester.pumpAndSettle(); + + expect(store.savedCodes, ['973']); + expect(find.text('more'), findsOneWidget); + }); + testWidgets( 'forwards the constructor replaceCode and returnToMore as query parameters', (tester) async { diff --git a/test/features/map/presentation/layers/rts_layer_eew_push_test.dart b/test/features/map/presentation/layers/rts_layer_eew_push_test.dart index da6872ff3..c84ddf3ff 100644 --- a/test/features/map/presentation/layers/rts_layer_eew_push_test.dart +++ b/test/features/map/presentation/layers/rts_layer_eew_push_test.dart @@ -13,8 +13,11 @@ /// dropped one is a ring that stops expanding during an earthquake. library; +import 'dart:async'; + import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/platform/render_tier.dart'; import 'package:dpip/core/realtime/clock.dart'; import 'package:dpip/core/realtime/elapsed.dart'; import 'package:dpip/core/realtime/realtime_channel.dart'; @@ -30,6 +33,7 @@ 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:fake_async/fake_async.dart'; import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter_test/flutter_test.dart'; @@ -53,6 +57,22 @@ class _EewRecordingController extends RecordingMapController { } } +/// Holds every EEW write open until [gate] completes — a phone whose map takes +/// longer than a wavefront tick to apply a ring set. +class _SlowEewController extends _EewRecordingController { + Completer? gate; + + @override + Future setGeoJsonSource( + String sourceId, + Map geojson, { + String? promoteId, + }) async { + await super.setGeoJsonSource(sourceId, geojson, promoteId: promoteId); + if (sourceId == _eewSourceId) await gate?.future; + } +} + class _FakeClock implements Clock { _FakeClock(this.current); DateTime current; @@ -142,7 +162,11 @@ Future< _FakeElapsed rtsElapsed, }) > -_build({required List alerts, required _FakeElapsed eewElapsed}) async { +_build({ + required List alerts, + required _FakeElapsed eewElapsed, + Future? renderTier, +}) async { final rtsElapsed = _FakeElapsed(); final rtsChannel = _channel(const Rts(), RealtimeConfig.rts, rtsElapsed); await rtsChannel.refreshNow(); @@ -165,6 +189,7 @@ _build({required List alerts, required _FakeElapsed eewElapsed}) async { ), boxGrid: Future.value(const RtsBoxGrid({})), townDirectory: const TownDirectory({}), + renderTier: renderTier, ), eewChannel: eewChannel, rtsChannel: rtsChannel, @@ -295,4 +320,114 @@ void main() { ); }, ); + + group('the wavefront ticker', () { + /// Renders a live alert inside [async] and returns the layer, settled. + RtsMapLayer attach( + FakeAsync async, + _EewRecordingController controller, { + RenderTier? tier, + }) { + late RtsMapLayer layer; + _build( + alerts: [_alert()], + eewElapsed: _FakeElapsed(), + renderTier: tier == null ? null : Future.value(tier), + ).then((built) => layer = built.layer); + async.flushMicrotasks(); + layer.render(controller); + async.flushMicrotasks(); + return layer; + } + + test('waits for the previous write instead of queueing behind it', () { + fakeAsync((async) { + final controller = _SlowEewController(); + final layer = attach(async, controller); + + final gate = controller.gate = Completer(); + final before = controller.eewPushes.length; + // Fifteen 16 ms ticks while the map is still busy with one write. + async.elapse(const Duration(milliseconds: 16 * 15)); + expect( + controller.eewPushes, + hasLength(before + 1), + reason: 'one write out, and every tick behind it skipped', + ); + + controller.gate = null; + gate.complete(); + async.flushMicrotasks(); + async.elapse(const Duration(milliseconds: 16 * 3)); + expect( + controller.eewPushes, + hasLength(before + 4), + reason: 'once it lands, every tick writes again', + ); + layer.clear(controller); + async.flushMicrotasks(); + }); + }); + + test('treats a write that never answers as lost, not slow', () { + fakeAsync((async) { + final controller = _SlowEewController(); + final layer = attach(async, controller); + + controller.gate = Completer(); // never completed + final before = controller.eewPushes.length; + async.elapse(const Duration(milliseconds: 1100)); + expect( + controller.eewPushes, + hasLength(before + 2), + reason: 'a lost platform call must not freeze the rings for good', + ); + layer.clear(controller); + async.flushMicrotasks(); + }); + }); + + test('a low-end phone redraws the rings five times a second', () { + fakeAsync((async) { + final controller = _EewRecordingController(); + final layer = attach(async, controller, tier: RenderTier.low); + + final before = controller.eewPushes.length; + async.elapse(const Duration(seconds: 1)); + expect(controller.eewPushes, hasLength(before + 5)); + layer.clear(controller); + async.flushMicrotasks(); + }); + }); + + test('a low-end phone still gives up on a lost write after a second', () { + fakeAsync((async) { + final controller = _SlowEewController(); + final layer = attach(async, controller, tier: RenderTier.low); + + controller.gate = Completer(); // never completed + final before = controller.eewPushes.length; + // A write at 200 ms, five skipped ticks behind it, a write at 1400. + async.elapse(const Duration(milliseconds: 1300)); + expect(controller.eewPushes, hasLength(before + 1)); + async.elapse(const Duration(milliseconds: 100)); + expect(controller.eewPushes, hasLength(before + 2)); + layer.clear(controller); + async.flushMicrotasks(); + }); + }); + + test('writes on every tick while the map keeps up', () { + fakeAsync((async) { + final controller = _EewRecordingController(); + final layer = attach(async, controller); + + final before = controller.eewPushes.length; + async.elapse(const Duration(milliseconds: 16 * 10)); + expect(controller.eewPushes, hasLength(before + 10)); + layer.clear(controller); + async.flushMicrotasks(); + }); + }); + }); } diff --git a/test/shared/map/map_timeline_test.dart b/test/shared/map/map_timeline_test.dart index f438f63ca..1718b9560 100644 --- a/test/shared/map/map_timeline_test.dart +++ b/test/shared/map/map_timeline_test.dart @@ -3,6 +3,7 @@ import 'package:dpip/shared/map/map_layer.dart'; import 'package:dpip/shared/map/map_timeline.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; List _frames(int n) => [ @@ -494,4 +495,71 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets('the ruler is one adjustable slider for a screen reader', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final frames = _frames(10); + final reported = []; + final scrubbing = []; + await tester.pumpWidget( + _wrap( + frames: frames, + selectedIndex: 5, + onSelected: reported.add, + onScrubbing: scrubbing.add, + ), + ); + await tester.pumpAndSettle(); + + final slider = find.semantics.byLabel('Timeline'); + expect(slider, findsOne); + // 07:00 + 5 × 10 min. Frame 9 is "now", so frame 5 is history. + expect( + tester.getSemantics(find.byType(ListView)), + isNot(matchesSemantics(label: 'Timeline')), + reason: 'the tick labels are excluded, not a second focus target', + ); + final node = slider.evaluate().single; + expect(node.getSemanticsData().flagsCollection.isSlider, isTrue); + expect(node.value, 'Past 07:50, 2026/07/13'); + expect(node.increasedValue, 'Past 08:00, 2026/07/13'); + expect(node.decreasedValue, 'Past 07:40, 2026/07/13'); + + tester.semantics.increase(slider); + await tester.pumpAndSettle(); + // Past the scrub's input-quiet window, so it settles. + await tester.pump(const Duration(milliseconds: 200)); + expect(reported.last, 6); + expect(slider.evaluate().single.value, 'Past 08:00, 2026/07/13'); + expect(scrubbing, [ + true, + false, + ], reason: 'a step settles like a short fling, so a raster layer loads it'); + + tester.semantics.decrease(slider); + tester.semantics.decrease(slider); + await tester.pumpAndSettle(); + expect(reported.last, 4); + expect(find.text('07:40'), findsWidgets); + expect(tester.takeException(), isNull); + semantics.dispose(); + }); + + testWidgets('the slider stops at both ends of the frames', (tester) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget( + _wrap(frames: _frames(3), selectedIndex: 2, onSelected: (_) {}), + ); + await tester.pumpAndSettle(); + + final node = find.semantics.byLabel('Timeline').evaluate().single; + final data = node.getSemanticsData(); + expect(node.value, 'Now 07:20, 2026/07/13'); + expect(data.hasAction(SemanticsAction.increase), isFalse); + expect(data.hasAction(SemanticsAction.decrease), isTrue); + expect(node.increasedValue, isEmpty); + semantics.dispose(); + }); } diff --git a/test/shared/map/wavefront_cadence_test.dart b/test/shared/map/wavefront_cadence_test.dart new file mode 100644 index 000000000..3eafd55dd --- /dev/null +++ b/test/shared/map/wavefront_cadence_test.dart @@ -0,0 +1,13 @@ +import 'package:dpip/core/platform/render_tier.dart'; +import 'package:dpip/shared/map/wavefront_cadence.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('a capable phone redraws the rings at about display rate', () { + expect(wavefrontTick(RenderTier.high), const Duration(milliseconds: 16)); + }); + + test('a low-end phone redraws them at 5 Hz', () { + expect(wavefrontTick(RenderTier.low), const Duration(milliseconds: 200)); + }); +} diff --git a/test/shared/widgets/async_view_test.dart b/test/shared/widgets/async_view_test.dart index 060c6cdae..d4abf0ec9 100644 --- a/test/shared/widgets/async_view_test.dart +++ b/test/shared/widgets/async_view_test.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'package:dpip/core/error/failure.dart'; import 'package:dpip/core/error/result.dart'; +import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; import 'package:dpip/shared/widgets/async_view.dart'; +import 'package:dpip/shared/widgets/freshness_banner.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -138,4 +140,87 @@ void main() { await tester.pumpAndSettle(); expect(find.text('v 2'), findsOneWidget); }); + + group('a saved copy', () { + // Ages are built from AppTime, the clock the banner measures them with. + testWidgets('renders under a banner with its age, never as current', ( + tester, + ) async { + await tester.pumpWidget( + _wrap( + AsyncView( + future: () => Future.value( + Ok( + 5, + stale: Staleness( + failure: const NetworkFailure('No connection'), + since: AppTime.utc.subtract(const Duration(minutes: 20)), + ), + ), + ), + builder: (_, value) => ListView(children: [Text('v $value')]), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('v 5'), findsOneWidget); + expect( + find.text("Can't connect · showing data from 20 minutes ago"), + findsOneWidget, + ); + }); + + testWidgets('of unknown age still says it is saved', (tester) async { + await tester.pumpWidget( + _wrap( + AsyncView( + future: () => Future.value( + const Ok( + 5, + stale: Staleness(failure: NetworkFailure('No connection')), + ), + ), + builder: (_, value) => Text('v $value'), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text("Can't connect · showing saved data"), findsOneWidget); + }); + + testWidgets('keeps its banner while a refresh is in flight', ( + tester, + ) async { + final refresh = ChangeNotifier(); + addTearDown(refresh.dispose); + var calls = 0; + final pending = Completer>(); + await tester.pumpWidget( + _wrap( + AsyncView( + refreshSignal: refresh, + future: () => calls++ == 0 + ? Future.value( + const Ok( + 5, + stale: Staleness(failure: NetworkFailure('down')), + ), + ) + : pending.future, + builder: (_, value) => Text('v $value'), + ), + ), + ); + await tester.pumpAndSettle(); + refresh.notifyListeners(); + await tester.pump(); + expect(find.text('v 5'), findsOneWidget); + expect(find.byType(FreshnessBanner), findsOneWidget); + + pending.complete(const Ok(6)); + await tester.pumpAndSettle(); + expect(find.text('v 6'), findsOneWidget); + expect(find.byType(FreshnessBanner), findsNothing); + }); + }); } diff --git a/test/shared/widgets/freshness_banner_test.dart b/test/shared/widgets/freshness_banner_test.dart new file mode 100644 index 000000000..e0be18810 --- /dev/null +++ b/test/shared/widgets/freshness_banner_test.dart @@ -0,0 +1,35 @@ +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/freshness_banner.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppLocalizations en; + late AppLocalizations zh; + + setUpAll(() async { + en = await AppLocalizations.delegate.load(const Locale('en')); + zh = await AppLocalizations.delegate.load(const Locale('zh', 'TW')); + }); + + String age(AppLocalizations l10n, Duration d) => + FreshnessBanner.ageLabel(l10n, d); + + test('minutes under an hour, never zero', () { + expect(age(en, Duration.zero), '1 minute'); + expect(age(en, const Duration(seconds: 59)), '1 minute'); + expect(age(en, const Duration(minutes: 59)), '59 minutes'); + expect(age(zh, const Duration(minutes: 5)), '5 分鐘'); + }); + + test('hours under two days, then days', () { + expect(age(en, const Duration(hours: 1)), '1 hour'); + expect(age(en, const Duration(hours: 47, minutes: 59)), '47 hours'); + expect(age(en, const Duration(days: 2)), '2 days'); + expect(age(zh, const Duration(days: 9)), '9 天'); + }); + + test('a clock that moved backwards reads as the minimum', () { + expect(age(en, const Duration(minutes: -3)), '1 minute'); + }); +}