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 createState() => _ReplayScreenState(); } class _ReplayScreenState extends ConsumerState { bool _isLoading = true; List _telemetry = []; List _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 _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, ) ] : []; 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)), ], ), ); } }