MotorSignal-App/lib/features/garage/motorcycle_detail_screen.dart

1476 lines
56 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../database/database.dart';
import '../../models/motorcycle_models.dart';
import '../../models/maintenance_models.dart';
import '../../core/responsive/responsive_layout.dart';
import '../../core/theme/theme_extensions.dart';
import 'widgets/motorcycle_photo.dart';
import 'garage_providers.dart';
class MotorcycleDetailScreen extends ConsumerWidget {
final int motorcycleId;
const MotorcycleDetailScreen({super.key, required this.motorcycleId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
final statsAsync = ref.watch(motorcyclePerformanceStatsProvider(motorcycleId));
final colors = context.motorColors;
final theme = Theme.of(context);
return bikesAsync.when(
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
error: (err, _) => Scaffold(body: Center(child: Text('Error: $err'))),
data: (bikes) {
final bike = bikes.firstWhere(
(b) => b.id == motorcycleId,
orElse: () => GarageMotorcycle(
id: 0,
year: 2024,
makeId: '',
makeName: 'Unknown',
modelId: '',
modelName: 'Unknown',
isDefault: false,
dateAdded: DateTime.now(),
lastUpdated: DateTime.now(),
),
);
if (bike.id == 0) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: Text('Motorcycle not found')),
);
}
MotorcycleSpecification? spec;
if (bike.specificationJson != null) {
try {
spec = MotorcycleSpecification.fromJson(json.decode(bike.specificationJson!));
} catch (_) {}
}
return Scaffold(
appBar: AppBar(
title: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
'${bike.year} ${bike.makeName} ${bike.modelName}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
actions: [
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Edit Nickname',
onPressed: () => _showEditNicknameDialog(context, ref, bike),
),
IconButton(
icon: Icon(Icons.delete_outline, color: colors.raceRed),
tooltip: 'Delete Motorcycle',
onPressed: () => _confirmDelete(context, ref, bike, colors),
),
],
),
body: ResponsiveLayout(
builder: (context, responsive) {
final isWide = responsive.isExpanded || (responsive.isMedium && responsive.isLandscape);
if (isWide) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 4,
child: _buildHeaderCard(context, ref, bike, spec, colors, theme),
),
const SizedBox(width: 16),
Expanded(
flex: 6,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildMyPerformanceSection(context, statsAsync, spec, colors, theme),
const SizedBox(height: 20),
_buildMaintenanceSection(context, ref, bike, statsAsync, colors, theme),
const SizedBox(height: 20),
_buildSpecificationSheet(context, spec, colors, theme),
],
),
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeaderCard(context, ref, bike, spec, colors, theme),
const SizedBox(height: 20),
_buildMyPerformanceSection(context, statsAsync, spec, colors, theme),
const SizedBox(height: 20),
_buildMaintenanceSection(context, ref, bike, statsAsync, colors, theme),
const SizedBox(height: 20),
_buildSpecificationSheet(context, spec, colors, theme),
const SizedBox(height: 24),
],
),
);
},
),
);
},
);
}
Widget _buildHeaderCard(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
MotorcycleSpecification? spec,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Card(
elevation: 2,
color: colors.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: bike.isDefault ? colors.cyberCyan : colors.outlineVariant,
width: bike.isDefault ? 1.8 : 1.0,
),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MotorcyclePhoto(
year: bike.year,
make: bike.makeName,
model: bike.modelName,
height: 190,
borderRadius: BorderRadius.circular(12),
showAttribution: true,
fit: BoxFit.cover,
),
const SizedBox(height: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${bike.year}',
style: TextStyle(
fontSize: 14,
color: colors.cyberCyan,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
),
),
Text(
'${bike.makeName} ${bike.modelName}'.toUpperCase(),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w900,
fontFamily: 'Sora',
color: theme.colorScheme.onSurface,
),
),
if (bike.nickname != null && bike.nickname!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'"${bike.nickname}"',
style: TextStyle(
fontSize: 15,
fontStyle: FontStyle.italic,
color: colors.warningAmber,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 16),
Divider(color: colors.outlineVariant, height: 1),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildHeaderStat(
'DISPLACEMENT',
spec?.displacementCc != null ? '${spec!.displacementCc!.toStringAsFixed(0)} cc' : '—',
theme,
),
_buildHeaderStat(
'POWER',
spec?.horsepower != null ? '${spec!.horsepower!.toStringAsFixed(0)} HP' : '—',
theme,
),
_buildHeaderStat(
'TORQUE',
spec?.torqueLbFt != null ? '${spec!.torqueLbFt!.toStringAsFixed(1)} lb-ft' : '—',
theme,
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: bike.isDefault
? Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.cyberCyan),
),
child: Center(
child: Text(
'CURRENT MOTORCYCLE',
style: TextStyle(
fontFamily: 'Sora',
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: colors.cyberCyan,
),
),
),
)
: OutlinedButton(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(color: colors.cyberCyan),
foregroundColor: colors.cyberCyan,
),
onPressed: () {
ref.read(garageControllerProvider).setDefaultMotorcycle(bike.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Set ${bike.makeName} ${bike.modelName} as current motorcycle.',
),
),
);
},
child: const Text(
'SET AS CURRENT MOTORCYCLE',
style: TextStyle(
fontFamily: 'Sora',
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
),
),
],
),
),
);
}
Widget _buildHeaderStat(String label, String value, ThemeData theme) {
return Expanded(
child: Column(
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
label,
style: TextStyle(
fontSize: 9.5,
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
letterSpacing: 1.0,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface,
),
),
),
],
),
);
}
Widget _buildMyPerformanceSection(
BuildContext context,
AsyncValue<MotorcycleStats> statsAsync,
MotorcycleSpecification? spec,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MY PERFORMANCE',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 16,
fontWeight: FontWeight.bold,
color: colors.cyberCyan,
letterSpacing: 0.8,
),
),
const SizedBox(height: 12),
statsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Text('Error loading stats: $err'),
data: (stats) {
return Column(
children: [
Row(
children: [
Expanded(child: _buildPerfTile('TOTAL RIDES', '${stats.rideCount}', colors, theme)),
const SizedBox(width: 12),
Expanded(
child: _buildPerfTile(
'TOTAL DISTANCE',
'${stats.totalDistanceMiles.toStringAsFixed(1)} MI',
colors,
theme,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildPerfTile(
'BEST TOP SPEED',
'${stats.topSpeedMph.toStringAsFixed(1)} MPH',
colors,
theme,
published: spec?.claimedTopSpeedMph != null
? '~${spec!.claimedTopSpeedMph!.toStringAsFixed(0)} MPH'
: null,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildPerfTile(
'BEST 0–60',
stats.best0to60Ms != null ? '${(stats.best0to60Ms! / 1000.0).toStringAsFixed(2)} s' : '—',
colors,
theme,
published: spec?.claimedZeroToSixtySec != null
? '~${spec!.claimedZeroToSixtySec!.toStringAsFixed(1)} s'
: null,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildPerfTile(
'MAX LEAN',
'${stats.maxLean.toStringAsFixed(1)}°',
colors,
theme,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildPerfTile(
'MAX ACCEL G',
'${stats.maxAccelG.toStringAsFixed(2)} G',
colors,
theme,
),
),
],
),
],
);
},
),
],
);
}
Widget _buildPerfTile(
String label,
String value,
MotorSignalThemeColors colors,
ThemeData theme, {
String? published,
}) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant, width: 1.2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
),
),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface,
),
),
),
if (published != null) ...[
const SizedBox(height: 4),
Text(
'PUBLISHED: $published',
style: TextStyle(
fontSize: 9.5,
color: colors.cyberCyan,
fontFamily: 'JetBrains Mono',
),
),
],
],
),
);
}
Widget _buildSpecificationSheet(
BuildContext context,
MotorcycleSpecification? spec,
MotorSignalThemeColors colors,
ThemeData theme,
) {
if (spec == null) {
return Card(
color: colors.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outlineVariant),
),
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'Detailed specifications unavailable for this motorcycle.',
style: TextStyle(color: Colors.white54),
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SPECIFICATIONS',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 16,
fontWeight: FontWeight.bold,
color: colors.cyberCyan,
letterSpacing: 0.8,
),
),
const SizedBox(height: 12),
_buildSpecGroup('ENGINE', [
_SpecRow('Displacement', spec.displacementCc != null ? '${spec.displacementCc!.toStringAsFixed(0)} cc' : null),
_SpecRow('Configuration', spec.engineConfiguration),
_SpecRow('Cylinders', spec.cylinderCount?.toString()),
_SpecRow('Horsepower', spec.horsepower != null ? '${spec.horsepower!.toStringAsFixed(0)} HP @ ${spec.horsepowerRpm ?? "—"} RPM' : null),
_SpecRow('Torque', spec.torqueLbFt != null ? '${spec.torqueLbFt!.toStringAsFixed(1)} lb-ft @ ${spec.torqueRpm ?? "—"} RPM' : null),
_SpecRow('Redline', spec.redlineRpm != null ? '${spec.redlineRpm} RPM' : null),
_SpecRow('Compression Ratio', spec.compressionRatio),
_SpecRow('Cooling System', spec.coolingSystem),
_SpecRow('Fuel System', spec.fuelSystem),
], colors, theme),
_buildSpecGroup('PERFORMANCE (PUBLISHED)', [
_SpecRow('Claimed Top Speed', spec.claimedTopSpeedMph != null ? '~${spec.claimedTopSpeedMph!.toStringAsFixed(0)} MPH' : null),
_SpecRow('Claimed 0–60 MPH', spec.claimedZeroToSixtySec != null ? '~${spec.claimedZeroToSixtySec!.toStringAsFixed(1)} sec' : null),
_SpecRow('Claimed 1/4 Mile', spec.claimedQuarterMileSec != null ? '~${spec.claimedQuarterMileSec!.toStringAsFixed(1)} sec' : null),
], colors, theme),
_buildSpecGroup('WEIGHT & DIMENSIONS', [
_SpecRow('Wet Weight', spec.wetWeightLbs != null ? '${spec.wetWeightLbs!.toStringAsFixed(0)} lbs' : null),
_SpecRow('Dry Weight', spec.dryWeightLbs != null ? '${spec.dryWeightLbs!.toStringAsFixed(0)} lbs' : null),
_SpecRow('Seat Height', spec.seatHeightInches != null ? '${spec.seatHeightInches!.toStringAsFixed(1)} in' : null),
_SpecRow('Wheelbase', spec.wheelbaseInches != null ? '${spec.wheelbaseInches!.toStringAsFixed(1)} in' : null),
], colors, theme),
_buildSpecGroup('FUEL', [
_SpecRow('Tank Capacity', spec.fuelCapacityGallons != null ? '${spec.fuelCapacityGallons!.toStringAsFixed(1)} gal' : null),
_SpecRow('Fuel Economy', spec.fuelEconomyMpg != null ? '${spec.fuelEconomyMpg!.toStringAsFixed(0)} MPG' : null),
_SpecRow('Estimated Range', spec.estimatedRangeMiles != null ? '~${spec.estimatedRangeMiles!.toStringAsFixed(0)} miles' : null),
], colors, theme),
_buildSpecGroup('CHASSIS & BRAKES', [
_SpecRow('Frame Type', spec.frameType),
_SpecRow('Front Suspension', spec.frontSuspension),
_SpecRow('Rear Suspension', spec.rearSuspension),
_SpecRow('Front Brake', spec.frontBrake),
_SpecRow('Rear Brake', spec.rearBrake),
_SpecRow('ABS System', spec.absType),
_SpecRow('Front Tire', spec.frontTireSize),
_SpecRow('Rear Tire', spec.rearTireSize),
], colors, theme),
_buildSpecGroup('TRANSMISSION', [
_SpecRow('Transmission Type', spec.transmissionType),
_SpecRow('Gears', spec.gearCount?.toString()),
_SpecRow('Final Drive', spec.finalDrive),
], colors, theme),
if (spec.source != null) ...[
const SizedBox(height: 12),
Text(
'Specifications Source: ${spec.source}',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
fontFamily: 'Inter',
),
),
],
],
);
}
Widget _buildSpecGroup(
String title,
List<_SpecRow> rows,
MotorSignalThemeColors colors,
ThemeData theme,
) {
final validRows = rows.where((r) => r.value != null && r.value!.isNotEmpty).toList();
if (validRows.isEmpty) return const SizedBox.shrink();
return Card(
elevation: 1,
color: colors.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outlineVariant),
),
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(14.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontFamily: 'Sora',
fontWeight: FontWeight.bold,
color: colors.cyberCyan,
fontSize: 12,
letterSpacing: 0.8,
),
),
const SizedBox(height: 8),
...validRows.map(
(r) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
r.label,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
r.value!,
textAlign: TextAlign.right,
style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: theme.colorScheme.onSurface,
),
),
),
],
),
),
),
],
),
),
);
}
void _showEditNicknameDialog(BuildContext context, WidgetRef ref, GarageMotorcycle bike) {
final controller = TextEditingController(text: bike.nickname ?? '');
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Edit Nickname'),
content: TextField(
controller: controller,
decoration: const InputDecoration(hintText: 'Enter nickname'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: () {
ref.read(garageControllerProvider).updateNickname(
bike.id,
controller.text.trim().isNotEmpty ? controller.text.trim() : null,
);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
);
},
);
}
void _confirmDelete(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
MotorSignalThemeColors colors,
) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete Motorcycle?'),
content: Text(
'Are you sure you want to remove ${bike.makeName} ${bike.modelName} from your Garage? Historical rides associated with this motorcycle will be preserved.',
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: colors.raceRed),
onPressed: () {
ref.read(garageControllerProvider).deleteMotorcycle(bike.id);
Navigator.pop(context);
context.pop();
},
child: const Text('Delete', style: TextStyle(color: Colors.white)),
),
],
);
},
);
}
Widget _buildMaintenanceSection(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
AsyncValue<MotorcycleStats> statsAsync,
MotorSignalThemeColors colors,
ThemeData theme,
) {
final stats = statsAsync.value;
final totalRiddenMiles = stats?.totalDistanceMiles ?? 0.0;
final maintenanceData = MaintenanceData.fromSpecificationJson(bike.specificationJson);
final currentOdometer = maintenanceData.baseOdometer + totalRiddenMiles;
final pills = maintenanceData.calculatePills(totalRiddenMiles);
final records = maintenanceData.serviceRecords;
return Card(
elevation: 2,
color: colors.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outlineVariant, width: 1.0),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Header
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MAINTENANCE DUE TRACKING',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
color: colors.cyberCyan,
letterSpacing: 1.0,
),
),
const SizedBox(height: 2),
InkWell(
onTap: () => _showSetOdometerDialog(context, ref, bike, maintenanceData.baseOdometer, colors),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Odo: ${currentOdometer.toStringAsFixed(0)} mi',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface,
),
),
const SizedBox(width: 4),
Icon(Icons.edit_note, size: 16, color: colors.cyberCyan),
],
),
),
),
],
),
ElevatedButton.icon(
onPressed: () => _showLogServiceDialog(context, ref, bike, currentOdometer, colors, theme),
icon: const Icon(Icons.add_circle_outline, size: 16),
label: const Text('Log Service'),
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
foregroundColor: colors.cyberCyan,
side: BorderSide(color: colors.cyberCyan.withValues(alpha: 0.4)),
elevation: 0,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
],
),
const SizedBox(height: 16),
// Countdown Pills Grid
if (pills.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: colors.outlineVariant.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(Icons.info_outline, size: 18, color: colors.cyberCyan),
const SizedBox(width: 10),
Expanded(
child: Text(
'Set your odometer or log your first service above to activate maintenance countdowns.',
style: TextStyle(
fontSize: 11.5,
fontFamily: 'Inter',
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
),
],
),
)
else
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final itemWidth = width >= 480
? (width - 20) / 3
: (width >= 280 ? (width - 10) / 2 : width);
return Wrap(
spacing: 10,
runSpacing: 10,
children: pills
.map(
(pill) => SizedBox(
width: itemWidth,
child: _buildMaintenancePill(context, ref, bike, pill, currentOdometer, colors, theme),
),
)
.toList(),
);
},
),
const SizedBox(height: 20),
Divider(color: colors.outlineVariant.withValues(alpha: 0.4), height: 1),
const SizedBox(height: 16),
// Service History Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'SERVICE HISTORY (${records.length})',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
color: colors.outlineVariant,
letterSpacing: 0.5,
),
),
],
),
const SizedBox(height: 10),
if (records.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.3)),
),
child: Column(
children: [
Icon(Icons.build_circle_outlined, size: 32, color: colors.outlineVariant),
const SizedBox(height: 8),
Text(
'No maintenance logged yet',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
'Tap "+ Log Service" to record oil changes, chain service, and tire wear.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: records.length,
separatorBuilder: (c, i) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final record = records[index];
final dateFormatted = DateFormat('MMM d, yyyy').format(record.date);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.4)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.build, size: 18, color: colors.cyberCyan),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
record.serviceType,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
if (record.cost != null && record.cost! > 0)
Text(
'\$${record.cost!.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
color: colors.cyberCyan,
),
),
],
),
const SizedBox(height: 3),
Row(
children: [
Text(
dateFormatted,
style: TextStyle(
fontSize: 11,
fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
Text(
' • ${record.odometer.toStringAsFixed(0)} mi',
style: TextStyle(
fontSize: 11,
fontFamily: 'JetBrains Mono',
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface.withValues(alpha: 0.8),
),
),
],
),
if (record.notes != null && record.notes!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
record.notes!,
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
),
],
),
),
IconButton(
icon: Icon(Icons.edit_outlined, size: 16, color: colors.cyberCyan),
tooltip: 'Edit Service Record',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _showEditServiceRecordDialog(context, ref, bike, record, colors, theme),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(Icons.delete_outline, size: 16, color: colors.outlineVariant),
tooltip: 'Delete Service Record',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _confirmDeleteServiceRecord(context, ref, bike, record.id, colors),
),
],
),
);
},
),
],
),
),
);
}
Widget _buildMaintenancePill(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
MaintenanceItemStatus pill,
double currentOdometer,
MotorSignalThemeColors colors,
ThemeData theme,
) {
final badgeColor = pill.getBadgeColor(context);
final isUrgent = pill.urgency == MaintenanceUrgency.overdue || pill.urgency == MaintenanceUrgency.dueSoon;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _showLogServiceForPill(context, ref, bike, pill, currentOdometer, colors, theme),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: badgeColor.withValues(alpha: isUrgent ? 0.8 : 0.35),
width: isUrgent ? 1.5 : 1.0,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(pill.icon, size: 15, color: badgeColor),
const SizedBox(width: 5),
Expanded(
child: Text(
pill.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface,
),
),
),
Icon(Icons.add_task, size: 13, color: badgeColor.withValues(alpha: 0.7)),
],
),
const SizedBox(height: 6),
Text(
pill.label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
fontFamily: 'JetBrains Mono',
color: badgeColor,
),
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: (1.0 - (pill.milesRemaining / pill.intervalMiles)).clamp(0.0, 1.0),
backgroundColor: colors.outlineVariant.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(badgeColor),
minHeight: 3,
),
),
],
),
),
);
}
void _showLogServiceForPill(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
MaintenanceItemStatus pill,
double currentOdometer,
MotorSignalThemeColors colors,
ThemeData theme,
) {
String serviceType = 'General Maintenance';
switch (pill.key) {
case 'oil':
serviceType = 'Engine Oil & Filter';
break;
case 'chain':
serviceType = 'Chain Clean & Lube';
break;
case 'tire':
serviceType = 'Tire Replacement (Rear)';
break;
case 'brake':
serviceType = 'Brake Fluid Flush';
break;
}
_showLogServiceDialog(
context,
ref,
bike,
currentOdometer,
colors,
theme,
initialServiceType: serviceType,
);
}
void _showSetOdometerDialog(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
double currentBaseOdo,
MotorSignalThemeColors colors,
) {
final controller = TextEditingController(text: currentBaseOdo.round().toString());
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Update Base Odometer'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Enter the odometer reading when this bike was added to your garage. Ridden telemetry miles are added automatically.',
style: TextStyle(fontSize: 12),
),
const SizedBox(height: 12),
TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Base Odometer (miles)',
suffixText: 'mi',
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: () {
final odo = double.tryParse(controller.text.trim()) ?? currentBaseOdo;
ref.read(garageControllerProvider).updateBaseOdometer(bike.id, odo);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
);
},
);
}
void _showLogServiceDialog(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
double currentOdometer,
MotorSignalThemeColors colors,
ThemeData theme, {
String? initialServiceType,
}) {
final serviceTypes = [
'Engine Oil & Filter',
'Chain Clean & Lube',
'Tire Replacement (Rear)',
'Tire Replacement (Front)',
'Tire Replacement (Set)',
'Brake Fluid Flush',
'Brake Pads Replacement',
'Air Filter Replacement',
'Spark Plugs',
'Valve Clearance Check',
'General Maintenance',
];
String selectedType = (initialServiceType != null && serviceTypes.contains(initialServiceType))
? initialServiceType
: serviceTypes[0];
final odoController = TextEditingController(text: currentOdometer.round().toString());
final notesController = TextEditingController();
final costController = TextEditingController();
DateTime serviceDate = DateTime.now();
showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
final size = MediaQuery.sizeOf(context);
final viewInsets = MediaQuery.viewInsetsOf(context);
return AlertDialog(
scrollable: true,
constraints: BoxConstraints(
maxWidth: 560,
maxHeight: size.height - viewInsets.bottom - 48,
),
title: const Text('Log New Service'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: selectedType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Service Type'),
items: serviceTypes
.map((t) => DropdownMenuItem(
value: t,
child: Text(t, maxLines: 1, overflow: TextOverflow.ellipsis),
))
.toList(),
onChanged: (val) {
if (val != null) setState(() => selectedType = val);
},
),
const SizedBox(height: 12),
TextField(
controller: odoController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Odometer at Service',
suffixText: 'mi',
),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Service Date', style: TextStyle(fontSize: 12)),
subtitle: Text(DateFormat('MMMM d, yyyy').format(serviceDate)),
trailing: const Icon(Icons.calendar_today, size: 18),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: serviceDate,
firstDate: DateTime(2000),
lastDate: DateTime.now().add(const Duration(days: 1)),
);
if (picked != null) {
setState(() => serviceDate = picked);
}
},
),
const SizedBox(height: 8),
TextField(
controller: costController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Cost (Optional)',
prefixText: '\$ ',
),
),
const SizedBox(height: 12),
TextField(
controller: notesController,
decoration: const InputDecoration(
labelText: 'Notes (e.g. Brand, weight, shop)',
hintText: 'e.g. Motul 300V 10W-40, OEM filter',
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: () {
final odo = double.tryParse(odoController.text.trim()) ?? currentOdometer;
final cost = double.tryParse(costController.text.trim());
final record = ServiceRecord(
id: 'srv_${DateTime.now().millisecondsSinceEpoch}',
date: serviceDate,
odometer: odo,
serviceType: selectedType,
notes: notesController.text.trim().isNotEmpty ? notesController.text.trim() : null,
cost: cost,
);
ref.read(garageControllerProvider).addServiceRecord(bike.id, record);
Navigator.pop(context);
},
child: const Text('Save Record'),
),
],
);
},
);
},
);
}
void _showEditServiceRecordDialog(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
ServiceRecord record,
MotorSignalThemeColors colors,
ThemeData theme,
) {
final serviceTypes = [
'Engine Oil & Filter',
'Chain Clean & Lube',
'Tire Replacement (Rear)',
'Tire Replacement (Front)',
'Tire Replacement (Set)',
'Brake Fluid Flush',
'Brake Pads Replacement',
'Air Filter Replacement',
'Spark Plugs',
'Valve Clearance Check',
'General Maintenance',
];
String selectedType = serviceTypes.contains(record.serviceType)
? record.serviceType
: serviceTypes.last;
final odoController = TextEditingController(text: record.odometer.round().toString());
final notesController = TextEditingController(text: record.notes ?? '');
final costController = TextEditingController(text: record.cost != null ? record.cost!.toStringAsFixed(2) : '');
DateTime serviceDate = record.date;
showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: const Text('Edit Service Record'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: selectedType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Service Type'),
items: serviceTypes
.map((t) => DropdownMenuItem(
value: t,
child: Text(t, maxLines: 1, overflow: TextOverflow.ellipsis),
))
.toList(),
onChanged: (val) {
if (val != null) setState(() => selectedType = val);
},
),
const SizedBox(height: 12),
TextField(
controller: odoController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Odometer at Service',
suffixText: 'mi',
),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Service Date', style: TextStyle(fontSize: 12)),
subtitle: Text(DateFormat('MMMM d, yyyy').format(serviceDate)),
trailing: const Icon(Icons.calendar_today, size: 18),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: serviceDate,
firstDate: DateTime(2000),
lastDate: DateTime.now().add(const Duration(days: 1)),
);
if (picked != null) {
setState(() => serviceDate = picked);
}
},
),
const SizedBox(height: 8),
TextField(
controller: costController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Cost (Optional)',
prefixText: '\$ ',
),
),
const SizedBox(height: 12),
TextField(
controller: notesController,
decoration: const InputDecoration(
labelText: 'Notes (e.g. Brand, weight, shop)',
hintText: 'e.g. Motul 300V 10W-40, OEM filter',
),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: () {
final odo = double.tryParse(odoController.text.trim()) ?? record.odometer;
final cost = double.tryParse(costController.text.trim());
final updated = ServiceRecord(
id: record.id,
date: serviceDate,
odometer: odo,
serviceType: selectedType,
notes: notesController.text.trim().isNotEmpty ? notesController.text.trim() : null,
cost: cost,
);
ref.read(garageControllerProvider).updateServiceRecord(bike.id, updated);
Navigator.pop(context);
},
child: const Text('Save Changes'),
),
],
);
},
);
},
);
}
void _confirmDeleteServiceRecord(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
String recordId,
MotorSignalThemeColors colors,
) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete Service Record?'),
content: const Text('Are you sure you want to remove this maintenance entry?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: colors.raceRed),
onPressed: () {
ref.read(garageControllerProvider).deleteServiceRecord(bike.id, recordId);
Navigator.pop(context);
},
child: const Text('Delete', style: TextStyle(color: Colors.white)),
),
],
);
},
);
}
}
class _SpecRow {
final String label;
final String? value;
_SpecRow(this.label, this.value);
}