297 lines
9.1 KiB
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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|