diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f841e78d9..e7a16f234 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -293,5 +293,19 @@ are async and log rather than throw. Add a setting = add one `SettingKey`; never change an existing key string without a migration. `shared_preferences` is **not a dependency** — nothing may import it — and only a table's owning store may import `sqflite`; both enforced by `tool/check/storage.sh`. + +The device's movement history is a third file, `location_track.db` in +application-support, outside that split because Dart does not own it: the +native background-location path writes it (`LocationTrackStore` on both +platforms, every background fix, delta-encoded, 50 MB with oldest-first +eviction) and Dart only reads it, through a read-only connection +(`core/geo/location_track.dart`). **It never leaves the device.** Nothing +uploads a position from it — a diagnostics dump the user sends carries only +its fix count and size — it is excluded from backups (iOS +`isExcludedFromBackup`, Android `allowBackup="false"`), and uninstalling the +app or clearing its data deletes it. Recording has no switch of its own — it +follows the background-location grant — and the map's 軌跡 layer, which shows +it, says all of this in its sheet; its clear goes through the native side, +never a file delete from Dart. - Every file starts with a doc comment; one public declaration = one clear responsibility. diff --git a/lib/core/geo/location_track.dart b/lib/core/geo/location_track.dart index 9c60bcafc..a256f0c66 100644 --- a/lib/core/geo/location_track.dart +++ b/lib/core/geo/location_track.dart @@ -6,7 +6,7 @@ import 'dart:io'; import 'package:dpip/core/logging/log.dart'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path_provider/path_provider.dart'; -import 'package:sqlite3/sqlite3.dart' show Database; +import 'package:sqlite3/sqlite3.dart' show Database, OpenMode, sqlite3; import 'package:sqlite_async/native.dart'; import 'package:sqlite_async/sqlite_async.dart'; @@ -108,6 +108,10 @@ class LocationTrack { Future close() => _db.close(); + /// The file this track reads — what a background isolate opens for itself + /// in [scan], since a connection cannot cross an isolate boundary. + String get path => _path; + /// The underlying connection, so a test can prove it rejects writes. /// /// Exposed rather than asserted in a comment: "Dart never writes here" is @@ -135,25 +139,101 @@ class LocationTrack { } } - /// How many fixes are stored and how large the file is. + /// How many fixes are stored, how large the file is, and when the oldest + /// surviving fix was taken (null when there is none). + /// + /// For the storage screen and the map's track layer. This file is the one + /// part of the app's disk use that grows without anybody opening anything, + /// so it is worth being able to see it — and once eviction has started, + /// [oldest] is the honest answer to "how far back does it go". /// - /// For the storage screen. This file is the one part of the app's disk use - /// that grows without anybody opening anything, so it is worth being able - /// to see it. - Future<({int fixes, int bytes})> stats() async { + /// [oldest] reads the first row's `t` as an instant, which is right because + /// the first row is always an anchor (see [_decodeFrom]); it is the same + /// reading the decoder gives it. + Future<({int fixes, int bytes, DateTime? oldest})> stats() async { try { final row = await _db.get('SELECT COUNT(*) AS n FROM fix'); + final first = await _db.getOptional( + 'SELECT t FROM fix ORDER BY id LIMIT 1', + ); final file = File(_path); + final oldest = (first?['t'] as num?)?.toInt(); return ( fixes: ((row['n'] as num?) ?? 0).toInt(), bytes: file.existsSync() ? file.lengthSync() : 0, + oldest: oldest == null + ? null + : DateTime.fromMillisecondsSinceEpoch(oldest * 1000, isUtc: true), ); } catch (error, stackTrace) { Log.handle(error, stackTrace, 'location track stats'); - return (fixes: 0, bytes: 0); + return (fixes: 0, bytes: 0, oldest: null); } } + /// Walks every fix at or after [from] in the file at [path], oldest first, + /// handing each to [visit] in the units the file stores: Unix seconds and + /// ten-thousandths of a degree. + /// + /// **Blocking — call it from a background isolate, never the UI one.** It is + /// what makes "the whole history" readable at all: the budget allows a few + /// million rows, and [since] would materialise every one of them as an + /// object on the calling isolate. This streams them through a cursor + /// instead, one row in memory at a time, so a caller that keeps only a + /// bounded summary (the map's track digest) stays bounded however long the + /// history is. + /// + /// Opens its own `SQLITE_OPEN_READONLY` connection for the same reason + /// [_ReadOnlyFactory] does, and closes it before returning. A missing or + /// unreadable file is not an error here: it visits nothing, and the caller + /// sees an empty history — the same answer [open] gives. + static void scan( + String path, + DateTime from, + void Function(int seconds, int latE4, int lngE4) visit, + ) { + if (!File(path).existsSync()) return; + final db = sqlite3.open(path, mode: OpenMode.readOnly); + try { + final fromMs = from.millisecondsSinceEpoch; + final anchor = db.select(_anchorQuery, [fromMs ~/ 1000]); + final start = anchor.isEmpty + ? 0 + : ((anchor.first.columnAt(0) as num?) ?? 0).toInt(); + final statement = db.prepare(_walkQuery); + try { + final cursor = statement.selectCursor([start]); + final decoder = _Decoder(); + while (cursor.moveNext()) { + final row = cursor.current; + decoder.feed( + (row.columnAt(0) as num).toInt(), + (row.columnAt(1) as num).toInt(), + (row.columnAt(2) as num).toInt(), + (row.columnAt(3) as num).toInt(), + ); + // The same cut [since] makes, in the same units: a fix stamped in + // whole seconds is in the window when its instant is not before it. + if (decoder.t * 1000 < fromMs) continue; + visit(decoder.t, decoder.lat, decoder.lng); + } + } finally { + statement.close(); + } + } finally { + db.close(); + } + } + + /// The newest anchor at or before an instant (see [_anchorAtOrBefore]). + static const _anchorQuery = + 'SELECT id FROM fix WHERE id % $_anchorEvery = 0 AND t <= ? ' + 'ORDER BY id DESC LIMIT 1'; + + /// Every row from an anchor forward, in the order the deltas chain. + static const _walkQuery = + 'SELECT id, t, lat, lng FROM fix WHERE id >= ? ORDER BY id'; + /// The rowid of the newest anchor no later than [time], or 0 for "start at /// the beginning". /// @@ -167,11 +247,9 @@ class LocationTrack { /// than the whole track is the one case that scans it all, and it correctly /// answers 0. Future _anchorAtOrBefore(DateTime time) async { - final rows = await _db.getAll( - 'SELECT id FROM fix WHERE id % $_anchorEvery = 0 AND t <= ? ' - 'ORDER BY id DESC LIMIT 1', - [time.millisecondsSinceEpoch ~/ 1000], - ); + final rows = await _db.getAll(_anchorQuery, [ + time.millisecondsSinceEpoch ~/ 1000, + ]); return rows.isEmpty ? 0 : ((rows.first['id'] as num?) ?? 0).toInt(); } @@ -179,37 +257,27 @@ class LocationTrack { /// /// [startId] has to be an anchor or 0 — the first row of the table is always /// an anchor, because the writer rounds up to a boundary whenever it has - /// nothing to count from and eviction only ever deletes whole groups. The - /// `t == null` arm below is the belt to that braces: a first row that - /// somehow is not an anchor is read as absolute, which is the only reading - /// that can be right. + /// nothing to count from and eviction only ever deletes whole groups. + /// [_Decoder] is the belt to that braces. Future> _decodeFrom(int startId) async { - final rows = await _db.getAll( - 'SELECT id, t, lat, lng FROM fix WHERE id >= ? ORDER BY id', - [startId], - ); + final rows = await _db.getAll(_walkQuery, [startId]); final fixes = []; - var t = 0; - var lat = 0; - var lng = 0; - var started = false; + final decoder = _Decoder(); for (final row in rows) { - final id = (row['id'] as num).toInt(); - if (!started || id % _anchorEvery == 0) { - t = (row['t'] as num).toInt(); - lat = (row['lat'] as num).toInt(); - lng = (row['lng'] as num).toInt(); - started = true; - } else { - t += (row['t'] as num).toInt(); - lat += (row['lat'] as num).toInt(); - lng += (row['lng'] as num).toInt(); - } + decoder.feed( + (row['id'] as num).toInt(), + (row['t'] as num).toInt(), + (row['lat'] as num).toInt(), + (row['lng'] as num).toInt(), + ); fixes.add( TrackFix( - time: DateTime.fromMillisecondsSinceEpoch(t * 1000, isUtc: true), - latitude: lat / _scale, - longitude: lng / _scale, + time: DateTime.fromMillisecondsSinceEpoch( + decoder.t * 1000, + isUtc: true, + ), + latitude: decoder.lat / _scale, + longitude: decoder.lng / _scale, ), ); } @@ -217,6 +285,41 @@ class LocationTrack { } } +/// The delta walk, written once. +/// +/// [LocationTrack.since] and [LocationTrack.scan] read the rows through +/// different connections on different isolates, and the one thing they must +/// never do is disagree about what a row means — a decoder copied into two +/// places is two decoders, and the day one of them is fixed the other becomes +/// the bug. +/// +/// The first row fed is read as absolute whatever its id: it is always an +/// anchor (the writer rounds up to a boundary whenever it has nothing to count +/// from, and eviction only deletes whole groups), and a first row that somehow +/// is not one is still read absolute, which is the only reading that can be +/// right. +class _Decoder { + var _started = false; + + /// The absolute values after the last [feed]. + int t = 0; + int lat = 0; + int lng = 0; + + void feed(int id, int t, int lat, int lng) { + if (!_started || id % LocationTrack._anchorEvery == 0) { + this.t = t; + this.lat = lat; + this.lng = lng; + _started = true; + } else { + this.t += t; + this.lat += lat; + this.lng += lng; + } + } +} + /// Opens every connection `SQLITE_OPEN_READONLY`, and runs no pragma that /// would persist anything. /// diff --git a/lib/core/geo/track_digest.dart b/lib/core/geo/track_digest.dart new file mode 100644 index 000000000..7b210bb90 --- /dev/null +++ b/lib/core/geo/track_digest.dart @@ -0,0 +1,358 @@ +/// The movement history boiled down to something a cheap phone can draw. +/// +/// The track's budget allows a few million fixes. Handing even a week of them +/// to MapLibre as one GeoJSON line is already a few thousand vertices; handing +/// it years would be a multi-megabyte string over the platform channel, parsed +/// and re-tiled natively, for a picture that cannot show more than a few +/// thousand distinct strokes at any zoom anyway. So the map never sees the +/// fixes — it sees a [TrackDigest], built off the UI isolate, whose size is +/// bounded by [TrackDigestBuilder.budget] however long the history is. +library; + +import 'dart:isolate'; +import 'dart:math' as math; + +import 'package:dpip/core/geo/location_track.dart'; +import 'package:dpip/core/logging/log.dart'; + +/// One straight stroke of the drawn track: the device moved between these two +/// places, with no recording gap in between. +class TrackSegment { + const TrackSegment({ + required this.fromLatitude, + required this.fromLongitude, + required this.toLatitude, + required this.toLongitude, + required this.time, + }); + + final double fromLatitude; + final double fromLongitude; + final double toLatitude; + final double toLongitude; + + /// The **last** time this stroke was travelled. A daily commute is one + /// stroke, not three hundred, and it is as recent as this morning — which is + /// what its age colour should say. + final DateTime time; +} + +/// A bounded summary of every fix in a window: what [TrackDigestBuilder] +/// produces and the map layer draws. +class TrackDigest { + const TrackDigest({ + required this.fixes, + required this.segments, + required this.dots, + required this.cellE4, + this.first, + this.latest, + this.bounds, + }); + + /// A window with nothing in it. + static const empty = TrackDigest(fixes: 0, segments: [], dots: [], cellE4: 1); + + /// How many recorded fixes the window held — before any thinning, so it is + /// the number a person would count, not the number drawn. + final int fixes; + + /// The strokes to draw, each at most once. + final List segments; + + /// Places visited with no stroke to or from them — a fix with a recording + /// gap on both sides. Without these, a lone report between two long silences + /// would simply vanish from the map. Each carries its last visit time. + final List dots; + + /// The oldest and newest fixes of the window, exactly as recorded — never + /// thinned, because "where was I last" must not be a grid cell's stand-in. + final TrackFix? first; + final TrackFix? latest; + + /// The box around every fix in the window (exact, not thinned), or null when + /// it is empty — what the camera fits. + final ({double south, double west, double north, double east})? bounds; + + /// The grid edge the thinning settled on, in ten-thousandths of a degree + /// (about 11 m each). 1 means nothing at all was merged: every stroke is + /// exactly as recorded. + final int cellE4; + + bool get isEmpty => fixes == 0; + + /// Reads every fix at or after [from] in [track] on a background isolate and + /// returns its digest. + /// + /// The whole read happens off the UI isolate — opening, walking and thinning + /// — and only the bounded result crosses back, so asking for the entire + /// history costs the UI nothing but the wait. A failed read is logged and + /// answers [empty], the same way [LocationTrack.since] does. + static Future read( + LocationTrack track, + DateTime from, { + int budget = TrackDigestBuilder.defaultBudget, + Duration gap = TrackDigestBuilder.defaultGap, + }) async { + final path = track.path; + try { + return await Isolate.run( + () => _digestFile(path, from, budget, gap), + debugName: 'location track digest', + ); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'reading location track digest'); + return empty; + } + } +} + +/// The isolate's whole job. Top-level so the closure handed to [Isolate.run] +/// captures four plain values and nothing that holds a connection. +TrackDigest _digestFile(String path, DateTime from, int budget, Duration gap) { + final builder = TrackDigestBuilder(budget: budget, gap: gap); + LocationTrack.scan(path, from, builder.add); + return builder.build(); +} + +/// Streams fixes in and keeps at most [budget] things to draw. +/// +/// ## How it thins +/// +/// Every fix is snapped to a square grid cell, and the output is the set of +/// **distinct cell-to-cell strokes** the device made, plus cells visited with +/// no stroke at all. Two consecutive fixes in the same cell add nothing; a +/// stroke already taken adds nothing but a fresher timestamp. +/// +/// The set rather than the sequence, because of what a long history actually +/// looks like: the same commute, hundreds of times. Douglas–Peucker or +/// per-run thinning keeps every repetition — each one a separate polyline +/// lying on top of the last — so a year of commuting is a year's worth of +/// vertices, and the only way to fit a budget is to coarsen until home and +/// work are one cell. Distinct strokes make a repeated route cost what it costs +/// once, and the budget goes on places, not on days. +/// +/// ## The budget +/// +/// Cells start at the precision the file stores (1e-4°, about 11 m), so a +/// short window is drawn exactly. Whenever cells + strokes exceed [budget], the +/// cell edge doubles and what is held so far is re-snapped. That re-snap is +/// exact, not an approximation of re-reading: cells nest (a coarse cell is +/// precisely four fine ones), so a fine stroke maps to the coarse stroke the +/// same two fixes would have produced, or to nothing when both ends fall into +/// one coarse cell. Each doubling is O(budget) and the edge can double at most +/// ~22 times before one cell spans a hemisphere, so the whole pass is +/// O(fixes + budget) with memory bounded by [budget] — independent of how many +/// fixes go in. +/// +/// Drawn coordinates are at most 2 × [budget] (two per stroke, one per dot). +/// At the default that is ≤ 8,000 coordinates, roughly 160 KB of GeoJSON: one +/// platform-channel write and a geojson-vt tiling that a low-end Android phone +/// does in tens of milliseconds, once per load rather than per frame. +/// +/// ## Gaps +/// +/// Two consecutive fixes more than [gap] apart are not joined. The device only +/// records while it is moving and background location is allowed, so a long +/// silence means "unknown", and a straight line across it would draw a route +/// nobody took — overnight at home, then the first fix two towns away in the +/// morning, is not a straight drive through the fields between. +class TrackDigestBuilder { + TrackDigestBuilder({this.budget = defaultBudget, this.gap = defaultGap}) + : assert(budget >= 16, 'a budget under 16 cannot hold even one hemisphere'); + + /// Cells + strokes held at once. See the class docs for what it costs. + static const int defaultBudget = 4000; + + /// The longest silence still drawn as one continuous movement. + static const Duration defaultGap = Duration(hours: 2); + + final int budget; + final Duration gap; + + late final int _gapSeconds = gap.inSeconds; + + /// log2 of the cell edge in stored units. + int _shift = 0; + + /// Visited cells, keyed by [_key]. Insertion-ordered, so a merged cell keeps + /// the representative of whichever of its children was visited first. + Map _cells = {}; + + /// Distinct strokes, keyed by their two cell keys low-first (a stroke has no + /// direction on a map), valued by the last time it was travelled. + Map<(int, int), int> _strokes = {}; + + int _fixes = 0; + int? _prevTime; + int _prevLat = 0; + int _prevLng = 0; + int _prevCell = 0; + + int _firstTime = 0; + int _firstLat = 0; + int _firstLng = 0; + int _lastTime = 0; + int _lastLat = 0; + int _lastLng = 0; + int _south = 0; + int _west = 0; + int _north = 0; + int _east = 0; + + /// Adds one fix, in the units the file stores: Unix seconds and + /// ten-thousandths of a degree. Fixes are expected oldest first. + void add(int seconds, int latE4, int lngE4) { + if (_fixes == 0) { + _firstTime = seconds; + _firstLat = latE4; + _firstLng = lngE4; + _south = _north = latE4; + _west = _east = lngE4; + } else { + _south = math.min(_south, latE4); + _north = math.max(_north, latE4); + _west = math.min(_west, lngE4); + _east = math.max(_east, lngE4); + } + _fixes++; + _lastTime = seconds; + _lastLat = latE4; + _lastLng = lngE4; + + final cell = _key(latE4, lngE4, _shift); + final known = _cells[cell]; + if (known == null) { + _cells[cell] = _Cell(latE4, lngE4, seconds); + } else { + known.time = math.max(known.time, seconds); + } + + final prevTime = _prevTime; + // `abs`: a device clock set backwards produces a negative step, which is + // still one movement and not a gap. + if (prevTime != null && + (seconds - prevTime).abs() <= _gapSeconds && + cell != _prevCell) { + final stroke = cell < _prevCell ? (cell, _prevCell) : (_prevCell, cell); + final seen = _strokes[stroke]; + _strokes[stroke] = seen == null ? seconds : math.max(seen, seconds); + } + _prevTime = seconds; + _prevLat = latE4; + _prevLng = lngE4; + _prevCell = cell; + + while (_cells.length + _strokes.length > budget && _shift < _maxShift) { + _coarsen(); + } + } + + /// Doubles the cell edge and re-snaps what is held. Exact — see the class + /// docs. + void _coarsen() { + _shift++; + final remap = {}; + final cells = {}; + for (final MapEntry(key: old, value: cell) in _cells.entries) { + // The representative lies inside its fine cell, so snapping it at the + // new size lands in exactly the coarse cell that contains the fine one. + final next = _key(cell.lat, cell.lng, _shift); + remap[old] = next; + final merged = cells[next]; + if (merged == null) { + cells[next] = cell; + } else { + merged.time = math.max(merged.time, cell.time); + } + } + final strokes = <(int, int), int>{}; + for (final MapEntry(key: (a, b), value: time) in _strokes.entries) { + final na = remap[a]!; + final nb = remap[b]!; + if (na == nb) continue; + final stroke = na < nb ? (na, nb) : (nb, na); + final seen = strokes[stroke]; + strokes[stroke] = seen == null ? time : math.max(seen, time); + } + _cells = cells; + _strokes = strokes; + _prevCell = _key(_prevLat, _prevLng, _shift); + } + + /// The digest of everything added so far. + TrackDigest build() { + if (_fixes == 0) return TrackDigest.empty; + final joined = {}; + final segments = []; + for (final MapEntry(key: (a, b), value: time) in _strokes.entries) { + joined + ..add(a) + ..add(b); + final from = _cells[a]!; + final to = _cells[b]!; + segments.add( + TrackSegment( + fromLatitude: from.lat / _scale, + fromLongitude: from.lng / _scale, + toLatitude: to.lat / _scale, + toLongitude: to.lng / _scale, + time: _instant(time), + ), + ); + } + return TrackDigest( + fixes: _fixes, + segments: segments, + dots: [ + for (final MapEntry(key: key, value: cell) in _cells.entries) + if (!joined.contains(key)) _fix(cell.time, cell.lat, cell.lng), + ], + cellE4: 1 << _shift, + first: _fix(_firstTime, _firstLat, _firstLng), + latest: _fix(_lastTime, _lastLat, _lastLng), + bounds: ( + south: _south / _scale, + west: _west / _scale, + north: _north / _scale, + east: _east / _scale, + ), + ); + } + + static const double _scale = 10000; + + /// Past this one cell is wider than any coordinate, so doubling again can + /// merge nothing more. + static const int _maxShift = 23; + + /// Packs a cell's two indices into one int. `>>` on a negative int floors, + /// which is what makes cells nest across the equator and the meridian too. + /// Offsets keep both indices positive: |lat| ≤ 900 000 and |lng| ≤ 1 800 000 + /// stored units both fit under 2^22, and the packed key stays under 2^46 — + /// inside a double's 53-bit mantissa, so the key is exact on every platform. + static int _key(int lat, int lng, int shift) => + ((lat >> shift) + _offset) * _span + ((lng >> shift) + _offset); + + static const int _offset = 1 << 22; + static const int _span = 1 << 23; + + static DateTime _instant(int seconds) => + DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true); + + static TrackFix _fix(int seconds, int lat, int lng) => TrackFix( + time: _instant(seconds), + latitude: lat / _scale, + longitude: lng / _scale, + ); +} + +/// One grid cell: where to draw it (the first fix that landed in it) and the +/// last time it was visited. +class _Cell { + _Cell(this.lat, this.lng, this.time); + + final int lat; + final int lng; + int time; +} diff --git a/lib/features/map/presentation/layers/location_track_layer.dart b/lib/features/map/presentation/layers/location_track_layer.dart new file mode 100644 index 000000000..201dce549 --- /dev/null +++ b/lib/features/map/presentation/layers/location_track_layer.dart @@ -0,0 +1,637 @@ +/// The device's own movement history, drawn on the map. +/// +/// A sheet layer, like the mesh nodes: no frames, no timeline, and nothing +/// fetched — it reads the file the native background-location path writes +/// (`LocationTrackStore` on both platforms) and draws it. The sheet carries +/// the time range, what is stored, and the one statement this layer must never +/// bury: the history exists only on this phone. +library; + +import 'dart:async'; + +import 'package:dpip/core/a11y/color_vision.dart'; +import 'package:dpip/core/geo/location_track.dart'; +import 'package:dpip/core/geo/track_digest.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/platform/background_location.dart'; +import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/features/map/presentation/widgets/location_track_sheet.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/map/camera_fit.dart'; +import 'package:dpip/shared/map/map_layer.dart'; +import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +/// The windows the sheet offers. [week] is the default: long enough to show a +/// routine, short enough to decode in a blink on any phone. +enum TrackRange { today, week, month, all } + +/// The instant [range] starts at, given the calibrated [now]. +/// +/// [TrackRange.today] is the Taipei calendar day — the same fixed +8 wall clock +/// every other date in the app is read in — and [TrackRange.all] is the epoch, +/// which is older than any fix. +DateTime trackRangeStart(TrackRange range, DateTime now) => switch (range) { + TrackRange.today => _taipeiMidnight(now), + TrackRange.week => now.toUtc().subtract(const Duration(days: 7)), + TrackRange.month => now.toUtc().subtract(const Duration(days: 30)), + TrackRange.all => DateTime.utc(1970), +}; + +DateTime _taipeiMidnight(DateTime now) { + final wall = AppTime.taipei(now); + return DateTime.utc( + wall.year, + wall.month, + wall.day, + ).subtract(const Duration(hours: 8)); +} + +/// What the sheet shows: whether anything has ever been recorded, what is +/// stored, and the digest of the selected window. +@immutable +class LocationTrackSnapshot { + const LocationTrackSnapshot({ + required this.recorded, + required this.range, + this.fixes = 0, + this.bytes = 0, + this.oldest, + this.digest = TrackDigest.empty, + }); + + /// False on a fresh install, on a device that never allowed background + /// location, and right after a clear — the sheet's empty state. + final bool recorded; + + /// The window [digest] covers. + final TrackRange range; + + /// Every fix in the file, whatever the window. + final int fixes; + + /// The file's size on disk. + final int bytes; + + /// The oldest fix still in the file. + final DateTime? oldest; + + /// The selected window, thinned for drawing. + final TrackDigest digest; +} + +/// Summarises the fixes at or after `from` — [TrackDigest.read] in the app; a +/// test can hand in one that does not spawn an isolate. +typedef TrackDigestReader = Future Function( + LocationTrack track, + DateTime from, +); + +class LocationTrackMapLayer with MapLayerDefaults implements MapLayer { + LocationTrackMapLayer( + this._backgroundLocation, { + Future Function()? open, + TrackDigestReader? digest, + DateTime Function()? now, + }) : _open = open ?? LocationTrack.open, + _readDigest = digest ?? TrackDigest.read, + _now = now ?? (() => AppTime.utc); + + final BackgroundLocationService _backgroundLocation; + final Future Function() _open; + final TrackDigestReader _readDigest; + + /// Calibrated now — [AppTime.utc] in the app, a fixed instant in a test so + /// "today" does not depend on when the suite runs. + final DateTime Function() _now; + + /// Wire id. Also what the picker groups by (see `categoryOf`). + static const String layerId = 'location-track'; + + static const String _sourceId = 'location-track-src'; + static const String _lineId = 'location-track-line'; + static const String _dotId = 'location-track-dot'; + static const String _latestId = 'location-track-latest'; + + /// How many shades the age ramp is cut into. + /// + /// Strokes are grouped into this many features rather than one feature each: + /// a stroke's coordinates are about 40 bytes of GeoJSON, a feature wrapper + /// around them another 80, so grouping cuts the upload to roughly a third. + /// Eight steps of a ramp that already fades by opacity are not visibly + /// different from a continuous one. + static const int _shades = 8; + + // Colours are getters, not `static const`: the colour-vision transform is not + // a compile-time constant. The legend converts the same hexes, so the key + // names the line it points at under every correction. + + /// The newest end of the ramp, and the last-position marker. Orange stands + /// clear of both base-map palettes' greys and of every warning red. + static String get _newColor => '#EF6C00'.vision; + + /// The oldest end — the same hue, paler, and drawn more transparent below. + static String get _oldColor => '#FFB74D'.vision; + static String get _strokeColor => '#FFFFFF'.vision; + + static const double _oldOpacity = 0.4; + static const double _newOpacity = 0.95; + + /// The time range picked in the sheet. + final ValueNotifier range = ValueNotifier(TrackRange.week); + + /// The latest load, or null before the first one finishes. + final ValueNotifier snapshot = ValueNotifier(null); + + /// A load or a clear is running. The map keeps showing the previous drawing + /// until the new one is ready, so this is what the sheet shows instead. + final ValueNotifier busy = ValueNotifier(false); + + MapLibreMapController? _controller; + LocationTrack? _track; + bool _added = false; + bool _surfaceVisible = true; + + /// Bumped by every load and by [clear]; a load that finds it moved on drops + /// its result instead of drawing over a newer one. + int _generation = 0; + + /// What the last load read, to skip a foreground refresh that would only + /// redraw the same thing. + Object? _loadedSignature; + + /// Serial lane for the map writes — a range change landing while a refresh + /// is drawing must not interleave two `setGeoJsonSource` calls. + Future _ops = Future.value(); + + /// The camera is fitted once per activation, not on every refresh: a fix + /// arriving while the user is looking at their own street must not throw the + /// map back out to the whole track. + bool _framed = false; + bool _wantFrame = false; + Size? _frameViewport; + double _frameTopInset = 0; + double _frameBottomInset = 0; + + @override + String get id => layerId; + + @override + IconData get icon => Icons.route_outlined; + + @override + String label(BuildContext context) => + AppLocalizations.of(context).mapLayerLocationTrack; + + @override + String? subtitle(BuildContext context) => + AppLocalizations.of(context).mapLayerLocationTrackSubtitle; + + @override + bool get usesTimeline => false; + + /// How much of the map the sheet covers at rest. The sheet measures it — + /// see [LocationTrackSheet] — and writes it back here for the scaffold's + /// framing. + double peekExtent = LocationTrackSheet.fallbackPeekExtent; + + @override + double get bottomChromeFraction => peekExtent; + + @override + Future render(MapLibreMapController controller) async { + _controller = controller; + // Mounted with whatever was read last, so coming back to the layer — or a + // theme change rebuilding the style — shows the track at once instead of + // an empty map while the file is read again. + await _enqueue(() => _mount(controller)); + // Not awaited. `render` runs on the scaffold's serial lane, and reading a + // long history can take seconds on a slow phone: a layer switch or a + // base-map toggle queued behind it would wait that long. The read draws + // through this layer's own lane when it lands; after a style rebuild it + // finds the file unchanged and reads nothing. + unawaited(_load(frame: !_framed, onlyIfChanged: true)); + } + + /// The most recent load, finished or not — for a test to wait on. + @visibleForTesting + Future get pending => _pending; + Future _pending = Future.value(); + + /// Picks another window and fits the camera to it — a deliberate choice of + /// what to look at, unlike a background refresh. + Future selectRange(TrackRange next) async { + if (range.value == next) return; + range.value = next; + await _load(frame: true); + } + + /// Re-reads the file when something in it may have changed: new fixes + /// recorded while the app was in the background, or the day rolling over + /// under "today". A refresh that would read the same thing reads nothing. + Future refresh() => _load(frame: false, onlyIfChanged: true); + + /// Deletes the whole history (after the sheet's confirmation), redraws, and + /// answers whether the file really is empty now. + /// + /// Through [BackgroundLocationService.clearTrack], never a file delete from + /// here: the native recorder holds the database open, and only it can empty + /// the file without leaving an unlinked inode it keeps writing into. That + /// call logs a failure rather than throwing, so the answer comes from + /// re-reading the file — "cleared" is said only when it is true. + Future clearTrack() async { + busy.value = true; + try { + await _backgroundLocation.clearTrack(); + } finally { + _loadedSignature = null; + await _load(frame: false); + } + return !(snapshot.value?.recorded ?? false); + } + + /// The map band the sheet leaves visible, for the camera fit. The sheet + /// measures it, because only the sheet knows how much it covers. + void rememberFrameMetrics({ + required Size viewport, + required double topInset, + required double bottomInset, + }) { + _frameViewport = viewport; + _frameTopInset = topInset; + _frameBottomInset = bottomInset; + if (_wantFrame) _frame(); + } + + Future _load({required bool frame, bool onlyIfChanged = false}) => + _pending = _read(frame: frame, onlyIfChanged: onlyIfChanged); + + Future _read({required bool frame, required bool onlyIfChanged}) async { + final generation = ++_generation; + busy.value = true; + try { + final track = await _ensureTrack(); + if (generation != _generation) return; + final current = range.value; + if (track == null) { + _loadedSignature = null; + await _publish(LocationTrackSnapshot(recorded: false, range: current)); + return; + } + final stats = await track.stats(); + if (generation != _generation) return; + // The window is compared in the units the file is stamped in: the + // native writers stamp each fix with the device clock, so the calibrated + // start is moved by the device's known offset before it is compared. + final from = trackRangeStart( + current, + _now(), + ).subtract(AppTime.calibratedTimeOffset); + final signature = ( + current, + stats.fixes, + stats.bytes, + stats.oldest, + // An hour's resolution: a rolling window that moved by minutes is not + // worth re-reading a year of history for. + from.millisecondsSinceEpoch ~/ Duration.millisecondsPerHour, + ); + if (onlyIfChanged && signature == _loadedSignature) return; + if (stats.fixes == 0) { + _loadedSignature = signature; + await _publish(LocationTrackSnapshot(recorded: false, range: current)); + return; + } + final digest = await _readDigest(track, from); + if (generation != _generation) return; + _loadedSignature = signature; + await _publish( + LocationTrackSnapshot( + recorded: true, + range: current, + fixes: stats.fixes, + bytes: stats.bytes, + oldest: stats.oldest, + digest: digest, + ), + ); + if (frame && !digest.isEmpty) { + _framed = true; + _wantFrame = true; + _frame(); + } + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'location track layer load'); + } finally { + if (generation == _generation) busy.value = false; + } + } + + /// The open track, opening it on first use. Two loads that both find it + /// closed share one open rather than leaving a second connection behind. + /// Absent is not cached: the file appears the first time the native side + /// records a fix, and the next load should find it. + Future _ensureTrack() async { + final open = _track; + if (open != null) return open; + final opening = _opening ??= _open(); + try { + return _track = await opening; + } finally { + _opening = null; + } + } + + Future? _opening; + + Future _publish(LocationTrackSnapshot next) async { + snapshot.value = next; + final controller = _controller; + if (controller == null) return; + await _enqueue(() => _draw(controller, next.digest)); + } + + Future _mount(MapLibreMapController controller) async { + await _removeFromMap(controller); + await controller.addSource( + _sourceId, + GeojsonSourceProperties(data: trackGeoJson(snapshot.value?.digest)), + ); + await controller.addLineLayer( + _sourceId, + _lineId, + LineLayerProperties( + lineColor: _ramp(_oldColor, _newColor), + lineOpacity: _ramp(_oldOpacity, _newOpacity), + lineWidth: const [ + Expressions.interpolate, + ['linear'], + [Expressions.zoom], + 6, + 2.0, + 12, + 3.5, + 16, + 5.0, + ], + lineCap: 'round', + lineJoin: 'round', + // Newer strokes on top, so today's route is never hidden under the + // faded one from last month that runs along the same street. + lineSortKey: [Expressions.get, 'r'], + ), + filter: _kindIs('line'), + enableInteraction: false, + ); + await controller.addCircleLayer( + _sourceId, + _dotId, + CircleLayerProperties( + circleRadius: 3.0, + circleColor: _ramp(_oldColor, _newColor), + circleOpacity: _ramp(_oldOpacity, _newOpacity), + ), + filter: _kindIs('dot'), + enableInteraction: false, + ); + await controller.addCircleLayer( + _sourceId, + _latestId, + CircleLayerProperties( + circleRadius: 7.0, + circleColor: _newColor, + circleStrokeColor: _strokeColor, + circleStrokeWidth: 2.5, + ), + filter: _kindIs('latest'), + enableInteraction: false, + ); + _added = true; + } + + Future _draw( + MapLibreMapController controller, + TrackDigest digest, + ) async { + if (!_added || !identical(controller, _controller)) return; + try { + await controller.setGeoJsonSource(_sourceId, trackGeoJson(digest)); + } catch (error, stackTrace) { + // The style can reload underneath us; the next render re-adds. + Log.handle(error, stackTrace, 'location track draw'); + } + } + + /// Fits the camera to the window's fixes, once the sheet has said how much + /// of the map it covers. + void _frame() { + final controller = _controller; + final viewport = _frameViewport; + final bounds = snapshot.value?.digest.bounds; + if (controller == null || viewport == null || bounds == null) return; + final safe = safeFitBounds( + LatLngBounds( + southwest: LatLng(bounds.south, bounds.west), + northeast: LatLng(bounds.north, bounds.east), + ), + ); + if (safe == null) return; + final fit = boundsFitCamera( + safe, + viewport: viewport, + topInset: _frameTopInset, + bottomInset: _frameBottomInset, + minZoom: mapMinZoom, + // A track that never left one block would otherwise fit to street level + // and look like a map with nothing on it. + maxZoom: 15, + ); + if (fit == null) return; + _wantFrame = false; + unawaited( + controller + .animateCamera(CameraUpdate.newLatLngZoom(fit.target, fit.zoom)) + .catchError((Object error, StackTrace stackTrace) { + Log.handle(error, stackTrace, 'location track camera fit'); + return null; + }), + ); + } + + @override + void onSurfaceVisibility(bool visible) { + final returned = visible && !_surfaceVisible; + _surfaceVisible = visible; + // Back from the background (or another tab): the native side may have + // recorded fixes in the meantime. + if (returned && _controller != null) unawaited(refresh()); + } + + @override + Widget buildSheet(BuildContext context) => LocationTrackSheet(layer: this); + + @override + Widget buildLegend(BuildContext context) { + final l10n = AppLocalizations.of(context); + return MapLegendCard( + child: SymbolLegend( + items: [ + SymbolLegendItem( + swatch: LineSwatch( + color: const Color(0xFFEF6C00).vision, + width: 3, + opacity: _newOpacity, + ), + label: l10n.locationTrackLegendNewer, + ), + SymbolLegendItem( + swatch: LineSwatch( + color: const Color(0xFFFFB74D).vision, + width: 3, + opacity: _oldOpacity, + ), + label: l10n.locationTrackLegendOlder, + ), + SymbolLegendItem( + swatch: LegendDot( + color: const Color(0xFFEF6C00).vision, + size: 12, + borderWidth: 2, + ), + label: l10n.locationTrackLegendLatest, + ), + ], + ), + ); + } + + @override + Future clear(MapLibreMapController controller) async { + _generation++; + _framed = false; + _wantFrame = false; + busy.value = false; + await _enqueue(() => _removeFromMap(controller)); + _controller = null; + _loadedSignature = null; + // The connection is the pool's isolate; there is no reason to keep it + // while nobody is looking at the track. An open still in flight would + // land after this and leave one behind, so it is waited out first. + await _opening; + final track = _track; + _track = null; + await track?.close(); + } + + @override + void onStyleReset() { + _added = false; + _controller = null; + } + + Future _removeFromMap(MapLibreMapController controller) async { + if (!_added) return; + _added = false; + for (final layerId in [_latestId, _dotId, _lineId]) { + try { + await controller.removeLayer(layerId); + } catch (_) { + // Not present — the style may have been rebuilt under us. + } + } + try { + await controller.removeSource(_sourceId); + } catch (_) { + // Same. + } + } + + Future _enqueue(Future Function() op) { + final next = _ops.then((_) => op()); + _ops = next.catchError((Object error, StackTrace stackTrace) { + Log.handle(error, stackTrace, 'location track map op'); + }); + return _ops; + } + + static List _ramp(Object old, Object fresh) => [ + Expressions.interpolate, + ['linear'], + [Expressions.get, 'r'], + 0, + old, + 1, + fresh, + ]; + + static List _kindIs(String kind) => [ + Expressions.equal, + [Expressions.get, 'kind'], + kind, + ]; +} + +/// The feature collection for [digest] — strokes and lone dots grouped into +/// [LocationTrackMapLayer._shades] shades of age, plus the last position. +/// +/// `r` is the recency within the window, 0 for its oldest fix and 1 for its +/// newest; the layer's paint ramps run on it. Relative rather than absolute +/// age, so every window uses the whole ramp: "today" fades across the day and +/// "all" across the years. +@visibleForTesting +Map trackGeoJson(TrackDigest? digest) { + final features = >[]; + final first = digest?.first; + final latest = digest?.latest; + if (digest != null && first != null && latest != null) { + const shades = LocationTrackMapLayer._shades; + final start = first.time.millisecondsSinceEpoch; + final span = latest.time.millisecondsSinceEpoch - start; + int shadeOf(DateTime time) { + if (span <= 0) return shades - 1; + final r = (time.millisecondsSinceEpoch - start) / span; + return (r * shades).floor().clamp(0, shades - 1); + } + + final lines = List.generate(shades, (_) => >>[]); + for (final segment in digest.segments) { + lines[shadeOf(segment.time)].add([ + [segment.fromLongitude, segment.fromLatitude], + [segment.toLongitude, segment.toLatitude], + ]); + } + final dots = List.generate(shades, (_) => >[]); + for (final dot in digest.dots) { + dots[shadeOf(dot.time)].add([dot.longitude, dot.latitude]); + } + for (var shade = 0; shade < shades; shade++) { + // The middle of the shade's band, so the oldest band is not drawn as + // faint as a single fix at the very start of the window would be. + final r = (shade + 0.5) / shades; + if (lines[shade].isNotEmpty) { + features.add({ + 'type': 'Feature', + 'geometry': {'type': 'MultiLineString', 'coordinates': lines[shade]}, + 'properties': {'kind': 'line', 'r': r}, + }); + } + if (dots[shade].isNotEmpty) { + features.add({ + 'type': 'Feature', + 'geometry': {'type': 'MultiPoint', 'coordinates': dots[shade]}, + 'properties': {'kind': 'dot', 'r': r}, + }); + } + } + features.add({ + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [latest.longitude, latest.latitude], + }, + 'properties': {'kind': 'latest', 'r': 1}, + }); + } + return {'type': 'FeatureCollection', 'features': features}; +} diff --git a/lib/features/map/presentation/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart index 4cb97a6c1..9af1be05a 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -2,6 +2,7 @@ library; import 'package:dpip/core/geo/town_directory.dart'; +import 'package:dpip/core/platform/background_location.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/settings/default_map_layer.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; @@ -21,6 +22,7 @@ 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/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'; import 'package:dpip/features/map/presentation/layers/pressure_layer.dart'; import 'package:dpip/features/map/presentation/layers/qpesums_layer.dart'; @@ -138,6 +140,7 @@ class _MapPageState extends State { context.read(), service: context.read(), ), + LocationTrackMapLayer(context.read()), ]; @override diff --git a/lib/features/map/presentation/widgets/location_track_sheet.dart b/lib/features/map/presentation/widgets/location_track_sheet.dart new file mode 100644 index 000000000..4e2f006dd --- /dev/null +++ b/lib/features/map/presentation/widgets/location_track_sheet.dart @@ -0,0 +1,515 @@ +/// The movement-track layer's sheet: the time range, what is stored, the clear +/// action, and — always in the resting peek, never behind a drag — the fact +/// that the history exists only on this phone. +library; + +import 'dart:async'; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/geo/track_digest.dart'; +import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/core/settings/locale_config.dart'; +import 'package:dpip/core/storage/app_storage_scan.dart'; +import 'package:dpip/features/map/presentation/layers/location_track_layer.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/loading_view.dart'; +import 'package:dpip/shared/widgets/section_header.dart'; +import 'package:dpip/shared/widgets/selectable_pill.dart'; +import 'package:dpip/shared/widgets/sheet_extent.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +/// Collapsible sheet on the same peek / rest / full skeleton as the station, +/// tsunami and mesh sheets — except that its peek is measured, not declared. +/// +/// The other sheets' peeks are a grip and a line, and a fixed fraction of the +/// screen fits that on any phone. This one has to show the local-only notice +/// at rest, and the shell draws its navigation bar *over* the bottom of the map +/// (`extendBody`): a fraction that clears the bar on a tall phone leaves the +/// notice behind it on a short one, or at a larger text size, or in a language +/// that wraps to three lines. So the sheet lays the peek block out, measures +/// it, and rests exactly that high above the bar. +class LocationTrackSheet extends StatefulWidget { + const LocationTrackSheet({super.key, required this.layer}); + + final LocationTrackMapLayer layer; + + /// The peek before the first measurement — and what the scaffold frames + /// around until then. + static const double fallbackPeekExtent = 0.3; + + /// Bounds on the measured peek, so a pathological measurement (a zero-height + /// first layout, an enormous text scale) still leaves a sheet and a map. + static const double _minPeek = 0.15; + static const double _maxPeek = 0.7; + + static const double _expanded = 1; + + @override + State createState() => _LocationTrackSheetState(); +} + +class _LocationTrackSheetState extends State { + late double _peek = widget.layer.peekExtent; + late final ValueNotifier _extent = ValueNotifier(_peek); + + /// The map's height at the last layout — what the measured block is a + /// fraction of. + double _height = 0; + + @override + void dispose() { + _extent.dispose(); + super.dispose(); + } + + /// [SheetGrip]'s bar plus its margins. Outside the measured block because + /// the grip hides when the sheet reaches the top — measuring it would change + /// the peek mid-drag and remount the sheet under the finger. + static const double _gripHeight = 4 + AppSpacing.sm * 2; + + /// The peek block laid out at [blockHeight]: rest the sheet so all of it + /// clears the navigation bar. + /// + /// Only while resting at the peek. Re-measuring a dragged-open sheet would + /// remount it, and snap it shut, because its content changed underneath — + /// pills appearing after the first read, say. + void _onPeekMeasured(double blockHeight) { + if (!mounted || _height <= 0) return; + if ((_extent.value - _peek).abs() > 0.02) return; + final covered = MediaQuery.paddingOf(context).bottom; + final next = ((_gripHeight + blockHeight + covered) / _height).clamp( + LocationTrackSheet._minPeek, + LocationTrackSheet._maxPeek, + ); + // A pixel or two of drift is not worth remounting the sheet for. + if ((next - _peek).abs() < 0.01) return; + widget.layer.peekExtent = next; + setState(() => _peek = next); + // The remount below starts the sheet at the new peek; the chrome only + // hears about the extent after a drag, so seed it. + _extent.value = next; + } + + @override + Widget build(BuildContext context) { + final layer = widget.layer; + final topInset = MediaQuery.paddingOf(context).top; + final rest = (_peek + 0.3).clamp(_peek, LocationTrackSheet._expanded); + return LayoutBuilder( + builder: (context, constraints) { + // The sheet fills the map, so its constraints are the map's own size — + // what the camera fit needs, rather than the screen's. + final viewport = constraints.biggest; + _height = viewport.height.isFinite ? viewport.height : 0; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !viewport.isFinite) return; + layer.rememberFrameMetrics( + viewport: viewport, + topInset: topInset, + bottomInset: viewport.height * _peek, + ); + }); + return ExtentSheetChrome( + extent: _extent, + // Keyed on the peek so a new measurement remounts the sheet at it. + keyValue: '${LocationTrackMapLayer.layerId}-$_peek', + initial: _peek, + min: _peek, + max: LocationTrackSheet._expanded, + snapSizes: [if (rest < LocationTrackSheet._expanded) rest], + content: (context, scrollController) => + ValueListenableBuilder( + valueListenable: layer.snapshot, + builder: (context, snapshot, _) => ListView( + controller: scrollController, + padding: EdgeInsets.only( + bottom: + MediaQuery.paddingOf(context).bottom + AppSpacing.xl, + ), + children: [ + SheetGrip(extent: _extent), + _Measured( + onHeight: _onPeekMeasured, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Header(layer: layer, snapshot: snapshot), + // Kept while loading too, so the peek does not jump + // when the first read lands. + if (snapshot?.recorded ?? true) + _RangePicker(layer: layer), + const _LocalOnlyNotice(), + ], + ), + ), + ...switch (snapshot) { + null => const [ + Padding( + padding: EdgeInsets.symmetric( + vertical: AppSpacing.xl, + ), + child: LoadingView(), + ), + ], + LocationTrackSnapshot(recorded: false) => const [ + _NothingRecorded(), + ], + final recorded => [ + _Storage(layer: layer, snapshot: recorded), + ], + }, + ], + ), + ), + ); + }, + ); + } +} + +/// Reports [child]'s laid-out height after every build. +class _Measured extends StatelessWidget { + const _Measured({required this.onHeight, required this.child}); + + final ValueChanged onHeight; + final Widget child; + + @override + Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + final box = context.findRenderObject(); + if (box is RenderBox && box.hasSize) onHeight(box.size.height); + }); + return child; + } +} + +/// Title, and how much the selected window holds — or a spinner while it is +/// being read, since "all" can take a moment on a long history. +class _Header extends StatelessWidget { + const _Header({required this.layer, required this.snapshot}); + + final LocationTrackMapLayer layer; + final LocationTrackSnapshot? snapshot; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final snapshot = this.snapshot; + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.sm, + ), + child: Row( + children: [ + Icon(Icons.route_outlined, color: colors.primary), + const SizedBox(width: AppSpacing.sm), + Text(l10n.mapLayerLocationTrack, style: theme.textTheme.titleMedium), + const SizedBox(width: AppSpacing.md), + Expanded( + child: ValueListenableBuilder( + valueListenable: layer.busy, + builder: (context, busy, _) { + if (busy) { + return const Align( + alignment: Alignment.centerRight, + child: InlineLoading(size: 16), + ); + } + if (snapshot == null || !snapshot.recorded) { + return const SizedBox.shrink(); + } + final fixes = snapshot.digest.fixes; + return Text( + fixes == 0 + ? l10n.locationTrackRangeEmpty + : l10n.locationTrackRangePoints( + formatTrackCount(context, fixes), + ), + textAlign: TextAlign.end, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +/// 今天 / 7 天 / 30 天 / 全部. +class _RangePicker extends StatelessWidget { + const _RangePicker({required this.layer}); + + final LocationTrackMapLayer layer; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return ValueListenableBuilder( + valueListenable: layer.range, + builder: (context, selected, _) => Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.sm, + ), + child: Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.xs, + children: [ + for (final range in TrackRange.values) + SelectablePill( + label: switch (range) { + TrackRange.today => l10n.locationTrackRangeToday, + TrackRange.week => l10n.locationTrackRangeWeek, + TrackRange.month => l10n.locationTrackRangeMonth, + TrackRange.all => l10n.locationTrackRangeAll, + }, + selected: range == selected, + onTap: () => unawaited(layer.selectRange(range)), + ), + ], + ), + ), + ); + } +} + +/// Where the data lives, and what loses it. +/// +/// In the peek, above everything that needs a drag, and present in every state +/// — empty, loading, recorded. A movement history is the most personal thing +/// the app holds; whether it leaves the phone is not a detail to find on page +/// two, and "uninstalling deletes it" is something to know before relying on +/// it, not after. +class _LocalOnlyNotice extends StatelessWidget { + const _LocalOnlyNotice(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.sm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.phonelink_lock_outlined, size: 18, color: colors.primary), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + AppLocalizations.of(context).locationTrackLocalOnly, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } +} + +/// Nothing has been recorded: say how recording starts, and offer the way +/// there. Recording itself has no switch — it follows the background-location +/// grant. +class _NothingRecorded extends StatelessWidget { + const _NothingRecorded(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Column( + children: [ + EmptyView( + icon: Icons.route_outlined, + message: l10n.locationTrackEmpty, + ), + Text( + l10n.locationTrackEmptyBody, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.lg), + FilledButton.tonalIcon( + onPressed: () => context.pushNamed(AppRoutes.permissions), + icon: const Icon(Icons.verified_user_outlined), + label: Text(l10n.permissionsTitle), + ), + ], + ), + ); + } +} + +/// What is on disk, how much fits, and the clear action. +class _Storage extends StatelessWidget { + const _Storage({required this.layer, required this.snapshot}); + + final LocationTrackMapLayer layer; + final LocationTrackSnapshot snapshot; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final body = theme.textTheme.bodyMedium; + final note = theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ); + final dateLocale = intlDateLocale(Localizations.localeOf(context)); + final oldest = snapshot.oldest; + final latest = snapshot.digest.latest; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SectionHeader(l10n.locationTrackStorage), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.locationTrackStored( + formatTrackCount(context, snapshot.fixes), + formatBytes(snapshot.bytes), + ), + style: body, + ), + if (oldest != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + l10n.locationTrackOldest( + // Taipei wall time, like every other date in the app. + DateFormat( + 'yyyy/MM/dd', + dateLocale, + ).format(AppTime.taipei(oldest)), + ), + style: body, + ), + ], + if (latest != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + l10n.locationTrackLatest( + DateFormat( + 'yyyy/MM/dd HH:mm', + dateLocale, + ).format(AppTime.taipei(latest.time)), + ), + style: body, + ), + ], + const SizedBox(height: AppSpacing.md), + Text(l10n.locationTrackCapacity, style: note), + const SizedBox(height: AppSpacing.xs), + Text( + l10n.locationTrackGapNote( + TrackDigestBuilder.defaultGap.inHours, + ), + style: note, + ), + const SizedBox(height: AppSpacing.lg), + ValueListenableBuilder( + valueListenable: layer.busy, + builder: (context, busy, _) => OutlinedButton.icon( + onPressed: busy ? null : () => _confirmClear(context), + icon: const Icon(Icons.delete_outline), + label: Text(l10n.locationTrackClear), + style: OutlinedButton.styleFrom( + foregroundColor: colors.error, + ), + ), + ), + ], + ), + ), + ], + ); + } + + /// Confirmed first, and worded as permanent: unlike a cache there is no + /// copy anywhere to fetch it back from — that is what "only on this phone" + /// means. + Future _confirmClear(BuildContext context) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (context) { + final colors = Theme.of(context).colorScheme; + return AlertDialog( + icon: const Icon(Icons.delete_outline), + title: Text(l10n.locationTrackClear), + content: Text(l10n.locationTrackClearConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.commonCancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + ), + child: Text(l10n.locationTrackClear), + ), + ], + ); + }, + ); + if (confirmed != true || !context.mounted) return; + final messenger = ScaffoldMessenger.maybeOf(context); + // A failed clear leaves the storage section showing what is still there, + // which says so better than a message would. + if (!await layer.clearTrack()) return; + messenger + ?..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(l10n.locationTrackCleared))); + } +} + +/// [count] with the reader's digit grouping (`1,234` / `1.234`). +/// +/// `intl` has number data for fewer locales than the app ships, and asking it +/// for one it lacks throws instead of falling back — so the full tag, then the +/// language, then English. +String formatTrackCount(BuildContext context, int count) { + final locale = Localizations.localeOf(context); + final tag = locale.toString(); + final resolved = NumberFormat.localeExists(tag) + ? tag + : NumberFormat.localeExists(locale.languageCode) + ? locale.languageCode + : 'en'; + return NumberFormat.decimalPattern(resolved).format(count); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b9736eec8..ea0b5dcf7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4128,5 +4128,125 @@ "type": "int" } } + }, + "mapLayerLocationTrack": "Track", + "@mapLayerLocationTrack": { + "description": "Map layer switcher name for the on-device movement history" + }, + "mapLayerLocationTrackSubtitle": "Where this phone has been, kept on this device", + "@mapLayerLocationTrackSubtitle": { + "description": "Map layer switcher subtitle for the movement-history layer; says it stays on the device" + }, + "locationTrackRangeToday": "Today", + "@locationTrackRangeToday": { + "description": "Movement-track time range pill: since midnight today" + }, + "locationTrackRangeWeek": "7 days", + "@locationTrackRangeWeek": { + "description": "Movement-track time range pill: the last 7 days" + }, + "locationTrackRangeMonth": "30 days", + "@locationTrackRangeMonth": { + "description": "Movement-track time range pill: the last 30 days" + }, + "locationTrackRangeAll": "All", + "@locationTrackRangeAll": { + "description": "Movement-track time range pill: the whole stored history" + }, + "locationTrackRangePoints": "{count} points", + "@locationTrackRangePoints": { + "description": "How many recorded points the selected range holds; count is pre-formatted with digit grouping", + "placeholders": { + "count": { + "type": "String" + } + } + }, + "locationTrackRangeEmpty": "Nothing recorded in this period", + "@locationTrackRangeEmpty": { + "description": "Shown when the selected time range holds no recorded points" + }, + "locationTrackLocalOnly": "Stored only on this phone and never uploaded. Uninstalling the app or clearing its data deletes it.", + "@locationTrackLocalOnly": { + "description": "Always-visible notice: the movement history is local only, never uploaded, and lost on uninstall or data clear" + }, + "locationTrackEmpty": "No track yet", + "@locationTrackEmpty": { + "description": "Empty state headline: nothing has been recorded yet" + }, + "locationTrackEmptyBody": "Once background location is allowed, this phone records where it goes on its own.", + "@locationTrackEmptyBody": { + "description": "Empty state body: recording starts by itself once background location is allowed" + }, + "locationTrackStorage": "Storage", + "@locationTrackStorage": { + "description": "Section header above what the movement history takes on disk" + }, + "locationTrackStored": "{count} points stored · {size}", + "@locationTrackStored": { + "description": "Total recorded points and the file size on disk", + "placeholders": { + "count": { + "type": "String" + }, + "size": { + "type": "String" + } + } + }, + "locationTrackOldest": "Oldest point: {date}", + "@locationTrackOldest": { + "description": "Date of the oldest point still stored", + "placeholders": { + "date": { + "type": "String" + } + } + }, + "locationTrackLatest": "Latest point: {time}", + "@locationTrackLatest": { + "description": "Date and time of the newest recorded point", + "placeholders": { + "time": { + "type": "String" + } + } + }, + "locationTrackCapacity": "Up to 50 MB is kept — roughly 3 million points or more. At a typical 100–300 points a day, that is on the order of decades. When it is full, the oldest points are removed first.", + "@locationTrackCapacity": { + "description": "Approximate capacity of the 50 MB budget (about 14 bytes a point, ~3.7 million points) and oldest-first eviction; keep it approximate" + }, + "locationTrackGapNote": "Points more than {hours} hours apart are not joined by a line.", + "@locationTrackGapNote": { + "description": "Explains why the line breaks: fixes further apart in time than this are not joined", + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "locationTrackClear": "Clear track", + "@locationTrackClear": { + "description": "Button and dialog title: delete the whole on-device movement history" + }, + "locationTrackClearConfirm": "Every point recorded on this phone will be deleted. This cannot be undone.", + "@locationTrackClearConfirm": { + "description": "Confirmation dialog body: deleting the movement history cannot be undone" + }, + "locationTrackCleared": "Track cleared", + "@locationTrackCleared": { + "description": "Snackbar after the movement history was deleted" + }, + "locationTrackLegendNewer": "Newer", + "@locationTrackLegendNewer": { + "description": "Map legend row: the solid (newer) end of the track's age ramp" + }, + "locationTrackLegendOlder": "Older", + "@locationTrackLegendOlder": { + "description": "Map legend row: the faded (older) end of the track's age ramp" + }, + "locationTrackLegendLatest": "Latest position", + "@locationTrackLegendLatest": { + "description": "Map legend row: the marker on the most recent recorded position" } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index b99fdcd6b..616f292cb 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -2067,5 +2067,28 @@ "type": "int" } } - } + }, + "mapLayerLocationTrack": "Ruta", + "mapLayerLocationTrackSubtitle": "Mga napuntahan ng teleponong ito, nasa device lang", + "locationTrackRangeToday": "Ngayon", + "locationTrackRangeWeek": "7 araw", + "locationTrackRangeMonth": "30 araw", + "locationTrackRangeAll": "Lahat", + "locationTrackRangePoints": "{count} na punto", + "locationTrackRangeEmpty": "Walang naitala sa panahong ito", + "locationTrackLocalOnly": "Nakaimbak lang sa teleponong ito at hindi kailanman ina-upload. Mabubura ito kapag in-uninstall ang app o binura ang data nito.", + "locationTrackEmpty": "Wala pang ruta", + "locationTrackEmptyBody": "Kapag pinayagan ang lokasyon sa background, kusang itatala ng teleponong ito ang mga dinaraanan nito.", + "locationTrackStorage": "Storage", + "locationTrackStored": "{count} na punto ang naka-store · {size}", + "locationTrackOldest": "Pinakalumang punto: {date}", + "locationTrackLatest": "Pinakabagong punto: {time}", + "locationTrackCapacity": "Hanggang 50 MB ang itinatabi — humigit-kumulang 3 milyong punto o higit pa. Sa karaniwang 100–300 punto bawat araw, katumbas ito ng ilang dekada. Kapag puno na, ang pinakalumang mga punto ang unang buburahin.", + "locationTrackGapNote": "Hindi pinagdudugtong ng linya ang mga puntong mahigit {hours} oras ang pagitan.", + "locationTrackClear": "I-clear ang ruta", + "locationTrackClearConfirm": "Mabubura ang lahat ng rutang naitala sa teleponong ito. Hindi na ito maibabalik.", + "locationTrackCleared": "Na-clear na ang ruta", + "locationTrackLegendNewer": "Mas bago", + "locationTrackLegendOlder": "Mas luma", + "locationTrackLegendLatest": "Pinakabagong lokasyon" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 82e4f8436..4fbcbeb86 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -2067,5 +2067,28 @@ "type": "int" } } - } + }, + "mapLayerLocationTrack": "Jejak", + "mapLayerLocationTrackSubtitle": "Tempat yang pernah dikunjungi ponsel ini, hanya disimpan di perangkat", + "locationTrackRangeToday": "Hari ini", + "locationTrackRangeWeek": "7 hari", + "locationTrackRangeMonth": "30 hari", + "locationTrackRangeAll": "Semua", + "locationTrackRangePoints": "{count} titik", + "locationTrackRangeEmpty": "Tidak ada catatan pada periode ini", + "locationTrackLocalOnly": "Hanya disimpan di ponsel ini dan tidak pernah diunggah. Data ini hilang jika aplikasi dihapus atau datanya dibersihkan.", + "locationTrackEmpty": "Belum ada jejak", + "locationTrackEmptyBody": "Setelah lokasi latar belakang diizinkan, ponsel ini akan mencatat perjalanannya secara otomatis.", + "locationTrackStorage": "Penyimpanan", + "locationTrackStored": "{count} titik tersimpan · {size}", + "locationTrackOldest": "Titik terlama: {date}", + "locationTrackLatest": "Titik terbaru: {time}", + "locationTrackCapacity": "Disimpan hingga 50 MB — sekitar 3 juta titik atau lebih. Dengan rata-rata 100–300 titik per hari, itu setara dengan puluhan tahun. Jika penuh, titik terlama dihapus lebih dulu.", + "locationTrackGapNote": "Titik yang berjarak lebih dari {hours} jam tidak dihubungkan dengan garis.", + "locationTrackClear": "Hapus jejak", + "locationTrackClearConfirm": "Semua jejak yang tercatat di ponsel ini akan dihapus dan tidak dapat dibatalkan.", + "locationTrackCleared": "Jejak dihapus", + "locationTrackLegendNewer": "Lebih baru", + "locationTrackLegendOlder": "Lebih lama", + "locationTrackLegendLatest": "Posisi terakhir" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d59546ebd..cffb2e617 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2067,5 +2067,28 @@ "type": "int" } } - } + }, + "mapLayerLocationTrack": "移動履歴", + "mapLayerLocationTrackSubtitle": "この端末の移動記録(端末内にのみ保存)", + "locationTrackRangeToday": "今日", + "locationTrackRangeWeek": "7日間", + "locationTrackRangeMonth": "30日間", + "locationTrackRangeAll": "すべて", + "locationTrackRangePoints": "{count} 地点", + "locationTrackRangeEmpty": "この期間の記録はありません", + "locationTrackLocalOnly": "この端末にのみ保存され、アップロードされることはありません。アプリを削除するか、アプリのデータを消去すると失われます。", + "locationTrackEmpty": "まだ記録がありません", + "locationTrackEmptyBody": "バックグラウンドでの位置情報を許可すると、この端末の移動が自動的に記録されます。", + "locationTrackStorage": "ストレージ", + "locationTrackStored": "保存済み {count} 地点 · {size}", + "locationTrackOldest": "最も古い地点:{date}", + "locationTrackLatest": "最新の地点:{time}", + "locationTrackCapacity": "上限は 50 MB で、およそ 300 万地点以上を保存できます。1 日 100〜300 地点の一般的なペースなら、数十年分が目安です。上限に達すると古い地点から削除されます。", + "locationTrackGapNote": "{hours} 時間以上離れた地点は線で結びません。", + "locationTrackClear": "履歴を消去", + "locationTrackClearConfirm": "この端末に記録されたすべての移動履歴が削除されます。元に戻すことはできません。", + "locationTrackCleared": "履歴を消去しました", + "locationTrackLegendNewer": "新しい", + "locationTrackLegendOlder": "古い", + "locationTrackLegendLatest": "最新の位置" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 6bc7e56a8..ddc1ce01e 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -2067,5 +2067,28 @@ "type": "int" } } - } + }, + "mapLayerLocationTrack": "이동 경로", + "mapLayerLocationTrackSubtitle": "이 기기가 다녀온 곳, 기기에만 저장", + "locationTrackRangeToday": "오늘", + "locationTrackRangeWeek": "7일", + "locationTrackRangeMonth": "30일", + "locationTrackRangeAll": "전체", + "locationTrackRangePoints": "{count}개 지점", + "locationTrackRangeEmpty": "이 기간에는 기록이 없습니다", + "locationTrackLocalOnly": "이 기기에만 저장되며 업로드되지 않습니다. 앱을 삭제하거나 앱 데이터를 지우면 사라집니다.", + "locationTrackEmpty": "아직 이동 경로가 없습니다", + "locationTrackEmptyBody": "백그라운드 위치를 허용하면 이 기기의 이동 경로가 자동으로 기록됩니다.", + "locationTrackStorage": "저장 공간", + "locationTrackStored": "저장된 지점 {count}개 · {size}", + "locationTrackOldest": "가장 오래된 지점: {date}", + "locationTrackLatest": "가장 최근 지점: {time}", + "locationTrackCapacity": "최대 50MB까지 보관하며, 약 300만 개 이상의 지점을 저장할 수 있습니다. 하루 100~300개 정도라면 대략 수십 년 분량입니다. 가득 차면 가장 오래된 지점부터 삭제됩니다.", + "locationTrackGapNote": "{hours}시간 넘게 떨어진 지점은 선으로 잇지 않습니다.", + "locationTrackClear": "이동 경로 지우기", + "locationTrackClearConfirm": "이 기기에 기록된 모든 이동 경로가 삭제되며 되돌릴 수 없습니다.", + "locationTrackCleared": "이동 경로를 지웠습니다", + "locationTrackLegendNewer": "최근", + "locationTrackLegendOlder": "이전", + "locationTrackLegendLatest": "마지막 위치" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index c88714726..52db18301 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -2067,5 +2067,28 @@ "type": "int" } } - } + }, + "mapLayerLocationTrack": "เส้นทาง", + "mapLayerLocationTrackSubtitle": "สถานที่ที่โทรศัพท์เครื่องนี้เคยไป เก็บไว้ในเครื่องเท่านั้น", + "locationTrackRangeToday": "วันนี้", + "locationTrackRangeWeek": "7 วัน", + "locationTrackRangeMonth": "30 วัน", + "locationTrackRangeAll": "ทั้งหมด", + "locationTrackRangePoints": "{count} จุด", + "locationTrackRangeEmpty": "ไม่มีบันทึกในช่วงเวลานี้", + "locationTrackLocalOnly": "เก็บไว้ในโทรศัพท์เครื่องนี้เท่านั้นและจะไม่ถูกอัปโหลด หากถอนการติดตั้งแอปหรือล้างข้อมูลแอป ข้อมูลจะหายไป", + "locationTrackEmpty": "ยังไม่มีเส้นทาง", + "locationTrackEmptyBody": "เมื่ออนุญาตตำแหน่งในเบื้องหลังแล้ว โทรศัพท์เครื่องนี้จะบันทึกเส้นทางการเดินทางโดยอัตโนมัติ", + "locationTrackStorage": "พื้นที่จัดเก็บ", + "locationTrackStored": "จัดเก็บแล้ว {count} จุด · {size}", + "locationTrackOldest": "จุดเก่าที่สุด: {date}", + "locationTrackLatest": "จุดล่าสุด: {time}", + "locationTrackCapacity": "เก็บได้สูงสุด 50 MB หรือประมาณ 3 ล้านจุดขึ้นไป หากบันทึกวันละ 100–300 จุดตามปกติ จะเก็บได้ราวหลายสิบปี เมื่อเต็มแล้วจุดที่เก่าที่สุดจะถูกลบก่อน", + "locationTrackGapNote": "จุดที่ห่างกันเกิน {hours} ชั่วโมงจะไม่ถูกลากเส้นเชื่อม", + "locationTrackClear": "ล้างเส้นทาง", + "locationTrackClearConfirm": "เส้นทางทั้งหมดที่บันทึกไว้ในโทรศัพท์เครื่องนี้จะถูกลบ และไม่สามารถกู้คืนได้", + "locationTrackCleared": "ล้างเส้นทางแล้ว", + "locationTrackLegendNewer": "ใหม่กว่า", + "locationTrackLegendOlder": "เก่ากว่า", + "locationTrackLegendLatest": "ตำแหน่งล่าสุด" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 7bb7e82cf..79e5a3591 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -2067,5 +2067,28 @@ "type": "int" } } - } + }, + "mapLayerLocationTrack": "Lộ trình", + "mapLayerLocationTrackSubtitle": "Những nơi điện thoại này đã đi qua, chỉ lưu trên máy", + "locationTrackRangeToday": "Hôm nay", + "locationTrackRangeWeek": "7 ngày", + "locationTrackRangeMonth": "30 ngày", + "locationTrackRangeAll": "Tất cả", + "locationTrackRangePoints": "{count} điểm", + "locationTrackRangeEmpty": "Không có dữ liệu trong khoảng thời gian này", + "locationTrackLocalOnly": "Chỉ được lưu trên điện thoại này và không bao giờ được tải lên. Gỡ cài đặt ứng dụng hoặc xóa dữ liệu ứng dụng sẽ làm mất dữ liệu này.", + "locationTrackEmpty": "Chưa có lộ trình", + "locationTrackEmptyBody": "Khi bạn cho phép vị trí chạy nền, điện thoại này sẽ tự động ghi lại lộ trình di chuyển.", + "locationTrackStorage": "Bộ nhớ", + "locationTrackStored": "Đã lưu {count} điểm · {size}", + "locationTrackOldest": "Điểm cũ nhất: {date}", + "locationTrackLatest": "Điểm mới nhất: {time}", + "locationTrackCapacity": "Lưu tối đa 50 MB, tức khoảng 3 triệu điểm trở lên. Với mức thông thường 100–300 điểm mỗi ngày, con số này tương đương vài chục năm. Khi đầy, các điểm cũ nhất sẽ bị xóa trước.", + "locationTrackGapNote": "Các điểm cách nhau hơn {hours} giờ sẽ không được nối bằng đường.", + "locationTrackClear": "Xóa lộ trình", + "locationTrackClearConfirm": "Toàn bộ lộ trình được ghi trên điện thoại này sẽ bị xóa và không thể hoàn tác.", + "locationTrackCleared": "Đã xóa lộ trình", + "locationTrackLegendNewer": "Mới hơn", + "locationTrackLegendOlder": "Cũ hơn", + "locationTrackLegendLatest": "Vị trí mới nhất" } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index fa5ede9c7..b30f955d0 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -2067,5 +2067,28 @@ "type": "int" } } - } + }, + "mapLayerLocationTrack": "軌跡", + "mapLayerLocationTrackSubtitle": "呢部手機去過嘅地方,只存喺本機", + "locationTrackRangeToday": "今日", + "locationTrackRangeWeek": "7 日", + "locationTrackRangeMonth": "30 日", + "locationTrackRangeAll": "全部", + "locationTrackRangePoints": "{count} 個點", + "locationTrackRangeEmpty": "呢段期間冇紀錄", + "locationTrackLocalOnly": "只會存喺呢部手機,唔會上載。解除安裝 App 或者清除 App 資料之後就會消失。", + "locationTrackEmpty": "仲未有軌跡", + "locationTrackEmptyBody": "允許背景位置之後,呢部手機會自動記錄移動軌跡。", + "locationTrackStorage": "儲存空間", + "locationTrackStored": "已儲存 {count} 個點 · {size}", + "locationTrackOldest": "最早一點:{date}", + "locationTrackLatest": "最新一點:{time}", + "locationTrackCapacity": "最多保留 50 MB,大約可以存 300 萬個點以上;以一般每日 100–300 個點估算,大約係幾十年嘅紀錄。存滿之後會先刪除最舊嘅點。", + "locationTrackGapNote": "相隔超過 {hours} 個鐘嘅點唔會連線。", + "locationTrackClear": "清除軌跡", + "locationTrackClearConfirm": "呢部手機記錄嘅所有軌跡都會刪除,而且無法復原。", + "locationTrackCleared": "已清除軌跡", + "locationTrackLegendNewer": "較新", + "locationTrackLegendOlder": "較舊", + "locationTrackLegendLatest": "最新位置" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bfc77482e..1e92b8f43 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2059,5 +2059,28 @@ "type": "int" } } - } + }, + "mapLayerLocationTrack": "軌跡", + "mapLayerLocationTrackSubtitle": "這支手機去過的地方,只存在本機", + "locationTrackRangeToday": "今天", + "locationTrackRangeWeek": "7 天", + "locationTrackRangeMonth": "30 天", + "locationTrackRangeAll": "全部", + "locationTrackRangePoints": "{count} 個點", + "locationTrackRangeEmpty": "這段期間沒有紀錄", + "locationTrackLocalOnly": "只存在這支手機上,不會上傳。解除安裝 App 或清除 App 資料後就會消失。", + "locationTrackEmpty": "還沒有軌跡", + "locationTrackEmptyBody": "允許背景位置後,這支手機會自動記錄移動軌跡。", + "locationTrackStorage": "儲存空間", + "locationTrackStored": "已儲存 {count} 個點 · {size}", + "locationTrackOldest": "最早一點:{date}", + "locationTrackLatest": "最新一點:{time}", + "locationTrackCapacity": "最多保留 50 MB,約可存 300 萬個點以上;以一般每天 100–300 個點估算,大約是數十年的紀錄。存滿後會先刪除最舊的點。", + "locationTrackGapNote": "相隔超過 {hours} 小時的點不會連線。", + "locationTrackClear": "清除軌跡", + "locationTrackClearConfirm": "這支手機上記錄的所有軌跡都會刪除,而且無法復原。", + "locationTrackCleared": "已清除軌跡", + "locationTrackLegendNewer": "較新", + "locationTrackLegendOlder": "較舊", + "locationTrackLegendLatest": "最新位置" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 78a171fb5..971839312 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -2067,5 +2067,28 @@ "type": "int" } } - } + }, + "mapLayerLocationTrack": "轨迹", + "mapLayerLocationTrackSubtitle": "这部手机去过的地方,只存在本机", + "locationTrackRangeToday": "今天", + "locationTrackRangeWeek": "7 天", + "locationTrackRangeMonth": "30 天", + "locationTrackRangeAll": "全部", + "locationTrackRangePoints": "{count} 个点", + "locationTrackRangeEmpty": "这段期间没有记录", + "locationTrackLocalOnly": "只存在这部手机上,不会上传。卸载 App 或清除 App 数据后就会消失。", + "locationTrackEmpty": "还没有轨迹", + "locationTrackEmptyBody": "允许后台定位后,这部手机会自动记录移动轨迹。", + "locationTrackStorage": "存储空间", + "locationTrackStored": "已存储 {count} 个点 · {size}", + "locationTrackOldest": "最早一点:{date}", + "locationTrackLatest": "最新一点:{time}", + "locationTrackCapacity": "最多保留 50 MB,约可存 300 万个点以上;按一般每天 100–300 个点估算,大约是数十年的记录。存满后会先删除最旧的点。", + "locationTrackGapNote": "相隔超过 {hours} 小时的点不会连线。", + "locationTrackClear": "清除轨迹", + "locationTrackClearConfirm": "这部手机上记录的所有轨迹都会被删除,且无法恢复。", + "locationTrackCleared": "已清除轨迹", + "locationTrackLegendNewer": "较新", + "locationTrackLegendOlder": "较旧", + "locationTrackLegendLatest": "最新位置" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 868d497df..56bfbb5c3 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -2067,5 +2067,28 @@ "type": "int" } } - } + }, + "mapLayerLocationTrack": "軌跡", + "mapLayerLocationTrackSubtitle": "這部手機去過的地方,只存在本機", + "locationTrackRangeToday": "今日", + "locationTrackRangeWeek": "7 日", + "locationTrackRangeMonth": "30 日", + "locationTrackRangeAll": "全部", + "locationTrackRangePoints": "{count} 個點", + "locationTrackRangeEmpty": "這段期間沒有紀錄", + "locationTrackLocalOnly": "只會儲存在這部手機上,不會上載。解除安裝 App 或清除 App 資料後就會消失。", + "locationTrackEmpty": "還沒有軌跡", + "locationTrackEmptyBody": "允許背景位置後,這部手機會自動記錄移動軌跡。", + "locationTrackStorage": "儲存空間", + "locationTrackStored": "已儲存 {count} 個點 · {size}", + "locationTrackOldest": "最早一點:{date}", + "locationTrackLatest": "最新一點:{time}", + "locationTrackCapacity": "最多保留 50 MB,約可存 300 萬個點以上;以一般每日 100–300 個點估算,大約是數十年的紀錄。存滿後會先刪除最舊的點。", + "locationTrackGapNote": "相隔超過 {hours} 小時的點不會連線。", + "locationTrackClear": "清除軌跡", + "locationTrackClearConfirm": "這部手機上記錄的所有軌跡都會刪除,而且無法復原。", + "locationTrackCleared": "已清除軌跡", + "locationTrackLegendNewer": "較新", + "locationTrackLegendOlder": "較舊", + "locationTrackLegendLatest": "最新位置" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 9bde57cce..5b1bd9d48 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -2067,5 +2067,28 @@ "type": "int" } } - } + }, + "mapLayerLocationTrack": "軌跡", + "mapLayerLocationTrackSubtitle": "這支手機去過的地方,只存在本機", + "locationTrackRangeToday": "今天", + "locationTrackRangeWeek": "7 天", + "locationTrackRangeMonth": "30 天", + "locationTrackRangeAll": "全部", + "locationTrackRangePoints": "{count} 個點", + "locationTrackRangeEmpty": "這段期間沒有紀錄", + "locationTrackLocalOnly": "只存在這支手機上,不會上傳。解除安裝 App 或清除 App 資料後就會消失。", + "locationTrackEmpty": "還沒有軌跡", + "locationTrackEmptyBody": "允許背景位置後,這支手機會自動記錄移動軌跡。", + "locationTrackStorage": "儲存空間", + "locationTrackStored": "已儲存 {count} 個點 · {size}", + "locationTrackOldest": "最早一點:{date}", + "locationTrackLatest": "最新一點:{time}", + "locationTrackCapacity": "最多保留 50 MB,約可存 300 萬個點以上;以一般每天 100–300 個點估算,大約是數十年的紀錄。存滿後會先刪除最舊的點。", + "locationTrackGapNote": "相隔超過 {hours} 小時的點不會連線。", + "locationTrackClear": "清除軌跡", + "locationTrackClearConfirm": "這支手機上記錄的所有軌跡都會刪除,而且無法復原。", + "locationTrackCleared": "已清除軌跡", + "locationTrackLegendNewer": "較新", + "locationTrackLegendOlder": "較舊", + "locationTrackLegendLatest": "最新位置" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index be23c3257..59b5f2dc8 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6509,6 +6509,144 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'{cm} cm'** String tsunamiWaveHeightCm(int cm); + + /// Map layer switcher name for the on-device movement history + /// + /// In en, this message translates to: + /// **'Track'** + String get mapLayerLocationTrack; + + /// Map layer switcher subtitle for the movement-history layer; says it stays on the device + /// + /// In en, this message translates to: + /// **'Where this phone has been, kept on this device'** + String get mapLayerLocationTrackSubtitle; + + /// Movement-track time range pill: since midnight today + /// + /// In en, this message translates to: + /// **'Today'** + String get locationTrackRangeToday; + + /// Movement-track time range pill: the last 7 days + /// + /// In en, this message translates to: + /// **'7 days'** + String get locationTrackRangeWeek; + + /// Movement-track time range pill: the last 30 days + /// + /// In en, this message translates to: + /// **'30 days'** + String get locationTrackRangeMonth; + + /// Movement-track time range pill: the whole stored history + /// + /// In en, this message translates to: + /// **'All'** + String get locationTrackRangeAll; + + /// How many recorded points the selected range holds; count is pre-formatted with digit grouping + /// + /// In en, this message translates to: + /// **'{count} points'** + String locationTrackRangePoints(String count); + + /// Shown when the selected time range holds no recorded points + /// + /// In en, this message translates to: + /// **'Nothing recorded in this period'** + String get locationTrackRangeEmpty; + + /// Always-visible notice: the movement history is local only, never uploaded, and lost on uninstall or data clear + /// + /// In en, this message translates to: + /// **'Stored only on this phone and never uploaded. Uninstalling the app or clearing its data deletes it.'** + String get locationTrackLocalOnly; + + /// Empty state headline: nothing has been recorded yet + /// + /// In en, this message translates to: + /// **'No track yet'** + String get locationTrackEmpty; + + /// Empty state body: recording starts by itself once background location is allowed + /// + /// In en, this message translates to: + /// **'Once background location is allowed, this phone records where it goes on its own.'** + String get locationTrackEmptyBody; + + /// Section header above what the movement history takes on disk + /// + /// In en, this message translates to: + /// **'Storage'** + String get locationTrackStorage; + + /// Total recorded points and the file size on disk + /// + /// In en, this message translates to: + /// **'{count} points stored · {size}'** + String locationTrackStored(String count, String size); + + /// Date of the oldest point still stored + /// + /// In en, this message translates to: + /// **'Oldest point: {date}'** + String locationTrackOldest(String date); + + /// Date and time of the newest recorded point + /// + /// In en, this message translates to: + /// **'Latest point: {time}'** + String locationTrackLatest(String time); + + /// Approximate capacity of the 50 MB budget (about 14 bytes a point, ~3.7 million points) and oldest-first eviction; keep it approximate + /// + /// In en, this message translates to: + /// **'Up to 50 MB is kept — roughly 3 million points or more. At a typical 100–300 points a day, that is on the order of decades. When it is full, the oldest points are removed first.'** + String get locationTrackCapacity; + + /// Explains why the line breaks: fixes further apart in time than this are not joined + /// + /// In en, this message translates to: + /// **'Points more than {hours} hours apart are not joined by a line.'** + String locationTrackGapNote(int hours); + + /// Button and dialog title: delete the whole on-device movement history + /// + /// In en, this message translates to: + /// **'Clear track'** + String get locationTrackClear; + + /// Confirmation dialog body: deleting the movement history cannot be undone + /// + /// In en, this message translates to: + /// **'Every point recorded on this phone will be deleted. This cannot be undone.'** + String get locationTrackClearConfirm; + + /// Snackbar after the movement history was deleted + /// + /// In en, this message translates to: + /// **'Track cleared'** + String get locationTrackCleared; + + /// Map legend row: the solid (newer) end of the track's age ramp + /// + /// In en, this message translates to: + /// **'Newer'** + String get locationTrackLegendNewer; + + /// Map legend row: the faded (older) end of the track's age ramp + /// + /// In en, this message translates to: + /// **'Older'** + String get locationTrackLegendOlder; + + /// Map legend row: the marker on the most recent recorded position + /// + /// In en, this message translates to: + /// **'Latest position'** + String get locationTrackLegendLatest; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 345d71a6e..4fb7df436 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3433,4 +3433,88 @@ class AppLocalizationsEn extends AppLocalizations { String tsunamiWaveHeightCm(int cm) { return '$cm cm'; } + + @override + String get mapLayerLocationTrack => 'Track'; + + @override + String get mapLayerLocationTrackSubtitle => + 'Where this phone has been, kept on this device'; + + @override + String get locationTrackRangeToday => 'Today'; + + @override + String get locationTrackRangeWeek => '7 days'; + + @override + String get locationTrackRangeMonth => '30 days'; + + @override + String get locationTrackRangeAll => 'All'; + + @override + String locationTrackRangePoints(String count) { + return '$count points'; + } + + @override + String get locationTrackRangeEmpty => 'Nothing recorded in this period'; + + @override + String get locationTrackLocalOnly => + 'Stored only on this phone and never uploaded. Uninstalling the app or clearing its data deletes it.'; + + @override + String get locationTrackEmpty => 'No track yet'; + + @override + String get locationTrackEmptyBody => + 'Once background location is allowed, this phone records where it goes on its own.'; + + @override + String get locationTrackStorage => 'Storage'; + + @override + String locationTrackStored(String count, String size) { + return '$count points stored · $size'; + } + + @override + String locationTrackOldest(String date) { + return 'Oldest point: $date'; + } + + @override + String locationTrackLatest(String time) { + return 'Latest point: $time'; + } + + @override + String get locationTrackCapacity => + 'Up to 50 MB is kept — roughly 3 million points or more. At a typical 100–300 points a day, that is on the order of decades. When it is full, the oldest points are removed first.'; + + @override + String locationTrackGapNote(int hours) { + return 'Points more than $hours hours apart are not joined by a line.'; + } + + @override + String get locationTrackClear => 'Clear track'; + + @override + String get locationTrackClearConfirm => + 'Every point recorded on this phone will be deleted. This cannot be undone.'; + + @override + String get locationTrackCleared => 'Track cleared'; + + @override + String get locationTrackLegendNewer => 'Newer'; + + @override + String get locationTrackLegendOlder => 'Older'; + + @override + String get locationTrackLegendLatest => 'Latest position'; } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 62a2a3afe..5e9a92bf9 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3454,4 +3454,88 @@ class AppLocalizationsFil extends AppLocalizations { String tsunamiWaveHeightCm(int cm) { return '$cm cm'; } + + @override + String get mapLayerLocationTrack => 'Ruta'; + + @override + String get mapLayerLocationTrackSubtitle => + 'Mga napuntahan ng teleponong ito, nasa device lang'; + + @override + String get locationTrackRangeToday => 'Ngayon'; + + @override + String get locationTrackRangeWeek => '7 araw'; + + @override + String get locationTrackRangeMonth => '30 araw'; + + @override + String get locationTrackRangeAll => 'Lahat'; + + @override + String locationTrackRangePoints(String count) { + return '$count na punto'; + } + + @override + String get locationTrackRangeEmpty => 'Walang naitala sa panahong ito'; + + @override + String get locationTrackLocalOnly => + 'Nakaimbak lang sa teleponong ito at hindi kailanman ina-upload. Mabubura ito kapag in-uninstall ang app o binura ang data nito.'; + + @override + String get locationTrackEmpty => 'Wala pang ruta'; + + @override + String get locationTrackEmptyBody => + 'Kapag pinayagan ang lokasyon sa background, kusang itatala ng teleponong ito ang mga dinaraanan nito.'; + + @override + String get locationTrackStorage => 'Storage'; + + @override + String locationTrackStored(String count, String size) { + return '$count na punto ang naka-store · $size'; + } + + @override + String locationTrackOldest(String date) { + return 'Pinakalumang punto: $date'; + } + + @override + String locationTrackLatest(String time) { + return 'Pinakabagong punto: $time'; + } + + @override + String get locationTrackCapacity => + 'Hanggang 50 MB ang itinatabi — humigit-kumulang 3 milyong punto o higit pa. Sa karaniwang 100–300 punto bawat araw, katumbas ito ng ilang dekada. Kapag puno na, ang pinakalumang mga punto ang unang buburahin.'; + + @override + String locationTrackGapNote(int hours) { + return 'Hindi pinagdudugtong ng linya ang mga puntong mahigit $hours oras ang pagitan.'; + } + + @override + String get locationTrackClear => 'I-clear ang ruta'; + + @override + String get locationTrackClearConfirm => + 'Mabubura ang lahat ng rutang naitala sa teleponong ito. Hindi na ito maibabalik.'; + + @override + String get locationTrackCleared => 'Na-clear na ang ruta'; + + @override + String get locationTrackLegendNewer => 'Mas bago'; + + @override + String get locationTrackLegendOlder => 'Mas luma'; + + @override + String get locationTrackLegendLatest => 'Pinakabagong lokasyon'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index b82910956..fc2457e88 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3447,4 +3447,88 @@ class AppLocalizationsId extends AppLocalizations { String tsunamiWaveHeightCm(int cm) { return '$cm cm'; } + + @override + String get mapLayerLocationTrack => 'Jejak'; + + @override + String get mapLayerLocationTrackSubtitle => + 'Tempat yang pernah dikunjungi ponsel ini, hanya disimpan di perangkat'; + + @override + String get locationTrackRangeToday => 'Hari ini'; + + @override + String get locationTrackRangeWeek => '7 hari'; + + @override + String get locationTrackRangeMonth => '30 hari'; + + @override + String get locationTrackRangeAll => 'Semua'; + + @override + String locationTrackRangePoints(String count) { + return '$count titik'; + } + + @override + String get locationTrackRangeEmpty => 'Tidak ada catatan pada periode ini'; + + @override + String get locationTrackLocalOnly => + 'Hanya disimpan di ponsel ini dan tidak pernah diunggah. Data ini hilang jika aplikasi dihapus atau datanya dibersihkan.'; + + @override + String get locationTrackEmpty => 'Belum ada jejak'; + + @override + String get locationTrackEmptyBody => + 'Setelah lokasi latar belakang diizinkan, ponsel ini akan mencatat perjalanannya secara otomatis.'; + + @override + String get locationTrackStorage => 'Penyimpanan'; + + @override + String locationTrackStored(String count, String size) { + return '$count titik tersimpan · $size'; + } + + @override + String locationTrackOldest(String date) { + return 'Titik terlama: $date'; + } + + @override + String locationTrackLatest(String time) { + return 'Titik terbaru: $time'; + } + + @override + String get locationTrackCapacity => + 'Disimpan hingga 50 MB — sekitar 3 juta titik atau lebih. Dengan rata-rata 100–300 titik per hari, itu setara dengan puluhan tahun. Jika penuh, titik terlama dihapus lebih dulu.'; + + @override + String locationTrackGapNote(int hours) { + return 'Titik yang berjarak lebih dari $hours jam tidak dihubungkan dengan garis.'; + } + + @override + String get locationTrackClear => 'Hapus jejak'; + + @override + String get locationTrackClearConfirm => + 'Semua jejak yang tercatat di ponsel ini akan dihapus dan tidak dapat dibatalkan.'; + + @override + String get locationTrackCleared => 'Jejak dihapus'; + + @override + String get locationTrackLegendNewer => 'Lebih baru'; + + @override + String get locationTrackLegendOlder => 'Lebih lama'; + + @override + String get locationTrackLegendLatest => 'Posisi terakhir'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 056743726..35c6e7d50 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3370,4 +3370,87 @@ class AppLocalizationsJa extends AppLocalizations { String tsunamiWaveHeightCm(int cm) { return '$cm cm'; } + + @override + String get mapLayerLocationTrack => '移動履歴'; + + @override + String get mapLayerLocationTrackSubtitle => 'この端末の移動記録(端末内にのみ保存)'; + + @override + String get locationTrackRangeToday => '今日'; + + @override + String get locationTrackRangeWeek => '7日間'; + + @override + String get locationTrackRangeMonth => '30日間'; + + @override + String get locationTrackRangeAll => 'すべて'; + + @override + String locationTrackRangePoints(String count) { + return '$count 地点'; + } + + @override + String get locationTrackRangeEmpty => 'この期間の記録はありません'; + + @override + String get locationTrackLocalOnly => + 'この端末にのみ保存され、アップロードされることはありません。アプリを削除するか、アプリのデータを消去すると失われます。'; + + @override + String get locationTrackEmpty => 'まだ記録がありません'; + + @override + String get locationTrackEmptyBody => + 'バックグラウンドでの位置情報を許可すると、この端末の移動が自動的に記録されます。'; + + @override + String get locationTrackStorage => 'ストレージ'; + + @override + String locationTrackStored(String count, String size) { + return '保存済み $count 地点 · $size'; + } + + @override + String locationTrackOldest(String date) { + return '最も古い地点:$date'; + } + + @override + String locationTrackLatest(String time) { + return '最新の地点:$time'; + } + + @override + String get locationTrackCapacity => + '上限は 50 MB で、およそ 300 万地点以上を保存できます。1 日 100〜300 地点の一般的なペースなら、数十年分が目安です。上限に達すると古い地点から削除されます。'; + + @override + String locationTrackGapNote(int hours) { + return '$hours 時間以上離れた地点は線で結びません。'; + } + + @override + String get locationTrackClear => '履歴を消去'; + + @override + String get locationTrackClearConfirm => + 'この端末に記録されたすべての移動履歴が削除されます。元に戻すことはできません。'; + + @override + String get locationTrackCleared => '履歴を消去しました'; + + @override + String get locationTrackLegendNewer => '新しい'; + + @override + String get locationTrackLegendOlder => '古い'; + + @override + String get locationTrackLegendLatest => '最新の位置'; } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index f3150cac2..1efec15e0 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3372,4 +3372,87 @@ class AppLocalizationsKo extends AppLocalizations { String tsunamiWaveHeightCm(int cm) { return '$cm cm'; } + + @override + String get mapLayerLocationTrack => '이동 경로'; + + @override + String get mapLayerLocationTrackSubtitle => '이 기기가 다녀온 곳, 기기에만 저장'; + + @override + String get locationTrackRangeToday => '오늘'; + + @override + String get locationTrackRangeWeek => '7일'; + + @override + String get locationTrackRangeMonth => '30일'; + + @override + String get locationTrackRangeAll => '전체'; + + @override + String locationTrackRangePoints(String count) { + return '$count개 지점'; + } + + @override + String get locationTrackRangeEmpty => '이 기간에는 기록이 없습니다'; + + @override + String get locationTrackLocalOnly => + '이 기기에만 저장되며 업로드되지 않습니다. 앱을 삭제하거나 앱 데이터를 지우면 사라집니다.'; + + @override + String get locationTrackEmpty => '아직 이동 경로가 없습니다'; + + @override + String get locationTrackEmptyBody => + '백그라운드 위치를 허용하면 이 기기의 이동 경로가 자동으로 기록됩니다.'; + + @override + String get locationTrackStorage => '저장 공간'; + + @override + String locationTrackStored(String count, String size) { + return '저장된 지점 $count개 · $size'; + } + + @override + String locationTrackOldest(String date) { + return '가장 오래된 지점: $date'; + } + + @override + String locationTrackLatest(String time) { + return '가장 최근 지점: $time'; + } + + @override + String get locationTrackCapacity => + '최대 50MB까지 보관하며, 약 300만 개 이상의 지점을 저장할 수 있습니다. 하루 100~300개 정도라면 대략 수십 년 분량입니다. 가득 차면 가장 오래된 지점부터 삭제됩니다.'; + + @override + String locationTrackGapNote(int hours) { + return '$hours시간 넘게 떨어진 지점은 선으로 잇지 않습니다.'; + } + + @override + String get locationTrackClear => '이동 경로 지우기'; + + @override + String get locationTrackClearConfirm => + '이 기기에 기록된 모든 이동 경로가 삭제되며 되돌릴 수 없습니다.'; + + @override + String get locationTrackCleared => '이동 경로를 지웠습니다'; + + @override + String get locationTrackLegendNewer => '최근'; + + @override + String get locationTrackLegendOlder => '이전'; + + @override + String get locationTrackLegendLatest => '마지막 위치'; } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index e0bf25627..0bac8d0df 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3426,4 +3426,88 @@ class AppLocalizationsTh extends AppLocalizations { String tsunamiWaveHeightCm(int cm) { return '$cm cm'; } + + @override + String get mapLayerLocationTrack => 'เส้นทาง'; + + @override + String get mapLayerLocationTrackSubtitle => + 'สถานที่ที่โทรศัพท์เครื่องนี้เคยไป เก็บไว้ในเครื่องเท่านั้น'; + + @override + String get locationTrackRangeToday => 'วันนี้'; + + @override + String get locationTrackRangeWeek => '7 วัน'; + + @override + String get locationTrackRangeMonth => '30 วัน'; + + @override + String get locationTrackRangeAll => 'ทั้งหมด'; + + @override + String locationTrackRangePoints(String count) { + return '$count จุด'; + } + + @override + String get locationTrackRangeEmpty => 'ไม่มีบันทึกในช่วงเวลานี้'; + + @override + String get locationTrackLocalOnly => + 'เก็บไว้ในโทรศัพท์เครื่องนี้เท่านั้นและจะไม่ถูกอัปโหลด หากถอนการติดตั้งแอปหรือล้างข้อมูลแอป ข้อมูลจะหายไป'; + + @override + String get locationTrackEmpty => 'ยังไม่มีเส้นทาง'; + + @override + String get locationTrackEmptyBody => + 'เมื่ออนุญาตตำแหน่งในเบื้องหลังแล้ว โทรศัพท์เครื่องนี้จะบันทึกเส้นทางการเดินทางโดยอัตโนมัติ'; + + @override + String get locationTrackStorage => 'พื้นที่จัดเก็บ'; + + @override + String locationTrackStored(String count, String size) { + return 'จัดเก็บแล้ว $count จุด · $size'; + } + + @override + String locationTrackOldest(String date) { + return 'จุดเก่าที่สุด: $date'; + } + + @override + String locationTrackLatest(String time) { + return 'จุดล่าสุด: $time'; + } + + @override + String get locationTrackCapacity => + 'เก็บได้สูงสุด 50 MB หรือประมาณ 3 ล้านจุดขึ้นไป หากบันทึกวันละ 100–300 จุดตามปกติ จะเก็บได้ราวหลายสิบปี เมื่อเต็มแล้วจุดที่เก่าที่สุดจะถูกลบก่อน'; + + @override + String locationTrackGapNote(int hours) { + return 'จุดที่ห่างกันเกิน $hours ชั่วโมงจะไม่ถูกลากเส้นเชื่อม'; + } + + @override + String get locationTrackClear => 'ล้างเส้นทาง'; + + @override + String get locationTrackClearConfirm => + 'เส้นทางทั้งหมดที่บันทึกไว้ในโทรศัพท์เครื่องนี้จะถูกลบ และไม่สามารถกู้คืนได้'; + + @override + String get locationTrackCleared => 'ล้างเส้นทางแล้ว'; + + @override + String get locationTrackLegendNewer => 'ใหม่กว่า'; + + @override + String get locationTrackLegendOlder => 'เก่ากว่า'; + + @override + String get locationTrackLegendLatest => 'ตำแหน่งล่าสุด'; } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 4b3482a80..3334d4bab 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3437,4 +3437,89 @@ class AppLocalizationsVi extends AppLocalizations { String tsunamiWaveHeightCm(int cm) { return '$cm cm'; } + + @override + String get mapLayerLocationTrack => 'Lộ trình'; + + @override + String get mapLayerLocationTrackSubtitle => + 'Những nơi điện thoại này đã đi qua, chỉ lưu trên máy'; + + @override + String get locationTrackRangeToday => 'Hôm nay'; + + @override + String get locationTrackRangeWeek => '7 ngày'; + + @override + String get locationTrackRangeMonth => '30 ngày'; + + @override + String get locationTrackRangeAll => 'Tất cả'; + + @override + String locationTrackRangePoints(String count) { + return '$count điểm'; + } + + @override + String get locationTrackRangeEmpty => + 'Không có dữ liệu trong khoảng thời gian này'; + + @override + String get locationTrackLocalOnly => + 'Chỉ được lưu trên điện thoại này và không bao giờ được tải lên. Gỡ cài đặt ứng dụng hoặc xóa dữ liệu ứng dụng sẽ làm mất dữ liệu này.'; + + @override + String get locationTrackEmpty => 'Chưa có lộ trình'; + + @override + String get locationTrackEmptyBody => + 'Khi bạn cho phép vị trí chạy nền, điện thoại này sẽ tự động ghi lại lộ trình di chuyển.'; + + @override + String get locationTrackStorage => 'Bộ nhớ'; + + @override + String locationTrackStored(String count, String size) { + return 'Đã lưu $count điểm · $size'; + } + + @override + String locationTrackOldest(String date) { + return 'Điểm cũ nhất: $date'; + } + + @override + String locationTrackLatest(String time) { + return 'Điểm mới nhất: $time'; + } + + @override + String get locationTrackCapacity => + 'Lưu tối đa 50 MB, tức khoảng 3 triệu điểm trở lên. Với mức thông thường 100–300 điểm mỗi ngày, con số này tương đương vài chục năm. Khi đầy, các điểm cũ nhất sẽ bị xóa trước.'; + + @override + String locationTrackGapNote(int hours) { + return 'Các điểm cách nhau hơn $hours giờ sẽ không được nối bằng đường.'; + } + + @override + String get locationTrackClear => 'Xóa lộ trình'; + + @override + String get locationTrackClearConfirm => + 'Toàn bộ lộ trình được ghi trên điện thoại này sẽ bị xóa và không thể hoàn tác.'; + + @override + String get locationTrackCleared => 'Đã xóa lộ trình'; + + @override + String get locationTrackLegendNewer => 'Mới hơn'; + + @override + String get locationTrackLegendOlder => 'Cũ hơn'; + + @override + String get locationTrackLegendLatest => 'Vị trí mới nhất'; } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index b0313cd30..2fd1eb96a 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3351,4 +3351,85 @@ class AppLocalizationsYue extends AppLocalizations { String tsunamiWaveHeightCm(int cm) { return '$cm 公分'; } + + @override + String get mapLayerLocationTrack => '軌跡'; + + @override + String get mapLayerLocationTrackSubtitle => '呢部手機去過嘅地方,只存喺本機'; + + @override + String get locationTrackRangeToday => '今日'; + + @override + String get locationTrackRangeWeek => '7 日'; + + @override + String get locationTrackRangeMonth => '30 日'; + + @override + String get locationTrackRangeAll => '全部'; + + @override + String locationTrackRangePoints(String count) { + return '$count 個點'; + } + + @override + String get locationTrackRangeEmpty => '呢段期間冇紀錄'; + + @override + String get locationTrackLocalOnly => + '只會存喺呢部手機,唔會上載。解除安裝 App 或者清除 App 資料之後就會消失。'; + + @override + String get locationTrackEmpty => '仲未有軌跡'; + + @override + String get locationTrackEmptyBody => '允許背景位置之後,呢部手機會自動記錄移動軌跡。'; + + @override + String get locationTrackStorage => '儲存空間'; + + @override + String locationTrackStored(String count, String size) { + return '已儲存 $count 個點 · $size'; + } + + @override + String locationTrackOldest(String date) { + return '最早一點:$date'; + } + + @override + String locationTrackLatest(String time) { + return '最新一點:$time'; + } + + @override + String get locationTrackCapacity => + '最多保留 50 MB,大約可以存 300 萬個點以上;以一般每日 100–300 個點估算,大約係幾十年嘅紀錄。存滿之後會先刪除最舊嘅點。'; + + @override + String locationTrackGapNote(int hours) { + return '相隔超過 $hours 個鐘嘅點唔會連線。'; + } + + @override + String get locationTrackClear => '清除軌跡'; + + @override + String get locationTrackClearConfirm => '呢部手機記錄嘅所有軌跡都會刪除,而且無法復原。'; + + @override + String get locationTrackCleared => '已清除軌跡'; + + @override + String get locationTrackLegendNewer => '較新'; + + @override + String get locationTrackLegendOlder => '較舊'; + + @override + String get locationTrackLegendLatest => '最新位置'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 9092a4204..037b983c6 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3351,6 +3351,87 @@ class AppLocalizationsZh extends AppLocalizations { String tsunamiWaveHeightCm(int cm) { return '$cm 公分'; } + + @override + String get mapLayerLocationTrack => '軌跡'; + + @override + String get mapLayerLocationTrackSubtitle => '這支手機去過的地方,只存在本機'; + + @override + String get locationTrackRangeToday => '今天'; + + @override + String get locationTrackRangeWeek => '7 天'; + + @override + String get locationTrackRangeMonth => '30 天'; + + @override + String get locationTrackRangeAll => '全部'; + + @override + String locationTrackRangePoints(String count) { + return '$count 個點'; + } + + @override + String get locationTrackRangeEmpty => '這段期間沒有紀錄'; + + @override + String get locationTrackLocalOnly => + '只存在這支手機上,不會上傳。解除安裝 App 或清除 App 資料後就會消失。'; + + @override + String get locationTrackEmpty => '還沒有軌跡'; + + @override + String get locationTrackEmptyBody => '允許背景位置後,這支手機會自動記錄移動軌跡。'; + + @override + String get locationTrackStorage => '儲存空間'; + + @override + String locationTrackStored(String count, String size) { + return '已儲存 $count 個點 · $size'; + } + + @override + String locationTrackOldest(String date) { + return '最早一點:$date'; + } + + @override + String locationTrackLatest(String time) { + return '最新一點:$time'; + } + + @override + String get locationTrackCapacity => + '最多保留 50 MB,約可存 300 萬個點以上;以一般每天 100–300 個點估算,大約是數十年的紀錄。存滿後會先刪除最舊的點。'; + + @override + String locationTrackGapNote(int hours) { + return '相隔超過 $hours 小時的點不會連線。'; + } + + @override + String get locationTrackClear => '清除軌跡'; + + @override + String get locationTrackClearConfirm => '這支手機上記錄的所有軌跡都會刪除,而且無法復原。'; + + @override + String get locationTrackCleared => '已清除軌跡'; + + @override + String get locationTrackLegendNewer => '較新'; + + @override + String get locationTrackLegendOlder => '較舊'; + + @override + String get locationTrackLegendLatest => '最新位置'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6697,6 +6778,86 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String tsunamiWaveHeightCm(int cm) { return '$cm cm'; } + + @override + String get mapLayerLocationTrack => '轨迹'; + + @override + String get mapLayerLocationTrackSubtitle => '这部手机去过的地方,只存在本机'; + + @override + String get locationTrackRangeToday => '今天'; + + @override + String get locationTrackRangeWeek => '7 天'; + + @override + String get locationTrackRangeMonth => '30 天'; + + @override + String get locationTrackRangeAll => '全部'; + + @override + String locationTrackRangePoints(String count) { + return '$count 个点'; + } + + @override + String get locationTrackRangeEmpty => '这段期间没有记录'; + + @override + String get locationTrackLocalOnly => '只存在这部手机上,不会上传。卸载 App 或清除 App 数据后就会消失。'; + + @override + String get locationTrackEmpty => '还没有轨迹'; + + @override + String get locationTrackEmptyBody => '允许后台定位后,这部手机会自动记录移动轨迹。'; + + @override + String get locationTrackStorage => '存储空间'; + + @override + String locationTrackStored(String count, String size) { + return '已存储 $count 个点 · $size'; + } + + @override + String locationTrackOldest(String date) { + return '最早一点:$date'; + } + + @override + String locationTrackLatest(String time) { + return '最新一点:$time'; + } + + @override + String get locationTrackCapacity => + '最多保留 50 MB,约可存 300 万个点以上;按一般每天 100–300 个点估算,大约是数十年的记录。存满后会先删除最旧的点。'; + + @override + String locationTrackGapNote(int hours) { + return '相隔超过 $hours 小时的点不会连线。'; + } + + @override + String get locationTrackClear => '清除轨迹'; + + @override + String get locationTrackClearConfirm => '这部手机上记录的所有轨迹都会被删除,且无法恢复。'; + + @override + String get locationTrackCleared => '已清除轨迹'; + + @override + String get locationTrackLegendNewer => '较新'; + + @override + String get locationTrackLegendOlder => '较旧'; + + @override + String get locationTrackLegendLatest => '最新位置'; } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -10043,6 +10204,87 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String tsunamiWaveHeightCm(int cm) { return '$cm 公分'; } + + @override + String get mapLayerLocationTrack => '軌跡'; + + @override + String get mapLayerLocationTrackSubtitle => '這部手機去過的地方,只存在本機'; + + @override + String get locationTrackRangeToday => '今日'; + + @override + String get locationTrackRangeWeek => '7 日'; + + @override + String get locationTrackRangeMonth => '30 日'; + + @override + String get locationTrackRangeAll => '全部'; + + @override + String locationTrackRangePoints(String count) { + return '$count 個點'; + } + + @override + String get locationTrackRangeEmpty => '這段期間沒有紀錄'; + + @override + String get locationTrackLocalOnly => + '只會儲存在這部手機上,不會上載。解除安裝 App 或清除 App 資料後就會消失。'; + + @override + String get locationTrackEmpty => '還沒有軌跡'; + + @override + String get locationTrackEmptyBody => '允許背景位置後,這部手機會自動記錄移動軌跡。'; + + @override + String get locationTrackStorage => '儲存空間'; + + @override + String locationTrackStored(String count, String size) { + return '已儲存 $count 個點 · $size'; + } + + @override + String locationTrackOldest(String date) { + return '最早一點:$date'; + } + + @override + String locationTrackLatest(String time) { + return '最新一點:$time'; + } + + @override + String get locationTrackCapacity => + '最多保留 50 MB,約可存 300 萬個點以上;以一般每日 100–300 個點估算,大約是數十年的紀錄。存滿後會先刪除最舊的點。'; + + @override + String locationTrackGapNote(int hours) { + return '相隔超過 $hours 小時的點不會連線。'; + } + + @override + String get locationTrackClear => '清除軌跡'; + + @override + String get locationTrackClearConfirm => '這部手機上記錄的所有軌跡都會刪除,而且無法復原。'; + + @override + String get locationTrackCleared => '已清除軌跡'; + + @override + String get locationTrackLegendNewer => '較新'; + + @override + String get locationTrackLegendOlder => '較舊'; + + @override + String get locationTrackLegendLatest => '最新位置'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -13389,4 +13631,85 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String tsunamiWaveHeightCm(int cm) { return '$cm 公分'; } + + @override + String get mapLayerLocationTrack => '軌跡'; + + @override + String get mapLayerLocationTrackSubtitle => '這支手機去過的地方,只存在本機'; + + @override + String get locationTrackRangeToday => '今天'; + + @override + String get locationTrackRangeWeek => '7 天'; + + @override + String get locationTrackRangeMonth => '30 天'; + + @override + String get locationTrackRangeAll => '全部'; + + @override + String locationTrackRangePoints(String count) { + return '$count 個點'; + } + + @override + String get locationTrackRangeEmpty => '這段期間沒有紀錄'; + + @override + String get locationTrackLocalOnly => + '只存在這支手機上,不會上傳。解除安裝 App 或清除 App 資料後就會消失。'; + + @override + String get locationTrackEmpty => '還沒有軌跡'; + + @override + String get locationTrackEmptyBody => '允許背景位置後,這支手機會自動記錄移動軌跡。'; + + @override + String get locationTrackStorage => '儲存空間'; + + @override + String locationTrackStored(String count, String size) { + return '已儲存 $count 個點 · $size'; + } + + @override + String locationTrackOldest(String date) { + return '最早一點:$date'; + } + + @override + String locationTrackLatest(String time) { + return '最新一點:$time'; + } + + @override + String get locationTrackCapacity => + '最多保留 50 MB,約可存 300 萬個點以上;以一般每天 100–300 個點估算,大約是數十年的紀錄。存滿後會先刪除最舊的點。'; + + @override + String locationTrackGapNote(int hours) { + return '相隔超過 $hours 小時的點不會連線。'; + } + + @override + String get locationTrackClear => '清除軌跡'; + + @override + String get locationTrackClearConfirm => '這支手機上記錄的所有軌跡都會刪除,而且無法復原。'; + + @override + String get locationTrackCleared => '已清除軌跡'; + + @override + String get locationTrackLegendNewer => '較新'; + + @override + String get locationTrackLegendOlder => '較舊'; + + @override + String get locationTrackLegendLatest => '最新位置'; } diff --git a/lib/shared/map/map_layer_category.dart b/lib/shared/map/map_layer_category.dart index bc837ddbd..50c9abf6e 100644 --- a/lib/shared/map/map_layer_category.dart +++ b/lib/shared/map/map_layer_category.dart @@ -29,7 +29,8 @@ enum MapLayerCategory { /// Geostationary-satellite imagery (Himawari infrared). satellite, - /// Everyday-life facilities (disaster-prevention map). + /// Everyday life: the disaster-prevention facilities, the mesh nodes, and + /// the device's own movement track. life, } @@ -69,6 +70,9 @@ MapLayerCategory categoryOf(String layerId) { // its own: a mesh node is a thing you go and find, like an AED or a // shelter, and a one-item group would be a header with one row under it. 'meshtastic' => MapLayerCategory.life, + // The device's own movement history: something from everyday life, kept + // beside the mesh nodes as the other layer drawn from on-device data. + 'location-track' => MapLayerCategory.life, _ => MapLayerCategory.weather, }; } diff --git a/test/core/geo/location_track_fixture.dart b/test/core/geo/location_track_fixture.dart new file mode 100644 index 000000000..749829603 --- /dev/null +++ b/test/core/geo/location_track_fixture.dart @@ -0,0 +1,83 @@ +/// Writes a `location_track.db` the way the native stores do, for tests that +/// read one back. +/// +/// The native stores write this file and Dart only reads it, so nothing in the +/// app exercises both halves — a disagreement about the encoding would show up +/// as a track that is simply wrong, on a device, with no exception anywhere. +/// [writeTrackFixture] is therefore a line-by-line restatement of what +/// `LocationTrackStore.swift` and `LocationTrackStore.kt` do. +library; + +import 'package:sqlite3/sqlite3.dart' as sqlite3; + +/// Rows between absolute anchors — `anchorEvery` in both native stores. +const trackAnchorEvery = 64; +const _scale = 10000.0; + +/// Writes fixes the way the native stores do: deltas, absolute every 64th row. +/// +/// Appends when [path] already holds a track, exactly as a later background +/// wake would. +void writeTrackFixture(String path, List<(int, double, double)> fixes) { + final db = sqlite3.sqlite3.open(path); + db.execute('PRAGMA auto_vacuum=INCREMENTAL'); + db.execute('PRAGMA journal_mode=WAL'); + db.execute( + 'CREATE TABLE IF NOT EXISTS fix (' + 'id INTEGER PRIMARY KEY, t INTEGER NOT NULL, ' + 'lat INTEGER NOT NULL, lng INTEGER NOT NULL)', + ); + + // One transaction for the whole fixture. The loop below writes a row at a + // time because that is what the native stores do, but in WAL mode each of + // those is its own commit and its own fsync — 20 000 of them for the + // compactness test in `location_track_test.dart`. A local SSD answers an fsync + // in tens of microseconds and a shared CI runner's disk in a millisecond or + // two, which is the whole distance between a one-second test here and the + // thirty-second timeout that took `main` red. Batching moves when the pages + // reach the disk, not what is in them: the file comes out at exactly the + // same 282 624 bytes, so the byte-per-fix budget still measures the encoding. + db.execute('BEGIN'); + for (final (time, latitude, longitude) in fixes) { + final lat = (latitude * _scale).round(); + final lng = (longitude * _scale).round(); + + final last = + db.select('SELECT IFNULL(MAX(id), 0) AS n FROM fix').first['n'] as int; + var rowid = last + 1; + final previous = rowid % trackAnchorEvery == 0 ? null : _lastAbsolute(db); + if (previous == null && rowid % trackAnchorEvery != 0) { + rowid += trackAnchorEvery - rowid % trackAnchorEvery; + } + + db.execute('INSERT INTO fix (id, t, lat, lng) VALUES (?, ?, ?, ?)', [ + rowid, + previous == null ? time : time - previous.$1, + previous == null ? lat : lat - previous.$2, + previous == null ? lng : lng - previous.$3, + ]); + } + db.execute('COMMIT'); + db.close(); +} + +(int, int, int)? _lastAbsolute(sqlite3.Database db) { + final last = + db.select('SELECT IFNULL(MAX(id), 0) AS n FROM fix').first['n'] as int; + if (last == 0) return null; + final anchor = last - last % trackAnchorEvery; + (int, int, int)? current; + for (final row in db.select( + 'SELECT id, t, lat, lng FROM fix WHERE id >= ? ORDER BY id', + [anchor], + )) { + final id = row['id'] as int; + final t = row['t'] as int; + final lat = row['lat'] as int; + final lng = row['lng'] as int; + current = (id % trackAnchorEvery == 0 || current == null) + ? (t, lat, lng) + : (current.$1 + t, current.$2 + lat, current.$3 + lng); + } + return current; +} diff --git a/test/core/geo/location_track_test.dart b/test/core/geo/location_track_test.dart index 5c3fae5fe..7bb37dc03 100644 --- a/test/core/geo/location_track_test.dart +++ b/test/core/geo/location_track_test.dart @@ -3,9 +3,10 @@ /// The native stores write this file and Dart only reads it, so nothing in the /// app exercises both halves — a disagreement about the encoding would show up /// as a track that is simply wrong, on a device, with no exception anywhere. -/// [_writeFixture] below is therefore a line-by-line restatement of what -/// `LocationTrackStore.swift` and `LocationTrackStore.kt` do, and these tests -/// assert the reader inverts it exactly. +/// [writeTrackFixture] (`location_track_fixture.dart`) is therefore a +/// line-by-line restatement of what `LocationTrackStore.swift` and +/// `LocationTrackStore.kt` do, and these tests assert the reader inverts it +/// exactly. library; import 'dart:io'; @@ -14,73 +15,7 @@ import 'package:dpip/core/geo/location_track.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sqlite3/sqlite3.dart' as sqlite3; -const _anchorEvery = 64; -const _scale = 10000.0; - -/// Writes fixes the way the native stores do: deltas, absolute every 64th row. -void _writeFixture(String path, List<(int, double, double)> fixes) { - final db = sqlite3.sqlite3.open(path); - db.execute('PRAGMA auto_vacuum=INCREMENTAL'); - db.execute('PRAGMA journal_mode=WAL'); - db.execute( - 'CREATE TABLE IF NOT EXISTS fix (' - 'id INTEGER PRIMARY KEY, t INTEGER NOT NULL, ' - 'lat INTEGER NOT NULL, lng INTEGER NOT NULL)', - ); - - // One transaction for the whole fixture. The loop below writes a row at a - // time because that is what the native stores do, but in WAL mode each of - // those is its own commit and its own fsync — 20 000 of them for the - // compactness test at the bottom of this file. A local SSD answers an fsync - // in tens of microseconds and a shared CI runner's disk in a millisecond or - // two, which is the whole distance between a one-second test here and the - // thirty-second timeout that took `main` red. Batching moves when the pages - // reach the disk, not what is in them: the file comes out at exactly the - // same 282 624 bytes, so the byte-per-fix budget still measures the encoding. - db.execute('BEGIN'); - for (final (time, latitude, longitude) in fixes) { - final lat = (latitude * _scale).round(); - final lng = (longitude * _scale).round(); - - final last = - db.select('SELECT IFNULL(MAX(id), 0) AS n FROM fix').first['n'] as int; - var rowid = last + 1; - final previous = rowid % _anchorEvery == 0 ? null : _lastAbsolute(db); - if (previous == null && rowid % _anchorEvery != 0) { - rowid += _anchorEvery - rowid % _anchorEvery; - } - - db.execute('INSERT INTO fix (id, t, lat, lng) VALUES (?, ?, ?, ?)', [ - rowid, - previous == null ? time : time - previous.$1, - previous == null ? lat : lat - previous.$2, - previous == null ? lng : lng - previous.$3, - ]); - } - db.execute('COMMIT'); - db.close(); -} - -(int, int, int)? _lastAbsolute(sqlite3.Database db) { - final last = - db.select('SELECT IFNULL(MAX(id), 0) AS n FROM fix').first['n'] as int; - if (last == 0) return null; - final anchor = last - last % _anchorEvery; - (int, int, int)? current; - for (final row in db.select( - 'SELECT id, t, lat, lng FROM fix WHERE id >= ? ORDER BY id', - [anchor], - )) { - final id = row['id'] as int; - final t = row['t'] as int; - final lat = row['lat'] as int; - final lng = row['lng'] as int; - current = (id % _anchorEvery == 0 || current == null) - ? (t, lat, lng) - : (current.$1 + t, current.$2 + lat, current.$3 + lng); - } - return current; -} +import 'location_track_fixture.dart'; /// A plausible walk: a fix a minute, drifting a few metres each time. List<(int, double, double)> _walk(int count, {int from = 1735689600}) => [ @@ -104,7 +39,7 @@ void main() { }); test('a single fix round-trips', () async { - _writeFixture(path, [(1735689600, 25.0330, 121.5654)]); + writeTrackFixture(path, [(1735689600, 25.0330, 121.5654)]); final track = LocationTrack.at(path)!; addTearDown(track.close); @@ -123,7 +58,7 @@ void main() { // switch between "absolute" and "add to the running total" repeatedly. A // one-row-out mistake there decodes into positions that drift. final expected = _walk(200); - _writeFixture(path, expected); + writeTrackFixture(path, expected); final track = LocationTrack.at(path)!; addTearDown(track.close); @@ -149,7 +84,7 @@ void main() { // own. Asking for a window that begins there must produce the same // coordinates as reading the whole track, not a delta read as a position. final expected = _walk(200); - _writeFixture(path, expected); + writeTrackFixture(path, expected); final track = LocationTrack.at(path)!; addTearDown(track.close); @@ -165,7 +100,7 @@ void main() { ); test('a window older than the whole track returns all of it', () async { - _writeFixture(path, _walk(70)); + writeTrackFixture(path, _walk(70)); final track = LocationTrack.at(path)!; addTearDown(track.close); expect(await track.since(DateTime.utc(1990)), hasLength(70)); @@ -173,7 +108,7 @@ void main() { test('limit keeps the most recent fixes', () async { final expected = _walk(200); - _writeFixture(path, expected); + writeTrackFixture(path, expected); final track = LocationTrack.at(path)!; addTearDown(track.close); @@ -189,14 +124,14 @@ void main() { // moves the row up to the next multiple of 64 rather than writing an // absolute value at a rowid the reader would treat as a delta. Everything // below depends on this, so it is asserted rather than assumed. - _writeFixture(path, _walk(3)); + writeTrackFixture(path, _walk(3)); final db = sqlite3.sqlite3.open(path); addTearDown(db.close); final ids = db .select('SELECT id FROM fix ORDER BY id') .map((row) => row['id'] as int) .toList(); - expect(ids, [_anchorEvery, _anchorEvery + 1, _anchorEvery + 2]); + expect(ids, [trackAnchorEvery, trackAnchorEvery + 1, trackAnchorEvery + 2]); }); test('the track still decodes after the oldest groups are evicted', () async { @@ -205,13 +140,13 @@ void main() { // is itself an anchor — drop one row fewer and every position after it // would be a delta read as a coordinate, somewhere off the coast. final expected = _walk(200); - _writeFixture(path, expected); + writeTrackFixture(path, expected); // Rows start at rowid 64, so expected[i] is at rowid 64 + i. Deleting // below 128 takes the first whole group, expected[0..63]. - const dropped = _anchorEvery; + const dropped = trackAnchorEvery; final writer = sqlite3.sqlite3.open(path); - writer.execute('DELETE FROM fix WHERE id < ${_anchorEvery * 2}'); + writer.execute('DELETE FROM fix WHERE id < ${trackAnchorEvery * 2}'); writer.close(); final track = LocationTrack.at(path)!; @@ -228,7 +163,7 @@ void main() { }); test('stats report the row count and the file size', () async { - _writeFixture(path, _walk(200)); + writeTrackFixture(path, _walk(200)); final track = LocationTrack.at(path)!; addTearDown(track.close); @@ -237,10 +172,75 @@ void main() { expect(stats.bytes, greaterThan(0)); }); + test('stats name the oldest fix still stored', () async { + final expected = _walk(200); + writeTrackFixture(path, expected); + // Eviction takes the oldest group, and the date has to move with it — + // "oldest point" is how a reader learns how far back the history goes. + final writer = sqlite3.sqlite3.open(path); + writer.execute('DELETE FROM fix WHERE id < ${trackAnchorEvery * 2}'); + writer.close(); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final stats = await track.stats(); + expect(stats.fixes, 200 - trackAnchorEvery); + expect( + stats.oldest!.millisecondsSinceEpoch ~/ 1000, + expected[trackAnchorEvery].$1, + ); + }); + + test('an empty file has no oldest fix', () async { + writeTrackFixture(path, const []); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + final stats = await track.stats(); + expect(stats.fixes, 0); + expect(stats.oldest, isNull); + }); + + group('scan', () { + List<(int, int, int)> scanned(DateTime from) { + final out = <(int, int, int)>[]; + LocationTrack.scan(path, from, (t, lat, lng) => out.add((t, lat, lng))); + return out; + } + + test('visits exactly what since returns, in stored units', () async { + // The two readers share one decoder; this pins that they also share one + // idea of the window, from a start in the middle of an anchor group. + writeTrackFixture(path, _walk(300)); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + final all = await track.since(DateTime.utc(2000)); + for (final from in [DateTime.utc(2000), all[100].time, all[257].time]) { + final expected = await track.since(from); + final got = scanned(from); + expect(got, hasLength(expected.length), reason: '$from'); + for (var i = 0; i < got.length; i++) { + final (t, lat, lng) = got[i]; + expect(t * 1000, expected[i].time.millisecondsSinceEpoch); + expect(lat / 10000, closeTo(expected[i].latitude, 1e-9)); + expect(lng / 10000, closeTo(expected[i].longitude, 1e-9)); + } + } + }); + + test('a missing file visits nothing', () { + expect(scanned(DateTime.utc(2000)), isEmpty); + }); + + test('a window after the last fix visits nothing', () { + writeTrackFixture(path, _walk(70)); + expect(scanned(DateTime.utc(2100)), isEmpty); + }); + }); + test('the connection cannot write', () async { // The separation of duties is enforced by the connection, not by care: // the native side owns every write, including eviction. - _writeFixture(path, _walk(10)); + writeTrackFixture(path, _walk(10)); final track = LocationTrack.at(path)!; addTearDown(track.close); await expectLater( @@ -254,7 +254,7 @@ void main() { // not pin the exact figure — page overhead and the WAL move it — but it // does fail if someone stores absolutes again, which would roughly double // it and quietly halve how much history fits. - _writeFixture(path, _walk(20000)); + writeTrackFixture(path, _walk(20000)); final track = LocationTrack.at(path)!; addTearDown(track.close); diff --git a/test/core/geo/track_digest_test.dart b/test/core/geo/track_digest_test.dart new file mode 100644 index 000000000..53d55d66f --- /dev/null +++ b/test/core/geo/track_digest_test.dart @@ -0,0 +1,231 @@ +/// The digest is what keeps the track layer cheap on a cheap phone: whatever +/// the history holds, the map is handed a bounded number of strokes, and a +/// recording gap is never drawn as a road. +library; + +import 'dart:io'; +import 'dart:math' as math; + +import 'package:dpip/core/geo/location_track.dart'; +import 'package:dpip/core/geo/track_digest.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'location_track_fixture.dart'; + +/// Base time for synthetic fixes: 2026-01-01T00:00Z. +const _t0 = 1767225600; + +TrackDigest _digest( + List<(int, int, int)> fixes, { + int budget = TrackDigestBuilder.defaultBudget, +}) { + final builder = TrackDigestBuilder(budget: budget); + for (final (t, lat, lng) in fixes) { + builder.add(t, lat, lng); + } + return builder.build(); +} + +/// A straight walk of [count] fixes a minute apart, each [step] stored units +/// (1e-4°) north-east of the last. +List<(int, int, int)> _walk( + int count, { + int from = _t0, + int lat = 250330, + int lng = 1215654, + int step = 3, + int every = 60, +}) => [ + for (var i = 0; i < count; i++) + (from + i * every, lat + i * step, lng + i * step), +]; + +void main() { + test('nothing in, nothing to draw', () { + final digest = _digest(const []); + expect(digest.isEmpty, isTrue); + expect(digest.segments, isEmpty); + expect(digest.dots, isEmpty); + expect(digest.latest, isNull); + expect(digest.bounds, isNull); + }); + + test('a short window is drawn exactly as recorded', () { + final fixes = _walk(50); + final digest = _digest(fixes); + expect(digest.cellE4, 1, reason: 'nothing needed merging'); + expect(digest.fixes, 50); + expect(digest.segments, hasLength(49)); + expect(digest.dots, isEmpty); + // The endpoints are the recorded fixes, never a cell's stand-in. + expect(digest.first!.latitude, closeTo(25.0330, 1e-9)); + expect(digest.latest!.latitude, closeTo(25.0330 + 49 * 3e-4, 1e-9)); + expect(digest.latest!.time.millisecondsSinceEpoch ~/ 1000, _t0 + 49 * 60); + final bounds = digest.bounds!; + expect(bounds.south, closeTo(25.0330, 1e-9)); + expect(bounds.north, closeTo(25.0330 + 49 * 3e-4, 1e-9)); + expect(bounds.west, closeTo(121.5654, 1e-9)); + expect(bounds.east, closeTo(121.5654 + 49 * 3e-4, 1e-9)); + }); + + group('gaps', () { + test('fixes more than two hours apart are not joined', () { + // Home in the evening, then the first fix two towns away the next + // morning: a straight line between them is a road nobody drove. + final evening = _walk(5); + final morning = _walk(5, from: _t0 + 10 * 3600, lat: 240000); + final digest = _digest([...evening, ...morning]); + expect(digest.segments, hasLength(8)); + for (final segment in digest.segments) { + final crosses = + (segment.fromLatitude < 24.5) != (segment.toLatitude < 24.5); + expect(crosses, isFalse, reason: 'a stroke spans the gap'); + } + }); + + test('exactly two hours apart is still one movement', () { + final digest = _digest([ + (_t0, 250000, 1210000), + (_t0 + 2 * 3600, 250100, 1210100), + ]); + expect(digest.segments, hasLength(1)); + }); + + test('a lone fix between two silences is kept as a dot', () { + final digest = _digest([ + ..._walk(3), + (_t0 + 6 * 3600, 240000, 1200000), + ..._walk(3, from: _t0 + 12 * 3600, lat: 230000), + ]); + expect(digest.segments, hasLength(4)); + expect(digest.dots, hasLength(1)); + expect(digest.dots.single.latitude, closeTo(24.0, 1e-9)); + expect(digest.dots.single.longitude, closeTo(120.0, 1e-9)); + }); + + test('a clock set backwards is a step, not a gap', () { + final digest = _digest([ + (_t0, 250000, 1210000), + (_t0 - 60, 250010, 1210010), + ]); + expect(digest.segments, hasLength(1)); + }); + }); + + test('a repeated route costs what it costs once, and is as new as today', () { + // A hundred days of the same commute is the shape of a long history, and + // the reason the digest keeps distinct strokes rather than every pass. + final once = _digest(_walk(40)); + final daily = _digest([ + for (var day = 0; day < 100; day++) ..._walk(40, from: _t0 + day * 86400), + ]); + expect(daily.fixes, 4000); + expect(daily.segments, hasLength(once.segments.length)); + expect(daily.cellE4, 1); + final newest = daily.segments + .map((segment) => segment.time.millisecondsSinceEpoch ~/ 1000) + .reduce(math.max); + expect(newest, _t0 + 99 * 86400 + 39 * 60); + }); + + group('the budget', () { + test('holds however long the history is', () { + // Two hundred thousand fixes wandering over the whole island — far more + // distinct places than the budget, so the grid has to coarsen. + final random = math.Random(7); + var lat = 240000; + var lng = 1210000; + final fixes = <(int, int, int)>[]; + for (var i = 0; i < 200000; i++) { + lat = (lat + random.nextInt(201) - 100).clamp(219000, 253000); + lng = (lng + random.nextInt(201) - 100).clamp(1200000, 1220000); + fixes.add((_t0 + i * 120, lat, lng)); + } + const budget = 600; + final digest = _digest(fixes, budget: budget); + expect(digest.fixes, fixes.length); + expect(digest.cellE4, greaterThan(1)); + expect( + digest.segments.length + digest.dots.length, + lessThanOrEqualTo(budget), + ); + // The exact ends survive the thinning. + expect(digest.latest!.latitude, closeTo(fixes.last.$2 / 10000, 1e-9)); + expect(digest.first!.longitude, closeTo(fixes.first.$3 / 10000, 1e-9)); + for (final segment in digest.segments) { + expect( + segment.fromLatitude == segment.toLatitude && + segment.fromLongitude == segment.toLongitude, + isFalse, + reason: 'a stroke that goes nowhere draws nothing', + ); + } + }); + + test('coarsening never joins across a gap', () { + // Two dense clusters with a long silence between them: however coarse the + // grid gets, no stroke may connect them. + final north = _walk(3000, lat: 250000, step: 2); + final south = _walk( + 3000, + from: _t0 + 3000 * 60 + 5 * 3600, + lat: 220000, + step: 2, + ); + final digest = _digest([...north, ...south], budget: 100); + expect(digest.cellE4, greaterThan(1)); + for (final segment in digest.segments) { + final crosses = + (segment.fromLatitude < 23.5) != (segment.toLatitude < 23.5); + expect(crosses, isFalse); + } + }); + + test('cells nest across the equator and the antimeridian', () { + // `>>` floors, so a negative coordinate snaps to the cell below zero — + // the property the exact re-snap relies on. + final digest = _digest([ + for (var i = 0; i < 2000; i++) + (_t0 + i * 60, -1000 + i * 3, -1799000 + i * 5), + ], budget: 64); + expect(digest.fixes, 2000); + expect( + digest.segments.length + digest.dots.length, + lessThanOrEqualTo(64), + ); + expect(digest.bounds!.south, closeTo(-0.1, 1e-9)); + expect(digest.bounds!.west, closeTo(-179.9, 1e-9)); + }); + }); + + group('read', () { + late Directory dir; + late String path; + + setUp(() { + dir = Directory.systemTemp.createTempSync('track_digest_test'); + path = '${dir.path}/location_track.db'; + }); + + tearDown(() => dir.deleteSync(recursive: true)); + + test('summarises only the window, off the calling isolate', () async { + final fixes = [ + for (final (t, lat, lng) in _walk(300)) (t, lat / 10000, lng / 10000), + ]; + writeTrackFixture(path, fixes); + final track = LocationTrack.at(path)!; + addTearDown(track.close); + + final from = DateTime.fromMillisecondsSinceEpoch( + (_t0 + 200 * 60) * 1000, + isUtc: true, + ); + final digest = await TrackDigest.read(track, from); + expect(digest.fixes, (await track.since(from)).length); + expect(digest.fixes, 100); + expect(digest.first!.time, from); + expect(digest.segments, hasLength(99)); + }); + }); +} diff --git a/test/features/map/presentation/layers/location_track_layer_test.dart b/test/features/map/presentation/layers/location_track_layer_test.dart new file mode 100644 index 000000000..86fef3265 --- /dev/null +++ b/test/features/map/presentation/layers/location_track_layer_test.dart @@ -0,0 +1,389 @@ +/// The track layer reads a file only this phone has. These pin what it draws +/// for each window, that a refresh and a clear reach the map, and that the +/// camera is fitted on a deliberate choice and never on a background refresh. +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:dpip/core/geo/location_track.dart'; +import 'package:dpip/core/geo/track_digest.dart'; +import 'package:dpip/core/platform/background_location.dart'; +import 'package:dpip/features/map/presentation/layers/location_track_layer.dart'; +import 'package:dpip/shared/map/map_layer_category.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite3; + +import '../../../../core/geo/location_track_fixture.dart'; +import '../../raster_timeline_harness.dart'; + +class _Map extends RecordingMapController { + final List> cameras = []; + + @override + Future animateCamera( + CameraUpdate cameraUpdate, { + Duration? duration, + }) async { + cameras.add(cameraUpdate.toJson() as List); + return true; + } +} + +/// 2026-10-16 03:00 UTC — 11:00 in Taipei. +final _now = DateTime.utc(2026, 10, 16, 3); + +int _secondsAgo(Duration ago) => + _now.subtract(ago).millisecondsSinceEpoch ~/ 1000; + +/// A short walk of [count] fixes a minute apart, starting [ago] before now. +List<(int, double, double)> _walk( + Duration ago, { + int count = 10, + double lat = 25.0330, +}) => [ + for (var i = 0; i < count; i++) + (_secondsAgo(ago) + i * 60, lat + i * 0.0003, 121.5654 + i * 0.0002), +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory dir; + late String path; + late _Map map; + late List channelCalls; + late BackgroundLocationService service; + const channel = MethodChannel('test/background_location'); + + setUp(() { + dir = Directory.systemTemp.createTempSync('location_track_layer_test'); + path = '${dir.path}/location_track.db'; + map = _Map(); + channelCalls = []; + service = BackgroundLocationService( + platform: 0, + version: 'test', + channel: channel, + ); + // What the native side does on `clearTrack`: empty the table through its + // own handle, then answer. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + channelCalls.add(call.method); + if (call.method == 'clearTrack') { + final db = sqlite3.sqlite3.open(path); + db.execute('DELETE FROM fix'); + db.close(); + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + dir.deleteSync(recursive: true); + }); + + LocationTrackMapLayer makeLayer() => LocationTrackMapLayer( + service, + open: () async => LocationTrack.at(path), + now: () => _now, + ); + + List> features() => [ + for (final feature + in (map.sourceData['location-track-src']?['features'] as List?) ?? + const []) + Map.from(feature as Map), + ]; + + Iterable> ofKind(String kind) => features().where( + (feature) => (feature['properties'] as Map)['kind'] == kind, + ); + + test('the layer is filed under daily life', () { + expect(LocationTrackMapLayer.layerId, 'location-track'); + expect(categoryOf(LocationTrackMapLayer.layerId), MapLayerCategory.life); + }); + + group('trackRangeStart', () { + test('today is the Taipei calendar day', () { + // 11:00 in Taipei → midnight Taipei is 16:00 UTC the day before. + expect( + trackRangeStart(TrackRange.today, _now), + DateTime.utc(2026, 10, 15, 16), + ); + // 00:30 Taipei on the 16th is still the 16th, though UTC says the 15th. + expect( + trackRangeStart(TrackRange.today, DateTime.utc(2026, 10, 15, 16, 30)), + DateTime.utc(2026, 10, 15, 16), + ); + }); + + test('the rolling windows and all', () { + expect( + trackRangeStart(TrackRange.week, _now), + _now.subtract(const Duration(days: 7)), + ); + expect( + trackRangeStart(TrackRange.month, _now), + _now.subtract(const Duration(days: 30)), + ); + expect( + trackRangeStart(TrackRange.all, _now).isBefore(DateTime.utc(1971)), + isTrue, + ); + }); + }); + + test('no file is the empty state, and an empty map', () async { + final layer = makeLayer(); + await layer.render(map); + await layer.pending; + expect(layer.snapshot.value!.recorded, isFalse); + expect(map.calls, contains('addLineLayer:location-track-line')); + expect(features(), isEmpty); + await layer.clear(map); + }); + + test('each window draws only its own fixes, seven days by default', () async { + writeTrackFixture(path, [ + ..._walk(const Duration(days: 40)), + ..._walk(const Duration(days: 20), lat: 24.5), + ..._walk(const Duration(days: 3), lat: 24.0), + ..._walk(const Duration(hours: 2), lat: 23.5), + ]); + final layer = makeLayer(); + await layer.render(map); + await layer.pending; + + LocationTrackSnapshot snapshot() => layer.snapshot.value!; + expect(layer.range.value, TrackRange.week); + expect(snapshot().recorded, isTrue); + expect(snapshot().fixes, 40, reason: 'stats count the whole file'); + expect(snapshot().digest.fixes, 20); + + await layer.selectRange(TrackRange.today); + expect(snapshot().digest.fixes, 10); + await layer.selectRange(TrackRange.month); + expect(snapshot().digest.fixes, 30); + await layer.selectRange(TrackRange.all); + expect(snapshot().digest.fixes, 40); + expect( + snapshot().oldest!.millisecondsSinceEpoch ~/ 1000, + _secondsAgo(const Duration(days: 40)), + ); + + // The newest fix is the marker, exactly where it was recorded. + final latest = ofKind('latest').single; + final coordinates = (latest['geometry'] as Map)['coordinates'] as List; + expect(coordinates[1], closeTo(23.5 + 9 * 0.0003, 1e-9)); + // Four separate outings, each more than two hours from the next: four + // runs of nine strokes, and nothing joining them. + final strokes = ofKind('line').fold( + 0, + (sum, feature) => + sum + ((feature['geometry'] as Map)['coordinates'] as List).length, + ); + expect(strokes, 36); + await layer.clear(map); + }); + + test('the camera fits a deliberate choice, not a refresh', () async { + writeTrackFixture(path, _walk(const Duration(hours: 3))); + final layer = makeLayer() + ..rememberFrameMetrics( + viewport: const Size(400, 800), + topInset: 24, + bottomInset: 200, + ); + await layer.render(map); + await layer.pending; + expect(map.cameras, hasLength(1)); + expect(map.cameras.single.first, 'newLatLngZoom'); + final zoom = map.cameras.single.last as double; + expect(zoom, lessThanOrEqualTo(15)); + + // New fixes arrive while the app is in the background. + layer.onSurfaceVisibility(false); + writeTrackFixture(path, _walk(const Duration(minutes: 30), count: 5)); + layer.onSurfaceVisibility(true); + await layer.pending; + expect(layer.snapshot.value!.digest.fixes, 15); + expect(map.cameras, hasLength(1), reason: 'a refresh moved the camera'); + + await layer.selectRange(TrackRange.today); + expect(map.cameras, hasLength(2)); + await layer.clear(map); + }); + + test( + 'coming back to the foreground re-reads only when the file changed', + () async { + writeTrackFixture(path, _walk(const Duration(hours: 3))); + final layer = makeLayer(); + await layer.render(map); + await layer.pending; + final uploads = map.calls + .where((call) => call == 'setGeoJsonSource:location-track-src') + .length; + + layer + ..onSurfaceVisibility(false) + ..onSurfaceVisibility(true); + await layer.pending; + expect( + map.calls.where( + (call) => call == 'setGeoJsonSource:location-track-src', + ), + hasLength(uploads), + reason: 'nothing changed, so nothing is redrawn', + ); + + layer.onSurfaceVisibility(false); + writeTrackFixture(path, _walk(const Duration(minutes: 10), count: 3)); + layer.onSurfaceVisibility(true); + await layer.pending; + expect(layer.snapshot.value!.digest.fixes, 13); + expect( + map.calls.where( + (call) => call == 'setGeoJsonSource:location-track-src', + ), + hasLength(uploads + 1), + ); + await layer.clear(map); + }, + ); + + test('a file that appears later is picked up on the next refresh', () async { + final layer = makeLayer(); + await layer.render(map); + await layer.pending; + expect(layer.snapshot.value!.recorded, isFalse); + + // Background location was granted while the app was away. + writeTrackFixture(path, _walk(const Duration(hours: 1))); + await layer.refresh(); + expect(layer.snapshot.value!.recorded, isTrue); + expect(ofKind('latest'), hasLength(1)); + await layer.clear(map); + }); + + test('clearing goes through the native side and empties the map', () async { + writeTrackFixture(path, _walk(const Duration(hours: 1))); + final layer = makeLayer(); + await layer.render(map); + await layer.pending; + expect(features(), isNotEmpty); + + expect(await layer.clearTrack(), isTrue); + expect(channelCalls, ['clearTrack']); + expect(layer.snapshot.value!.recorded, isFalse); + expect(features(), isEmpty); + expect(layer.busy.value, isFalse); + await layer.clear(map); + }); + + test('a clear the native side did not perform is not reported', () async { + writeTrackFixture(path, _walk(const Duration(hours: 1))); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + channelCalls.add(call.method); + throw PlatformException(code: 'io', message: 'disk'); + }); + final layer = makeLayer(); + await layer.render(map); + await layer.pending; + + expect(await layer.clearTrack(), isFalse); + expect(channelCalls, ['clearTrack']); + expect(layer.snapshot.value!.recorded, isTrue); + expect(features(), isNotEmpty); + await layer.clear(map); + }); + + test('a load superseded by a newer one never lands', () async { + writeTrackFixture(path, [ + ..._walk(const Duration(days: 20)), + ..._walk(const Duration(hours: 1)), + ]); + final entered = Completer(); + final gate = Completer(); + var reads = 0; + final layer = LocationTrackMapLayer( + service, + open: () async => LocationTrack.at(path), + now: () => _now, + digest: (track, from) async { + // Hold the "today" read (the second) until the month read has drawn. + if (reads++ == 1) { + entered.complete(); + await gate.future; + } + return TrackDigest.read(track, from); + }, + ); + await layer.render(map); + await layer.pending; + final slow = layer.selectRange(TrackRange.today); + await entered.future; + await layer.selectRange(TrackRange.month); + gate.complete(); + await slow; + expect(layer.range.value, TrackRange.month); + expect(layer.snapshot.value!.digest.fixes, 20); + await layer.clear(map); + }); + + group('trackGeoJson', () { + TrackDigest digestOf(List<(int, int, int)> fixes) { + final builder = TrackDigestBuilder(); + for (final (t, lat, lng) in fixes) { + builder.add(t, lat, lng); + } + return builder.build(); + } + + test('groups strokes into at most eight shades of age', () { + final digest = digestOf([ + for (var i = 0; i < 500; i++) (i * 600, 250000 + i * 5, 1210000), + ]); + final json = trackGeoJson(digest); + final lines = [ + for (final feature in json['features'] as List) + if ((feature as Map)['properties']['kind'] == 'line') feature, + ]; + expect(lines.length, lessThanOrEqualTo(8)); + final total = lines.fold( + 0, + (sum, feature) => + sum + ((feature['geometry'] as Map)['coordinates'] as List).length, + ); + expect(total, digest.segments.length); + final shades = [ + for (final feature in lines) feature['properties']['r'] as double, + ]; + expect(shades.every((r) => r > 0 && r < 1), isTrue); + expect(shades, orderedEquals([...shades]..sort())); + }); + + test('a window with nothing in it draws nothing', () { + expect(trackGeoJson(TrackDigest.empty)['features'], isEmpty); + expect(trackGeoJson(null)['features'], isEmpty); + }); + + test('a single fix is only the latest marker', () { + final json = trackGeoJson(digestOf([(0, 250000, 1210000)])); + final features = json['features'] as List; + // The lone fix is both a dot and the marker; the marker draws on top. + expect( + features.map((feature) => (feature as Map)['properties']['kind']), + containsAll(['latest']), + ); + }); + }); +} diff --git a/test/features/map/presentation/pages/map_page_test.dart b/test/features/map/presentation/pages/map_page_test.dart index 3b68add8f..89373be1d 100644 --- a/test/features/map/presentation/pages/map_page_test.dart +++ b/test/features/map/presentation/pages/map_page_test.dart @@ -8,6 +8,7 @@ import 'package:dpip/core/error/result.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/meshtastic/domain/meshtastic_service.dart'; import 'package:dpip/core/meshtastic/mesh_node_store.dart'; +import 'package:dpip/core/platform/background_location.dart'; import 'package:dpip/core/realtime/clock.dart'; import 'package:dpip/core/realtime/elapsed.dart'; import 'package:dpip/core/realtime/realtime_channel.dart'; @@ -17,6 +18,7 @@ import 'package:dpip/core/realtime/realtime_source.dart'; import 'package:dpip/core/realtime/ticker.dart'; import 'package:dpip/core/settings/default_map_layer.dart'; import 'package:dpip/core/settings/default_map_layer_controller.dart'; +import 'package:dpip/core/settings/map_layer_order_controller.dart'; import 'package:dpip/core/settings/map_layer_visibility_controller.dart'; import 'package:dpip/core/settings/map_reference_outline_controller.dart'; import 'package:dpip/core/settings/settings_store.dart'; @@ -201,6 +203,9 @@ void main() { ChangeNotifierProvider.value( value: visibility, ), + ChangeNotifierProvider.value( + value: MapLayerOrderController(settings), + ), ChangeNotifierProvider.value( value: MapReferenceOutlineController(settings), ), @@ -249,6 +254,11 @@ void main() { value: MeshNodeStore(FakeMeshPageService(), settings), ), Provider.value(value: FakeMeshPageService()), + // No channel behind it: the track layer is never selected here, and + // a missing plugin is what the service already treats as a no-op. + Provider.value( + value: BackgroundLocationService(platform: 0, version: 'test'), + ), ChangeNotifierProvider.value( value: MapCameraHandoff(), ), @@ -305,12 +315,49 @@ void main() { 'rain', 'dpm', 'meshtastic', + 'location-track', 'tsunami', }, ); expect(find.text(l10n.mapLayerRadar), findsWidgets); }); + testWidgets('the picker lists the track under daily life', (tester) async { + // Tall enough that every group of the picker is on screen at once — the + // sheet opens at 55 % of the height and lists thirty-odd rows. + tester.view.physicalSize = const Size(800, 8000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + await pumpPage(tester); + await tester.tap(find.text(l10n.mapLayerRadar).first); + await tester.pumpAndSettle(); + + double top(String text) => tester.getTopLeft(find.text(text).last).dy; + expect(find.text(l10n.mapLayerLocationTrack), findsOneWidget); + expect( + find.text(l10n.mapLayerLocationTrackSubtitle), + findsOneWidget, + reason: 'the picker row itself says the data stays on the device', + ); + // Daily life is the last group, and the track is its last row. + expect( + top(l10n.mapLayerLocationTrack), + greaterThan(top(l10n.mapLayerCategoryLife)), + ); + expect( + top(l10n.mapLayerLocationTrack), + greaterThan(top(l10n.mapLayerMeshtastic)), + ); + for (final other in [ + l10n.mapLayerCategoryRadar, + l10n.mapLayerCategoryWeather, + l10n.mapLayerCategoryEarthquake, + ]) { + expect(top(other), lessThan(top(l10n.mapLayerCategoryLife))); + } + }); + testWidgets('the disaster map is the layer that starts on streets', ( tester, ) async { diff --git a/test/features/map/presentation/widgets/location_track_sheet_test.dart b/test/features/map/presentation/widgets/location_track_sheet_test.dart new file mode 100644 index 000000000..d3a4efbbf --- /dev/null +++ b/test/features/map/presentation/widgets/location_track_sheet_test.dart @@ -0,0 +1,279 @@ +/// The sheet is where the track layer says what it is: the local-only notice +/// at rest, the empty state that leads to the grant, what is stored, and a +/// clear that cannot happen by accident. +library; + +import 'dart:io'; + +import 'package:dpip/core/geo/location_track.dart'; +import 'package:dpip/core/platform/background_location.dart'; +import 'package:dpip/core/storage/app_storage_scan.dart'; +import 'package:dpip/features/map/presentation/layers/location_track_layer.dart'; +import 'package:dpip/features/map/presentation/widgets/location_track_sheet.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite3; + +import '../../../../core/geo/location_track_fixture.dart'; +import '../../raster_timeline_harness.dart'; + +/// 2026-10-16 03:00 UTC — 11:00 in Taipei. +final _now = DateTime.utc(2026, 10, 16, 3); + +List<(int, double, double)> _walk(Duration ago, {int count = 10}) { + final start = _now.subtract(ago).millisecondsSinceEpoch ~/ 1000; + return [ + for (var i = 0; i < count; i++) + (start + i * 60, 25.0330 + i * 0.0003, 121.5654 + i * 0.0002), + ]; +} + +void main() { + late Directory dir; + late String path; + late List channelCalls; + late BackgroundLocationService service; + late AppLocalizations l10n; + const channel = MethodChannel('test/background_location_sheet'); + + setUpAll(() async { + l10n = await AppLocalizations.delegate.load(const Locale('en')); + }); + + setUp(() { + dir = Directory.systemTemp.createTempSync('location_track_sheet_test'); + path = '${dir.path}/location_track.db'; + channelCalls = []; + service = BackgroundLocationService( + platform: 0, + version: 'test', + channel: channel, + ); + }); + + tearDown(() => dir.deleteSync(recursive: true)); + + void mockChannel(WidgetTester tester) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + channelCalls.add(call.method); + if (call.method == 'clearTrack') { + final db = sqlite3.sqlite3.open(path); + db.execute('DELETE FROM fix'); + db.close(); + } + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + } + + LocationTrackMapLayer makeLayer() => LocationTrackMapLayer( + service, + open: () async => LocationTrack.at(path), + now: () => _now, + ); + + /// Lets the layer's database and isolate work finish: real time for the I/O, + /// a pump for the callbacks it schedules back into the test zone. + Future drain(WidgetTester tester, LocationTrackMapLayer layer) async { + for (var i = 0; i < 200; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(); + if (!layer.busy.value) return; + } + fail('the layer never finished loading'); + } + + Future pumpSheet( + WidgetTester tester, + LocationTrackMapLayer layer, { + Size size = const Size(400, 900), + double navBar = 0, + double textScale = 1, + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, _) => Scaffold( + body: MediaQuery( + // What the shell's `extendBody` hands the map: the navigation + // bar's height as bottom padding, over the bottom of the map. + data: MediaQuery.of(context).copyWith( + padding: EdgeInsets.only(bottom: navBar), + textScaler: TextScaler.linear(textScale), + ), + child: Stack( + children: [ + Positioned.fill(child: LocationTrackSheet(layer: layer)), + ], + ), + ), + ), + ), + GoRoute( + path: AppRoutes.permissionsPath, + name: AppRoutes.permissions, + builder: (_, _) => const Scaffold(body: Text('permission check')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ); + // Two frames: the peek is measured after the first one. + await tester.pump(); + await tester.pump(); + } + + Future expandSheet(WidgetTester tester) async { + await tester.drag( + find.text(l10n.mapLayerLocationTrack), + const Offset(0, -2000), + ); + await tester.pumpAndSettle(); + } + + Future closeLayer(WidgetTester tester, LocationTrackMapLayer layer) => + tester.runAsync(() => layer.clear(RecordingMapController())); + + testWidgets('nothing recorded: how recording starts, and the way there', ( + tester, + ) async { + final layer = makeLayer(); + await tester.runAsync(layer.refresh); + await pumpSheet(tester, layer); + await expandSheet(tester); + + expect(find.text(l10n.locationTrackEmpty), findsOneWidget); + expect(find.text(l10n.locationTrackEmptyBody), findsOneWidget); + // The notice says what will happen to the data before there is any. + expect(find.text(l10n.locationTrackLocalOnly), findsOneWidget); + // No range to pick and nothing to clear when nothing was recorded. + expect(find.text(l10n.locationTrackRangeAll), findsNothing); + expect(find.text(l10n.locationTrackClear), findsNothing); + + await tester.tap(find.text(l10n.permissionsTitle)); + await tester.pumpAndSettle(); + expect(find.text('permission check'), findsOneWidget); + }); + + testWidgets('recorded: what is stored, and the pills pick the window', ( + tester, + ) async { + writeTrackFixture(path, [ + ..._walk(const Duration(days: 20)), + ..._walk(const Duration(days: 3)), + ]); + final layer = makeLayer(); + await tester.runAsync(layer.refresh); + final bytes = layer.snapshot.value!.bytes; + await pumpSheet(tester, layer); + await expandSheet(tester); + + expect(find.text(l10n.locationTrackLocalOnly), findsOneWidget); + expect(find.text(l10n.locationTrackRangePoints('10')), findsOneWidget); + expect( + find.text(l10n.locationTrackStored('20', formatBytes(bytes))), + findsOneWidget, + ); + expect( + find.text(l10n.locationTrackOldest('2026/09/26')), + findsOneWidget, + reason: 'twenty days before 2026-10-16, in Taipei', + ); + expect(find.text(l10n.locationTrackCapacity), findsOneWidget); + expect(find.text(l10n.locationTrackGapNote(2)), findsOneWidget); + + await tester.tap(find.text(l10n.locationTrackRangeMonth)); + expect(layer.range.value, TrackRange.month); + await drain(tester, layer); + expect(find.text(l10n.locationTrackRangePoints('20')), findsOneWidget); + + await closeLayer(tester, layer); + }); + + testWidgets('clearing asks first, and cancelling keeps everything', ( + tester, + ) async { + mockChannel(tester); + writeTrackFixture(path, _walk(const Duration(hours: 1))); + final layer = makeLayer(); + await tester.runAsync(layer.refresh); + await pumpSheet(tester, layer); + await expandSheet(tester); + + // Exactly one match while the dialog is closed: the sheet's button. + await tester.tap(find.text(l10n.locationTrackClear)); + await tester.pumpAndSettle(); + expect(find.text(l10n.locationTrackClearConfirm), findsOneWidget); + await tester.tap(find.text(l10n.commonCancel)); + await tester.pumpAndSettle(); + expect(channelCalls, isEmpty); + expect(layer.snapshot.value!.recorded, isTrue); + + await tester.tap(find.text(l10n.locationTrackClear)); + await tester.pumpAndSettle(); + await tester.tap( + find.widgetWithText(FilledButton, l10n.locationTrackClear), + ); + await tester.pump(); + await drain(tester, layer); + await tester.pump(); + + expect(channelCalls, ['clearTrack']); + expect(layer.snapshot.value!.recorded, isFalse); + expect(find.text(l10n.locationTrackCleared), findsOneWidget); + expect(find.text(l10n.locationTrackEmpty), findsOneWidget); + await closeLayer(tester, layer); + }); + + for (final (label, textScale) in [ + ('default text', 1.0), + ('large text', 1.3), + ]) { + testWidgets('the local-only notice rests above the navigation bar ' + '($label)', (tester) async { + writeTrackFixture(path, _walk(const Duration(hours: 1))); + final layer = makeLayer(); + await tester.runAsync(layer.refresh); + // A small phone, with the shell's navigation bar over the map. + const height = 640.0; + const navBar = 96.0; + await pumpSheet( + tester, + layer, + size: const Size(360, height), + navBar: navBar, + textScale: textScale, + ); + + final notice = tester.getRect(find.text(l10n.locationTrackLocalOnly)); + expect(notice.bottom, lessThanOrEqualTo(height - navBar)); + expect(layer.peekExtent, greaterThan(navBar / height)); + expect(layer.bottomChromeFraction, layer.peekExtent); + await closeLayer(tester, layer); + }); + } +} diff --git a/test/shared/map/map_layer_category_test.dart b/test/shared/map/map_layer_category_test.dart index 5f0725921..c406547bf 100644 --- a/test/shared/map/map_layer_category_test.dart +++ b/test/shared/map/map_layer_category_test.dart @@ -53,6 +53,13 @@ void main() { expect(categoryOf('dpm'), MapLayerCategory.life); }); + test('the device-local layers share the daily-life group', () { + // The mesh nodes and the movement track: both drawn from data on the + // phone rather than fetched, and both things of everyday life. + expect(categoryOf('meshtastic'), MapLayerCategory.life); + expect(categoryOf('location-track'), MapLayerCategory.life); + }); + test('an unknown id resolves deterministically instead of throwing', () { // A layer added after the classification was written must not crash. expect(categoryOf('next-weather-layer'), MapLayerCategory.weather);