833 lines
30 KiB
Dart
833 lines
30 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.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';
|
|
import '../../core/theme/theme_extensions.dart';
|
|
|
|
/// Corner performance metrics and apex grading for forensic telemetry replay.
|
|
class CornerAnalysis {
|
|
final int id;
|
|
final String direction; // 'Left' or 'Right'
|
|
final double entrySpeed;
|
|
final double apexSpeed;
|
|
final double exitSpeed;
|
|
final double apexLeanAngle;
|
|
final String grade; // 'A+', 'A', 'B', 'C', 'D'
|
|
final int startIndex;
|
|
final int apexIndex;
|
|
final int endIndex;
|
|
|
|
const CornerAnalysis({
|
|
required this.id,
|
|
required this.direction,
|
|
required this.entrySpeed,
|
|
required this.apexSpeed,
|
|
required this.exitSpeed,
|
|
required this.apexLeanAngle,
|
|
required this.grade,
|
|
required this.startIndex,
|
|
required this.apexIndex,
|
|
required this.endIndex,
|
|
});
|
|
|
|
double get speedDelta => exitSpeed - entrySpeed;
|
|
|
|
Color getGradeColor() {
|
|
switch (grade) {
|
|
case 'A+':
|
|
case 'A':
|
|
return const Color(0xFF10B981); // Emerald
|
|
case 'B':
|
|
return Colors.cyanAccent;
|
|
case 'C':
|
|
return Colors.amberAccent;
|
|
case 'D':
|
|
default:
|
|
return const Color(0xFFFF334B); // Red
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = [];
|
|
List<AppPolyline> _heatmapPolylines = [];
|
|
List<CornerAnalysis> _corners = [];
|
|
|
|
// 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);
|
|
var targetRideId = widget.rideId;
|
|
if (targetRideId == 0) {
|
|
final latestRide = await (db.select(db.rides)
|
|
..orderBy([(r) => OrderingTerm(expression: r.startTime, mode: OrderingMode.desc)])
|
|
..limit(1))
|
|
.getSingleOrNull();
|
|
if (latestRide != null) {
|
|
targetRideId = latestRide.id;
|
|
}
|
|
}
|
|
|
|
final telemetry = await (db.select(db.telemetrySamples)
|
|
..where((t) => t.rideId.equals(targetRideId))
|
|
..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();
|
|
|
|
_heatmapPolylines = _buildHeatmapPolylines(telemetry);
|
|
_corners = _detectCorners(telemetry);
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_telemetry = telemetry;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Builds multi-color lean angle route heatmap:
|
|
/// Yellow (<=20° straight/mild) -> Cyan (20°-35° cornering) -> Red (>35° apex peak).
|
|
List<AppPolyline> _buildHeatmapPolylines(List<TelemetrySample> telemetry) {
|
|
final List<AppPolyline> polylines = [];
|
|
final validSamples = telemetry.where((t) => t.latitude != null && t.longitude != null).toList();
|
|
if (validSamples.length < 2) return polylines;
|
|
|
|
Color getColorForLean(double absLean) {
|
|
if (absLean > 35.0) {
|
|
return const Color(0xFFFF334B); // Red apex
|
|
} else if (absLean > 20.0) {
|
|
return Colors.cyanAccent; // Cyan mid-lean
|
|
} else {
|
|
return Colors.amberAccent; // Yellow mild/straight
|
|
}
|
|
}
|
|
|
|
double getWidthForLean(double absLean) {
|
|
if (absLean > 35.0) return 6.0;
|
|
if (absLean > 20.0) return 4.5;
|
|
return 3.5;
|
|
}
|
|
|
|
List<AppLatLng> currentSegment = [AppLatLng(validSamples[0].latitude!, validSamples[0].longitude!)];
|
|
Color currentColor = getColorForLean((validSamples[0].leanAngle ?? 0.0).abs());
|
|
double currentWidth = getWidthForLean((validSamples[0].leanAngle ?? 0.0).abs());
|
|
|
|
for (int i = 1; i < validSamples.length; i++) {
|
|
final sample = validSamples[i];
|
|
final absLean = (sample.leanAngle ?? 0.0).abs();
|
|
final sampleColor = getColorForLean(absLean);
|
|
final sampleWidth = getWidthForLean(absLean);
|
|
final pt = AppLatLng(sample.latitude!, sample.longitude!);
|
|
|
|
if (sampleColor == currentColor) {
|
|
currentSegment.add(pt);
|
|
} else {
|
|
currentSegment.add(pt); // Overlap adjacent segments to avoid rendering seams
|
|
polylines.add(AppPolyline(
|
|
id: 'heatmap_${polylines.length}',
|
|
points: List<AppLatLng>.from(currentSegment),
|
|
color: currentColor,
|
|
width: currentWidth,
|
|
));
|
|
currentSegment = [pt];
|
|
currentColor = sampleColor;
|
|
currentWidth = sampleWidth;
|
|
}
|
|
}
|
|
|
|
if (currentSegment.length >= 2) {
|
|
polylines.add(AppPolyline(
|
|
id: 'heatmap_${polylines.length}',
|
|
points: currentSegment,
|
|
color: currentColor,
|
|
width: currentWidth,
|
|
));
|
|
}
|
|
|
|
return polylines;
|
|
}
|
|
|
|
/// Automatically parses corners, detects apexes, and grades cornering efficiency.
|
|
List<CornerAnalysis> _detectCorners(List<TelemetrySample> telemetry) {
|
|
final List<CornerAnalysis> corners = [];
|
|
if (telemetry.length < 10) return corners;
|
|
|
|
bool inCorner = false;
|
|
int startIndex = 0;
|
|
double maxLean = 0.0;
|
|
int apexIdx = 0;
|
|
String dir = 'Left';
|
|
|
|
for (int i = 0; i < telemetry.length; i++) {
|
|
final lean = telemetry[i].leanAngle ?? 0.0;
|
|
final absLean = lean.abs();
|
|
|
|
if (absLean > 14.0) {
|
|
if (!inCorner) {
|
|
inCorner = true;
|
|
startIndex = (i > 0) ? i - 1 : 0;
|
|
maxLean = absLean;
|
|
apexIdx = i;
|
|
dir = lean < 0 ? 'Left' : 'Right';
|
|
} else {
|
|
if (absLean > maxLean) {
|
|
maxLean = absLean;
|
|
apexIdx = i;
|
|
dir = lean < 0 ? 'Left' : 'Right';
|
|
}
|
|
}
|
|
} else {
|
|
if (inCorner) {
|
|
final endIndex = i;
|
|
final durationCount = endIndex - startIndex;
|
|
if (durationCount >= 4 && maxLean >= 16.0) {
|
|
final entrySpeed = telemetry[startIndex].speed ?? 0.0;
|
|
final apexSpeed = telemetry[apexIdx].speed ?? 0.0;
|
|
final exitSpeed = telemetry[endIndex].speed ?? 0.0;
|
|
|
|
final speedRetention = entrySpeed > 0 ? (apexSpeed / entrySpeed) : 1.0;
|
|
final speedExitDelta = exitSpeed - apexSpeed;
|
|
|
|
String grade = 'B';
|
|
if (maxLean >= 35.0 && speedRetention >= 0.82 && speedExitDelta >= 0) {
|
|
grade = 'A+';
|
|
} else if (maxLean >= 28.0 && speedRetention >= 0.75) {
|
|
grade = 'A';
|
|
} else if (maxLean >= 20.0) {
|
|
grade = 'B';
|
|
} else if (speedRetention < 0.6) {
|
|
grade = 'D';
|
|
} else {
|
|
grade = 'C';
|
|
}
|
|
|
|
corners.add(CornerAnalysis(
|
|
id: corners.length + 1,
|
|
direction: dir,
|
|
entrySpeed: entrySpeed,
|
|
apexSpeed: apexSpeed,
|
|
exitSpeed: exitSpeed,
|
|
apexLeanAngle: maxLean,
|
|
grade: grade,
|
|
startIndex: startIndex,
|
|
apexIndex: apexIdx,
|
|
endIndex: endIndex,
|
|
));
|
|
}
|
|
inCorner = false;
|
|
maxLean = 0.0;
|
|
}
|
|
}
|
|
}
|
|
|
|
return corners;
|
|
}
|
|
|
|
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) {
|
|
final colors = context.motorColors;
|
|
final theme = Theme.of(context);
|
|
|
|
if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
if (_telemetry.isEmpty) {
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFF101419),
|
|
appBar: AppBar(
|
|
backgroundColor: const Color(0xFF0B0E13),
|
|
title: const Text('Ride Replay'),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back_rounded),
|
|
onPressed: () {
|
|
if (Navigator.of(context).canPop()) {
|
|
Navigator.of(context).pop();
|
|
} else {
|
|
context.go('/activity');
|
|
}
|
|
},
|
|
),
|
|
),
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.analytics_outlined, size: 56, color: Color(0xFF4A5568)),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'No telemetry data available for replay.',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white70,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'Record or restore a ride with telemetry samples to replay.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Color(0xFF8B98A5),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
);
|
|
|
|
// Apex Markers for detected corners on the map
|
|
final apexMarkers = _corners
|
|
.where((c) =>
|
|
c.apexIndex < _telemetry.length &&
|
|
_telemetry[c.apexIndex].latitude != null &&
|
|
_telemetry[c.apexIndex].longitude != null)
|
|
.map((c) {
|
|
final s = _telemetry[c.apexIndex];
|
|
final isCurrent = _currentIndex >= c.startIndex && _currentIndex <= c.endIndex;
|
|
|
|
return AppMapMarker(
|
|
id: 'apex_${c.id}',
|
|
position: AppLatLng(s.latitude!, s.longitude!),
|
|
heading: 0,
|
|
onTap: () => setState(() => _currentIndex = c.apexIndex),
|
|
iconWidget: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: isCurrent ? const Color(0xFFFF334B) : Colors.black87,
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(
|
|
color: isCurrent ? Colors.white : c.getGradeColor(),
|
|
width: isCurrent ? 2 : 1.2,
|
|
),
|
|
),
|
|
child: Text(
|
|
'T${c.id}',
|
|
style: const TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList();
|
|
|
|
final allMarkers = [riderMarker, ...apexMarkers];
|
|
final polylines = _heatmapPolylines.isNotEmpty
|
|
? _heatmapPolylines
|
|
: (_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}'),
|
|
actions: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 16.0),
|
|
child: Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: colors.cyberCyan.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: colors.cyberCyan.withValues(alpha: 0.4)),
|
|
),
|
|
child: Text(
|
|
'${_corners.length} Corners Graded',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontFamily: 'JetBrains Mono',
|
|
fontWeight: FontWeight.bold,
|
|
color: colors.cyberCyan,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
// Map View (with multi-color lean heatmap and telemetry HUD overlay)
|
|
Expanded(
|
|
flex: 5,
|
|
child: Stack(
|
|
children: [
|
|
UnifiedMapView(
|
|
center: currentPos,
|
|
zoom: 17.0,
|
|
markers: allMarkers,
|
|
polylines: polylines,
|
|
),
|
|
// Telemetry Overlay
|
|
Positioned(
|
|
top: 14,
|
|
left: 14,
|
|
right: 14,
|
|
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'),
|
|
],
|
|
),
|
|
),
|
|
// Heatmap Legend Overlay
|
|
Positioned(
|
|
bottom: 10,
|
|
left: 14,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.8),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.white24),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildLegendItem(Colors.amberAccent, '≤20°'),
|
|
const SizedBox(width: 8),
|
|
_buildLegendItem(Colors.cyanAccent, '20°-35°'),
|
|
const SizedBox(width: 8),
|
|
_buildLegendItem(const Color(0xFFFF334B), '>35° Apex'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Playback Controls Bar
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
color: theme.colorScheme.surface,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
currentSample.timestamp.toLocal().toString().split('.')[0],
|
|
style: const TextStyle(
|
|
color: Colors.cyanAccent,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
Text(
|
|
'Sample ${_currentIndex + 1} / ${_telemetry.length}',
|
|
style: TextStyle(
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
fontSize: 11,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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),
|
|
tooltip: 'Restart',
|
|
onPressed: () {
|
|
setState(() {
|
|
_currentIndex = 0;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(width: 16),
|
|
FloatingActionButton.small(
|
|
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, fontFamily: 'JetBrains Mono'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Corner Entry / Apex / Exit Breakdown Table
|
|
Expanded(
|
|
flex: 4,
|
|
child: Container(
|
|
color: theme.colorScheme.surfaceContainerLow,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
color: theme.colorScheme.surfaceContainer,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'CORNER APEX BREAKDOWN & GRADING',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
fontFamily: 'JetBrains Mono',
|
|
color: colors.cyberCyan,
|
|
letterSpacing: 0.8,
|
|
),
|
|
),
|
|
Text(
|
|
'Tap row to seek apex',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontFamily: 'JetBrains Mono',
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_corners.isEmpty)
|
|
Expanded(
|
|
child: Center(
|
|
child: Text(
|
|
'No distinct corners detected over 15° lean threshold.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
Expanded(
|
|
child: ListView.separated(
|
|
itemCount: _corners.length,
|
|
separatorBuilder: (c, i) => Divider(
|
|
color: colors.outlineVariant.withValues(alpha: 0.25),
|
|
height: 1,
|
|
),
|
|
itemBuilder: (context, index) {
|
|
final corner = _corners[index];
|
|
final isCurrent =
|
|
_currentIndex >= corner.startIndex && _currentIndex <= corner.endIndex;
|
|
|
|
return InkWell(
|
|
onTap: () {
|
|
setState(() {
|
|
_currentIndex = corner.apexIndex;
|
|
});
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
|
color: isCurrent
|
|
? colors.cyberCyan.withValues(alpha: 0.12)
|
|
: Colors.transparent,
|
|
child: Row(
|
|
children: [
|
|
// Corner ID & Direction
|
|
SizedBox(
|
|
width: 75,
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
'T${corner.id}',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
corner.direction == 'Left' ? '↰ L' : '↱ R',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
color: corner.direction == 'Left'
|
|
? Colors.blueAccent
|
|
: Colors.greenAccent,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Entry Speed
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'ENTRY',
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
Text(
|
|
'${corner.entrySpeed.toStringAsFixed(0)} mph',
|
|
style: const TextStyle(fontSize: 12, fontFamily: 'JetBrains Mono'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Apex Speed & Lean
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'APEX',
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
Text(
|
|
'${corner.apexSpeed.toStringAsFixed(0)} mph • ${corner.apexLeanAngle.toStringAsFixed(0)}°',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: colors.cyberCyan,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Exit Speed & Delta
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'EXIT (Δ)',
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
Text(
|
|
'${corner.exitSpeed.toStringAsFixed(0)} mph (${corner.speedDelta >= 0 ? '+' : ''}${corner.speedDelta.toStringAsFixed(0)})',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: corner.speedDelta >= 0 ? Colors.greenAccent : Colors.orangeAccent,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Grade Badge
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: corner.getGradeColor().withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: corner.getGradeColor().withValues(alpha: 0.5)),
|
|
),
|
|
child: Text(
|
|
corner.grade,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
fontFamily: 'JetBrains Mono',
|
|
color: corner.getGradeColor(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLegendItem(Color color, String text) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
text,
|
|
style: const TextStyle(fontSize: 10, color: Colors.white, fontFamily: 'JetBrains Mono'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|