254 lines
7.7 KiB
Dart
254 lines
7.7 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 polylines = _routePoints.length >= 2
|
|
? [
|
|
AppPolyline(
|
|
id: 'replay_route',
|
|
points: _routePoints,
|
|
color: Colors.cyanAccent,
|
|
width: 5.0,
|
|
)
|
|
]
|
|
: <AppPolyline>[];
|
|
|
|
final maxSliderValue = _telemetry.length > 1 ? (_telemetry.length - 1).toDouble() : 1.0;
|
|
final currentSliderValue = _telemetry.length > 1 ? _currentIndex.toDouble().clamp(0.0, maxSliderValue) : 0.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: polylines,
|
|
),
|
|
// 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: currentSliderValue,
|
|
min: 0,
|
|
max: maxSliderValue,
|
|
onChanged: _telemetry.length > 1
|
|
? (val) {
|
|
setState(() {
|
|
_currentIndex = val.toInt();
|
|
});
|
|
}
|
|
: null,
|
|
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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|