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

991 lines
37 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;
final bool autoPlay;
const ReplayScreen({super.key, required this.rideId, this.autoPlay = false});
@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;
double _zoom = 17.0;
bool _isFollowingRider = true;
@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.id, 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;
});
if (widget.autoPlay && telemetry.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_isPlaying) {
_togglePlayback();
}
});
}
}
}
/// 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);
final hasLeanData = _telemetry.any((t) => t.leanAngle != null);
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: _zoom,
markers: allMarkers,
polylines: polylines,
isFollowModeOverride: _isFollowingRider,
onUserInteraction: () {
if (_isFollowingRider) {
setState(() => _isFollowingRider = false);
}
},
),
// Map Navigation Controls (Zoom In, Zoom Out, Recenter)
Positioned(
right: 14,
bottom: 14,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!_isFollowingRider) ...[
_ReplayMapButton(
key: const ValueKey('replay_recenter_button'),
icon: Icons.my_location,
tooltip: 'Recenter on Rider',
color: colors.cyberCyan,
backgroundColor: colors.cyberCyan.withValues(alpha: 0.25),
onTap: () => setState(() => _isFollowingRider = true),
),
const SizedBox(height: 8),
],
_ReplayMapButton(
key: const ValueKey('replay_zoom_in_button'),
icon: Icons.add,
tooltip: 'Zoom In',
onTap: () => setState(() => _zoom = (_zoom + 1.0).clamp(3.0, 19.0)),
),
const SizedBox(height: 8),
_ReplayMapButton(
key: const ValueKey('replay_zoom_out_button'),
icon: Icons.remove,
tooltip: 'Zoom Out',
onTap: () => setState(() => _zoom = (_zoom - 1.0).clamp(3.0, 19.0)),
),
],
),
),
// Telemetry Overlay
Positioned(
top: 14,
left: 14,
right: 14,
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildOverlayStat('SPEED', '${currentSample.speed?.toStringAsFixed(0) ?? 0} mph'),
const SizedBox(width: 8),
_buildOverlayStat('LEAN', '${currentSample.leanAngle?.abs().toStringAsFixed(0) ?? 0}°'),
const SizedBox(width: 8),
_buildOverlayStat('G', '${currentSample.longitudinalG?.abs().toStringAsFixed(2) ?? 0}G'),
],
),
),
),
),
// Heatmap Legend Overlay
Positioned(
bottom: 10,
left: 14,
right: 14,
child: Align(
alignment: Alignment.centerLeft,
child: FittedBox(
fit: BoxFit.scaleDown,
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: [
Flexible(
child: Text(
currentSample.timestamp.toLocal().toString().split('.')[0],
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.cyanAccent,
fontWeight: FontWeight.bold,
fontSize: 12,
fontFamily: 'JetBrains Mono',
),
),
),
const SizedBox(width: 8),
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: _corners.isEmpty ? 3 : 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: [
Flexible(
child: Text(
'CORNER APEX BREAKDOWN & GRADING',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
color: colors.cyberCyan,
letterSpacing: 0.8,
),
),
),
const SizedBox(width: 8),
Text(
_corners.isEmpty ? '' : '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: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
hasLeanData
? Icons.straighten_rounded
: Icons.sensors_off_rounded,
size: 26,
color: hasLeanData
? colors.cyberCyan.withValues(alpha: 0.7)
: colors.warningAmber.withValues(alpha: 0.8),
),
const SizedBox(height: 6),
Text(
hasLeanData
? 'No distinct corners detected over 15° lean threshold.'
: 'Sensor Fusion Uncalibrated / No Lean Telemetry',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface.withValues(alpha: 0.85),
fontFamily: 'JetBrains Mono',
),
),
const SizedBox(height: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Text(
hasLeanData
? 'Upright riding recorded. Lean angle remained below 15° throughout this session.'
: 'Lean angle was not recorded or IMU was uncalibrated during this session.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withValues(alpha: 0.55),
fontFamily: 'Inter',
),
),
),
],
),
),
),
),
)
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)),
],
),
);
}
}
class _ReplayMapButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onTap;
final Color? color;
final Color? backgroundColor;
const _ReplayMapButton({
super.key,
required this.icon,
required this.tooltip,
required this.onTap,
this.color,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
return Material(
color: backgroundColor ?? Colors.black87,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: (color ?? Colors.white).withValues(alpha: 0.3)),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Tooltip(
message: tooltip,
child: SizedBox(
width: 48,
height: 48,
child: Icon(
icon,
color: color ?? Colors.white,
size: 24,
),
),
),
),
);
}
}