1091 lines
35 KiB
Dart
1091 lines
35 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),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: colors.cyberCyan.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
'VIEW MAP >',
|
|
style: TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 8,
|
|
fontWeight: FontWeight.w800,
|
|
color: colors.cyberCyan,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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.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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|