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:
119
lib/main.dart
119
lib/main.dart
@@ -2,125 +2,38 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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() {
|
||||
runApp(const ProviderScope(child: RipprApp()));
|
||||
}
|
||||
|
||||
class RipprApp extends StatelessWidget {
|
||||
class RipprApp extends ConsumerStatefulWidget {
|
||||
const RipprApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
ConsumerState<RipprApp> createState() => _RipprAppState();
|
||||
}
|
||||
|
||||
class _RecorderHarness extends ConsumerStatefulWidget {
|
||||
const _RecorderHarness();
|
||||
|
||||
@override
|
||||
ConsumerState<_RecorderHarness> createState() => _RecorderHarnessState();
|
||||
}
|
||||
|
||||
class _RecorderHarnessState extends ConsumerState<_RecorderHarness> {
|
||||
String? _error;
|
||||
class _RipprAppState extends ConsumerState<RipprApp> {
|
||||
final _router = buildRouter();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Re-attach to a ride that was in progress when the process died. Must happen before
|
||||
// any user interaction, and the database is the only thing that knows.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await ref.read(recordingEngineProvider).restoreAfterProcessDeath();
|
||||
// Re-attach to a ride that was in progress when the process died. Must run before any
|
||||
// user interaction, and the database is the only thing that knows a ride was open.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final engine = ref.watch(recordingEngineProvider);
|
||||
final state = ref.watch(recorderStateProvider).valueOrNull ?? engine.state;
|
||||
final trip = ref.watch(activeTripProvider).valueOrNull;
|
||||
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget build(BuildContext context) => MaterialApp.router(
|
||||
title: 'Rippr',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ripprTheme(),
|
||||
routerConfig: _router,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user