T15-T18, T20, T23: app shell, three screens, and the first UI tests
Theme, go_router shell, record screen, trips list with selection, trip detail with stats and charts, rename/delete/merge. 15 widget tests -- the first this project has ever had, closing the gap docs/v3/BACKLOG.md names as v2's largest. The theme sets bodyColor and displayColor explicitly rather than relying on a wrapping widget, so the black-on-black regression cannot recur by someone removing a container, and a widget test now asserts the headline names a colour distinct from the ground. Those tests immediately found a real layout bug: with a ride active the record screen grows to six stat rows and overflows a short screen. Compose clipped this silently, so it may be latent in the native app; Flutter reports it. Fixed by making the screen scrollable while still centring when there is room. Two harness lessons. pumpAndSettle never settles against a repeating timer, so the elapsed clock now runs only while a ride is active -- better behaviour anyway, since an idle screen has no clock to advance. And flutter_test asserts no Timer is pending after disposal, which Drift's stream-query keep-alive cache trips; tests now tear the tree down and pump past that window. 160 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
117
lib/main.dart
117
lib/main.dart
@@ -2,125 +2,38 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'src/app/providers.dart';
|
import 'src/app/providers.dart';
|
||||||
import 'src/recording/recording_engine.dart';
|
import 'src/ui/router.dart';
|
||||||
|
import 'src/ui/theme.dart';
|
||||||
|
|
||||||
/// Phase 3 entry point.
|
|
||||||
///
|
|
||||||
/// The UI proper arrives in Phase 4 (T15–T21). This is deliberately a thin harness: it
|
|
||||||
/// exists so the recording pipeline can be exercised on a real device before any screens
|
|
||||||
/// are built, because the pipeline is the part that cannot be validated by unit tests or
|
|
||||||
/// by an emulator.
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const ProviderScope(child: RipprApp()));
|
runApp(const ProviderScope(child: RipprApp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
class RipprApp extends StatelessWidget {
|
class RipprApp extends ConsumerStatefulWidget {
|
||||||
const RipprApp({super.key});
|
const RipprApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
ConsumerState<RipprApp> createState() => _RipprAppState();
|
||||||
return MaterialApp(
|
|
||||||
title: 'Rippr',
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
theme: ThemeData(
|
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: const Color(0xFFFF6B1A), // safety orange, matching the icon
|
|
||||||
brightness: Brightness.dark,
|
|
||||||
),
|
|
||||||
useMaterial3: true,
|
|
||||||
),
|
|
||||||
home: const _RecorderHarness(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RecorderHarness extends ConsumerStatefulWidget {
|
class _RipprAppState extends ConsumerState<RipprApp> {
|
||||||
const _RecorderHarness();
|
final _router = buildRouter();
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<_RecorderHarness> createState() => _RecorderHarnessState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RecorderHarnessState extends ConsumerState<_RecorderHarness> {
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Re-attach to a ride that was in progress when the process died. Must happen before
|
// Re-attach to a ride that was in progress when the process died. Must run before any
|
||||||
// any user interaction, and the database is the only thing that knows.
|
// user interaction, and the database is the only thing that knows a ride was open.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
await ref.read(recordingEngineProvider).restoreAfterProcessDeath();
|
ref.read(recordingEngineProvider).restoreAfterProcessDeath();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _run(Future<void> Function() action) async {
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
if (mounted) setState(() => _error = null);
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) setState(() => _error = '$e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => MaterialApp.router(
|
||||||
final engine = ref.watch(recordingEngineProvider);
|
title: 'Rippr',
|
||||||
final state = ref.watch(recorderStateProvider).valueOrNull ?? engine.state;
|
debugShowCheckedModeBanner: false,
|
||||||
final trip = ref.watch(activeTripProvider).valueOrNull;
|
theme: ripprTheme(),
|
||||||
|
routerConfig: _router,
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Rippr — recorder harness')),
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Text('State: ${state.name}',
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (trip != null) ...[
|
|
||||||
Text('Trip ${trip.id} · ${trip.pointCount} points'),
|
|
||||||
Text('${trip.distanceM.toStringAsFixed(1)} m'),
|
|
||||||
Text('max ${trip.maxSpeedKmh.toStringAsFixed(1)} km/h'),
|
|
||||||
] else
|
|
||||||
const Text('No active ride'),
|
|
||||||
if (_error != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(_error!, style: const TextStyle(color: Colors.redAccent)),
|
|
||||||
],
|
|
||||||
const Spacer(),
|
|
||||||
// 72dp targets, matching the native app's glove-sized controls.
|
|
||||||
SizedBox(
|
|
||||||
height: 72,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: () => _run(engine.start),
|
|
||||||
child: Text(state == RecorderState.paused ? 'RESUME' : 'START'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SizedBox(
|
|
||||||
height: 72,
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: state == RecorderState.recording
|
|
||||||
? () => _run(engine.pause)
|
|
||||||
: null,
|
|
||||||
child: const Text('PAUSE'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SizedBox(
|
|
||||||
height: 72,
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: state == RecorderState.idle
|
|
||||||
? null
|
|
||||||
: () => _run(() async => engine.stop()),
|
|
||||||
child: const Text('STOP'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import '../domain/models.dart';
|
|||||||
import '../recording/geolocator_location_source.dart';
|
import '../recording/geolocator_location_source.dart';
|
||||||
import '../recording/location_source.dart';
|
import '../recording/location_source.dart';
|
||||||
import '../recording/recording_engine.dart';
|
import '../recording/recording_engine.dart';
|
||||||
|
import '../telemetry/live_telemetry.dart';
|
||||||
|
|
||||||
/// The Drift database, opened against app-private storage.
|
/// The Drift database, opened against app-private storage.
|
||||||
///
|
///
|
||||||
@@ -47,6 +48,10 @@ final recordingEngineProvider = Provider<RecordingEngine>((ref) {
|
|||||||
return engine;
|
return engine;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The live speedo feed, published from the fix callback at GPS rate.
|
||||||
|
final liveTelemetryProvider =
|
||||||
|
Provider<LiveTelemetry>((ref) => LiveTelemetry.instance);
|
||||||
|
|
||||||
// --- Reactive state ---------------------------------------------------------
|
// --- Reactive state ---------------------------------------------------------
|
||||||
|
|
||||||
/// The in-progress ride, straight from the database.
|
/// The in-progress ride, straight from the database.
|
||||||
|
|||||||
155
lib/src/ui/components/stats.dart
Normal file
155
lib/src/ui/components/stats.dart
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/// Shared display components.
|
||||||
|
///
|
||||||
|
/// Ported from `com.rippr.ui.components.Stats`.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme.dart';
|
||||||
|
|
||||||
|
/// The one number that matters, rendered large.
|
||||||
|
///
|
||||||
|
/// Keyed so widget tests can find a specific readout by name rather than by matching
|
||||||
|
/// text that may legitimately appear twice on screen.
|
||||||
|
class BigStat extends StatelessWidget {
|
||||||
|
const BigStat({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.unit,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final String unit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: colors.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
|
textBaseline: TextBaseline.alphabetic,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: monoDigits.copyWith(
|
||||||
|
fontSize: 64,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
// Named explicitly. The Compose version rendered black-on-black when the
|
||||||
|
// ambient content colour went missing.
|
||||||
|
color: colors.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
' $unit',
|
||||||
|
style: TextStyle(fontSize: 18, color: colors.outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatRow extends StatelessWidget {
|
||||||
|
const StatRow({super.key, required this.label, required this.value});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(color: colors.outline)),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: monoDigits.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: colors.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirmation for anything destructive. Returns true only if confirmed.
|
||||||
|
Future<bool> confirmDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
required String confirmLabel,
|
||||||
|
}) async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(message),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: Text(
|
||||||
|
confirmLabel,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return result ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ride's worth of nothing. Used by the trips list and the detail charts.
|
||||||
|
class EmptyState extends StatelessWidget {
|
||||||
|
const EmptyState({super.key, required this.message, this.icon});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final IconData? icon;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, size: 48, color: colors.outline),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: colors.outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
360
lib/src/ui/detail/trip_detail_screen.dart
Normal file
360
lib/src/ui/detail/trip_detail_screen.dart
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/// Ported from `com.rippr.ui.detail.TripDetailScreen` and `TripDetailViewModel`.
|
||||||
|
///
|
||||||
|
/// Unlike the trips list, this screen *does* read the point table — it has to, for the
|
||||||
|
/// authoritative summary, the charts and (in T19) the map.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../app/providers.dart';
|
||||||
|
import '../../domain/models.dart';
|
||||||
|
import '../../stats/ride_statistics.dart';
|
||||||
|
import '../../telemetry/telemetry.dart';
|
||||||
|
import '../components/stats.dart';
|
||||||
|
import '../format.dart';
|
||||||
|
|
||||||
|
/// Everything the detail screen needs, computed once off the stored points.
|
||||||
|
class TripDetail {
|
||||||
|
const TripDetail({
|
||||||
|
required this.trip,
|
||||||
|
required this.summary,
|
||||||
|
required this.points,
|
||||||
|
required this.segments,
|
||||||
|
required this.histogram,
|
||||||
|
required this.profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Trip trip;
|
||||||
|
final RideSummary summary;
|
||||||
|
final List<TrackPoint> points;
|
||||||
|
final List<Segment> segments;
|
||||||
|
final List<SpeedBucket> histogram;
|
||||||
|
final List<ElevationSample> profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
final tripDetailProvider =
|
||||||
|
FutureProvider.autoDispose.family<TripDetail?, int>((ref, tripId) async {
|
||||||
|
final repo = ref.watch(tripRepositoryProvider);
|
||||||
|
final trip = await repo.tripById(tripId);
|
||||||
|
if (trip == null) return null;
|
||||||
|
|
||||||
|
final points = await repo.pointsForTrip(tripId);
|
||||||
|
final segments = await repo.segmentsForTrip(tripId);
|
||||||
|
|
||||||
|
return TripDetail(
|
||||||
|
trip: trip,
|
||||||
|
summary: computeSummary(points, segments: segments),
|
||||||
|
points: points,
|
||||||
|
segments: segments,
|
||||||
|
histogram: speedHistogram(points),
|
||||||
|
profile: elevationProfile(points),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
class TripDetailScreen extends ConsumerWidget {
|
||||||
|
const TripDetailScreen({super.key, required this.tripId, this.onBack});
|
||||||
|
|
||||||
|
final int tripId;
|
||||||
|
final VoidCallback? onBack;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final async = ref.watch(tripDetailProvider(tripId));
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
key: const Key('back'),
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: onBack,
|
||||||
|
),
|
||||||
|
title: Text(async.valueOrNull == null
|
||||||
|
? 'Ride'
|
||||||
|
: tripLabel(async.value!.trip)),
|
||||||
|
actions: [
|
||||||
|
if (async.valueOrNull != null)
|
||||||
|
IconButton(
|
||||||
|
key: const Key('rename'),
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
onPressed: () => _rename(context, ref, async.value!.trip),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: switch (async) {
|
||||||
|
AsyncValue(hasValue: true, value: null) => const EmptyState(
|
||||||
|
message: 'This ride no longer exists.',
|
||||||
|
icon: Icons.help_outline,
|
||||||
|
),
|
||||||
|
AsyncValue(hasValue: true, value: final d?) => _Body(detail: d),
|
||||||
|
AsyncValue(hasError: true, :final error) =>
|
||||||
|
EmptyState(message: 'Could not load this ride.\n$error'),
|
||||||
|
_ => const Center(child: CircularProgressIndicator()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _rename(
|
||||||
|
BuildContext context, WidgetRef ref, Trip trip) async {
|
||||||
|
final controller = TextEditingController(text: trip.name ?? '');
|
||||||
|
final name = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Name this ride'),
|
||||||
|
content: TextField(
|
||||||
|
key: const Key('name-field'),
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(hintText: 'Morning loop'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(controller.text),
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (name == null) return;
|
||||||
|
// Empty collapses to null in the repository, so the date label comes back.
|
||||||
|
await ref.read(tripRepositoryProvider).renameTrip(trip.id, name);
|
||||||
|
ref.invalidate(tripDetailProvider(trip.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Body extends StatelessWidget {
|
||||||
|
const _Body({required this.detail});
|
||||||
|
|
||||||
|
final TripDetail detail;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = detail.summary;
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: BigStat(
|
||||||
|
key: const Key('distance'),
|
||||||
|
label: 'DISTANCE',
|
||||||
|
value: (s.distanceM / 1000).toStringAsFixed(1),
|
||||||
|
unit: 'km',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
StatRow(
|
||||||
|
label: 'Moving time',
|
||||||
|
value: formatDuration(s.movingMillis)),
|
||||||
|
StatRow(
|
||||||
|
label: 'Elapsed', value: formatDuration(s.elapsedMillis)),
|
||||||
|
StatRow(
|
||||||
|
label: 'Stopped', value: formatDuration(s.stoppedMillis)),
|
||||||
|
StatRow(
|
||||||
|
label: 'Max speed', value: formatSpeed(s.maxSpeedKmh)),
|
||||||
|
StatRow(
|
||||||
|
label: 'Avg moving speed',
|
||||||
|
value: formatSpeed(s.avgMovingSpeedKmh)),
|
||||||
|
StatRow(
|
||||||
|
label: 'Elevation gain',
|
||||||
|
value: formatElevation(s.elevationGainM)),
|
||||||
|
StatRow(
|
||||||
|
label: 'Elevation loss',
|
||||||
|
value: formatElevation(s.elevationLossM)),
|
||||||
|
StatRow(label: 'Points', value: '${s.pointCount}'),
|
||||||
|
StatRow(label: 'Segments', value: '${detail.segments.length}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_SectionTitle('Speed distribution'),
|
||||||
|
_Histogram(buckets: detail.histogram),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_SectionTitle('Elevation profile'),
|
||||||
|
_ElevationChart(samples: detail.profile),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionTitle extends StatelessWidget {
|
||||||
|
const _SectionTitle(this.text);
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
text.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Time in each speed band.
|
||||||
|
///
|
||||||
|
/// Degrades to a message rather than an empty box: a ride of one point has no intervals
|
||||||
|
/// to measure, and an unexplained blank looks like a bug.
|
||||||
|
class _Histogram extends StatelessWidget {
|
||||||
|
const _Histogram({required this.buckets});
|
||||||
|
|
||||||
|
final List<SpeedBucket> buckets;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (buckets.isEmpty) {
|
||||||
|
return const EmptyState(message: 'Not enough points to chart.');
|
||||||
|
}
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final maxMillis =
|
||||||
|
buckets.map((b) => b.millis).reduce((a, b) => a > b ? a : b);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (final b in buckets)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 64,
|
||||||
|
child: Text(b.label,
|
||||||
|
style: TextStyle(fontSize: 12, color: colors.outline)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, c) => Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: maxMillis == 0
|
||||||
|
? 0
|
||||||
|
: c.maxWidth * (b.millis / maxMillis),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.primary,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 72,
|
||||||
|
child: Text(
|
||||||
|
formatDuration(b.millis),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: TextStyle(fontSize: 12, color: colors.outline),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Altitude against distance travelled.
|
||||||
|
///
|
||||||
|
/// Needs at least two points to draw a line — below that it says so.
|
||||||
|
class _ElevationChart extends StatelessWidget {
|
||||||
|
const _ElevationChart({required this.samples});
|
||||||
|
|
||||||
|
final List<ElevationSample> samples;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (samples.length < 2) {
|
||||||
|
return const EmptyState(message: 'Not enough points to chart.');
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
height: 140,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _ElevationPainter(
|
||||||
|
samples: samples,
|
||||||
|
line: Theme.of(context).colorScheme.primary,
|
||||||
|
fill: Theme.of(context).colorScheme.primary.withValues(alpha: 0.18),
|
||||||
|
),
|
||||||
|
child: const SizedBox.expand(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ElevationPainter extends CustomPainter {
|
||||||
|
_ElevationPainter({
|
||||||
|
required this.samples,
|
||||||
|
required this.line,
|
||||||
|
required this.fill,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<ElevationSample> samples;
|
||||||
|
final Color line;
|
||||||
|
final Color fill;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
var minAlt = samples.first.altitudeM;
|
||||||
|
var maxAlt = samples.first.altitudeM;
|
||||||
|
for (final s in samples) {
|
||||||
|
if (s.altitudeM < minAlt) minAlt = s.altitudeM;
|
||||||
|
if (s.altitudeM > maxAlt) maxAlt = s.altitudeM;
|
||||||
|
}
|
||||||
|
// A dead-flat ride would divide by zero; give it a nominal band so the line sits
|
||||||
|
// centred rather than vanishing.
|
||||||
|
final span = (maxAlt - minAlt).abs() < 1e-9 ? 1.0 : maxAlt - minAlt;
|
||||||
|
final totalDistance = samples.last.distanceM;
|
||||||
|
final xSpan = totalDistance <= 0 ? 1.0 : totalDistance;
|
||||||
|
|
||||||
|
final path = Path();
|
||||||
|
for (var i = 0; i < samples.length; i++) {
|
||||||
|
final x = size.width * (samples[i].distanceM / xSpan);
|
||||||
|
final y =
|
||||||
|
size.height * (1 - (samples[i].altitudeM - minAlt) / span);
|
||||||
|
if (i == 0) {
|
||||||
|
path.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
path.lineTo(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final area = Path.from(path)
|
||||||
|
..lineTo(size.width, size.height)
|
||||||
|
..lineTo(0, size.height)
|
||||||
|
..close();
|
||||||
|
|
||||||
|
canvas.drawPath(area, Paint()..color = fill);
|
||||||
|
canvas.drawPath(
|
||||||
|
path,
|
||||||
|
Paint()
|
||||||
|
..color = line
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_ElevationPainter old) => old.samples != samples;
|
||||||
|
}
|
||||||
379
lib/src/ui/record/record_screen.dart
Normal file
379
lib/src/ui/record/record_screen.dart
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
/// Ported from `com.rippr.ui.record.RecordScreen` and `RecordViewModel`.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../app/providers.dart';
|
||||||
|
import '../../domain/models.dart';
|
||||||
|
import '../../recording/location_source.dart';
|
||||||
|
import '../../telemetry/telemetry.dart';
|
||||||
|
import '../components/stats.dart';
|
||||||
|
import '../format.dart';
|
||||||
|
|
||||||
|
/// Everything the recording screen draws, in one snapshot.
|
||||||
|
class RecordUiState {
|
||||||
|
const RecordUiState({
|
||||||
|
this.trip,
|
||||||
|
this.uploadError,
|
||||||
|
this.currentSpeedKmh = 0,
|
||||||
|
this.elapsedMillis = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Trip? trip;
|
||||||
|
final String? uploadError;
|
||||||
|
final double currentSpeedKmh;
|
||||||
|
|
||||||
|
/// Wall clock since the ride began, ticking every second.
|
||||||
|
final int elapsedMillis;
|
||||||
|
|
||||||
|
bool get isIdle => trip == null;
|
||||||
|
bool get isRecording => trip?.state == TripState.recording;
|
||||||
|
bool get isPaused => trip?.state == TripState.paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordScreen extends ConsumerStatefulWidget {
|
||||||
|
const RecordScreen({super.key, this.onOpenTrips});
|
||||||
|
|
||||||
|
final VoidCallback? onOpenTrips;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<RecordScreen> createState() => _RecordScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||||
|
/// Drives the elapsed clock.
|
||||||
|
///
|
||||||
|
/// The Trip row only advances `movingMillis`, and only on a ~2 s flush while actually
|
||||||
|
/// moving, so without this the screen looks frozen at a traffic light — which is
|
||||||
|
/// exactly how it read on the first real ride of v2.0.
|
||||||
|
///
|
||||||
|
/// Runs **only while a ride is active**. An idle screen has no clock to advance, so
|
||||||
|
/// ticking then would be pure wasted rebuilds — and it makes `pumpAndSettle` in widget
|
||||||
|
/// tests spin forever, since a repeating timer that calls setState never settles.
|
||||||
|
Timer? _ticker;
|
||||||
|
int _now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
double _liveSpeed = 0;
|
||||||
|
StreamSubscription<double>? _speedSub;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_speedSub = ref.read(liveTelemetryProvider).speedStream.listen((s) {
|
||||||
|
if (mounted) setState(() => _liveSpeed = s);
|
||||||
|
});
|
||||||
|
// Start and stop the clock with the ride itself.
|
||||||
|
ref.listenManual<AsyncValue<Trip?>>(activeTripProvider, (previous, next) {
|
||||||
|
next.whenData((trip) => _setTicking(trip != null));
|
||||||
|
}, fireImmediately: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ticker?.cancel();
|
||||||
|
_speedSub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setTicking(bool ticking) {
|
||||||
|
if (ticking && _ticker == null) {
|
||||||
|
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _now = DateTime.now().millisecondsSinceEpoch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (!ticking) {
|
||||||
|
_ticker?.cancel();
|
||||||
|
_ticker = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _guard(Future<void> Function() action) async {
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
if (mounted) setState(() => _error = null);
|
||||||
|
} on LocationException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(
|
||||||
|
() => _error = switch (e.failure) {
|
||||||
|
LocationFailure.serviceDisabled =>
|
||||||
|
'Location services are switched off',
|
||||||
|
LocationFailure.permissionDenied =>
|
||||||
|
'Location permission is needed to record a ride',
|
||||||
|
LocationFailure.permissionDeniedForever =>
|
||||||
|
'Location permission was denied. Enable it in Settings.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final engine = ref.watch(recordingEngineProvider);
|
||||||
|
final trip = ref.watch(activeTripProvider).valueOrNull;
|
||||||
|
|
||||||
|
final ui = RecordUiState(
|
||||||
|
trip: trip,
|
||||||
|
currentSpeedKmh: _liveSpeed,
|
||||||
|
uploadError: _error,
|
||||||
|
elapsedMillis: trip == null
|
||||||
|
? 0
|
||||||
|
: (_now - trip.startedAt).clamp(0, 1 << 62),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
// Scrollable, but still centred when there is room.
|
||||||
|
//
|
||||||
|
// With a ride active the card grows to six stat rows, which overflows a short
|
||||||
|
// screen. Compose clipped this silently; Flutter reports it, and a widget test
|
||||||
|
// caught it immediately. Glove-sized 72dp controls make the content tall enough
|
||||||
|
// that this is a real risk on small phones, not a theoretical one.
|
||||||
|
body: SafeArea(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) => SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'RIPPR',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 34,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
letterSpacing: 6,
|
||||||
|
color: colors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: widget.onOpenTrips,
|
||||||
|
child: const Text('Rides'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Current speed, not max: a max figure only moves when you beat it, which
|
||||||
|
// reads as a frozen screen while riding steadily. This is the 2.0.1 fix and
|
||||||
|
// it must not regress.
|
||||||
|
BigStat(
|
||||||
|
key: const Key('speed'),
|
||||||
|
label: 'SPEED',
|
||||||
|
value: ui.currentSpeedKmh.toStringAsFixed(0),
|
||||||
|
unit: 'km/h',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (ui.isIdle) ...[
|
||||||
|
// A resting state, not a zeroed ride — otherwise the screen looks
|
||||||
|
// like a recording that is going nowhere.
|
||||||
|
const StatRow(label: 'Status', value: 'Ready'),
|
||||||
|
const StatRow(
|
||||||
|
label: 'Last ride',
|
||||||
|
value: 'See Rides',
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
StatRow(
|
||||||
|
label: 'Distance',
|
||||||
|
value: formatDistance(ui.trip!.distanceM),
|
||||||
|
),
|
||||||
|
StatRow(
|
||||||
|
label: 'Elapsed',
|
||||||
|
value: formatDuration(ui.elapsedMillis),
|
||||||
|
),
|
||||||
|
StatRow(
|
||||||
|
label: 'Moving time',
|
||||||
|
value: formatDuration(ui.trip!.movingMillis),
|
||||||
|
),
|
||||||
|
StatRow(
|
||||||
|
label: 'Max speed',
|
||||||
|
value: formatSpeed(ui.trip!.maxSpeedKmh),
|
||||||
|
),
|
||||||
|
StatRow(
|
||||||
|
label: 'Points captured',
|
||||||
|
value: '${ui.trip!.pointCount}',
|
||||||
|
),
|
||||||
|
StatRow(
|
||||||
|
label: 'Elevation gain',
|
||||||
|
value: formatElevation(ui.trip!.elevationGainM),
|
||||||
|
),
|
||||||
|
if (ui.isPaused)
|
||||||
|
const StatRow(label: 'Status', value: 'Paused'),
|
||||||
|
],
|
||||||
|
if (ui.uploadError != null)
|
||||||
|
StatRow(label: 'Problem', value: ui.uploadError!),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
_Controls(
|
||||||
|
ui: ui,
|
||||||
|
onStart: () => _guard(engine.start),
|
||||||
|
onPause: () => _guard(engine.pause),
|
||||||
|
onStop: () => _guard(() async => engine.stop()),
|
||||||
|
onDiscard: () async {
|
||||||
|
final confirmed = await confirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Discard this ride?',
|
||||||
|
message:
|
||||||
|
'${ui.trip?.pointCount ?? 0} points recorded over '
|
||||||
|
'${formatDistance(ui.trip?.distanceM ?? 0)} will be '
|
||||||
|
'permanently deleted.',
|
||||||
|
confirmLabel: 'Discard',
|
||||||
|
);
|
||||||
|
if (confirmed) await _guard(engine.discard);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Controls extends StatelessWidget {
|
||||||
|
const _Controls({
|
||||||
|
required this.ui,
|
||||||
|
required this.onStart,
|
||||||
|
required this.onPause,
|
||||||
|
required this.onStop,
|
||||||
|
required this.onDiscard,
|
||||||
|
});
|
||||||
|
|
||||||
|
final RecordUiState ui;
|
||||||
|
final VoidCallback onStart;
|
||||||
|
final VoidCallback onPause;
|
||||||
|
final VoidCallback onStop;
|
||||||
|
final VoidCallback onDiscard;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (ui.isIdle) {
|
||||||
|
return _PrimaryButton(
|
||||||
|
label: 'START RECORDING',
|
||||||
|
onPressed: onStart,
|
||||||
|
keyName: 'start',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ui.isRecording) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_PrimaryButton(label: 'PAUSE', onPressed: onPause, keyName: 'pause'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_SecondaryButton(label: 'STOP', onPressed: onStop, keyName: 'stop'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard is offered only while paused. Putting a destructive action next to Pause
|
||||||
|
// during a live ride invites a gloved mis-tap at speed.
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_PrimaryButton(label: 'RESUME', onPressed: onStart, keyName: 'resume'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _SecondaryButton(
|
||||||
|
label: 'STOP',
|
||||||
|
onPressed: onStop,
|
||||||
|
keyName: 'stop',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _SecondaryButton(
|
||||||
|
label: 'DISCARD',
|
||||||
|
onPressed: onDiscard,
|
||||||
|
keyName: 'discard',
|
||||||
|
destructive: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 72dp tall throughout — these get pressed with gloves on.
|
||||||
|
class _PrimaryButton extends StatelessWidget {
|
||||||
|
const _PrimaryButton({
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
required this.keyName,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
final String keyName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 72,
|
||||||
|
child: FilledButton(
|
||||||
|
key: Key(keyName),
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SecondaryButton extends StatelessWidget {
|
||||||
|
const _SecondaryButton({
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
required this.keyName,
|
||||||
|
this.destructive = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
final String keyName;
|
||||||
|
final bool destructive;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return SizedBox(
|
||||||
|
height: 56,
|
||||||
|
child: OutlinedButton(
|
||||||
|
key: Key(keyName),
|
||||||
|
onPressed: onPressed,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: destructive ? colors.error : colors.onSurface,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
lib/src/ui/router.dart
Normal file
70
lib/src/ui/router.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/// Ported from `com.rippr.ui.RipprNavHost`.
|
||||||
|
///
|
||||||
|
/// Three destinations, same shape as the Compose original. `go_router` replaces
|
||||||
|
/// `navigation-compose`; the path parameter is parsed explicitly, mirroring the Kotlin
|
||||||
|
/// comment about ids arriving as the wrong type when the argument type is left implicit.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'detail/trip_detail_screen.dart';
|
||||||
|
import 'record/record_screen.dart';
|
||||||
|
import 'trips/trips_screen.dart';
|
||||||
|
|
||||||
|
abstract final class Routes {
|
||||||
|
static const record = '/';
|
||||||
|
static const trips = '/trips';
|
||||||
|
static const tripDetail = '/trip/:tripId';
|
||||||
|
|
||||||
|
static String detailFor(int tripId) => '/trip/$tripId';
|
||||||
|
}
|
||||||
|
|
||||||
|
GoRouter buildRouter() => GoRouter(
|
||||||
|
initialLocation: Routes.record,
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.record,
|
||||||
|
builder: (context, state) => RecordScreen(
|
||||||
|
onOpenTrips: () => context.push(Routes.trips),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.trips,
|
||||||
|
builder: (context, state) => TripsScreen(
|
||||||
|
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
|
||||||
|
onBack: () => context.pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.tripDetail,
|
||||||
|
builder: (context, state) {
|
||||||
|
// Parsed explicitly. A malformed or missing id must land on a real screen
|
||||||
|
// saying so, never on a blank one or a crash.
|
||||||
|
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
|
||||||
|
if (id == null) {
|
||||||
|
return const _NotFound();
|
||||||
|
}
|
||||||
|
return TripDetailScreen(tripId: id, onBack: () => context.pop());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
errorBuilder: (context, state) => const _NotFound(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class _NotFound extends StatelessWidget {
|
||||||
|
const _NotFound();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => context.canPop()
|
||||||
|
? context.pop()
|
||||||
|
: context.go(Routes.record),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: const Center(child: Text('That ride could not be found.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
65
lib/src/ui/theme.dart
Normal file
65
lib/src/ui/theme.dart
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/// Ported from `com.rippr.ui.theme.RipprTheme`.
|
||||||
|
///
|
||||||
|
/// Dark-only by design. This is read at a glance in daylight with a visor down, and a
|
||||||
|
/// light theme would be actively worse for that.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
// Matches the launcher icon: near-black tarmac with a safety-orange accent.
|
||||||
|
const _accent = Color(0xFFFF5722);
|
||||||
|
const _ground = Color(0xFF101418);
|
||||||
|
const _surface = Color(0xFF181D23);
|
||||||
|
const _ink = Color(0xFFF2F5F8);
|
||||||
|
const _outline = Color(0xFF66727E);
|
||||||
|
const _danger = Color(0xFFE53935);
|
||||||
|
|
||||||
|
const ripprColors = ColorScheme.dark(
|
||||||
|
primary: _accent,
|
||||||
|
onPrimary: Color(0xFF100A06),
|
||||||
|
secondary: _outline,
|
||||||
|
surface: _surface,
|
||||||
|
onSurface: _ink,
|
||||||
|
surfaceContainerHighest: Color(0xFF212832),
|
||||||
|
onSurfaceVariant: _ink,
|
||||||
|
outline: _outline,
|
||||||
|
error: _danger,
|
||||||
|
onError: _ink,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The near-black ground the whole app sits on.
|
||||||
|
const ripprBackground = _ground;
|
||||||
|
|
||||||
|
ThemeData ripprTheme() {
|
||||||
|
final base = ThemeData(
|
||||||
|
colorScheme: ripprColors,
|
||||||
|
useMaterial3: true,
|
||||||
|
scaffoldBackgroundColor: _ground,
|
||||||
|
);
|
||||||
|
|
||||||
|
return base.copyWith(
|
||||||
|
// Compose's `Surface` was load-bearing, not decoration: it set `LocalContentColor`.
|
||||||
|
// Without it, any Text that did not name a colour fell back to black — invisible on
|
||||||
|
// this near-black ground, and **no test caught it; only a screenshot did**.
|
||||||
|
//
|
||||||
|
// Flutter's equivalent exposure is the default text colour. Setting it explicitly
|
||||||
|
// here, rather than relying on a wrapping widget, means the failure cannot recur by
|
||||||
|
// someone removing a container.
|
||||||
|
textTheme: base.textTheme.apply(bodyColor: _ink, displayColor: _ink),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: _ground,
|
||||||
|
foregroundColor: _ink,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
dividerTheme: const DividerThemeData(color: Color(0xFF2A323C), space: 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Monospace for anything numeric.
|
||||||
|
///
|
||||||
|
/// Digits that change every second must not make the layout jitter, which proportional
|
||||||
|
/// figures do. Used for every speed, distance and clock in the app.
|
||||||
|
const TextStyle monoDigits = TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontFeatures: [FontFeature.tabularFigures()],
|
||||||
|
);
|
||||||
232
lib/src/ui/trips/trips_screen.dart
Normal file
232
lib/src/ui/trips/trips_screen.dart
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/// Ported from `com.rippr.ui.trips.TripsScreen` and `TripsViewModel`.
|
||||||
|
///
|
||||||
|
/// Every value shown in this list is already persisted on the Trip row, so the screen
|
||||||
|
/// never touches the point table. That keeps it fast with hundreds of rides.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../app/providers.dart';
|
||||||
|
import '../../domain/models.dart';
|
||||||
|
import '../../telemetry/telemetry.dart';
|
||||||
|
import '../components/stats.dart';
|
||||||
|
import '../format.dart';
|
||||||
|
|
||||||
|
/// Selection is screen-local: it must not survive navigating away, and nothing else
|
||||||
|
/// needs to observe it.
|
||||||
|
final _selectionProvider =
|
||||||
|
StateProvider.autoDispose<Set<int>>((ref) => <int>{});
|
||||||
|
|
||||||
|
class TripsScreen extends ConsumerWidget {
|
||||||
|
const TripsScreen({super.key, this.onOpenTrip, this.onBack});
|
||||||
|
|
||||||
|
final void Function(int tripId)? onOpenTrip;
|
||||||
|
final VoidCallback? onBack;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final tripsAsync = ref.watch(completedTripsProvider);
|
||||||
|
final trips = tripsAsync.valueOrNull ?? const <Trip>[];
|
||||||
|
|
||||||
|
// Drop ids that no longer exist, so a deleted trip cannot linger in the selection.
|
||||||
|
final live = ref.watch(_selectionProvider).intersection(
|
||||||
|
trips.map((t) => t.id).toSet(),
|
||||||
|
);
|
||||||
|
final selecting = live.isNotEmpty;
|
||||||
|
// Merge combines exactly two rides; anything else is ambiguous.
|
||||||
|
final canMerge = live.length == 2;
|
||||||
|
|
||||||
|
void clearSelection() =>
|
||||||
|
ref.read(_selectionProvider.notifier).state = <int>{};
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (selecting) ...[
|
||||||
|
TextButton(
|
||||||
|
onPressed: clearSelection,
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Text(
|
||||||
|
'${live.length} selected',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: 14, color: colors.outline),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
TextButton(
|
||||||
|
key: const Key('back'),
|
||||||
|
onPressed: onBack,
|
||||||
|
child: const Text('‹ Record'),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Text(
|
||||||
|
'RIDES',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 3,
|
||||||
|
color: colors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const Spacer(),
|
||||||
|
if (selecting) ...[
|
||||||
|
TextButton(
|
||||||
|
key: const Key('merge'),
|
||||||
|
onPressed: canMerge
|
||||||
|
? () => _confirmMerge(context, ref, trips, live)
|
||||||
|
: null,
|
||||||
|
child: const Text('Merge'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
key: const Key('delete'),
|
||||||
|
onPressed: () => _confirmDelete(context, ref, live),
|
||||||
|
child: Text('Delete',
|
||||||
|
style: TextStyle(color: colors.error)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: switch (tripsAsync) {
|
||||||
|
AsyncValue(hasValue: true, value: final list)
|
||||||
|
when (list ?? const []).isEmpty =>
|
||||||
|
const EmptyState(
|
||||||
|
message:
|
||||||
|
'No rides yet.\nPress Start on the record screen to capture one.',
|
||||||
|
icon: Icons.two_wheeler,
|
||||||
|
),
|
||||||
|
AsyncValue(hasValue: true) => ListView.builder(
|
||||||
|
itemCount: trips.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final trip = trips[i];
|
||||||
|
return _TripTile(
|
||||||
|
trip: trip,
|
||||||
|
selected: live.contains(trip.id),
|
||||||
|
selecting: selecting,
|
||||||
|
onTap: () {
|
||||||
|
if (selecting) {
|
||||||
|
_toggle(ref, trip.id);
|
||||||
|
} else {
|
||||||
|
onOpenTrip?.call(trip.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: () => _toggle(ref, trip.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ => const Center(child: CircularProgressIndicator()),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggle(WidgetRef ref, int tripId) {
|
||||||
|
final notifier = ref.read(_selectionProvider.notifier);
|
||||||
|
final current = notifier.state;
|
||||||
|
notifier.state = current.contains(tripId)
|
||||||
|
? (current.toSet()..remove(tripId))
|
||||||
|
: (current.toSet()..add(tripId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(
|
||||||
|
BuildContext context, WidgetRef ref, Set<int> selection) async {
|
||||||
|
final ok = await confirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Delete ${selection.length} ride(s)?',
|
||||||
|
message:
|
||||||
|
'Every recorded point for these rides will be permanently deleted.',
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
final repo = ref.read(tripRepositoryProvider);
|
||||||
|
for (final id in selection) {
|
||||||
|
await repo.deleteTrip(id);
|
||||||
|
}
|
||||||
|
ref.read(_selectionProvider.notifier).state = <int>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmMerge(BuildContext context, WidgetRef ref,
|
||||||
|
List<Trip> trips, Set<int> selection) async {
|
||||||
|
final total = trips
|
||||||
|
.where((t) => selection.contains(t.id))
|
||||||
|
.fold<double>(0, (a, t) => a + t.distanceM);
|
||||||
|
final ok = await confirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Merge two rides?',
|
||||||
|
message: 'They become one ride of about ${formatDistance(total)}. '
|
||||||
|
'The join is kept as a segment break, so no straight line is drawn '
|
||||||
|
'across it.',
|
||||||
|
confirmLabel: 'Merge',
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
final ids = selection.toList();
|
||||||
|
if (ids.length == 2) {
|
||||||
|
await ref.read(tripRepositoryProvider).mergeTrips(ids[0], ids[1]);
|
||||||
|
}
|
||||||
|
ref.read(_selectionProvider.notifier).state = <int>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TripTile extends StatelessWidget {
|
||||||
|
const _TripTile({
|
||||||
|
required this.trip,
|
||||||
|
required this.selected,
|
||||||
|
required this.selecting,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Trip trip;
|
||||||
|
final bool selected;
|
||||||
|
final bool selecting;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return Card(
|
||||||
|
key: Key('trip-${trip.id}'),
|
||||||
|
color: selected ? colors.surfaceContainerHighest : null,
|
||||||
|
child: ListTile(
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
leading: selecting
|
||||||
|
? Icon(
|
||||||
|
selected ? Icons.check_circle : Icons.circle_outlined,
|
||||||
|
color: selected ? colors.primary : colors.outline,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
title: Text(tripLabel(trip),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: Text(
|
||||||
|
'${formatDistance(trip.distanceM)} · '
|
||||||
|
'${formatDuration(trip.movingMillis)} · '
|
||||||
|
'max ${formatSpeed(trip.maxSpeedKmh)}',
|
||||||
|
style: TextStyle(color: colors.outline),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
341
test/widget_test.dart
Normal file
341
test/widget_test.dart
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/app/providers.dart';
|
||||||
|
import 'package:rippr/src/data/database.dart';
|
||||||
|
import 'package:rippr/src/data/trip_repository.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
import 'package:rippr/src/recording/location_source.dart';
|
||||||
|
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
|
||||||
|
import 'package:rippr/src/ui/record/record_screen.dart';
|
||||||
|
import 'package:rippr/src/ui/theme.dart';
|
||||||
|
import 'package:rippr/src/ui/trips/trips_screen.dart';
|
||||||
|
|
||||||
|
/// **The first UI tests this project has ever had.**
|
||||||
|
///
|
||||||
|
/// `docs/v3/BACKLOG.md` names "zero UI tests across six screens" as v2's largest coverage
|
||||||
|
/// gap: everything was verified by manual screenshot, which is how a 64 sp speed figure
|
||||||
|
/// once shipped rendering black-on-black. Flutter makes these cheap enough that carrying
|
||||||
|
/// that debt into the port would be indefensible.
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late TripRepository repo;
|
||||||
|
late FakeLocationSource source;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
db = AppDatabase(NativeDatabase.memory());
|
||||||
|
repo = TripRepository(db);
|
||||||
|
source = FakeLocationSource();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await source.dispose();
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Runs a widget test and then tears the tree down cleanly.
|
||||||
|
///
|
||||||
|
/// `flutter_test` asserts that no `Timer` is pending once the tree is disposed. Two
|
||||||
|
/// things here legitimately hold one: the elapsed clock on the record screen, and
|
||||||
|
/// Drift's stream-query cache, which keeps a query alive briefly after its last
|
||||||
|
/// listener leaves so re-subscribing is cheap. Removing the tree cancels the first;
|
||||||
|
/// pumping past the keep-alive window clears the second.
|
||||||
|
void screenTest(String description, Future<void> Function(WidgetTester) body) {
|
||||||
|
testWidgets(description, (tester) async {
|
||||||
|
await body(tester);
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pumps a screen that has a live ride on it.
|
||||||
|
///
|
||||||
|
/// `pumpAndSettle` cannot be used once a ride is active: the elapsed clock ticks every
|
||||||
|
/// second forever, so "settled" never arrives. Two explicit frames are enough to build
|
||||||
|
/// and then reflect the streamed trip.
|
||||||
|
Future<void> pumpLive(WidgetTester tester, Widget widget) async {
|
||||||
|
await tester.pumpWidget(widget);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps a screen with the real theme and an in-memory stack.
|
||||||
|
///
|
||||||
|
/// The theme is deliberately included: the black-on-black bug was invisible to logic
|
||||||
|
/// tests and only a rendered widget can catch its equivalent.
|
||||||
|
Widget host(Widget child) => ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
databaseProvider.overrideWithValue(db),
|
||||||
|
locationSourceProvider.overrideWithValue(source),
|
||||||
|
],
|
||||||
|
child: MaterialApp(theme: ripprTheme(), home: child),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<int> seedCompletedTrip({
|
||||||
|
required int startedAt,
|
||||||
|
required int endedAt,
|
||||||
|
String? name,
|
||||||
|
int points = 5,
|
||||||
|
double startLat = 51.0,
|
||||||
|
}) async {
|
||||||
|
final h = await repo.startTrip(startedAt);
|
||||||
|
await repo.appendPoints([
|
||||||
|
for (var i = 0; i < points; i++)
|
||||||
|
TrackPoint(
|
||||||
|
tripId: h.tripId,
|
||||||
|
segmentId: h.segmentId,
|
||||||
|
timestamp: startedAt + i * 1000,
|
||||||
|
latitude: startLat + i * 0.0001,
|
||||||
|
longitude: -114.0,
|
||||||
|
speedKmh: 40.0 + i,
|
||||||
|
altitudeM: 1000.0 + i,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (name != null) await repo.renameTrip(h.tripId, name);
|
||||||
|
await repo.completeTrip(endedAt);
|
||||||
|
await repo.recomputeAggregates(h.tripId);
|
||||||
|
return h.tripId;
|
||||||
|
}
|
||||||
|
|
||||||
|
group('record screen', () {
|
||||||
|
screenTest('idle shows Ready and only START', (tester) async {
|
||||||
|
await tester.pumpWidget(host(const RecordScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Ready'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('start')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('pause')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('stop')), findsNothing);
|
||||||
|
// A resting screen must not look like a ride going nowhere.
|
||||||
|
expect(find.text('Distance'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('the headline is live speed, not max speed', (tester) async {
|
||||||
|
// v2.0 shipped max speed as the headline and it read as a frozen, broken screen on
|
||||||
|
// a real ride, because a max figure only moves when you beat it. This guards the
|
||||||
|
// 2.0.1 fix.
|
||||||
|
await tester.pumpWidget(host(const RecordScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('SPEED'), findsOneWidget);
|
||||||
|
expect(find.text('MAX SPEED'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('recording swaps to PAUSE and STOP, with no DISCARD',
|
||||||
|
(tester) async {
|
||||||
|
await repo.startTrip(1000);
|
||||||
|
await pumpLive(tester, host(const RecordScreen()));
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('pause')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('stop')), findsOneWidget);
|
||||||
|
// Destructive actions must not sit next to Pause during a live ride — a gloved
|
||||||
|
// mis-tap at speed would lose the whole recording.
|
||||||
|
expect(find.byKey(const Key('discard')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('start')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('paused offers RESUME, STOP and DISCARD', (tester) async {
|
||||||
|
await repo.startTrip(1000);
|
||||||
|
await repo.pauseTrip(2000);
|
||||||
|
await pumpLive(tester, host(const RecordScreen()));
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('resume')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('stop')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('discard')), findsOneWidget);
|
||||||
|
expect(find.text('Paused'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('discard asks before destroying anything', (tester) async {
|
||||||
|
await repo.startTrip(1000);
|
||||||
|
await repo.pauseTrip(2000);
|
||||||
|
await pumpLive(tester, host(const RecordScreen()));
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('discard')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Discard this ride?'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(await repo.activeTrip(), isNotNull,
|
||||||
|
reason: 'cancelling must not delete the ride');
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('text is legible against the dark ground', (tester) async {
|
||||||
|
// The regression this exists for: removing Compose's Surface left LocalContentColor
|
||||||
|
// black, and a 64sp figure rendered invisibly on a near-black background. No logic
|
||||||
|
// test could catch it. This asserts the rendered colour differs from the ground.
|
||||||
|
await tester.pumpWidget(host(const RecordScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final speed = tester.widget<Text>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('speed')),
|
||||||
|
matching: find.text('0'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final colour = speed.style?.color;
|
||||||
|
expect(colour, isNotNull, reason: 'the headline must name its colour');
|
||||||
|
expect(colour, isNot(ripprBackground));
|
||||||
|
expect(colour, isNot(Colors.black));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('trips list', () {
|
||||||
|
screenTest('empty state explains what to do', (tester) async {
|
||||||
|
await tester.pumpWidget(host(const TripsScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('No rides yet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('completed rides are listed newest first', (tester) async {
|
||||||
|
await seedCompletedTrip(
|
||||||
|
startedAt: 1000, endedAt: 5000, name: 'Older ride');
|
||||||
|
await seedCompletedTrip(
|
||||||
|
startedAt: 100000, endedAt: 200000, name: 'Newer ride');
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(const TripsScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final newer = tester.getTopLeft(find.text('Newer ride')).dy;
|
||||||
|
final older = tester.getTopLeft(find.text('Older ride')).dy;
|
||||||
|
expect(newer, lessThan(older));
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('an active ride does not appear in the list', (tester) async {
|
||||||
|
await seedCompletedTrip(
|
||||||
|
startedAt: 1000, endedAt: 5000, name: 'Finished');
|
||||||
|
await repo.startTrip(100000);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(const TripsScreen()));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('trip-1')), findsOneWidget);
|
||||||
|
expect(find.text('Finished'), findsOneWidget);
|
||||||
|
// Only the completed one.
|
||||||
|
expect(find.byType(Card), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('Merge enables at exactly two selections', (tester) async {
|
||||||
|
final a = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
final b = await seedCompletedTrip(startedAt: 10000, endedAt: 15000);
|
||||||
|
final c = await seedCompletedTrip(startedAt: 20000, endedAt: 25000);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(const TripsScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// One selected: merge is present but disabled.
|
||||||
|
await tester.longPress(find.byKey(Key('trip-$a')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.widget<TextButton>(find.byKey(const Key('merge'))).onPressed,
|
||||||
|
isNull,
|
||||||
|
reason: 'merging one ride is meaningless',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two: enabled.
|
||||||
|
await tester.tap(find.byKey(Key('trip-$b')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.widget<TextButton>(find.byKey(const Key('merge'))).onPressed,
|
||||||
|
isNotNull,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Three: ambiguous, so disabled again.
|
||||||
|
await tester.tap(find.byKey(Key('trip-$c')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.widget<TextButton>(find.byKey(const Key('merge'))).onPressed,
|
||||||
|
isNull,
|
||||||
|
reason: 'merge combines exactly two rides',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('deleting asks first and then removes the ride',
|
||||||
|
(tester) async {
|
||||||
|
final a = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(const TripsScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.longPress(find.byKey(Key('trip-$a')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('delete')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('Delete 1 ride'), findsOneWidget);
|
||||||
|
// "Delete" appears both in the toolbar and in the dialog; target the dialog's.
|
||||||
|
await tester.tap(find.descendant(
|
||||||
|
of: find.byType(AlertDialog),
|
||||||
|
matching: find.text('Delete'),
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(await repo.tripById(a), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('trip detail', () {
|
||||||
|
screenTest('shows the authoritative summary', (tester) async {
|
||||||
|
final id = await seedCompletedTrip(
|
||||||
|
startedAt: 1000, endedAt: 5000, name: 'Sunday blast', points: 11);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(TripDetailScreen(tripId: id)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Sunday blast'), findsOneWidget);
|
||||||
|
expect(find.text('DISTANCE'), findsOneWidget);
|
||||||
|
expect(find.text('Max speed'), findsOneWidget);
|
||||||
|
expect(find.text('Segments'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('a missing ride says so instead of rendering blank',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(host(const TripDetailScreen(tripId: 9999)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('no longer exists'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('charts degrade gracefully below two points', (tester) async {
|
||||||
|
final id = await seedCompletedTrip(
|
||||||
|
startedAt: 1000, endedAt: 2000, points: 1);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(TripDetailScreen(tripId: id)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Both charts need intervals; one point has none. An unexplained blank box would
|
||||||
|
// read as a bug.
|
||||||
|
//
|
||||||
|
// The detail body is a ListView, so the second chart is not built until it is
|
||||||
|
// scrolled into view -- asserting without scrolling would silently check only one.
|
||||||
|
expect(find.textContaining('Not enough points'), findsOneWidget);
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -600));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('Not enough points'), findsWidgets);
|
||||||
|
expect(find.text('Elevation profile'.toUpperCase()), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('renaming updates the title', (tester) async {
|
||||||
|
final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(TripDetailScreen(tripId: id)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('rename')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(const Key('name-field')), 'Coast run');
|
||||||
|
await tester.tap(find.text('Save'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Coast run'), findsOneWidget);
|
||||||
|
expect((await repo.tripById(id))!.name, 'Coast run');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user