mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
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:
parent
63caf9dee9
commit
b4e69dbff4
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
@ -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)}';
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
204
stronglift_replacement/workout_app/lib/widgets/run_map.dart
Normal file
204
stronglift_replacement/workout_app/lib/widgets/run_map.dart
Normal 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',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user