MotorSignal-App/lib/features/history/ride_detail_screen.dart

1161 lines
38 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:go_router/go_router.dart';
import '../../database/database.dart';
import '../../core/responsive/responsive_layout.dart';
import '../../core/theme/theme_extensions.dart';
import '../../services/export_service.dart';
import '../../services/entitlements/entitlement_providers.dart';
import '../../services/entitlements/feature_access_service.dart';
import '../../services/github/github_sync_service.dart';
import '../entitlements/widgets/pro_lock_badge.dart';
import '../replay/replay_screen.dart';
import '../map/map_screen.dart';
class RideDetailScreen extends ConsumerStatefulWidget {
final Ride ride;
const RideDetailScreen({super.key, required this.ride});
@override
ConsumerState<RideDetailScreen> createState() => _RideDetailScreenState();
}
class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
List<RideEvent> _events = [];
bool _isLoadingEvents = true;
@override
void initState() {
super.initState();
_loadEvents();
}
Future<void> _loadEvents() async {
final db = ref.read(databaseProvider);
final events = await (db.select(
db.rideEvents,
)..where((e) => e.rideId.equals(widget.ride.id))).get();
if (mounted) {
setState(() {
_events = events;
_isLoadingEvents = false;
});
}
}
@override
Widget build(BuildContext context) {
final ride = widget.ride;
final colors = context.motorColors;
final theme = Theme.of(context);
final bikeName = (ride.motorcycleYear != null &&
ride.motorcycleMake != null &&
ride.motorcycleModel != null)
? '${ride.motorcycleYear} ${ride.motorcycleMake} ${ride.motorcycleModel}'
.toUpperCase()
: null;
final durationSec = ride.endTime != null
? ride.endTime!.difference(ride.startTime).inSeconds
: (ride.movingTime + ride.stoppedTime);
final durationMin = (durationSec / 60).toStringAsFixed(0);
final movingMin = (ride.movingTime / 60).toStringAsFixed(0);
final syncService = ref.watch(githubSyncServiceProvider);
final isUploaded = syncService.isRideUploaded(ride.id);
final commitSha = syncService.getRideCommitSha(ride.id);
return Scaffold(
appBar: AppBar(
title: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'Ride #${ride.id} Telemetry',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
actions: [
IconButton(
icon: Icon(Icons.share, color: colors.cyberCyan),
tooltip: 'Export CSV',
onPressed: () async {
final isPro = ref
.read(featureAccessProvider)
.canAccess(MotorSignalFeature.csvExport);
if (!isPro) {
showProUpgradeSheet(
context,
ref,
feature: MotorSignalFeature.csvExport,
);
return;
}
final exporter = ref.read(exportServiceProvider);
await exporter.exportRide(ride.id, asGpx: false);
},
),
IconButton(
icon: Icon(Icons.delete_outline, color: colors.raceRed),
tooltip: 'Delete Ride',
onPressed: () => _confirmDelete(context, ride),
),
],
),
body: ResponsiveLayout(
builder: (context, responsive) {
final isWide = responsive.isExpanded || (responsive.isMedium && responsive.isLandscape);
if (isWide) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildGitHubSyncBanner(context, ref, syncService, isUploaded, commitSha, colors),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 5,
child: Column(
children: [
_buildHeroHeaderRow(context, ride, bikeName, colors),
const SizedBox(height: 16),
_buildSessionOverview(ride, durationMin, movingMin, colors, theme),
const SizedBox(height: 16),
_buildActionButtons(context, ride, colors, isNarrow: responsive.width < 800),
],
),
),
const SizedBox(width: 16),
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHandlingSection(
ride,
colors,
isLandscape: responsive.isExpanded && responsive.width >= 900,
),
const SizedBox(height: 16),
_buildForensicEventsSection(context, colors, theme),
],
),
),
],
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildGitHubSyncBanner(context, ref, syncService, isUploaded, commitSha, colors),
const SizedBox(height: 14),
_buildHeroHeaderRow(context, ride, bikeName, colors),
const SizedBox(height: 16),
_buildSessionOverview(ride, durationMin, movingMin, colors, theme),
const SizedBox(height: 16),
_buildHandlingSection(ride, colors, isLandscape: false),
const SizedBox(height: 16),
_buildForensicEventsSection(context, colors, theme),
const SizedBox(height: 20),
_buildActionButtons(context, ride, colors, isNarrow: responsive.width < 450),
const SizedBox(height: 24),
],
),
);
},
),
);
}
Widget _buildGitHubSyncBanner(
BuildContext context,
WidgetRef ref,
GitHubSyncService syncService,
bool isUploaded,
String? commitSha,
MotorSignalThemeColors colors,
) {
final isSyncing = syncService.state == GitHubUploadState.uploading;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isUploaded
? colors.launchGreen.withValues(alpha: 0.6)
: colors.cyberCyan.withValues(alpha: 0.4),
width: 1.2,
),
),
child: Row(
children: [
Icon(
isUploaded
? Icons.cloud_done
: (isSyncing ? Icons.cloud_sync : Icons.cloud_upload_outlined),
color: isUploaded ? colors.launchGreen : colors.cyberCyan,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
isUploaded
? 'GITHUB LOGS SYNCHRONIZED'
: (isSyncing ? 'UPLOADING TO GITHUB...' : 'GITHUB LOG SYNC'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Sora',
fontSize: 10.5,
fontWeight: FontWeight.w800,
color: isUploaded ? colors.launchGreen : Theme.of(context).colorScheme.onSurface,
),
),
Text(
isUploaded
? 'Commit: ${commitSha != null && commitSha.length >= 7 ? commitSha.substring(0, 7) : "synced"}'
: 'Repository: bponders12-cell/ride_logs',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
if (!isUploaded) ...[
const SizedBox(width: 6),
TextButton(
onPressed: isSyncing
? null
: () async {
final result = await syncService.uploadRide(
widget.ride.id,
isManual: true,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result.message),
backgroundColor: result.success
? colors.launchGreen
: colors.raceRed,
),
);
}
},
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
isSyncing ? 'SYNCING...' : 'UPLOAD',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9.5,
fontWeight: FontWeight.w800,
color: colors.cyberCyan,
),
),
),
],
],
),
);
}
Widget _buildHeroHeaderRow(
BuildContext context,
Ride ride,
String? bikeName,
MotorSignalThemeColors colors,
) {
return Row(
children: [
Expanded(
child: Container(
constraints: const BoxConstraints(minHeight: 120),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: colors.outlineVariant, width: 1.2),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.two_wheeler,
color: colors.cyberCyan,
size: 32,
),
const SizedBox(height: 4),
Text(
bikeName ?? 'MOTORCYCLE',
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Sora',
fontSize: 10.5,
fontWeight: FontWeight.w800,
color: Theme.of(context).colorScheme.onSurface,
),
),
if (ride.motorcycleId != null) ...[
const SizedBox(height: 4),
InkWell(
onTap: () => context.push('/garage/${ride.motorcycleId}'),
child: Text(
'VIEW SPECS >',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 8.5,
fontWeight: FontWeight.bold,
color: colors.cyberCyan,
),
),
),
],
],
),
),
),
const SizedBox(width: 10),
Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MapScreen(rideId: ride.id),
),
);
},
borderRadius: BorderRadius.circular(14),
child: Container(
constraints: const BoxConstraints(minHeight: 120),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: colors.cyberCyan.withValues(alpha: 0.5),
width: 1.2,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.map_rounded,
color: colors.cyberCyan,
size: 32,
),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'ROUTE PREVIEW',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 10.5,
fontWeight: FontWeight.w900,
color: colors.cyberCyan,
letterSpacing: 0.08,
),
),
),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'VIEW MAP',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 8,
fontWeight: FontWeight.w800,
color: colors.cyberCyan,
),
),
),
const SizedBox(width: 4),
InkWell(
onTap: () {
if (ride.distanceMiles <= 0.05) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Stationary session — no moving route recorded to replay.'),
duration: const Duration(seconds: 2),
backgroundColor: colors.surfaceContainer,
),
);
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReplayScreen(rideId: ride.id, autoPlay: true),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: (ride.distanceMiles > 0.05 ? colors.launchGreen : colors.outlineVariant)
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: (ride.distanceMiles > 0.05 ? colors.launchGreen : colors.outlineVariant)
.withValues(alpha: 0.6),
width: 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
ride.distanceMiles > 0.05 ? Icons.play_arrow_rounded : Icons.pause_circle_outline,
size: 10,
color: ride.distanceMiles > 0.05 ? colors.launchGreen : colors.outlineVariant,
),
const SizedBox(width: 2),
Text(
ride.distanceMiles > 0.05 ? 'PLAY >' : '0.0 MI',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 8,
fontWeight: FontWeight.w800,
color: ride.distanceMiles > 0.05 ? colors.launchGreen : colors.outlineVariant,
),
),
],
),
),
),
],
),
),
],
),
),
),
),
),
],
);
}
Widget _buildSessionOverview(
Ride ride,
String durationMin,
String movingMin,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('SESSION OVERVIEW', colors),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: colors.outlineVariant, width: 1.2),
),
child: Column(
children: [
_buildMetricRow(
'DISTANCE:',
'${ride.distanceMiles.toStringAsFixed(2)} mi',
'DURATION:',
'$durationMin min',
theme,
colors,
),
Divider(color: colors.outlineVariant, height: 18),
_buildMetricRow(
'MOVING TIME:',
'$movingMin min',
'AVG SPEED:',
'${ride.averageSpeed.toStringAsFixed(1)} mph',
theme,
colors,
),
Divider(color: colors.outlineVariant, height: 18),
_buildMetricRow(
'TOP SPEED:',
'${ride.topSpeedMph.toStringAsFixed(1)} mph',
'DATE:',
DateFormat('MM/dd/yyyy').format(
(ride.startTime.year > 3000
? DateTime.fromMillisecondsSinceEpoch(
ride.startTime.millisecondsSinceEpoch ~/ 1000,
isUtc: true,
)
: ride.startTime)
.toLocal(),
),
theme,
colors,
),
Divider(color: colors.outlineVariant, height: 18),
_buildMetricRow(
'ELEV GAIN:',
'${ride.elevationGain.toStringAsFixed(0)} m',
'ELEV LOSS:',
'${ride.elevationLoss.toStringAsFixed(0)} m',
theme,
colors,
),
],
),
),
],
);
}
Widget _buildHandlingSection(
Ride ride,
MotorSignalThemeColors colors, {
required bool isLandscape,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('HANDLING & DYNAMICS', colors),
const SizedBox(height: 8),
_buildResponsiveHandlingCards(ride, colors, isLandscape),
],
);
}
Widget _buildResponsiveHandlingCards(
Ride ride,
MotorSignalThemeColors colors,
bool isLandscape,
) {
final cards = [
_buildHandlingKpiCard(
'MAX LEAN (LEFT)',
'${ride.maxLeanLeft.toStringAsFixed(1)}°',
Icons.turn_left,
colors.leanAngleLeft,
colors,
),
_buildHandlingKpiCard(
'MAX LEAN (RIGHT)',
'${ride.maxLeanRight.toStringAsFixed(1)}°',
Icons.turn_right,
colors.leanAngleRight,
colors,
),
_buildHandlingKpiCard(
'MAX ACCELERATION',
'${ride.maxAccelG.toStringAsFixed(2)} G',
Icons.speed,
colors.gForcePositive,
colors,
),
_buildHandlingKpiCard(
'MAX BRAKING',
'${ride.maxBrakingG.toStringAsFixed(2)} G',
Icons.pan_tool_alt,
colors.gForceNegative,
colors,
),
];
if (isLandscape) {
return Row(
children: cards
.map(
(c) => Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: c,
),
),
)
.toList(),
);
}
return Column(
children: [
Row(
children: [
Expanded(child: cards[0]),
const SizedBox(width: 8),
Expanded(child: cards[1]),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: cards[2]),
const SizedBox(width: 8),
Expanded(child: cards[3]),
],
),
],
);
}
Widget _buildHandlingKpiCard(
String label,
String value,
IconData icon,
Color accentColor,
MotorSignalThemeColors colors,
) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant, width: 1.2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 9.5,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
),
Icon(icon, color: accentColor, size: 16),
],
),
const SizedBox(height: 6),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 18,
fontWeight: FontWeight.w900,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
],
),
);
}
Widget _buildForensicEventsSection(
BuildContext context,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: _buildSectionHeader('FORENSIC EVENTS (${_events.length})', colors),
),
if (_events.isNotEmpty)
Text(
'TAP TO INSPECT',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: colors.outlineVariant, width: 1.2),
),
child: _isLoadingEvents
? const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
)
: _events.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
'No threshold handling events registered during this session.',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
),
)
: Column(
children: _events
.take(15)
.map(
(event) => _buildInteractiveEventTile(context, event, colors),
)
.toList(),
),
),
],
);
}
Widget _buildInteractiveEventTile(
BuildContext context,
RideEvent event,
MotorSignalThemeColors colors,
) {
Color badgeColor = colors.warningAmber;
IconData eventIcon = Icons.warning_amber_rounded;
if (event.eventType.toLowerCase().contains('accel')) {
badgeColor = colors.launchGreen;
eventIcon = Icons.arrow_upward;
} else if (event.eventType.toLowerCase().contains('brak')) {
badgeColor = colors.raceRed;
eventIcon = Icons.arrow_downward;
} else if (event.eventType.toLowerCase().contains('corner') ||
event.eventType.toLowerCase().contains('lean')) {
badgeColor = colors.cyberCyan;
eventIcon = Icons.cached;
}
String formattedValue = '${event.value.toStringAsFixed(1)}°';
if (event.eventType.toLowerCase().contains('accel') ||
event.eventType.toLowerCase().contains('brak')) {
formattedValue = '${event.value.toStringAsFixed(2)} G';
}
final timeStr = DateFormat('HH:mm:ss.SSS').format(event.timestamp.toLocal());
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showEventDetailsDialog(
context,
event,
formattedValue,
timeStr,
badgeColor,
),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
child: Row(
children: [
Icon(eventIcon, color: badgeColor, size: 16),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.eventType.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
fontWeight: FontWeight.w800,
color: Theme.of(context).colorScheme.onSurface,
),
),
Text(
timeStr,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
formattedValue,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
fontWeight: FontWeight.w700,
color: badgeColor,
),
),
),
const SizedBox(width: 6),
Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
size: 16,
),
],
),
),
),
);
}
void _showEventDetailsDialog(
BuildContext context,
RideEvent event,
String formattedValue,
String timeStr,
Color badgeColor,
) {
showDialog(
context: context,
builder: (c) => AlertDialog(
title: Row(
children: [
Icon(Icons.info_outline, color: badgeColor),
const SizedBox(width: 8),
Text(event.eventType.toUpperCase()),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Timestamp: $timeStr'),
const SizedBox(height: 8),
Text('Recorded Value: $formattedValue'),
const SizedBox(height: 8),
Text('Ride ID: ${event.rideId}'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('CLOSE'),
),
],
),
);
}
Widget _buildActionButtons(
BuildContext context,
Ride ride,
MotorSignalThemeColors colors, {
required bool isNarrow,
}) {
Widget buildReplayButton() => OutlinedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReplayScreen(rideId: ride.id),
),
);
},
style: OutlinedButton.styleFrom(
foregroundColor: colors.cyberCyan,
side: BorderSide(color: colors.cyberCyan, width: 1.5),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'REPLAY RIDE',
style: TextStyle(
fontFamily: 'Sora',
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
),
);
Widget buildGpxButton() => OutlinedButton(
onPressed: () async {
final exporter = ref.read(exportServiceProvider);
await exporter.exportRide(ride.id, asGpx: true);
},
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
side: BorderSide(color: colors.outlineVariant, width: 1.2),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: const FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'EXPORT GPX',
style: TextStyle(
fontFamily: 'Sora',
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
),
);
Widget buildCsvButton() => Builder(
builder: (context) {
final isProCsv = ref
.watch(featureAccessProvider)
.canAccess(MotorSignalFeature.csvExport);
return OutlinedButton(
onPressed: () async {
if (!isProCsv) {
showProUpgradeSheet(
context,
ref,
feature: MotorSignalFeature.csvExport,
);
return;
}
final exporter = ref.read(exportServiceProvider);
await exporter.exportRide(ride.id, asGpx: false);
},
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
side: BorderSide(color: colors.outlineVariant, width: 1.2),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'EXPORT CSV',
style: TextStyle(
fontFamily: 'Sora',
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
if (!isProCsv) ...[
const SizedBox(width: 6),
const ProBadge(
fontSize: 8,
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 1),
),
],
],
),
),
);
},
);
if (isNarrow) {
return Column(
children: [
Row(
children: [
Expanded(child: buildReplayButton()),
const SizedBox(width: 8),
Expanded(child: buildGpxButton()),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: buildCsvButton(),
),
],
);
}
return Row(
children: [
Expanded(child: buildReplayButton()),
const SizedBox(width: 8),
Expanded(child: buildGpxButton()),
const SizedBox(width: 8),
Expanded(child: buildCsvButton()),
],
);
}
Widget _buildSectionHeader(String title, MotorSignalThemeColors colors) {
return Text(
title,
style: TextStyle(
fontFamily: 'Sora',
fontSize: 12,
fontWeight: FontWeight.w800,
color: colors.cyberCyan,
letterSpacing: 0.1,
),
);
}
Widget _buildMetricRow(
String label1,
String val1,
String label2,
String val2,
ThemeData theme,
MotorSignalThemeColors colors,
) {
Widget buildItem(String label, String val) {
return Row(
children: [
Flexible(
flex: 6,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 9.5,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
),
const SizedBox(width: 4),
Expanded(
flex: 5,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
val,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
fontWeight: FontWeight.w800,
color: theme.colorScheme.onSurface,
),
),
),
),
],
);
}
return Row(
children: [
Expanded(child: buildItem(label1, val1)),
const SizedBox(width: 8),
Expanded(child: buildItem(label2, val2)),
],
);
}
Future<void> _confirmDelete(BuildContext context, Ride r) async {
final colors = context.motorColors;
final confirm = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Delete Ride?'),
content: const Text('This cannot be undone. Historical telemetry for this ride will be removed.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(c, false),
child: const Text('CANCEL'),
),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: Text(
'DELETE',
style: TextStyle(color: colors.raceRed, fontWeight: FontWeight.bold),
),
),
],
),
);
if (confirm == true && mounted) {
final db = ref.read(databaseProvider);
await (db.delete(
db.telemetrySamples,
)..where((t) => t.rideId.equals(r.id))).go();
await (db.delete(
db.rideEvents,
)..where((e) => e.rideId.equals(r.id))).go();
await (db.delete(
db.rides,
)..where((ride) => ride.id.equals(r.id))).go();
if (mounted && context.mounted) {
Navigator.of(context).pop();
}
}
}
}