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

245 lines
7.3 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart' hide Column;
import '../../database/database.dart';
import '../../services/map/map_provider_interface.dart';
import '../../services/map/unified_map_view.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> {
bool _isLoading = true;
List<TelemetrySample> _telemetry = [];
List<AppLatLng> _routePoints = [];
// 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) {
_routePoints = telemetry
.where((t) => t.latitude != null && t.longitude != null)
.map((t) => AppLatLng(t.latitude!, t.longitude!))
.toList();
}
if (mounted) {
setState(() {
_telemetry = telemetry;
_isLoading = false;
});
}
}
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;
final waitMs = (diffMs.clamp(0, 1000) / _playbackSpeed).round();
_playbackTimer = Timer(Duration(milliseconds: waitMs), () {
if (mounted) {
setState(() {
_currentIndex++;
});
_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 currentPos = (currentSample.latitude != null && currentSample.longitude != null)
? AppLatLng(currentSample.latitude!, currentSample.longitude!)
: (_routePoints.isNotEmpty ? _routePoints.first : const AppLatLng(0, 0));
final riderMarker = AppMapMarker(
id: 'replay_rider',
position: currentPos,
heading: currentSample.heading ?? 0.0,
iconWidget: const Icon(
Icons.navigation,
color: Colors.cyanAccent,
size: 38,
),
);
final polyline = AppPolyline(
id: 'replay_route',
points: _routePoints,
color: Colors.cyanAccent,
width: 5.0,
);
return Scaffold(
appBar: AppBar(title: Text('Replay - Ride ${widget.rideId}')),
body: Column(
children: [
// Map View
Expanded(
flex: 3,
child: Stack(
children: [
UnifiedMapView(
center: currentPos,
zoom: 17.0,
markers: [riderMarker],
polylines: [polyline],
),
// 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
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, fontWeight: FontWeight.bold),
),
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;
});
},
),
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', style: const TextStyle(fontWeight: FontWeight.bold)),
),
],
),
],
),
),
],
),
);
}
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.cyanAccent.withValues(alpha: 0.5)),
),
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)),
],
),
);
}
}