Skip to content
Merged
12 changes: 10 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<T>` maps a
one-shot `Future<Result<T>>` to loading/data/empty/error (with retry);
one-shot `Future<Result<T>>` to loading/data/empty/error (with retry), and a
saved copy (`Ok.stale`) to data under a `FreshnessBanner` with its age;
`RealtimeView<T>` maps a `RealtimeState<T>`, 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`.

Expand Down
19 changes: 12 additions & 7 deletions lib/app/router/app_router.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
),
),
],
Expand Down Expand Up @@ -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',
),
Expand Down Expand Up @@ -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),
),
],
),
Expand Down
74 changes: 74 additions & 0 deletions lib/app/router/route_params.dart
Original file line number Diff line number Diff line change
@@ -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;
33 changes: 30 additions & 3 deletions lib/core/error/result.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,41 @@ sealed class Result<T> {
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<R> map<R>(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<T> extends Result<T> {
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].
Expand All @@ -55,3 +77,8 @@ final class Err<T> extends Result<T> {
/// 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> = ({T data, Staleness? stale});
121 changes: 121 additions & 0 deletions lib/core/geo/place_search.dart
Original file line number Diff line number Diff line change
@@ -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<String> cities, List<Town> 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<String> cities, Iterable<Town> 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<String> 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,
],
);
}
}
14 changes: 13 additions & 1 deletion lib/core/network/api_exception.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,21 @@ import 'package:dpip/core/logging/log.dart';
Future<Result<T>> guardResult<T>(
Future<T> 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<Result<T>> guardFetched<T>(
Future<Fetched<T>> 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
Expand Down
Loading
Loading