Add RunMap, run detail and runs in history

RunMap.route/.live (flutter_map, OSM tiles cached on disk,
collapsed icon-only attribution, one warning when tiles fail),
RunDetailScreen (full-bleed map + numbers, no scrolling), a runs
strip in the history, and day+sport dedupe that hides the PC's
runnerup_verified entry once the phone has the run itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 16:00:01 +01:00
parent 63caf9dee9
commit b4e69dbff4
17 changed files with 1581 additions and 36 deletions

View File

@ -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<HistoryScreen> createState() => _HistoryScreenState();
}
@ -64,6 +75,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
List<Map<String, dynamic>> _rows = [];
List<Map<String, dynamic>> _syncedRows = [];
List<Map<String, dynamic>> _plannedRows = [];
List<RunRecord> _runs = [];
bool _loading = true;
String _selected = _kTotal;
List<String> _exerciseNames = [];
@ -103,13 +115,16 @@ class _HistoryScreenState extends State<HistoryScreen> {
// 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<HistoryScreen> {
}
}
// ── Data helpers ──────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final allNames = [_kTotal, ..._exerciseNames];
@ -155,7 +168,10 @@ class _HistoryScreenState extends State<HistoryScreen> {
? 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<HistoryScreen> {
);
}
void _shiftMonth(int delta) => setState(() {
_calendarMonth = DateTime(
_calendarMonth.year,
_calendarMonth.month + delta,
);
});
List<Widget> _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<HistoryScreen> {
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),

View File

@ -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<String> _allWorkoutDates(
List<Map<String, dynamic>> rows,
List<Map<String, dynamic>> syncedRows,
List<RunRecord> 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

View File

@ -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<String> dates) => WorkoutCalendar(
workoutDates: dates,
month: _calendarMonth,
onPrevMonth: () => _shiftMonth(-1),
onNextMonth: () => _shiftMonth(1),
);
Future<void> _openRun(RunRecord record) async {
final full = await StorageService.instance.getRun(record.track.id);
if (full == null || !mounted) return;
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => RunDetailScreen(
track: full.track,
tileProviderOpener: widget.tileProviderOpener,
),
),
);
}
}
class _RunsStrip extends StatelessWidget {
const _RunsStrip({required this.runs, required this.onOpen});
final List<RunRecord> 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,
),
),
],
),
),
),
);
}
}

View File

@ -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,
),
),
),
],
),
);
}
}

View File

@ -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<TileProvider> 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<TileProvider>? _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<TileProvider> openOsmTileProvider() => _opened ??= _open();
/// Forgets the opened provider (tests only).
@visibleForTesting
void resetOsmTileProvider() => _opened = null;
Future<TileProvider> _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');
}

View File

@ -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)}';
}

View File

@ -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:<YYYY-MM-DD>` (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<String, dynamic> 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<String, dynamic> 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<Map<String, dynamic>> withoutLocalRuns(
List<Map<String, dynamic>> synced,
Iterable<RunRecord> 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,
];
}

View File

@ -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<RunTrack?>` (a `ValueNotifier<RunTrack?>`, 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<RunPoint> 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<RunTrack?> 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<RunPoint>? points;
/// The current track in live mode; null in detail mode.
final ValueListenable<RunTrack?>? 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<RunMap> createState() => _RunMapState();
}
class _RunMapState extends State<RunMap> {
final _controller = MapController();
TileProvider? _tiles;
bool _ready = false;
bool _follow = true;
bool _warned = false;
bool get _live => widget.trackListenable != null;
List<RunPoint> 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<LatLng> 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<RunPoint> 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<AppStatusColors>()!,
),
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<RunTrack?>(
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',
),
),
),
],
);
}
}

View File

@ -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<LatLng> toLatLngs(List<RunPoint> 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<LatLng> 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<LatLng> 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',
),
),
);

View File

@ -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<FirebaseRestClient?> Function()? firebaseFactory,
DateTime Function()? clock,
TileProviderOpener? tileProviderOpener,
}) => MaterialApp(
theme: buildAppTheme(),
home: HistoryScreen(
httpClient: httpClient,
firebaseFactory: firebaseFactory ?? () async => null,
clock: clock,
tileProviderOpener: tileProviderOpener ?? openOsmTileProvider,
),
);

View File

@ -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<void> _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<void> _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<void> _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<WorkoutCalendar>(
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<void>.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<void>.delayed(const Duration(milliseconds: 200)),
);
await tester.pumpAndSettle();
expect(find.byType(RunDetailScreen), findsNothing);
});
}

View File

@ -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<void> _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<Text>(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<void>(
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<ScrollableState>(
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);
});
}

View File

@ -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<String?> 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<CachedTileProvider>());
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<CachedTileProvider>());
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<NetworkTileProvider>());
expect(
lines.where((l) => l.contains('WARNING') && l.contains('persist')),
hasLength(1),
);
});
}

View File

@ -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');
});
}

View File

@ -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<String, dynamic> _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);
});
}

View File

@ -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<FailingImage> {
/// Creates the failing image.
const FailingImage();
@override
Future<FailingImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture(this);
@override
ImageStreamCompleter loadImage(
FailingImage key,
ImageDecoderCallback decode,
) => OneFrameImageStreamCompleter(
Future<ImageInfo>.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<TileProvider> 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<List<String>> capturingDebugPrint(Future<void> Function() body) async {
final lines = <String>[];
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<void> 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();
}

View File

@ -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<void> _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<PolylineLayer>(find.byType(PolylineLayer));
expect(line.polylines, hasLength(1));
expect(line.polylines.single.points, hasLength(track.points.length));
final markers = tester.widget<MarkerLayer>(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<TileLayer>(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<AnimatedOpacity>(
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<MarkerLayer>(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<TileProvider>();
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<RunTrack?>(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<MarkerLayer>(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<RunTrack?>(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<RunTrack?>(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);
});
});
}