MotorSignal-App/lib/features/replay/replay_screen.dart

297 lines
9.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'dart:async';
import '../../core/theme/map_style.dart';
import 'package:drift/drift.dart' hide Column;
import '../../database/database.dart';
class ReplayScreen extends ConsumerStatefulWidget {
final int rideId;
const ReplayScreen({super.key, required this.rideId});
@override
ConsumerState<ReplayScreen> createState() => _ReplayScreenState();
}
class _ReplayScreenState extends ConsumerState<ReplayScreen> {
GoogleMapController? _mapController;
bool _isLoading = true;
List<TelemetrySample> _telemetry = [];
final Set<Polyline> _polylines = {};
// Playback state
bool _isPlaying = false;
double _playbackSpeed = 1.0;
int _currentIndex = 0;
Timer? _playbackTimer;
@override
void initState() {
super.initState();
_loadData();
}
@override
void dispose() {
_playbackTimer?.cancel();
super.dispose();
}
Future<void> _loadData() async {
final db = ref.read(databaseProvider);
final telemetry = await (db.select(db.telemetrySamples)
..where((t) => t.rideId.equals(widget.rideId))
..orderBy([(t) => OrderingTerm(expression: t.timestamp)])
).get();
if (telemetry.isNotEmpty) {
final List<LatLng> points = telemetry
.where((t) => t.latitude != null && t.longitude != null)
.map((t) => LatLng(t.latitude!, t.longitude!))
.toList();
if (points.isNotEmpty) {
_polylines.add(
Polyline(
polylineId: const PolylineId('route'),
points: points,
color: Colors.cyanAccent,
width: 5,
)
);
}
}
if (mounted) {
setState(() {
_telemetry = telemetry;
_isLoading = false;
});
_fitRoute();
}
}
void _fitRoute() {
if (_mapController == null || _polylines.isEmpty) return;
final points = _polylines.first.points;
if (points.isEmpty) return;
double minLat = points.first.latitude;
double maxLat = points.first.latitude;
double minLng = points.first.longitude;
double maxLng = points.first.longitude;
for (final p in points) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLng) minLng = p.longitude;
if (p.longitude > maxLng) maxLng = p.longitude;
}
_mapController!.animateCamera(
CameraUpdate.newLatLngBounds(
LatLngBounds(southwest: LatLng(minLat, minLng), northeast: LatLng(maxLat, maxLng)),
50.0,
)
);
}
void _togglePlayback() {
setState(() {
_isPlaying = !_isPlaying;
});
if (_isPlaying) {
if (_currentIndex >= _telemetry.length - 1) {
_currentIndex = 0; // restart
}
_scheduleNextFrame();
} else {
_playbackTimer?.cancel();
}
}
void _scheduleNextFrame() {
if (!_isPlaying || _currentIndex >= _telemetry.length - 1) {
setState(() => _isPlaying = false);
return;
}
final currentPoint = _telemetry[_currentIndex];
final nextPoint = _telemetry[_currentIndex + 1];
final diffMs = nextPoint.timestamp.difference(currentPoint.timestamp).inMilliseconds;
// Cap at 1 second to avoid massive pauses if GPS dropped out
final waitMs = (diffMs.clamp(0, 1000) / _playbackSpeed).round();
_playbackTimer = Timer(Duration(milliseconds: waitMs), () {
if (mounted) {
setState(() {
_currentIndex++;
});
// Follow camera
if (_mapController != null) {
final t = _telemetry[_currentIndex];
if (t.latitude != null && t.longitude != null) {
_mapController!.animateCamera(
CameraUpdate.newCameraPosition(
CameraPosition(
target: LatLng(t.latitude!, t.longitude!),
zoom: 18.0,
bearing: t.heading ?? 0,
)
)
);
}
}
_scheduleNextFrame();
}
});
}
void _changeSpeed() {
setState(() {
if (_playbackSpeed == 0.25) {
_playbackSpeed = 0.5;
} else if (_playbackSpeed == 0.5) {
_playbackSpeed = 1.0;
} else if (_playbackSpeed == 1.0) {
_playbackSpeed = 2.0;
} else if (_playbackSpeed == 2.0) {
_playbackSpeed = 4.0;
} else {
_playbackSpeed = 0.25;
}
});
}
@override
Widget build(BuildContext context) {
if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator()));
if (_telemetry.isEmpty) return const Scaffold(body: Center(child: Text('No telemetry data to replay.')));
final currentSample = _telemetry[_currentIndex];
final Set<Marker> markers = {};
if (currentSample.latitude != null && currentSample.longitude != null) {
markers.add(
Marker(
markerId: const MarkerId('current'),
position: LatLng(currentSample.latitude!, currentSample.longitude!),
icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan),
rotation: currentSample.heading ?? 0,
flat: true,
)
);
}
return Scaffold(
appBar: AppBar(title: Text('Replay - Ride ${widget.rideId}')),
body: Column(
children: [
// Map
Expanded(
flex: 3,
child: Stack(
children: [
GoogleMap(
initialCameraPosition: const CameraPosition(target: LatLng(0, 0), zoom: 15),
polylines: _polylines,
markers: markers,
onMapCreated: (controller) {
_mapController = controller;
controller.setMapStyle(darkMapStyle);
_fitRoute();
},
),
// Telemetry Overlay
Positioned(
top: 16, left: 16, right: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph'),
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'),
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G'),
],
),
),
],
),
),
// Controls
Container(
padding: const EdgeInsets.all(16),
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
Text(currentSample.timestamp.toLocal().toString().split('.')[0], style: const TextStyle(color: Colors.cyanAccent)),
Slider(
value: _currentIndex.toDouble(),
min: 0,
max: (_telemetry.length - 1).toDouble(),
onChanged: (val) {
setState(() {
_currentIndex = val.toInt();
});
},
activeColor: Colors.cyanAccent,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.restart_alt),
onPressed: () {
setState(() { _currentIndex = 0; });
if (_mapController != null && _telemetry.isNotEmpty) {
final t = _telemetry.first;
if (t.latitude != null && t.longitude != null) {
_mapController!.animateCamera(CameraUpdate.newLatLng(LatLng(t.latitude!, t.longitude!)));
}
}
},
),
const SizedBox(width: 16),
FloatingActionButton(
onPressed: _togglePlayback,
child: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
),
const SizedBox(width: 16),
TextButton(
onPressed: _changeSpeed,
child: Text('${_playbackSpeed}x'),
)
],
)
],
),
)
],
),
);
}
Widget _buildOverlayStat(String label, String value) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white24),
),
child: Column(
children: [
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white70)),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent)),
],
),
);
}
}