diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen.dart b/stronglift_replacement/workout_app/lib/screens/history_screen.dart index 21cef3f..d1f940c 100644 --- a/stronglift_replacement/workout_app/lib/screens/history_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/history_screen.dart @@ -11,8 +11,14 @@ import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:workout_app/models/manual_workout.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; import 'package:workout_app/sandbox/sandbox_log.dart'; +import 'package:workout_app/screens/run_detail_screen.dart'; import 'package:workout_app/services/done_today.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; +import 'package:workout_app/services/run_format.dart'; +import 'package:workout_app/services/run_history_merge.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/workout_sync_service.dart'; import 'package:workout_app/ui/theme.dart'; @@ -23,6 +29,7 @@ part 'history_screen_charts.dart'; part 'history_screen_data.dart'; part 'history_screen_painter.dart'; part 'history_screen_planned.dart'; +part 'history_screen_runs.dart'; part 'history_screen_tiles.dart'; const _kTotal = 'Total (all workouts)'; @@ -44,6 +51,7 @@ class HistoryScreen extends StatefulWidget { this.httpClient, this.firebaseFactory, this.clock, + this.tileProviderOpener = openOsmTileProvider, }); /// Overrides the HTTP client used to read synced workouts (tests only). @@ -56,6 +64,9 @@ class HistoryScreen extends StatefulWidget { /// defaults to [DateTime.now], injected by tests. final DateTime Function()? clock; + /// Where a run's map tiles come from; a fake in tests. + final TileProviderOpener tileProviderOpener; + @override State createState() => _HistoryScreenState(); } @@ -64,6 +75,7 @@ class _HistoryScreenState extends State { List> _rows = []; List> _syncedRows = []; List> _plannedRows = []; + List _runs = []; bool _loading = true; String _selected = _kTotal; List _exerciseNames = []; @@ -103,13 +115,16 @@ class _HistoryScreenState extends State { // coverage:ignore-end } final today = dayKey((widget.clock ?? DateTime.now)()); - final synced = _syncedOnly(remote, today); + final runs = await StorageService.instance.listRuns(); + // The phone's own copy of a run (it has the map) replaces the PC's entry. + final synced = withoutLocalRuns(_syncedOnly(remote, today), runs); final planned = _plannedOnly(remote, today); if (mounted) { setState(() { _rows = rows; _syncedRows = synced; _plannedRows = planned; + _runs = runs; _exerciseNames = names; _selectedState = state; _loading = false; @@ -137,8 +152,6 @@ class _HistoryScreenState extends State { } } - // ── Data helpers ────────────────────────────────────────────────────────── - @override Widget build(BuildContext context) { final allNames = [_kTotal, ..._exerciseNames]; @@ -155,7 +168,10 @@ class _HistoryScreenState extends State { ? const Center(child: CircularProgressIndicator()) // Synced and planned entries count too: a phone with no local // sessions must still list (and let the user delete) its plans. - : _rows.isEmpty && _syncedRows.isEmpty && _plannedRows.isEmpty + : _rows.isEmpty && + _syncedRows.isEmpty && + _plannedRows.isEmpty && + _runs.isEmpty ? Center( child: Text( 'No workouts yet.', @@ -180,28 +196,24 @@ class _HistoryScreenState extends State { ); } + void _shiftMonth(int delta) => setState(() { + _calendarMonth = DateTime( + _calendarMonth.year, + _calendarMonth.month + delta, + ); + }); + List _buildTotalView() => [ ..._plannedSection(), + if (_runs.isNotEmpty) ...[ + _RunsStrip(runs: _runs, onOpen: _openRun), + const SizedBox(height: 16), + ], const _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'), const SizedBox(height: 6), _WeightChart(points: _rollingAvg2(_totalVolumePoints(_rows))), const SizedBox(height: 16), - WorkoutCalendar( - workoutDates: _allWorkoutDates(_rows, _syncedRows), - month: _calendarMonth, - onPrevMonth: () => setState(() { - _calendarMonth = DateTime( - _calendarMonth.year, - _calendarMonth.month - 1, - ); - }), - onNextMonth: () => setState(() { - _calendarMonth = DateTime( - _calendarMonth.year, - _calendarMonth.month + 1, - ); - }), - ), + _calendar(_allWorkoutDates(_rows, _syncedRows, _runs)), const SizedBox(height: 16), const _SectionLabel('ALL SESSIONS'), const SizedBox(height: 8), @@ -223,22 +235,7 @@ class _HistoryScreenState extends State { const SizedBox(height: 6), _WeightChart(points: _exerciseWeightPoints(_rows, name)), const SizedBox(height: 16), - WorkoutCalendar( - workoutDates: _exerciseDates(_rows, name), - month: _calendarMonth, - onPrevMonth: () => setState(() { - _calendarMonth = DateTime( - _calendarMonth.year, - _calendarMonth.month - 1, - ); - }), - onNextMonth: () => setState(() { - _calendarMonth = DateTime( - _calendarMonth.year, - _calendarMonth.month + 1, - ); - }), - ), + _calendar(_exerciseDates(_rows, name)), const SizedBox(height: 16), _SectionLabel(name.toUpperCase()), const SizedBox(height: 8), diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart b/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart index 792f34a..8b59cb1 100644 --- a/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart +++ b/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart @@ -117,12 +117,17 @@ List<(DateTime, double)> _rollingAvg2(List<(DateTime, double)> pts) { /// /// Synced dates are included so a day you only worked out on the PC (a /// RunnerUp run) still marks the calendar here. +/// +/// Runs recorded here count by their local start day, which is also the day +/// of the PC entry they replaced. Set _allWorkoutDates( List> rows, List> syncedRows, + List runs, ) => { ...rows.map((r) => r['date'] as String), ...syncedRows.map((r) => '${r['date']}'), + ...runs.map((r) => dayKey(r.track.startedAt.toLocal())), }; /// Synced kinds this phone has no local record of, so showing them can't diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen_runs.dart b/stronglift_replacement/workout_app/lib/screens/history_screen_runs.dart new file mode 100644 index 0000000..da00e81 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/history_screen_runs.dart @@ -0,0 +1,126 @@ +// The runs overview on HistoryScreen: one fixed-height strip of compact +// tiles (newest first, swipe sideways for older ones) so it costs no +// vertical space (R7). No words: icons and numbers only. +// +// See history_screen_charts.dart for why these are `part` files. +part of 'history_screen.dart'; + +/// Pieces of the State kept here so history_screen.dart stays under the line +/// cap: the month-paged calendar and opening a run's detail. +extension _RunsAndCalendar on _HistoryScreenState { + Widget _calendar(Set dates) => WorkoutCalendar( + workoutDates: dates, + month: _calendarMonth, + onPrevMonth: () => _shiftMonth(-1), + onNextMonth: () => _shiftMonth(1), + ); + + Future _openRun(RunRecord record) async { + final full = await StorageService.instance.getRun(record.track.id); + if (full == null || !mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RunDetailScreen( + track: full.track, + tileProviderOpener: widget.tileProviderOpener, + ), + ), + ); + } +} + +class _RunsStrip extends StatelessWidget { + const _RunsStrip({required this.runs, required this.onOpen}); + + final List runs; + final void Function(RunRecord) onOpen; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return SizedBox( + key: const Key('runs_strip'), + height: 92, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: runs.length, + separatorBuilder: (_, _) => const SizedBox(width: AppSpacing.sm), + itemBuilder: (_, i) => _RunTile( + run: runs[i].track, + scheme: scheme, + onTap: () => onOpen(runs[i]), + ), + ), + ); + } +} + +class _RunTile extends StatelessWidget { + const _RunTile({ + required this.run, + required this.scheme, + required this.onTap, + }); + + final RunTrack run; + final ColorScheme scheme; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final isRun = run.sport == RunSport.running; + final value = TextStyle( + color: scheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: AppTextSize.label, + ); + return Semantics( + button: true, + label: isRun ? 'Run' : 'Walk', + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Container( + width: 104, + padding: const EdgeInsets.all(AppSpacing.sm), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: scheme.outline), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + isRun ? Icons.directions_run : Icons.directions_walk, + size: 18, + color: scheme.primary, + ), + const Spacer(), + Text( + formatRunDay(run.startedAt), + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + ], + ), + Text(formatDistanceKm(run.distanceM), style: value), + Text( + formatDuration(run.totalSeconds), + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/run_detail_screen.dart b/stronglift_replacement/workout_app/lib/screens/run_detail_screen.dart new file mode 100644 index 0000000..c64c494 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/run_detail_screen.dart @@ -0,0 +1,160 @@ +/// One run on one screen: the whole route on a full-bleed map with its +/// numbers (distance, moving time, average pace, date) in a panel over the +/// bottom edge. Nothing scrolls (R7); no words (textgate), icons name each +/// value. +library; + +import 'package:flutter/material.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; +import 'package:workout_app/services/run_format.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/run_map.dart'; + +/// Height reserved under the route for the stats panel, so the fitted route +/// is not hidden behind it. +const _panelClearance = 190.0; + +/// Detail of the finished [track] (it must carry its points). +class RunDetailScreen extends StatelessWidget { + /// Creates the screen; [tileProviderOpener] is swapped by tests. + const RunDetailScreen({ + required this.track, + super.key, + this.tileProviderOpener = openOsmTileProvider, + }); + + /// The run, with points (see `StorageServiceRuns.getRun`). + final RunTrack track; + + /// Where the map's tiles come from. + final TileProviderOpener tileProviderOpener; + + @override + Widget build(BuildContext context) { + final top = MediaQuery.paddingOf(context).top; + return Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + RunMap.route( + points: track.points, + fitPadding: EdgeInsets.fromLTRB( + AppSpacing.xl, + top + 64, + AppSpacing.xl, + _panelClearance, + ), + tileProviderOpener: tileProviderOpener, + ), + Positioned( + left: AppSpacing.sm, + top: top + AppSpacing.sm, + child: IconButton.filledTonal( + onPressed: () => Navigator.maybePop(context), + icon: const Icon(Icons.arrow_back, semanticLabel: 'Back'), + ), + ), + Positioned( + left: AppSpacing.md, + right: AppSpacing.md, + bottom: MediaQuery.paddingOf(context).bottom + AppSpacing.md, + child: _StatsPanel(track: track), + ), + ], + ), + ); + } +} + +class _StatsPanel extends StatelessWidget { + const _StatsPanel({required this.track}); + + final RunTrack track; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final isRun = track.sport == RunSport.running; + return Container( + key: const Key('run_stats_panel'), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: scheme.outline), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon( + isRun ? Icons.directions_run : Icons.directions_walk, + color: scheme.primary, + semanticLabel: isRun ? 'Run' : 'Walk', + ), + const SizedBox(width: AppSpacing.sm), + Text( + formatFullDate(track.startedAt), + style: TextStyle(color: scheme.onSurfaceVariant), + ), + const Spacer(), + if (track.source == RunSource.runnerupImport) + Icon( + Icons.history, + color: scheme.onSurfaceVariant, + semanticLabel: 'Imported', + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + _Stat(Icons.straighten, formatDistanceKm(track.distanceM)), + _Stat(Icons.timer_outlined, formatDuration(track.totalSeconds)), + _Stat( + Icons.speed, + formatPace( + seconds: track.totalSeconds, + distanceM: track.distanceM, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _Stat extends StatelessWidget { + const _Stat(this.icon, this.value); + + final IconData icon; + final String value; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Expanded( + child: Column( + children: [ + Icon(icon, size: 20, color: scheme.onSurfaceVariant), + const SizedBox(height: AppSpacing.xs), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + value, + style: TextStyle( + color: scheme.onSurface, + fontSize: AppTextSize.title, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart b/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart new file mode 100644 index 0000000..a5a6cdf --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/map_tile_cache.dart @@ -0,0 +1,66 @@ +/// The OSM tile source and its on-disk cache. +/// +/// Tiles come from tile.openstreetmap.org and are kept in the app's cache +/// directory (flutter_map_cache over dio_cache_interceptor) with a one-year +/// max-stale, so an area visited once draws offline later. OSM's tile usage +/// policy needs a real User-Agent: the [TileLayer] sends +/// [osmUserAgentPackage]. +library; + +import 'dart:async'; +import 'dart:developer' as developer; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_cache/flutter_map_cache.dart'; +import 'package:http_cache_file_store/http_cache_file_store.dart'; +import 'package:path_provider/path_provider.dart'; + +/// The OSM raster tile template. +const osmTileUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + +/// Sent as the tile User-Agent, as OSM's tile policy asks. +const osmUserAgentPackage = 'com.kuhy.workout_app'; + +/// How long a cached tile may be served when the network cannot refresh it. +const tileMaxStale = Duration(days: 365); + +/// Opens a [TileProvider]; swapped for a fake in tests. +typedef TileProviderOpener = Future Function(); + +/// A provider that caches into [cacheDir] (and serves stale tiles offline). +TileProvider buildCachedTileProvider(String cacheDir, {Dio? dio}) => + CachedTileProvider( + store: FileCacheStore(cacheDir), + maxStale: tileMaxStale, + dio: dio, + ); + +Future? _opened; + +/// The shared disk-cached OSM provider; opened once per process. +/// +/// If the cache directory cannot be had, the map still works over the +/// network without a cache, and the reason is logged at warning level. +Future openOsmTileProvider() => _opened ??= _open(); + +/// Forgets the opened provider (tests only). +@visibleForTesting +void resetOsmTileProvider() => _opened = null; + +Future _open() async { + try { + final dir = await getApplicationCacheDirectory(); + return buildCachedTileProvider('${dir.path}/osm_tiles'); + } on Object catch (error) { + logMapWarning('map tile cache unavailable, tiles will not persist: $error'); + return NetworkTileProvider(); + } +} + +/// Logs [message] at warning level (900), loud in `flutter logs` too. +void logMapWarning(String message) { + developer.log(message, name: 'RunMap', level: 900); + debugPrint('RunMap WARNING: $message'); +} diff --git a/stronglift_replacement/workout_app/lib/services/run_format.dart b/stronglift_replacement/workout_app/lib/services/run_format.dart new file mode 100644 index 0000000..1386da3 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/run_format.dart @@ -0,0 +1,40 @@ +/// Number formatting for run tiles and the run detail screen. +/// +/// Numbers only: textgate bans words in the UI, so there are no unit +/// suffixes. The icon next to a value (distance, clock, pace) names it. +library; + +String _two(int v) => v.toString().padLeft(2, '0'); + +/// Kilometres with two decimals, e.g. `5.20`. +String formatDistanceKm(double distanceM) => + (distanceM / 1000).toStringAsFixed(2); + +/// `m:ss`, or `h:mm:ss` from an hour up; [seconds] is moving time. +String formatDuration(int seconds) { + final h = seconds ~/ 3600; + final m = (seconds ~/ 60).remainder(60); + final s = seconds % 60; + return h > 0 ? '$h:${_two(m)}:${_two(s)}' : '$m:${_two(s)}'; +} + +/// Average pace as `m:ss` per kilometre, or `-:--` when under 10 m was +/// covered (the quotient is meaningless, and 0 would divide by zero). +String formatPace({required int seconds, required double distanceM}) { + if (distanceM < 10) return '-:--'; + final perKm = (seconds / (distanceM / 1000)).round(); + return '${perKm ~/ 60}:${_two(perKm % 60)}'; +} + +/// `dd.MM` of [when] in the device's local time. +String formatRunDay(DateTime when) { + final l = when.toLocal(); + return '${_two(l.day)}.${_two(l.month)}'; +} + +/// `yyyy-MM-dd HH:mm` of [when] in the device's local time. +String formatFullDate(DateTime when) { + final l = when.toLocal(); + return '${l.year}-${_two(l.month)}-${_two(l.day)} ' + '${_two(l.hour)}:${_two(l.minute)}'; +} diff --git a/stronglift_replacement/workout_app/lib/services/run_history_merge.dart b/stronglift_replacement/workout_app/lib/services/run_history_merge.dart new file mode 100644 index 0000000..7cc197d --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/run_history_merge.dart @@ -0,0 +1,59 @@ +/// Merges the phone's own runs into the history list without doubling them. +/// +/// The PC pushes a `runnerup_verified` calendar entry for every day it +/// credited a RunnerUp run or walk. Once the app records (or imports) the same +/// run itself, that entry says nothing the local run does not, and the local +/// one has the map. This hides the duplicate. +/// +/// The key is **day + sport**, not a start time, because that is all the PC +/// entry carries: its record id is `runnerup_verified:` (one slot +/// per day, runs and walks merged), and the payload holds `date`, +/// `distance_km`, `duration_minutes`, an optional `completed_at` (the *end*, +/// often absent) and `activity: walking` for a day of walks. There is no +/// start time to match within a window. Sport keeps a stock-RunnerUp run +/// visible next to an app-recorded walk on the same day. +/// +/// When nothing matches, both stay visible: a duplicate is a nuisance, a +/// hidden credited run is a lie. +library; + +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/done_today.dart'; + +/// The `kind` the PC gives its run/walk entries. +const kRunnerUpVerifiedKind = 'runnerup_verified'; + +String _key(RunSport sport, String day) => '${sport.tcxName}|$day'; + +/// The `YYYY-MM-DD` part of a PC entry's `date`. +String _entryDay(Map payload) { + final date = '${payload['date']}'; + return date.length > 10 ? date.substring(0, 10) : date; +} + +/// The sport a PC entry stands for: a day of walks says so, anything else is +/// a run. +RunSport _entrySport(Map payload) => + payload['activity'] == 'walking' ? RunSport.walking : RunSport.running; + +/// [synced] without the `runnerup_verified` entries that [runs] already +/// cover (same local day and same sport). +/// +/// Days are the device's local calendar days, the calendar the PC dates its +/// records by (`dayKey`). Other kinds pass through untouched. +List> withoutLocalRuns( + List> synced, + Iterable runs, +) { + final covered = { + for (final r in runs) + _key(r.track.sport, dayKey(r.track.startedAt.toLocal())), + }; + return [ + for (final p in synced) + if (p['kind'] != kRunnerUpVerifiedKind || + !covered.contains(_key(_entrySport(p), _entryDay(p)))) + p, + ]; +} diff --git a/stronglift_replacement/workout_app/lib/widgets/run_map.dart b/stronglift_replacement/workout_app/lib/widgets/run_map.dart new file mode 100644 index 0000000..582d3db --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/run_map.dart @@ -0,0 +1,204 @@ +/// The map of a run: route polyline, start/end markers, live position. +/// +/// Two constructors, both drop-ins (no other required argument): +/// +/// * `RunMap.route(points: track.points)` -- a finished run. The camera +/// fits the whole route ([fitPadding] keeps it clear of overlays). +/// * `RunMap.live(trackListenable: notifier)` -- the live screen. Pass a +/// `ValueListenable` (a `ValueNotifier`, or the +/// recorder's own, anything that holds the current track); the route +/// redraws on every change and, in follow mode (the default), the camera +/// keeps the last point centred. Dragging the map ends follow mode; the +/// recentre button (icon only) turns it back on. The listenable must stay +/// the same object for the widget's lifetime (give it a new `Key` to swap). +/// +/// Tiles are OSM raster tiles from tile.openstreetmap.org, cached on disk +/// (`map_tile_cache.dart`) so revisited areas draw offline. The OSM credit is +/// a collapsed info icon; its text shows only when tapped. When no tile +/// loads (offline, first visit) the route still draws on the plain +/// background and ONE warning-level line is logged per map. +/// +/// Tests inject `tileProviderOpener` with a fake provider: nothing here +/// touches the network by itself. +library; + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/run_map_layers.dart'; + +/// Where an empty map starts: Poland, zoomed out, until a first fix. +const _fallbackCenter = LatLng(52, 19); +const _fallbackZoom = 5.0; + +/// The zoom a one-point track or a first live fix gets. +const _closeZoom = 17.0; + +/// A map of a run; see the library comment for the two constructors. +class RunMap extends StatefulWidget { + /// A finished run: [points] drawn and fitted. + const RunMap.route({ + required List this.points, + super.key, + this.fitPadding = const EdgeInsets.all(40), + this.tileProviderOpener = openOsmTileProvider, + }) : trackListenable = null; + + /// A live run: follows the last point of the track in [trackListenable]. + const RunMap.live({ + required ValueListenable this.trackListenable, + super.key, + this.tileProviderOpener = openOsmTileProvider, + }) : points = null, + fitPadding = const EdgeInsets.all(40); + + /// The route in detail mode; null in live mode. + final List? points; + + /// The current track in live mode; null in detail mode. + final ValueListenable? trackListenable; + + /// Space kept free around the fitted route (detail mode). + final EdgeInsets fitPadding; + + /// Where the tile provider comes from; tests pass a fake. + final TileProviderOpener tileProviderOpener; + + @override + State createState() => _RunMapState(); +} + +class _RunMapState extends State { + final _controller = MapController(); + TileProvider? _tiles; + bool _ready = false; + bool _follow = true; + bool _warned = false; + + bool get _live => widget.trackListenable != null; + + List get _points => _live + ? widget.trackListenable!.value?.points ?? const [] + : widget.points!; + + @override + void initState() { + super.initState(); + widget.trackListenable?.addListener(_followLast); + unawaited( + widget.tileProviderOpener().then((p) { + if (mounted) setState(() => _tiles = p); + }), + ); + } + + @override + void dispose() { + widget.trackListenable?.removeListener(_followLast); + _controller.dispose(); + super.dispose(); + } + + void _followLast() { + final coords = toLatLngs(_points); + if (!_ready || !_follow || coords.isEmpty) return; + final zoom = _controller.camera.zoom; + _controller.move(coords.last, zoom < 12 ? _closeZoom : zoom); + } + + void _warnOnce(Object error) { + if (_warned) return; + _warned = true; + logMapWarning( + 'map tiles failed to load ($error); drawing the route on a plain ' + 'background', + ); + } + + MapOptions _options(List coords, ColorScheme scheme) { + final fit = !_live && coords.length > 1 + ? CameraFit.coordinates( + coordinates: coords, + padding: widget.fitPadding, + maxZoom: _closeZoom, + ) + : null; + return MapOptions( + initialCenter: coords.isEmpty ? _fallbackCenter : coords.last, + initialZoom: coords.isEmpty ? _fallbackZoom : _closeZoom, + initialCameraFit: fit, + backgroundColor: scheme.surfaceContainerHigh, + onMapReady: () { + _ready = true; + _followLast(); + }, + onPositionChanged: (camera, byGesture) { + if (_live && byGesture && _follow) setState(() => _follow = false); + }, + ); + } + + Widget _map(BuildContext context, List points) { + final scheme = Theme.of(context).colorScheme; + final coords = toLatLngs(points); + return FlutterMap( + mapController: _controller, + options: _options(coords, scheme), + children: [ + if (_tiles != null) + TileLayer( + urlTemplate: osmTileUrl, + userAgentPackageName: osmUserAgentPackage, + tileProvider: _tiles, + errorTileCallback: (tile, error, stack) => _warnOnce(error), + ), + if (coords.length > 1) routeLayer(coords, scheme), + if (coords.isNotEmpty) + endpointLayer( + coords, + live: _live, + scheme: scheme, + status: Theme.of(context).extension()!, + ), + attributionButton(scheme), + ], + ); + } + + @override + Widget build(BuildContext context) { + final listenable = widget.trackListenable; + if (listenable == null) return _map(context, widget.points!); + return Stack( + fit: StackFit.expand, + children: [ + ValueListenableBuilder( + valueListenable: listenable, + builder: (context, track, _) => + _map(context, track?.points ?? const []), + ), + if (!_follow) + Positioned( + left: AppSpacing.sm, + bottom: AppSpacing.sm, + child: IconButton.filled( + onPressed: () { + setState(() => _follow = true); + _followLast(); + }, + icon: const Icon( + Icons.my_location, + semanticLabel: 'Follow position', + ), + ), + ), + ], + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/widgets/run_map_layers.dart b/stronglift_replacement/workout_app/lib/widgets/run_map_layers.dart new file mode 100644 index 0000000..57c4c82 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/run_map_layers.dart @@ -0,0 +1,82 @@ +/// The route, endpoint markers and attribution button drawn by `RunMap`. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/ui/theme.dart'; + +/// [points] as map coordinates. +List toLatLngs(List points) => [ + for (final p in points) LatLng(p.latitude, p.longitude), +]; + +/// The route as one polyline; drawn even when no tile ever loads. +PolylineLayer routeLayer(List coords, ColorScheme scheme) => + PolylineLayer( + polylines: [ + Polyline( + points: coords, + strokeWidth: 5, + color: scheme.primary, + borderStrokeWidth: 2, + borderColor: scheme.surface, + ), + ], + ); + +Marker _dot(LatLng at, Color fill, IconData icon, Color ink) => Marker( + point: at, + child: DecoratedBox( + decoration: BoxDecoration( + color: fill, + shape: BoxShape.circle, + border: Border.all(color: ink, width: 2), + ), + child: Icon(icon, size: 16, color: ink), + ), +); + +/// Start marker (green play), and either the live position (accent dot) or +/// the finish flag (red); no end marker for a one-point track. +MarkerLayer endpointLayer( + List coords, { + required bool live, + required ColorScheme scheme, + required AppStatusColors status, +}) { + final start = _dot( + coords.first, + status.success, + Icons.play_arrow, + scheme.surface, + ); + final last = coords.last; + return MarkerLayer( + markers: [ + start, + if (live) + _dot(last, scheme.primary, Icons.navigation, scheme.surface) + else if (coords.length > 1) + _dot(last, scheme.error, Icons.flag, scheme.surface), + ], + ); +} + +/// The collapsed, icon-only OSM credit; the (c) text shows only when tapped. +RichAttributionWidget attributionButton(ColorScheme scheme) => + RichAttributionWidget( + showFlutterMapAttribution: false, + attributions: const [TextSourceAttribution('OpenStreetMap contributors')], + popupBackgroundColor: scheme.surfaceContainerHigh, + openButton: (context, open) => IconButton( + onPressed: open, + icon: Icon( + Icons.info_outline, + size: 20, + color: scheme.onSurface, + semanticLabel: 'Map attribution', + ), + ), + ); diff --git a/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart b/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart index 0f2fe0d..f8a1754 100644 --- a/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart +++ b/stronglift_replacement/workout_app/test/screens/_history_test_fixtures.dart @@ -12,6 +12,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:workout_app/screens/history_screen.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; @@ -35,12 +36,14 @@ Widget wrapHistory({ http.Client? httpClient, Future Function()? firebaseFactory, DateTime Function()? clock, + TileProviderOpener? tileProviderOpener, }) => MaterialApp( theme: buildAppTheme(), home: HistoryScreen( httpClient: httpClient, firebaseFactory: firebaseFactory ?? () async => null, clock: clock, + tileProviderOpener: tileProviderOpener ?? openOsmTileProvider, ), ); diff --git a/stronglift_replacement/workout_app/test/screens/history_runs_test.dart b/stronglift_replacement/workout_app/test/screens/history_runs_test.dart new file mode 100644 index 0000000..a32d981 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/history_runs_test.dart @@ -0,0 +1,157 @@ +// Runs in the history: the overview strip, the one-entry-per-run merge with +// the PC's runnerup_verified rows, the calendar, and opening the detail. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/screens/run_detail_screen.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/services/tcx_codec.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/widgets/calendar_widget.dart'; + +import '../fake_secure_storage.dart'; +import '../widgets/_run_map_fixtures.dart'; +import '_history_test_fixtures.dart'; + +// historySyncMock() holds a PC run and a PC manual workout, both dated this. +final _pcDay = DateTime(2026, 7, 13, 12); + +Future _seedRun( + WidgetTester tester, + DateTime startedAt, { + RunSport sport = RunSport.running, +}) async { + final track = buildTrack( + startedAt: startedAt.toUtc(), + sport: sport, + count: 3, + ); + await tester.runAsync( + () => StorageService.instance.insertRun( + track, + tcxFilename: 'run_${track.id}.tcx', + tcxSha256: tcxSha256(encodeTcx(track)), + uploadState: RunUploadState.pending, + ), + ); +} + +Future _pump(WidgetTester tester, {bool withPc = false}) async { + if (withPc) installFakeSecureStorage(initial: {'sync.token': 'tok'}); + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2.625; + addTearDown(tester.view.reset); + await pumpHistory( + tester, + wrapHistory( + httpClient: withPc ? historySyncMock() : null, + tileProviderOpener: fakeTiles(), + ), + ); +} + +Future _scrollToSynced(WidgetTester tester) => tester.scrollUntilVisible( + find.text('SYNCED FROM PC'), + 200, + scrollable: find.byType(Scrollable).first, +); + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + }); + + testWidgets('runs alone are history: no empty state, a tile per run', ( + tester, + ) async { + await _seedRun(tester, DateTime(2026, 7, 13, 12)); + await _seedRun(tester, DateTime(2026, 7, 14, 12), sport: RunSport.walking); + await _pump(tester); + expect(find.text('No workouts yet.'), findsNothing); + expect(find.byKey(const Key('runs_strip')), findsOneWidget); + expect(find.text('5.20'), findsNWidgets(2)); + expect(find.text('32:10'), findsNWidgets(2)); + expect(find.text('14.07'), findsOneWidget); + expect(find.byIcon(Icons.directions_walk), findsOneWidget); + }); + + testWidgets('no runs: no strip', (tester) async { + await _pump(tester); + expect(find.byKey(const Key('runs_strip')), findsNothing); + }); + + testWidgets('the local run replaces the PC entry of its day, once', ( + tester, + ) async { + await _seedRun(tester, _pcDay); + await _pump(tester, withPc: true); + await _scrollToSynced(tester); + // The PC's manual workout of that day stays; its run entry is gone. + expect(find.textContaining('2026-07-13 · Manual'), findsOneWidget); + expect(find.textContaining('2026-07-13 · Run'), findsNothing); + expect(find.text('5.20'), findsOneWidget); + }); + + testWidgets('a run on another day leaves the PC entry visible', ( + tester, + ) async { + await _seedRun(tester, DateTime(2026, 7, 20, 12)); + await _pump(tester, withPc: true); + await _scrollToSynced(tester); + expect(find.textContaining('2026-07-13 · Run'), findsOneWidget); + expect(find.text('5.20'), findsOneWidget); + }); + + testWidgets('a run marks its day on the calendar', (tester) async { + await _seedRun(tester, DateTime(2026, 7, 13, 12)); + await _pump(tester); + final calendar = tester.widget( + find.byType(WorkoutCalendar), + ); + expect(calendar.workoutDates, contains('2026-07-13')); + }); + + testWidgets('tapping a run opens its detail, back returns', (tester) async { + await _seedRun(tester, DateTime(2026, 7, 13, 12)); + await _pump(tester); + await tester.tap(find.text('5.20')); + await tester.pumpAndSettle(); + // getRun is real sqflite: let it finish on the real event loop. + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pumpAndSettle(); + expect(find.byType(RunDetailScreen), findsOneWidget); + expect(find.text('2026-07-13 12:00'), findsOneWidget); + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + expect(find.byType(RunDetailScreen), findsNothing); + expect(find.byKey(const Key('runs_strip')), findsOneWidget); + }); + + testWidgets('a run deleted before the tap lands opens nothing', ( + tester, + ) async { + await _seedRun(tester, DateTime(2026, 7, 13, 12)); + await _pump(tester); + await tester.runAsync( + () => StorageService.instance.deleteRun( + RunTrack.idFor(DateTime(2026, 7, 13, 12).toUtc()), + ), + ); + await tester.tap(find.text('5.20')); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pumpAndSettle(); + expect(find.byType(RunDetailScreen), findsNothing); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/run_detail_screen_test.dart b/stronglift_replacement/workout_app/test/screens/run_detail_screen_test.dart new file mode 100644 index 0000000..e09c2c9 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/run_detail_screen_test.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/screens/run_detail_screen.dart'; +import 'package:workout_app/widgets/run_map.dart'; + +import '../widgets/_run_map_fixtures.dart'; + +// Pixel 6a, physical px (adb dumpsys window), per build-mobile-app R7. +const _phonePhysical = Size(1080, 2400); +const _phoneDpr = 2.625; + +Future _pump(WidgetTester tester, RunTrack track) async { + await tester.pumpWidget( + themed(RunDetailScreen(track: track, tileProviderOpener: fakeTiles())), + ); + await tester.pump(); + await tester.pump(); +} + +void _phone(WidgetTester tester) { + tester.view.physicalSize = _phonePhysical; + tester.view.devicePixelRatio = _phoneDpr; + tester.view.padding = const FakeViewPadding(top: 132, bottom: 63); + addTearDown(tester.view.reset); +} + +void main() { + setUpAll(loadRoboto); + + testWidgets('shows the route map and the four numbers', (tester) async { + _phone(tester); + final track = buildTrack(startedAt: DateTime(2026, 11, 10, 6, 30).toUtc()); + await _pump(tester, track); + expect(find.byType(RunMap), findsOneWidget); + expect(find.text('5.20'), findsOneWidget); + expect(find.text('32:10'), findsOneWidget); + expect(find.text('6:11'), findsOneWidget); + expect(find.text('2026-11-10 06:30'), findsOneWidget); + expect(find.byIcon(Icons.directions_run), findsOneWidget); + expect(find.byIcon(Icons.history), findsNothing); + }); + + testWidgets('a walk gets the walking icon, an import the history mark', ( + tester, + ) async { + _phone(tester); + await _pump( + tester, + buildTrack(sport: RunSport.walking, source: RunSource.runnerupImport), + ); + expect(find.byIcon(Icons.directions_walk), findsOneWidget); + expect(find.byIcon(Icons.directions_run), findsNothing); + expect(find.byIcon(Icons.history), findsOneWidget); + }); + + testWidgets('no words: every visible text is digits and separators', ( + tester, + ) async { + _phone(tester); + await _pump(tester, buildTrack(totalSeconds: 3725)); + final numeric = RegExp(r'^[0-9:.\- ]+$'); + final words = tester + .widgetList(find.byType(Text)) + .map((t) => t.data ?? '') + // The OSM credit is mandatory; it is fully transparent until the + // collapsed info icon is tapped (see run_map_test.dart). + .where((t) => !numeric.hasMatch(t) && !t.contains('OpenStreetMap')); + expect(words, isEmpty); + expect(find.text('1:02:05'), findsOneWidget); + }); + + testWidgets('a zero-distance run has a dash pace, not a crash', ( + tester, + ) async { + _phone(tester); + await _pump(tester, buildTrack(count: 1, distanceM: 0, totalSeconds: 0)); + expect(find.text('-:--'), findsOneWidget); + expect(find.text('0.00'), findsOneWidget); + }); + + testWidgets('the back button leaves the screen', (tester) async { + _phone(tester); + final track = buildTrack(); + await tester.pumpWidget( + themed( + Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RunDetailScreen( + track: track, + tileProviderOpener: fakeTiles(), + ), + ), + ), + child: const Icon(Icons.add), + ), + ), + ), + ); + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + expect(find.byType(RunDetailScreen), findsOneWidget); + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + expect(find.byType(RunDetailScreen), findsNothing); + }); + + testWidgets('Pixel 6a insets: one screen, nothing scrolls', (tester) async { + _phone(tester); + await _pump(tester, buildTrack(totalSeconds: 3725)); + final scrollables = tester.stateList( + find.byType(Scrollable), + ); + for (final s in scrollables) { + expect(s.position.maxScrollExtent, 0); + } + // With no list on the screen the loop above is vacuously true, so also + // pin the geometry: panel above the nav bar, back button below the + // status bar. + final viewport = _phonePhysical / _phoneDpr; + final panel = tester.getRect(find.byKey(const Key('run_stats_panel'))); + expect(panel.bottom, lessThanOrEqualTo(viewport.height - 63 / _phoneDpr)); + expect(panel.left, greaterThanOrEqualTo(0)); + expect(panel.right, lessThanOrEqualTo(viewport.width)); + final back = tester.getRect(find.byIcon(Icons.arrow_back)); + expect(back.top, greaterThanOrEqualTo(132 / _phoneDpr)); + expect(tester.takeException(), isNull); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/map_tile_cache_test.dart b/stronglift_replacement/workout_app/test/services/map_tile_cache_test.dart new file mode 100644 index 0000000..9b12089 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/map_tile_cache_test.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_cache/flutter_map_cache.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; + +import '../fake_path_provider.dart'; +import '../widgets/_run_map_fixtures.dart'; + +/// A platform with no cache directory (getApplicationCacheDirectory throws). +class _NoCacheDir extends PathProviderPlatform { + @override + Future getApplicationCachePath() async => null; +} + +void main() { + setUp(resetOsmTileProvider); + + test('the OSM source and policy constants', () { + expect(osmTileUrl, 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'); + expect(osmUserAgentPackage, 'com.kuhy.workout_app'); + expect(tileMaxStale, const Duration(days: 365)); + }); + + test('buildCachedTileProvider caches on disk under the given dir', () { + final dir = Directory.systemTemp.createTempSync('tile_cache_test_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final provider = buildCachedTileProvider('${dir.path}/osm_tiles'); + expect(provider, isA()); + expect(provider.supportsCancelLoading, isTrue); + }); + + test('openOsmTileProvider opens a cached provider once', () async { + final dir = installFakePathProvider(); + addTearDown(() => dir.deleteSync(recursive: true)); + final first = await openOsmTileProvider(); + expect(first, isA()); + expect(identical(await openOsmTileProvider(), first), isTrue); + }); + + test('without a cache dir: network provider and a warning', () async { + PathProviderPlatform.instance = _NoCacheDir(); + late TileProvider provider; + final lines = await capturingDebugPrint(() async { + provider = await openOsmTileProvider(); + }); + expect(provider, isA()); + expect( + lines.where((l) => l.contains('WARNING') && l.contains('persist')), + hasLength(1), + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/run_format_test.dart b/stronglift_replacement/workout_app/test/services/run_format_test.dart new file mode 100644 index 0000000..9e00fdb --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/run_format_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/run_format.dart'; + +void main() { + test('distance is km with two decimals', () { + expect(formatDistanceKm(5200), '5.20'); + expect(formatDistanceKm(0), '0.00'); + expect(formatDistanceKm(12345.6), '12.35'); + }); + + test('duration is m:ss under an hour, h:mm:ss from it', () { + expect(formatDuration(0), '0:00'); + expect(formatDuration(65), '1:05'); + expect(formatDuration(3599), '59:59'); + expect(formatDuration(3600), '1:00:00'); + expect(formatDuration(3725), '1:02:05'); + }); + + test('pace is m:ss per km, and a dash when nothing was covered', () { + expect(formatPace(seconds: 1800, distanceM: 5000), '6:00'); + expect(formatPace(seconds: 1930, distanceM: 5200), '6:11'); + expect(formatPace(seconds: 100, distanceM: 9.9), '-:--'); + expect(formatPace(seconds: 100, distanceM: 0), '-:--'); + }); + + test('dates are shown in local time', () { + final when = DateTime(2026, 11, 3, 7, 5); + expect(formatRunDay(when.toUtc()), '03.11'); + expect(formatFullDate(when.toUtc()), '2026-11-03 07:05'); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/run_history_merge_test.dart b/stronglift_replacement/workout_app/test/services/run_history_merge_test.dart new file mode 100644 index 0000000..7a0e1f1 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/run_history_merge_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/run_record.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/run_history_merge.dart'; + +RunRecord _run( + DateTime startedAt, { + RunSport sport = RunSport.running, + RunSource source = RunSource.app, +}) => RunRecord( + track: RunTrack.started( + sport: sport, + startedAt: startedAt, + points: const [], + totalSeconds: 1800, + distanceM: 5000, + source: source, + ), + uploadState: RunUploadState.never, + tcxFilename: 'x.tcx', + tcxSha256: 'x', +); + +Map _pc( + String date, { + String kind = kRunnerUpVerifiedKind, + String? activity, +}) => { + 'kind': kind, + 'date': date, + 'distance_km': 5.0, + if (activity != null) 'activity': activity, +}; + +void main() { + // Local noon: the same calendar day in every time zone the suite may run in. + final noon = DateTime(2026, 11, 9, 12); + + test('a local run hides the PC run entry of its day', () { + final out = withoutLocalRuns([_pc('2026-11-09')], [_run(noon)]); + expect(out, isEmpty); + }); + + test('another day, or no local run, keeps the PC entry', () { + final entries = [_pc('2026-11-08'), _pc('2026-11-09')]; + expect(withoutLocalRuns(entries, [_run(noon)]), [_pc('2026-11-08')]); + expect(withoutLocalRuns(entries, const []), entries); + }); + + test('an imported run covers the day too', () { + final out = withoutLocalRuns([ + _pc('2026-11-09'), + ], [_run(noon, source: RunSource.runnerupImport)]); + expect(out, isEmpty); + }); + + test('sport must match: a walk does not hide a run, nor a run a walk', () { + final walk = _run(noon, sport: RunSport.walking); + final pcRun = _pc('2026-11-09'); + final pcWalk = _pc('2026-11-09', activity: 'walking'); + expect(withoutLocalRuns([pcRun], [walk]), [pcRun]); + expect(withoutLocalRuns([pcWalk], [walk]), isEmpty); + expect(withoutLocalRuns([pcWalk], [_run(noon)]), [pcWalk]); + }); + + test('other kinds are never hidden', () { + final manual = _pc('2026-11-09', kind: 'manual_workout'); + expect(withoutLocalRuns([manual], [_run(noon)]), [manual]); + }); + + test('a full timestamp date still matches on its day', () { + final out = withoutLocalRuns([_pc('2026-11-09T06:30:00')], [_run(noon)]); + expect(out, isEmpty); + }); + + test('the day is the local one, not the UTC one', () { + // 00:30 local on the 9th is the 8th in UTC for any zone east of UTC. + final early = DateTime(2026, 11, 9, 0, 30); + expect(withoutLocalRuns([_pc('2026-11-09')], [_run(early)]), isEmpty); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/_run_map_fixtures.dart b/stronglift_replacement/workout_app/test/widgets/_run_map_fixtures.dart new file mode 100644 index 0000000..be89ed8 --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/_run_map_fixtures.dart @@ -0,0 +1,119 @@ +// Shared fixtures for the run map / run detail / history-runs tests. +// +// The map never gets a real tile provider here: [FakeTileProvider] serves a +// 1x1 transparent PNG (or an image that fails to load, to play "offline"), +// so no test can reach tile.openstreetmap.org. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/ui/theme.dart'; + +final _transparentPng = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA' + '60e6kgAAAABJRU5ErkJggg==', +); + +/// An image that always fails, as a tile does with no network and no cache. +class FailingImage extends ImageProvider { + /// Creates the failing image. + const FailingImage(); + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + FailingImage key, + ImageDecoderCallback decode, + ) => OneFrameImageStreamCompleter( + Future.error(StateError('no network')), + ); +} + +/// A [TileProvider] that never touches the network. +class FakeTileProvider extends TileProvider { + /// Serves transparent tiles, or failing ones when [offline]. + FakeTileProvider({this.offline = false}); + + /// Whether every tile fails to load. + final bool offline; + + @override + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => + offline ? const FailingImage() : MemoryImage(_transparentPng); +} + +/// Opener returning a fresh [FakeTileProvider]. +Future Function() fakeTiles({bool offline = false}) => + () async => FakeTileProvider(offline: offline); + +/// A track of [count] fixes heading north-east from Warsaw. +RunTrack buildTrack({ + int count = 5, + RunSport sport = RunSport.running, + RunSource source = RunSource.app, + DateTime? startedAt, + double distanceM = 5200, + int totalSeconds = 1930, +}) { + final start = startedAt ?? DateTime.utc(2026, 11, 10, 6, 30); + return RunTrack.started( + sport: sport, + startedAt: start, + totalSeconds: totalSeconds, + distanceM: distanceM, + source: source, + points: [ + for (var i = 0; i < count; i++) + RunPoint( + time: start.add(Duration(seconds: i * 10)), + latitude: 52.2297 + i * 0.001, + longitude: 21.0122 + i * 0.0015, + distanceM: i * 100.0, + ), + ], + ); +} + +/// Wraps [child] in the app theme, filling a phone-sized surface. +Widget themed(Widget child) => + MaterialApp(theme: buildAppTheme(), home: child); + +/// Runs [body] and returns every `debugPrint` line it wrote. +/// +/// Restored before returning: the test binding fails a test that leaves +/// `debugPrint` swapped, and it checks that before any tearDown runs. +Future> capturingDebugPrint(Future Function() body) async { + final lines = []; + final original = debugPrint; + debugPrint = (message, {wrapWidth}) => lines.add(message ?? ''); + try { + await body(); + } finally { + debugPrint = original; + } + return lines; +} + +/// Registers the SDK's Roboto as the test font, so widths mean something. +/// +/// The default test font draws every glyph one em wide; this is the same +/// loader as in `workout_layout_test.dart`. +Future loadRoboto() async { + final cache = File(Platform.resolvedExecutable).parent.parent.parent.parent; + final loader = FontLoader('Roboto'); + for (final weight in ['Regular', 'Medium', 'Bold']) { + final ttf = File( + '${cache.path}/artifacts/material_fonts/Roboto-$weight.ttf', + ); + loader.addFont(Future.value(ByteData.sublistView(ttf.readAsBytesSync()))); + } + await loader.load(); +} diff --git a/stronglift_replacement/workout_app/test/widgets/run_map_test.dart b/stronglift_replacement/workout_app/test/widgets/run_map_test.dart new file mode 100644 index 0000000..28b0b61 --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/run_map_test.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:workout_app/models/run_track.dart'; +import 'package:workout_app/services/map_tile_cache.dart'; +import 'package:workout_app/widgets/run_map.dart'; + +import '_run_map_fixtures.dart'; + +Future _pump(WidgetTester tester, Widget map) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(themed(map)); + await tester.pump(); + await tester.pump(); +} + +MapCamera _camera(WidgetTester tester) => + MapCamera.of(tester.element(find.byType(RichAttributionWidget))); + +void main() { + group('RunMap.route', () { + testWidgets('draws one polyline, start and end markers', (tester) async { + final track = buildTrack(); + await _pump( + tester, + RunMap.route(points: track.points, tileProviderOpener: fakeTiles()), + ); + final line = tester.widget(find.byType(PolylineLayer)); + expect(line.polylines, hasLength(1)); + expect(line.polylines.single.points, hasLength(track.points.length)); + final markers = tester.widget(find.byType(MarkerLayer)); + expect(markers.markers, hasLength(2)); + }); + + testWidgets('asks OSM with the real user agent package', (tester) async { + await _pump( + tester, + RunMap.route( + points: buildTrack().points, + tileProviderOpener: fakeTiles(), + ), + ); + final tiles = tester.widget(find.byType(TileLayer)); + expect(tiles.urlTemplate, contains('tile.openstreetmap.org')); + // TileLayer folds userAgentPackageName into the provider's headers. + expect( + tiles.tileProvider.headers['User-Agent'], + contains('com.kuhy.workout_app'), + ); + }); + + testWidgets('fits every point on screen', (tester) async { + final track = buildTrack(); + await _pump( + tester, + RunMap.route(points: track.points, tileProviderOpener: fakeTiles()), + ); + final camera = _camera(tester); + final area = Offset.zero & const Size(400, 600); + for (final p in track.points) { + final at = camera.latLngToScreenOffset( + LatLng(p.latitude, p.longitude), + ); + expect(area.contains(at), isTrue, reason: '$at is off screen'); + } + }); + + testWidgets('collapsed attribution shows no words until tapped', ( + tester, + ) async { + await _pump( + tester, + RunMap.route( + points: buildTrack().points, + tileProviderOpener: fakeTiles(), + ), + ); + // flutter_map keeps the credit in the tree and fades it in on tap, so + // "no visible words" means: fully transparent until the icon is tapped. + final credit = find.textContaining('OpenStreetMap'); + expect(find.byType(Text), findsOneWidget); + double opacity() => tester + .widget( + find + .ancestor(of: credit, matching: find.byType(AnimatedOpacity)) + .first, + ) + .opacity; + expect(opacity(), 0); + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + expect(opacity(), 1); + }); + + testWidgets('one point: a marker, no line, no crash', (tester) async { + await _pump( + tester, + RunMap.route( + points: buildTrack(count: 1).points, + tileProviderOpener: fakeTiles(), + ), + ); + expect(find.byType(PolylineLayer), findsNothing); + expect( + tester.widget(find.byType(MarkerLayer)).markers, + hasLength(1), + ); + }); + + testWidgets('no points: plain map, no layers', (tester) async { + await _pump( + tester, + RunMap.route(points: const [], tileProviderOpener: fakeTiles()), + ); + expect(find.byType(PolylineLayer), findsNothing); + expect(find.byType(MarkerLayer), findsNothing); + expect(find.byType(FlutterMap), findsOneWidget); + }); + + testWidgets('no tile layer until the provider has opened', (tester) async { + final opening = Completer(); + await _pump( + tester, + RunMap.route( + points: buildTrack().points, + tileProviderOpener: () => opening.future, + ), + ); + expect(find.byType(TileLayer), findsNothing); + expect(find.byType(PolylineLayer), findsOneWidget); + opening.complete(FakeTileProvider()); + await tester.pump(); + await tester.pump(); + expect(find.byType(TileLayer), findsOneWidget); + }); + + testWidgets('offline: route still drawn, one loud warning', (tester) async { + final lines = await capturingDebugPrint(() async { + await _pump( + tester, + RunMap.route( + points: buildTrack().points, + tileProviderOpener: fakeTiles(offline: true), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + }); + expect(find.byType(PolylineLayer), findsOneWidget); + final warnings = lines.where((l) => l.contains('WARNING')); + expect(warnings, hasLength(1), reason: lines.join('\n')); + expect(warnings.single, contains('plain background')); + }); + }); + + group('RunMap.live', () { + testWidgets('follows the last point and draws as the track grows', ( + tester, + ) async { + final track = ValueNotifier(null); + addTearDown(track.dispose); + await _pump( + tester, + RunMap.live(trackListenable: track, tileProviderOpener: fakeTiles()), + ); + expect(find.byType(PolylineLayer), findsNothing); + expect(find.byType(MarkerLayer), findsNothing); + + final full = buildTrack(count: 4); + track.value = full; + await tester.pump(); + expect(find.byType(PolylineLayer), findsOneWidget); + expect( + tester.widget(find.byType(MarkerLayer)).markers, + hasLength(2), + ); + final last = full.points.last; + final center = _camera(tester).center; + expect(center.latitude, closeTo(last.latitude, 1e-6)); + expect(center.longitude, closeTo(last.longitude, 1e-6)); + expect(find.byIcon(Icons.my_location), findsNothing); + }); + + testWidgets('a drag ends follow mode, the button resumes it', ( + tester, + ) async { + final full = buildTrack(count: 4); + final track = ValueNotifier(full); + addTearDown(track.dispose); + await _pump( + tester, + RunMap.live(trackListenable: track, tileProviderOpener: fakeTiles()), + ); + await tester.drag(find.byType(FlutterMap), const Offset(120, 0)); + await tester.pump(); + expect(find.byIcon(Icons.my_location), findsOneWidget); + final dragged = _camera(tester).center.longitude; + expect(dragged, isNot(closeTo(full.points.last.longitude, 1e-6))); + + // New fixes no longer pull the camera while follow is off. + track.value = buildTrack(count: 6); + await tester.pump(); + expect(_camera(tester).center.longitude, closeTo(dragged, 1e-9)); + + await tester.tap(find.byIcon(Icons.my_location)); + await tester.pump(); + expect(find.byIcon(Icons.my_location), findsNothing); + final last = track.value!.points.last; + expect(_camera(tester).center.longitude, closeTo(last.longitude, 1e-6)); + }); + + testWidgets('stops listening when disposed', (tester) async { + final track = ValueNotifier(buildTrack()); + addTearDown(track.dispose); + await _pump( + tester, + RunMap.live(trackListenable: track, tileProviderOpener: fakeTiles()), + ); + await tester.pumpWidget(const SizedBox()); + track.value = buildTrack(count: 7); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + }); +}