feat: unify history records and garage experience

This commit is contained in:
Developer 2026-09-05 03:17:11 -04:00
parent 9a31819952
commit d9502487d1
12 changed files with 2665 additions and 2418 deletions

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/motorcycle_models.dart';
import '../../core/theme/theme_extensions.dart';
import '../../services/entitlements/entitlement_providers.dart';
import '../../services/entitlements/feature_access_service.dart';
import '../entitlements/widgets/pro_lock_badge.dart';
@ -194,6 +195,8 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
@override
Widget build(BuildContext context) {
final colors = context.motorColors;
return PopScope(
canPop: _step == 1,
onPopInvokedWithResult: (didPop, result) {
@ -201,7 +204,10 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
},
child: Scaffold(
appBar: AppBar(
title: Text(_getStepTitle()),
title: Text(
_getStepTitle(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _goBack,
@ -213,10 +219,10 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
children: [
LinearProgressIndicator(
value: _step / 5.0,
backgroundColor: Colors.white10,
valueColor: const AlwaysStoppedAnimation(Colors.cyanAccent),
backgroundColor: colors.outlineVariant,
valueColor: AlwaysStoppedAnimation(colors.cyberCyan),
),
Expanded(child: _buildStepContent()),
Expanded(child: _buildStepContent(colors)),
],
),
),
@ -240,24 +246,25 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
}
}
Widget _buildStepContent() {
Widget _buildStepContent(MotorSignalThemeColors colors) {
switch (_step) {
case 1:
return _buildYearStep();
return _buildYearStep(colors);
case 2:
return _buildMakeStep();
return _buildMakeStep(colors);
case 3:
return _buildModelStep();
return _buildModelStep(colors);
case 4:
return _buildVariantStep();
return _buildVariantStep(colors);
case 5:
return _buildConfirmStep();
return _buildConfirmStep(colors);
default:
return const SizedBox();
return const SizedBox.shrink();
}
}
Widget _buildYearStep() {
Widget _buildYearStep(MotorSignalThemeColors colors) {
final theme = Theme.of(context);
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
@ -269,22 +276,25 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
itemCount: _years.length,
itemBuilder: (context, index) {
final year = _years[index].year;
final isSelected = _selectedYear == year;
return OutlinedButton(
style: OutlinedButton.styleFrom(
side: BorderSide(
color: _selectedYear == year ? Colors.cyanAccent : Colors.white24,
color: isSelected ? colors.cyberCyan : colors.outlineVariant,
width: isSelected ? 2 : 1,
),
backgroundColor: _selectedYear == year
? Colors.cyanAccent.withValues(alpha: 0.2)
: null,
backgroundColor: isSelected
? colors.cyberCyan.withValues(alpha: 0.2)
: colors.surfaceContainer,
),
onPressed: () => _selectYear(year),
child: Text(
'$year',
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Colors.white,
fontFamily: 'JetBrains Mono',
color: isSelected ? colors.cyberCyan : theme.colorScheme.onSurface,
),
),
);
@ -292,11 +302,13 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
);
}
Widget _buildMakeStep() {
Widget _buildMakeStep(MotorSignalThemeColors colors) {
final query = _searchController.text.toLowerCase();
final filtered = _makes
.where((m) => m.name.toLowerCase().contains(query))
.toList();
final theme = Theme.of(context);
return Column(
children: [
Padding(
@ -305,12 +317,12 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search Manufacturer...',
prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent),
prefixIcon: Icon(Icons.search, color: colors.cyberCyan),
filled: true,
fillColor: Colors.white10,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
borderSide: BorderSide(color: colors.outlineVariant),
),
),
onChanged: (_) => setState(() {}),
@ -321,20 +333,21 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: filtered.length,
separatorBuilder: (_, _) =>
const Divider(height: 1, color: Colors.white10),
Divider(height: 1, color: colors.outlineVariant),
itemBuilder: (context, index) {
final make = filtered[index];
return ListTile(
title: Text(
make.name,
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
fontSize: 17,
color: theme.colorScheme.onSurface,
),
),
trailing: const Icon(
trailing: Icon(
Icons.chevron_right,
color: Colors.white30,
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
),
onTap: () => _selectMake(make),
);
@ -345,11 +358,13 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
);
}
Widget _buildModelStep() {
Widget _buildModelStep(MotorSignalThemeColors colors) {
final query = _searchController.text.toLowerCase();
final filtered = _models
.where((m) => m.name.toLowerCase().contains(query))
.toList();
final theme = Theme.of(context);
return Column(
children: [
Padding(
@ -358,12 +373,12 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search Model...',
prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent),
prefixIcon: Icon(Icons.search, color: colors.cyberCyan),
filled: true,
fillColor: Colors.white10,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
borderSide: BorderSide(color: colors.outlineVariant),
),
),
onChanged: (_) => setState(() {}),
@ -374,21 +389,27 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: filtered.length,
separatorBuilder: (_, _) =>
const Divider(height: 1, color: Colors.white10),
Divider(height: 1, color: colors.outlineVariant),
itemBuilder: (context, index) {
final model = filtered[index];
return ListTile(
title: Text(
model.name,
style: const TextStyle(
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
fontSize: 17,
color: theme.colorScheme.onSurface,
),
),
subtitle: Text('$_selectedYear ${_selectedMake?.name}'),
trailing: const Icon(
subtitle: Text(
'$_selectedYear ${_selectedMake?.name}',
style: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
trailing: Icon(
Icons.chevron_right,
color: Colors.white30,
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
),
onTap: () => _selectModel(model),
);
@ -399,7 +420,8 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
);
}
Widget _buildVariantStep() {
Widget _buildVariantStep(MotorSignalThemeColors colors) {
final theme = Theme.of(context);
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _variants.length,
@ -407,14 +429,23 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
itemBuilder: (context, index) {
final variant = _variants[index];
return Card(
color: colors.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: colors.outlineVariant),
),
child: ListTile(
title: Text(
variant.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 17,
color: theme.colorScheme.onSurface,
),
),
trailing: const Icon(
trailing: Icon(
Icons.check_circle_outline,
color: Colors.cyanAccent,
color: colors.cyberCyan,
),
onTap: () => _selectVariant(variant),
),
@ -423,19 +454,21 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
);
}
Widget _buildConfirmStep() {
Widget _buildConfirmStep(MotorSignalThemeColors colors) {
final spec = _previewSpec;
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white10,
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.cyanAccent, width: 2),
border: Border.all(color: colors.cyberCyan, width: 2),
),
child: Column(
children: [
@ -443,16 +476,18 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
year: _selectedYear!,
make: _selectedMake!.name,
model: _selectedModel!.name,
height: 220,
height: 200,
borderRadius: BorderRadius.circular(10),
),
const SizedBox(height: 14),
Text(
'$_selectedYear ${_selectedMake?.name} ${_selectedModel?.name}',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 24,
style: TextStyle(
fontFamily: 'Sora',
fontSize: 20,
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
if (_selectedVariant?.name != null &&
@ -460,23 +495,28 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
const SizedBox(height: 4),
Text(
_selectedVariant!.name,
style: const TextStyle(color: Colors.white70, fontSize: 16),
style: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
fontSize: 15,
),
),
],
],
),
),
const SizedBox(height: 24),
const SizedBox(height: 20),
if (spec != null) ...[
const Text(
Text(
'SPECIFICATION SUMMARY',
style: TextStyle(
color: Colors.cyanAccent,
fontFamily: 'Sora',
color: colors.cyberCyan,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
letterSpacing: 1.0,
fontSize: 13,
),
),
const SizedBox(height: 12),
const SizedBox(height: 10),
Row(
children: [
Expanded(
@ -485,20 +525,24 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
spec.displacementCc != null
? '${spec.displacementCc!.toStringAsFixed(0)} cc'
: '—',
colors,
theme,
),
),
const SizedBox(width: 12),
const SizedBox(width: 10),
Expanded(
child: _buildSpecChip(
'HORSEPOWER',
spec.horsepower != null
? '${spec.horsepower!.toStringAsFixed(0)} HP'
: '—',
colors,
theme,
),
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 10),
Row(
children: [
Expanded(
@ -507,36 +551,43 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
spec.torqueLbFt != null
? '${spec.torqueLbFt!.toStringAsFixed(1)} lb-ft'
: '—',
colors,
theme,
),
),
const SizedBox(width: 12),
const SizedBox(width: 10),
Expanded(
child: _buildSpecChip(
'WET WEIGHT',
spec.wetWeightLbs != null
? '${spec.wetWeightLbs!.toStringAsFixed(0)} lbs'
: '—',
colors,
theme,
),
),
],
),
const SizedBox(height: 24),
const SizedBox(height: 20),
],
TextField(
controller: _nicknameController,
decoration: InputDecoration(
labelText: 'Nickname (Optional)',
hintText: 'e.g., Blue Beast, The Track Bike',
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colors.outlineVariant),
),
prefixIcon: const Icon(Icons.edit, color: Colors.cyanAccent),
prefixIcon: Icon(Icons.edit, color: colors.cyberCyan),
),
),
const SizedBox(height: 32),
const SizedBox(height: 24),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.cyanAccent,
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
@ -547,9 +598,10 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
child: const Text(
'ADD TO GARAGE',
style: TextStyle(
fontSize: 18,
fontFamily: 'Sora',
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
letterSpacing: 1.2,
),
),
),
@ -558,32 +610,39 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
);
}
Widget _buildSpecChip(String label, String value) {
Widget _buildSpecChip(
String label,
String value,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white12),
border: Border.all(color: colors.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 10,
color: Colors.white54,
letterSpacing: 1.0,
style: TextStyle(
fontSize: 9.5,
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
letterSpacing: 0.8,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 18,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
fontFamily: 'JetBrains Mono',
color: theme.colorScheme.onSurface,
),
),
],
@ -591,3 +650,4 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
);
}
}

View File

@ -4,7 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../database/database.dart';
import '../../models/motorcycle_models.dart';
import '../../core/theme/evolution_theme_config.dart';
import '../../core/responsive/responsive_layout.dart';
import '../../core/theme/theme_extensions.dart';
import 'garage_providers.dart';
import 'widgets/motorcycle_photo.dart';
import '../../services/entitlements/entitlement_providers.dart';
@ -17,32 +18,20 @@ class GarageScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
final themeMode = ref.watch(dashboardThemeModeProvider);
final colors = context.motorColors;
return Scaffold(
backgroundColor: themeMode == DashboardThemeMode.evolution
? Colors.black
: null,
appBar: AppBar(
backgroundColor: themeMode == DashboardThemeMode.evolution
? Colors.transparent
: null,
elevation: 0,
title: Text(
title: const Text(
'My Garage',
style: TextStyle(
fontFamily: themeMode == DashboardThemeMode.evolution
? 'Sora'
: null,
fontWeight: FontWeight.bold,
),
style: TextStyle(fontWeight: FontWeight.bold),
),
actions: [
IconButton(
key: const ValueKey('garage_add_motorcycle_appbar_button'),
icon: const Icon(
icon: Icon(
Icons.add_circle_outline,
color: Color(0xFF00E5FF),
color: colors.cyberCyan,
),
tooltip: 'Add Motorcycle',
onPressed: () => _onAddMotorcycle(context, ref, bikesAsync.value),
@ -51,34 +40,50 @@ class GarageScreen extends ConsumerWidget {
),
body: bikesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) =>
Center(child: Text('Error loading garage: $err')),
error: (err, stack) => Center(child: Text('Error loading garage: $err')),
data: (bikes) {
if (bikes.isEmpty) return _buildEmptyState(context);
return ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: bikes.length,
itemBuilder: (context, index) {
return _buildMotorcycleCard(
context,
ref,
bikes[index],
themeMode,
if (bikes.isEmpty) return _buildEmptyState(context, colors);
return ResponsiveLayout(
builder: (context, responsive) {
final isMultiColumn = responsive.isExpanded || (responsive.isMedium && responsive.isLandscape);
if (isMultiColumn) {
return GridView.builder(
padding: const EdgeInsets.all(16.0),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 550,
mainAxisExtent: 420,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: bikes.length,
itemBuilder: (context, index) {
return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: true);
},
);
}
return ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: bikes.length,
itemBuilder: (context, index) {
return _buildMotorcycleCard(context, ref, bikes[index], colors, isGrid: false);
},
);
},
);
},
),
floatingActionButton:
bikesAsync.value != null && bikesAsync.value!.isNotEmpty
floatingActionButton: bikesAsync.value != null && bikesAsync.value!.isNotEmpty
? FloatingActionButton.extended(
key: const ValueKey('garage_add_motorcycle_fab'),
backgroundColor: Colors.cyanAccent,
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
icon: const Icon(Icons.two_wheeler),
label: const Text(
'ADD MOTORCYCLE',
style: TextStyle(fontWeight: FontWeight.bold),
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 0.8),
),
onPressed: () => _onAddMotorcycle(context, ref, bikesAsync.value),
)
@ -107,34 +112,44 @@ class GarageScreen extends ConsumerWidget {
context.push('/garage/add');
}
Widget _buildEmptyState(BuildContext context) {
Widget _buildEmptyState(BuildContext context, MotorSignalThemeColors colors) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.two_wheeler, size: 80, color: Colors.white24),
Icon(
Icons.two_wheeler,
size: 80,
color: theme.colorScheme.onSurface.withValues(alpha: 0.25),
),
const SizedBox(height: 24),
const Text(
Text(
'YOUR GARAGE IS EMPTY',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
fontFamily: 'Sora',
fontSize: 18,
fontWeight: FontWeight.w900,
letterSpacing: 1.2,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 12),
const Text(
Text(
'Add your motorcycle to personalize your ride history and performance data.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70, height: 1.4),
style: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.4,
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
key: const ValueKey('garage_add_motorcycle_empty_button'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.cyanAccent,
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: 24,
@ -164,8 +179,9 @@ class GarageScreen extends ConsumerWidget {
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
DashboardThemeMode themeMode,
) {
MotorSignalThemeColors colors, {
bool isGrid = false,
}) {
MotorcycleSpecification? spec;
if (bike.specificationJson != null) {
try {
@ -178,24 +194,25 @@ class GarageScreen extends ConsumerWidget {
final displayName = bike.nickname != null && bike.nickname!.isNotEmpty
? '${bike.nickname} (${bike.year} ${bike.makeName} ${bike.modelName})'
: '${bike.year} ${bike.makeName} ${bike.modelName}';
final isEvolution = themeMode == DashboardThemeMode.evolution;
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.only(bottom: 16),
margin: EdgeInsets.only(bottom: isGrid ? 0 : 16),
decoration: BoxDecoration(
color: isEvolution ? const Color(0xFF0D0D0D) : null,
borderRadius: BorderRadius.circular(isEvolution ? 14 : 12),
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: bike.isDefault
? const Color(0xFF00E5FF)
: (isEvolution ? const Color(0xFF1F1F1F) : Colors.white12),
? colors.cyberCyan
: colors.outlineVariant,
width: bike.isDefault ? 1.8 : 1.0,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(isEvolution ? 14 : 12),
borderRadius: BorderRadius.circular(14),
onTap: () => context.push('/garage/${bike.id}'),
child: Padding(
padding: const EdgeInsets.all(12.0),
@ -206,10 +223,10 @@ class GarageScreen extends ConsumerWidget {
year: bike.year,
make: bike.makeName,
model: bike.modelName,
height: 190,
borderRadius: BorderRadius.circular(isEvolution ? 10 : 8),
height: 150,
borderRadius: BorderRadius.circular(10),
),
const SizedBox(height: 14),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -223,10 +240,10 @@ class GarageScreen extends ConsumerWidget {
child: Text(
displayName,
style: TextStyle(
fontFamily: isEvolution ? 'Sora' : null,
fontSize: 17,
fontFamily: 'Sora',
fontSize: 16,
fontWeight: FontWeight.w800,
color: Colors.white,
color: theme.colorScheme.onSurface,
),
),
),
@ -237,41 +254,35 @@ class GarageScreen extends ConsumerWidget {
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(
0xFF00E5FF,
).withValues(alpha: 0.15),
color: colors.cyberCyan.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: const Color(0xFF00E5FF),
color: colors.cyberCyan,
),
),
child: const Text(
child: Text(
'CURRENT',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w900,
color: Color(0xFF00E5FF),
color: colors.cyberCyan,
letterSpacing: 0.5,
),
),
),
],
),
const SizedBox(height: 6),
const SizedBox(height: 4),
Text(
spec != null
? '${spec.displacementCc?.toStringAsFixed(0) ?? "—"} cc • ${spec.horsepower?.toStringAsFixed(0) ?? "—"} hp'
: '${bike.year} • ${bike.makeName}',
style: TextStyle(
fontFamily: isEvolution ? 'JetBrains Mono' : null,
color: isEvolution
? const Color(0xFF00E5FF)
: Colors.white70,
fontSize: 13,
fontWeight: isEvolution
? FontWeight.w700
: FontWeight.normal,
fontFamily: 'JetBrains Mono',
color: colors.cyberCyan,
fontSize: 12.5,
fontWeight: FontWeight.w700,
),
),
],
@ -287,15 +298,18 @@ class GarageScreen extends ConsumerWidget {
runSpacing: 4,
children: [
TextButton.icon(
icon: const Icon(Icons.directions_bike, size: 18),
label: const Text('View Specs & Stats'),
icon: Icon(Icons.directions_bike, size: 18, color: colors.cyberCyan),
label: Text(
'View Specs & Stats',
style: TextStyle(color: colors.cyberCyan, fontWeight: FontWeight.bold),
),
onPressed: () => context.push('/garage/${bike.id}'),
),
if (!bike.isDefault)
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white30),
foregroundColor: theme.colorScheme.onSurface,
side: BorderSide(color: colors.outlineVariant),
),
onPressed: () {
ref
@ -321,3 +335,4 @@ class GarageScreen extends ConsumerWidget {
);
}
}

View File

@ -4,8 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../database/database.dart';
import '../../models/motorcycle_models.dart';
import '../../core/theme/evolution_theme_config.dart';
import 'widgets/evolution_motorcycle_detail_view.dart';
import '../../core/responsive/responsive_layout.dart';
import '../../core/theme/theme_extensions.dart';
import 'widgets/motorcycle_photo.dart';
import 'garage_providers.dart';
@ -18,7 +18,8 @@ class MotorcycleDetailScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
final statsAsync = ref.watch(motorcyclePerformanceStatsProvider(motorcycleId));
final themeMode = ref.watch(dashboardThemeModeProvider);
final colors = context.motorColors;
final theme = Theme.of(context);
return bikesAsync.when(
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
@ -53,21 +54,15 @@ class MotorcycleDetailScreen extends ConsumerWidget {
} catch (_) {}
}
if (themeMode == DashboardThemeMode.evolution) {
return EvolutionMotorcycleDetailView(
bike: bike,
spec: spec,
onEdit: () => _showEditNicknameDialog(context, ref, bike),
onDelete: () => _confirmDelete(context, ref, bike),
);
}
return Scaffold(
appBar: AppBar(
title: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text('${bike.year} ${bike.makeName} ${bike.modelName}'),
child: Text(
'${bike.year} ${bike.makeName} ${bike.modelName}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
actions: [
IconButton(
@ -76,87 +71,154 @@ class MotorcycleDetailScreen extends ConsumerWidget {
onPressed: () => _showEditNicknameDialog(context, ref, bike),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
icon: Icon(Icons.delete_outline, color: colors.raceRed),
tooltip: 'Delete Motorcycle',
onPressed: () => _confirmDelete(context, ref, bike),
onPressed: () => _confirmDelete(context, ref, bike, colors),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeaderCard(context, ref, bike, spec),
const SizedBox(height: 24),
_buildMyPerformanceSection(context, statsAsync, spec),
const SizedBox(height: 24),
_buildSpecificationSheet(context, spec),
],
),
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),
_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),
_buildSpecificationSheet(context, spec, colors, theme),
const SizedBox(height: 24),
],
),
);
},
),
);
},
);
}
Widget _buildHeaderCard(BuildContext context, WidgetRef ref, GarageMotorcycle bike, MotorcycleSpecification? spec) {
Widget _buildHeaderCard(
BuildContext context,
WidgetRef ref,
GarageMotorcycle bike,
MotorcycleSpecification? spec,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Card(
color: const Color(0xFF161616),
elevation: 2,
color: colors.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: bike.isDefault ? Colors.cyanAccent : Colors.white12, width: 2),
side: BorderSide(
color: bike.isDefault ? colors.cyberCyan : colors.outlineVariant,
width: bike.isDefault ? 1.8 : 1.0,
),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MotorcyclePhoto(
year: bike.year,
make: bike.makeName,
model: bike.modelName,
height: 210,
height: 190,
borderRadius: BorderRadius.circular(12),
showAttribution: true,
fit: BoxFit.cover,
),
const SizedBox(height: 16),
Row(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${bike.year}',
style: const TextStyle(fontSize: 16, color: Colors.cyanAccent, fontWeight: FontWeight.bold),
),
Text(
'${bike.makeName} ${bike.modelName}'.toUpperCase(),
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w900, letterSpacing: 0.5),
),
if (bike.nickname != null && bike.nickname!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'"${bike.nickname}"',
style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.amberAccent),
),
),
],
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),
const Divider(color: Colors.white10),
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' : '—'),
_buildHeaderStat('POWER', spec?.horsepower != null ? '${spec!.horsepower!.toStringAsFixed(0)} HP' : '—'),
_buildHeaderStat('TORQUE', spec?.torqueLbFt != null ? '${spec!.torqueLbFt!.toStringAsFixed(1)} lb-ft' : '—'),
_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),
@ -166,30 +228,46 @@ class MotorcycleDetailScreen extends ConsumerWidget {
? Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: Colors.cyanAccent.withValues(alpha: 0.15),
color: colors.cyberCyan.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.cyanAccent),
border: Border.all(color: colors.cyberCyan),
),
child: const Center(
child: Center(
child: Text(
'CURRENT MOTORCYCLE',
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.5, color: Colors.cyanAccent),
style: TextStyle(
fontFamily: 'Sora',
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: colors.cyberCyan,
),
),
),
)
: OutlinedButton(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: const BorderSide(color: Colors.cyanAccent),
foregroundColor: Colors.cyanAccent,
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.')),
SnackBar(
content: Text(
'Set ${bike.makeName} ${bike.modelName} as current motorcycle.',
),
),
);
},
child: const Text('SET AS CURRENT MOTORCYCLE', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2)),
child: const Text(
'SET AS CURRENT MOTORCYCLE',
style: TextStyle(
fontFamily: 'Sora',
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
),
),
],
@ -198,23 +276,59 @@ class MotorcycleDetailScreen extends ConsumerWidget {
);
}
Widget _buildHeaderStat(String label, String value) {
return Column(
children: [
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white54, letterSpacing: 1.2)),
const SizedBox(height: 4),
Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white)),
],
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) {
Widget _buildMyPerformanceSection(
BuildContext context,
AsyncValue<MotorcycleStats> statsAsync,
MotorcycleSpecification? spec,
MotorSignalThemeColors colors,
ThemeData theme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'MY PERFORMANCE',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.cyanAccent, letterSpacing: 1.2),
style: TextStyle(
fontFamily: 'Sora',
fontSize: 16,
fontWeight: FontWeight.bold,
color: colors.cyberCyan,
letterSpacing: 0.8,
),
),
const SizedBox(height: 12),
statsAsync.when(
@ -225,9 +339,16 @@ class MotorcycleDetailScreen extends ConsumerWidget {
children: [
Row(
children: [
Expanded(child: _buildPerfTile('TOTAL RIDES', '${stats.rideCount}')),
Expanded(child: _buildPerfTile('TOTAL RIDES', '${stats.rideCount}', colors, theme)),
const SizedBox(width: 12),
Expanded(child: _buildPerfTile('TOTAL DISTANCE', '${stats.totalDistanceMiles.toStringAsFixed(1)} MI')),
Expanded(
child: _buildPerfTile(
'TOTAL DISTANCE',
'${stats.totalDistanceMiles.toStringAsFixed(1)} MI',
colors,
theme,
),
),
],
),
const SizedBox(height: 12),
@ -237,7 +358,11 @@ class MotorcycleDetailScreen extends ConsumerWidget {
child: _buildPerfTile(
'BEST TOP SPEED',
'${stats.topSpeedMph.toStringAsFixed(1)} MPH',
published: spec?.claimedTopSpeedMph != null ? '~${spec!.claimedTopSpeedMph!.toStringAsFixed(0)} MPH' : null,
colors,
theme,
published: spec?.claimedTopSpeedMph != null
? '~${spec!.claimedTopSpeedMph!.toStringAsFixed(0)} MPH'
: null,
),
),
const SizedBox(width: 12),
@ -245,7 +370,11 @@ class MotorcycleDetailScreen extends ConsumerWidget {
child: _buildPerfTile(
'BEST 0–60',
stats.best0to60Ms != null ? '${(stats.best0to60Ms! / 1000.0).toStringAsFixed(2)} s' : '—',
published: spec?.claimedZeroToSixtySec != null ? '~${spec!.claimedZeroToSixtySec!.toStringAsFixed(1)} s' : null,
colors,
theme,
published: spec?.claimedZeroToSixtySec != null
? '~${spec!.claimedZeroToSixtySec!.toStringAsFixed(1)} s'
: null,
),
),
],
@ -253,9 +382,23 @@ class MotorcycleDetailScreen extends ConsumerWidget {
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildPerfTile('MAX LEAN', '${stats.maxLean.toStringAsFixed(1)}°')),
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')),
Expanded(
child: _buildPerfTile(
'MAX ACCEL G',
'${stats.maxAccelG.toStringAsFixed(2)} G',
colors,
theme,
),
),
],
),
],
@ -266,35 +409,80 @@ class MotorcycleDetailScreen extends ConsumerWidget {
);
}
Widget _buildPerfTile(String label, String value, {String? published}) {
Widget _buildPerfTile(
String label,
String value,
MotorSignalThemeColors colors,
ThemeData theme, {
String? published,
}) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white10),
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: const TextStyle(fontSize: 10, color: Colors.white54, letterSpacing: 1.0)),
Text(
label,
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
),
),
const SizedBox(height: 4),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white)),
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: const TextStyle(fontSize: 10, color: Colors.cyanAccent)),
Text(
'PUBLISHED: $published',
style: TextStyle(
fontSize: 9.5,
color: colors.cyberCyan,
fontFamily: 'JetBrains Mono',
),
),
],
],
),
);
}
Widget _buildSpecificationSheet(BuildContext context, MotorcycleSpecification? spec) {
Widget _buildSpecificationSheet(
BuildContext context,
MotorcycleSpecification? spec,
MotorSignalThemeColors colors,
ThemeData theme,
) {
if (spec == null) {
return const Card(
child: Padding(
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)),
child: Text(
'Detailed specifications unavailable for this motorcycle.',
style: TextStyle(color: Colors.white54),
),
),
);
}
@ -302,9 +490,15 @@ class MotorcycleDetailScreen extends ConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'SPECIFICATIONS',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.cyanAccent, letterSpacing: 1.2),
style: TextStyle(
fontFamily: 'Sora',
fontSize: 16,
fontWeight: FontWeight.bold,
color: colors.cyberCyan,
letterSpacing: 0.8,
),
),
const SizedBox(height: 12),
_buildSpecGroup('ENGINE', [
@ -317,23 +511,23 @@ class MotorcycleDetailScreen extends ConsumerWidget {
_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),
@ -343,48 +537,84 @@ class MotorcycleDetailScreen extends ConsumerWidget {
_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: const TextStyle(fontSize: 12, color: Colors.white38),
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
fontFamily: 'Inter',
),
),
],
],
);
}
Widget _buildSpecGroup(String title, List<_SpecRow> rows) {
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();
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(16.0),
padding: const EdgeInsets.all(14.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.cyanAccent, fontSize: 14, letterSpacing: 1.0)),
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(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(r.label, style: const TextStyle(color: Colors.white70)),
Flexible(
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: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: theme.colorScheme.onSurface,
),
),
),
],
@ -412,7 +642,10 @@ class MotorcycleDetailScreen extends ConsumerWidget {
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);
ref.read(garageControllerProvider).updateNickname(
bike.id,
controller.text.trim().isNotEmpty ? controller.text.trim() : null,
);
Navigator.pop(context);
},
child: const Text('Save'),
@ -423,17 +656,24 @@ class MotorcycleDetailScreen extends ConsumerWidget {
);
}
void _confirmDelete(BuildContext context, WidgetRef ref, GarageMotorcycle bike) {
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.'),
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.redAccent),
style: ElevatedButton.styleFrom(backgroundColor: colors.raceRed),
onPressed: () {
ref.read(garageControllerProvider).deleteMotorcycle(bike.id);
Navigator.pop(context);
@ -453,3 +693,4 @@ class _SpecRow {
final String? value;
_SpecRow(this.label, this.value);
}

View File

@ -1,269 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../database/database.dart';
import '../../../models/motorcycle_models.dart';
import 'motorcycle_photo.dart';
class EvolutionMotorcycleDetailView extends ConsumerWidget {
final GarageMotorcycle bike;
final MotorcycleSpecification? spec;
final VoidCallback onEdit;
final VoidCallback onDelete;
const EvolutionMotorcycleDetailView({
super.key,
required this.bike,
required this.spec,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final title = '${bike.year} ${bike.makeName} ${bike.modelName}'.toUpperCase();
final powerHp = spec?.horsepower != null && spec!.horsepower! > 0
? spec!.horsepower!.toStringAsFixed(0)
: '124';
final torqueVal = spec?.torqueLbFt;
final torqueLbFt = torqueVal != null && torqueVal > 0
? torqueVal.toStringAsFixed(1)
: '76.6';
final wetWeight = spec?.wetWeightLbs != null && spec!.wetWeightLbs! > 0
? spec!.wetWeightLbs!.toStringAsFixed(0)
: (spec?.dryWeightLbs != null && spec!.dryWeightLbs! > 0
? (spec!.dryWeightLbs! + 25).toStringAsFixed(0)
: '432');
final displacement = spec?.displacementCc != null && spec!.displacementCc! > 0
? '${spec!.displacementCc!.toStringAsFixed(0)} cc'
: '599 cc';
final engineType = spec?.engineConfiguration ?? 'Inline-4';
final cylinders = spec?.cylinderCount != null ? '${spec!.cylinderCount}' : '4';
final redline = spec?.redlineRpm != null && spec!.redlineRpm! > 0
? '${spec!.redlineRpm} RPM'
: '15500 RPM';
final compression = spec?.compressionRatio ?? '12.4:1';
final fuelSystem = spec?.fuelSystem ?? 'Electronic Fuel Injection';
final frameType = spec?.frameType ?? 'Aluminum Deltabox Perimeter';
final frontBrake = spec?.frontBrake ?? 'Dual Hydraulic Discs';
final absSystem = spec?.absType ?? 'Dual-Channel ABS';
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF00E5FF), size: 18),
onPressed: () => Navigator.of(context).pop(),
),
centerTitle: true,
title: Text(
title,
style: const TextStyle(
fontFamily: 'Sora',
fontSize: 15,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 0.1,
),
),
actions: [
IconButton(
icon: const Icon(Icons.edit_note, color: Colors.white70),
tooltip: 'Edit Bike',
onPressed: onEdit,
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
tooltip: 'Delete Bike',
onPressed: onDelete,
),
],
),
body: LayoutBuilder(
builder: (context, constraints) {
final isLandscape = constraints.maxWidth > constraints.maxHeight;
if (isLandscape) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: _buildHeroSpecCard(bike, powerHp, torqueLbFt, wetWeight),
),
),
Expanded(
flex: 6,
child: SingleChildScrollView(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildEngineCard(displacement, engineType, cylinders, redline, compression, fuelSystem),
const SizedBox(height: 12),
_buildChassisCard(frameType, frontBrake, absSystem),
const SizedBox(height: 12),
_buildDimensionsCard(spec),
],
),
),
),
],
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeroSpecCard(bike, powerHp, torqueLbFt, wetWeight),
const SizedBox(height: 16),
_buildEngineCard(displacement, engineType, cylinders, redline, compression, fuelSystem),
const SizedBox(height: 14),
_buildChassisCard(frameType, frontBrake, absSystem),
const SizedBox(height: 14),
_buildDimensionsCard(spec),
const SizedBox(height: 24),
],
),
);
},
),
);
}
Widget _buildHeroSpecCard(GarageMotorcycle bike, String powerHp, String torqueLbFt, String wetWeight) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF00E5FF).withValues(alpha: 0.6), width: 1.2),
),
child: Column(
children: [
MotorcyclePhoto(
year: bike.year,
make: bike.makeName,
model: bike.modelName,
height: 140,
borderRadius: BorderRadius.circular(12),
showAttribution: true,
fit: BoxFit.cover,
),
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildBigKpi(powerHp, 'HP'),
Container(width: 1, height: 44, color: const Color(0xFF222222)),
_buildBigKpi(torqueLbFt, 'lb-ft'),
Container(width: 1, height: 44, color: const Color(0xFF222222)),
_buildBigKpi(wetWeight, 'lbs (Wet)'),
],
),
],
),
);
}
Widget _buildBigKpi(String value, String unit) {
return Expanded(
child: Column(
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 26,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
unit,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w700,
color: Color(0xFF00E5FF),
),
),
),
],
),
);
}
Widget _buildEngineCard(String displacement, String engineType, String cylinders, String redline, String compression, String fuelSystem) {
return _buildCard('ENGINE', [
_buildSpecLine('Displacement', displacement),
_buildSpecLine('Configuration', engineType),
_buildSpecLine('Cylinders', cylinders),
_buildSpecLine('Redline', redline),
_buildSpecLine('Compression Ratio', compression),
_buildSpecLine('Fuel System', fuelSystem),
]);
}
Widget _buildChassisCard(String frameType, String frontBrake, String absSystem) {
return _buildCard('CHASSIS & BRAKES', [
_buildSpecLine('Frame Type', frameType),
_buildSpecLine('Front Brake', frontBrake),
_buildSpecLine('ABS System', absSystem),
]);
}
Widget _buildDimensionsCard(MotorcycleSpecification? spec) {
return _buildCard('WEIGHT & DIMENSIONS', [
_buildSpecLine('Dry Weight', spec?.dryWeightLbs != null ? '${spec!.dryWeightLbs!.toStringAsFixed(0)} lbs' : '375 lbs'),
_buildSpecLine('Fuel Capacity', spec?.fuelCapacityGallons != null ? '${spec!.fuelCapacityGallons!.toStringAsFixed(1)} Gal' : '4.5 Gal'),
_buildSpecLine('Top Speed Potential', spec?.claimedTopSpeedMph != null ? '${spec!.claimedTopSpeedMph!.toStringAsFixed(0)} MPH' : '160+ MPH'),
]);
}
Widget _buildCard(String title, List<Widget> children) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontFamily: 'Sora', fontSize: 12, fontWeight: FontWeight.w800, color: Color(0xFF00E5FF), letterSpacing: 0.1)),
const SizedBox(height: 10),
...children,
],
),
);
}
Widget _buildSpecLine(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontFamily: 'Inter', fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white54)),
Flexible(
child: Text(value, textAlign: TextAlign.right, style: const TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white)),
),
],
),
);
}
}

View File

@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:math' as math;
import 'package:intl/intl.dart';
import '../../database/database.dart';
import '../../core/responsive/responsive_layout.dart';
import '../../core/theme/theme_extensions.dart';
import '../garage/garage_providers.dart';
import 'ride_detail_screen.dart';
@ -13,20 +16,25 @@ class HistoryScreen extends ConsumerStatefulWidget {
}
class _HistoryScreenState extends ConsumerState<HistoryScreen> {
int?
_selectedMotorcycleId; // null = All Motorcycles, -1 = Current, else specific ID
int? _selectedMotorcycleId; // null = All Motorcycles, -1 = Current, else specific ID
@override
Widget build(BuildContext context) {
final database = ref.watch(databaseProvider);
final garageBikesAsync = ref.watch(garageMotorcyclesStreamProvider);
final defaultBike = ref.watch(defaultMotorcycleProvider);
final colors = context.motorColors;
return Scaffold(
appBar: AppBar(title: const Text('Ride History')),
appBar: AppBar(
title: const Text(
'Ride History',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
body: Column(
children: [
_buildFilterBar(garageBikesAsync.value ?? [], defaultBike),
_buildFilterBar(context, garageBikesAsync.value ?? [], defaultBike),
Expanded(
child: StreamBuilder<List<Ride>>(
stream: database.select(database.rides).watch(),
@ -57,148 +65,54 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
rides.sort((a, b) => b.startTime.compareTo(a.startTime));
if (rides.isEmpty) {
return const Center(
child: Text(
'No recorded rides matching filter.',
style: TextStyle(fontSize: 18, color: Colors.grey),
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 64,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'No recorded rides matching filter.',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: rides.length,
itemBuilder: (context, index) {
final ride = rides[index];
final bikeName =
(ride.motorcycleYear != null &&
ride.motorcycleMake != null &&
ride.motorcycleModel != null)
? '${ride.motorcycleYear} ${ride.motorcycleMake} ${ride.motorcycleModel}'
: null;
return ResponsiveLayout(
builder: (context, responsive) {
final isMultiColumn = responsive.isExpanded || (responsive.isMedium && responsive.isLandscape);
return Card(
elevation: 4,
margin: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 4,
),
child: InkWell(
key: ValueKey('history_ride_card_${ride.id}'),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => RideDetailScreen(ride: ride),
),
);
},
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
children: [
Text(
'Ride ${ride.id}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
if (bikeName != null) ...[
const SizedBox(width: 8),
Flexible(
child: Container(
padding:
const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.cyanAccent
.withValues(alpha: 0.15),
borderRadius:
BorderRadius.circular(4),
border: Border.all(
color: Colors.cyanAccent
.withValues(alpha: 0.5),
),
),
child: Text(
bikeName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.cyanAccent,
),
),
),
),
],
],
),
),
const SizedBox(width: 8),
Text(
ride.startTime.toLocal().toString().split(
'.',
)[0],
style: const TextStyle(
color: Colors.grey,
fontSize: 11,
),
),
],
),
const Divider(),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
_buildStatCol(
'TRIP',
'${ride.distanceMiles.toStringAsFixed(1)} mi',
),
_buildStatCol(
'TIME',
'${(ride.movingTime / 60).toStringAsFixed(0)} min',
),
_buildStatCol(
'TOP SPD',
'${ride.topSpeedMph.toStringAsFixed(0)} mph',
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
_buildStatCol(
'MAX LEAN',
'${math.max(ride.maxLeanLeft, ride.maxLeanRight).toStringAsFixed(0)}°',
),
_buildStatCol(
'HARD BRK',
'${ride.maxBrakingG.toStringAsFixed(2)}G',
),
_buildStatCol(
'ELEV GAIN',
'${ride.elevationGain.toStringAsFixed(0)}m',
),
],
),
],
),
if (isMultiColumn) {
return GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 600,
mainAxisExtent: 175,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
),
itemCount: rides.length,
itemBuilder: (context, index) {
return _buildRideCard(context, rides[index], colors);
},
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: rides.length,
itemBuilder: (context, index) {
return _buildRideCard(context, rides[index], colors);
},
);
},
);
@ -210,45 +124,186 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
);
}
Widget _buildRideCard(BuildContext context, Ride ride, MotorSignalThemeColors colors) {
final bikeName = (ride.motorcycleYear != null &&
ride.motorcycleMake != null &&
ride.motorcycleModel != null)
? '${ride.motorcycleYear} ${ride.motorcycleMake} ${ride.motorcycleModel}'
: null;
final theme = Theme.of(context);
final dateFormatted = DateFormat('yyyy-MM-dd HH:mm').format(ride.startTime.toLocal());
return Card(
elevation: 2,
margin: const EdgeInsets.symmetric(vertical: 6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outlineVariant),
),
color: colors.surfaceContainer,
child: InkWell(
key: ValueKey('history_ride_card_${ride.id}'),
borderRadius: BorderRadius.circular(12),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => RideDetailScreen(ride: ride),
),
);
},
child: Padding(
padding: const EdgeInsets.all(14.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Ride ${ride.id}',
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 16,
fontFamily: 'Sora',
color: theme.colorScheme.onSurface,
),
),
if (bikeName != null) ...[
const SizedBox(width: 6),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 2,
),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: colors.cyberCyan.withValues(alpha: 0.4),
),
),
child: Text(
bikeName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.bold,
color: colors.cyberCyan,
),
),
),
),
] else ...[
const Spacer(),
],
const SizedBox(width: 6),
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Text(
dateFormatted,
style: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
fontSize: 10.5,
fontFamily: 'JetBrains Mono',
),
),
),
),
],
),
const SizedBox(height: 8),
Divider(height: 1, color: colors.outlineVariant),
const SizedBox(height: 10),
Row(
children: [
Expanded(child: _buildStatCol(context, 'TRIP', '${ride.distanceMiles.toStringAsFixed(1)} mi')),
Expanded(child: _buildStatCol(context, 'TIME', '${(ride.movingTime / 60).toStringAsFixed(0)} min')),
Expanded(child: _buildStatCol(context, 'TOP SPD', '${ride.topSpeedMph.toStringAsFixed(0)} mph')),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _buildStatCol(
context,
'MAX LEAN',
'${math.max(ride.maxLeanLeft, ride.maxLeanRight).toStringAsFixed(0)}°',
valueColor: colors.cyberCyan,
),
),
Expanded(
child: _buildStatCol(
context,
'HARD BRK',
'${ride.maxBrakingG.toStringAsFixed(2)}G',
valueColor: colors.gForceNegative,
),
),
Expanded(
child: _buildStatCol(
context,
'ELEV GAIN',
'${ride.elevationGain.toStringAsFixed(0)}m',
),
),
],
),
],
),
),
),
);
}
Widget _buildFilterBar(
BuildContext context,
List<GarageMotorcycle> bikes,
GarageMotorcycle? defaultBike,
) {
if (bikes.isEmpty) return const SizedBox();
if (bikes.isEmpty) return const SizedBox.shrink();
final colors = context.motorColors;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
children: [
const Icon(Icons.filter_alt, color: Colors.cyanAccent, size: 20),
Icon(Icons.filter_alt, color: colors.cyberCyan, size: 20),
const SizedBox(width: 8),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white10,
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.outlineVariant),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int?>(
value: _selectedMotorcycleId,
isExpanded: true,
dropdownColor: const Color(0xFF1E1E1E),
dropdownColor: colors.surfaceContainerHigh,
hint: const Text('Filter by Motorcycle'),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('All Motorcycles'),
child: Text('All Motorcycles', overflow: TextOverflow.ellipsis),
),
if (defaultBike != null)
DropdownMenuItem<int?>(
value: -1,
child: Text('Current (${defaultBike.modelName})'),
child: Text('Current (${defaultBike.modelName})', overflow: TextOverflow.ellipsis),
),
...bikes.map(
(b) => DropdownMenuItem<int?>(
value: b.id,
child: Text('${b.year} ${b.makeName} ${b.modelName}'),
child: Text('${b.year} ${b.makeName} ${b.modelName}', overflow: TextOverflow.ellipsis),
),
),
],
@ -266,16 +321,38 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
);
}
Widget _buildStatCol(String label, String val) {
Widget _buildStatCol(BuildContext context, String label, String val, {Color? valueColor}) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 10, color: Colors.grey)),
Text(
val,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 2),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
val,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
fontFamily: 'JetBrains Mono',
color: valueColor ?? theme.colorScheme.onSurface,
),
),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,307 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'records_service.dart';
class EvolutionRecordsScreen extends ConsumerWidget {
const EvolutionRecordsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final records = ref.watch(recordsServiceProvider);
final topSpeed = records.topSpeedMph > 0 ? records.topSpeedMph : 164.0;
final zeroToSixty = records.fastest0To60 < 20 ? records.fastest0To60 : 2.9;
final zeroToHundred = records.fastest0To100 < 30 ? records.fastest0To100 : 6.4;
final leanLeft = records.maxLeanLeft > 0 ? records.maxLeanLeft : 48.0;
final leanRight = records.maxLeanRight > 0 ? records.maxLeanRight : 50.0;
final maxAccel = records.maxAccelG > 0 ? records.maxAccelG : 1.2;
final maxBrake = records.maxBrakingG > 0 ? records.maxBrakingG : 1.4;
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
toolbarHeight: isLandscape ? 36 : 52,
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: isLandscape
? const Text(
'MOTOR SIGNAL // PERSONAL RECORDS',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 13,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 0.12,
),
)
: const Column(
children: [
Text(
'MOTOR SIGNAL',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 18,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 0.15,
),
),
Text(
'PERSONAL RECORDS',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.white54,
letterSpacing: 0.1,
),
),
],
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section 1: SPEED
_buildSectionHeader('SPEED'),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'TOP SPEED',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w800,
color: Colors.white70,
letterSpacing: 0.08,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF00363D),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFF00E5FF), width: 0.8),
),
child: const Text(
'NEW RECORD',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 10,
fontWeight: FontWeight.w800,
color: Color(0xFF00E5FF),
letterSpacing: 0.05,
),
),
),
],
),
const SizedBox(height: 12),
Center(
child: Text(
'${topSpeed.toStringAsFixed(0)} MPH',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 64,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: -0.02,
),
),
),
],
),
),
const SizedBox(height: 20),
// Section 2: PERFORMANCE TIMES
_buildSectionHeader('PERFORMANCE TIMES'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
),
child: Row(
children: [
Expanded(
child: _buildBenchmarkCard('0-60', '${zeroToSixty.toStringAsFixed(1)} s', zeroToSixty, 5.0),
),
Container(width: 1, height: 64, color: const Color(0xFF222222)),
Expanded(
child: _buildBenchmarkCard('0-100', '${zeroToHundred.toStringAsFixed(1)} s', zeroToHundred, 10.0),
),
],
),
),
const SizedBox(height: 20),
// Section 3: HANDLING
_buildSectionHeader('HANDLING'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0D0D0D),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1.2),
),
child: Column(
children: [
Row(
children: [
Expanded(child: _buildHandlingCell('MAX LEAN LEFT', '${leanLeft.toStringAsFixed(0)}°')),
Container(width: 1, height: 60, color: const Color(0xFF222222)),
Expanded(child: _buildHandlingCell('MAX LEAN RIGHT', '${leanRight.toStringAsFixed(0)}°')),
],
),
const Divider(color: Color(0xFF222222), height: 24),
Row(
children: [
Expanded(child: _buildHandlingCell('MAX ACCEL', '${maxAccel.toStringAsFixed(1)} G')),
Container(width: 1, height: 60, color: const Color(0xFF222222)),
Expanded(child: _buildHandlingCell('MAX BRAKING Gs', '${maxBrake.toStringAsFixed(1)} G')),
],
),
],
),
),
const SizedBox(height: 24),
],
),
),
);
}
Widget _buildSectionHeader(String title) {
return Center(
child: Text(
title,
style: const TextStyle(
fontFamily: 'Sora',
fontSize: 13,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 0.12,
),
),
);
}
Widget _buildBenchmarkCard(String label, String value, double time, double maxScale) {
final frac = (time / maxScale).clamp(0.1, 1.0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.white54,
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
value,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 32,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
Container(
width: 6,
height: 14,
decoration: BoxDecoration(
color: const Color(0xFF00E5FF),
borderRadius: BorderRadius.circular(2),
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: frac,
backgroundColor: const Color(0xFF1F1F1F),
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF00E5FF)),
minHeight: 2,
),
],
),
);
}
Widget _buildHandlingCell(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.white54,
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
value,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 28,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
Container(
width: 6,
height: 12,
decoration: BoxDecoration(
color: const Color(0xFF00E5FF),
borderRadius: BorderRadius.circular(2),
),
),
],
),
],
),
);
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/evolution_theme_config.dart';
import 'evolution_records_screen.dart';
import '../../core/responsive/responsive_layout.dart';
import '../../core/theme/theme_extensions.dart';
import 'records_service.dart';
class RecordsScreen extends ConsumerWidget {
@ -9,113 +9,511 @@ class RecordsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(dashboardThemeModeProvider);
if (themeMode == DashboardThemeMode.evolution) {
return const EvolutionRecordsScreen();
}
final records = ref.watch(recordsServiceProvider);
final colors = context.motorColors;
return Scaffold(
appBar: AppBar(
title: const Text('Personal Records'),
title: const Text(
'Personal Records',
style: TextStyle(fontWeight: FontWeight.bold),
),
actions: [
IconButton(
icon: const Icon(Icons.emoji_events, color: Colors.amberAccent),
icon: Icon(Icons.emoji_events, color: colors.warningAmber),
tooltip: 'Trophies',
onPressed: () {},
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
body: ResponsiveLayout(
builder: (context, responsive) {
final isMultiColumn = responsive.isExpanded || (responsive.isMedium && responsive.isLandscape);
if (isMultiColumn) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('SPEED', colors),
const SizedBox(height: 8),
_buildHeroTopSpeedCard(context, records.topSpeedMph, colors),
const SizedBox(height: 16),
_buildSectionHeader('PERFORMANCE TIMES', colors),
const SizedBox(height: 8),
_buildPerformanceTimesCard(context, records, colors),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('HANDLING & DYNAMICS', colors),
const SizedBox(height: 8),
_buildHandlingCard(context, records, colors),
const SizedBox(height: 16),
_buildSectionHeader('ENDURANCE & ROUTE', colors),
const SizedBox(height: 8),
_buildEnduranceCard(context, records, colors),
],
),
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('SPEED', colors),
const SizedBox(height: 8),
_buildHeroTopSpeedCard(context, records.topSpeedMph, colors),
const SizedBox(height: 18),
_buildSectionHeader('PERFORMANCE TIMES', colors),
const SizedBox(height: 8),
_buildPerformanceTimesCard(context, records, colors),
const SizedBox(height: 18),
_buildSectionHeader('HANDLING & DYNAMICS', colors),
const SizedBox(height: 8),
_buildHandlingCard(context, records, colors),
const SizedBox(height: 18),
_buildSectionHeader('ENDURANCE & ROUTE', colors),
const SizedBox(height: 8),
_buildEnduranceCard(context, records, colors),
const SizedBox(height: 24),
],
),
);
},
),
);
}
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 _buildHeroTopSpeedCard(BuildContext context, double topSpeedMph, MotorSignalThemeColors colors) {
final hasRecord = topSpeedMph > 0;
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outlineVariant, width: 1.2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('SPEED'),
_buildRecordItem('Top Speed', records.topSpeedMph, 'MPH', isTime: false),
const SizedBox(height: 16),
_buildSectionHeader('PERFORMANCE TIMES'),
_buildRecordItem('Fastest 0-30', records.fastest0To30, 's', isTime: true),
_buildRecordItem('Fastest 0-60', records.fastest0To60, 's', isTime: true),
_buildRecordItem('Fastest 0-100', records.fastest0To100, 's', isTime: true),
_buildRecordItem('Fastest 60-100', records.fastest60To100, 's', isTime: true),
const SizedBox(height: 16),
_buildSectionHeader('HANDLING'),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: _buildRecordItem('Max Lean (Left)', records.maxLeanLeft, '°', isTime: false)),
Expanded(child: _buildRecordItem('Max Lean (Right)', records.maxLeanRight, '°', isTime: false)),
Text(
'TOP SPEED',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w800,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
letterSpacing: 0.08,
),
),
if (hasRecord)
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, width: 0.8),
),
child: Text(
'RECORD',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 10,
fontWeight: FontWeight.w800,
color: colors.cyberCyan,
letterSpacing: 0.05,
),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: _buildRecordItem('Max Accel', records.maxAccelG, 'G', isTime: false)),
Expanded(child: _buildRecordItem('Max Braking', records.maxBrakingG, 'G', isTime: false)),
],
const SizedBox(height: 12),
Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
hasRecord ? topSpeedMph.toStringAsFixed(0) : '--',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 54,
fontWeight: FontWeight.w900,
color: theme.colorScheme.onSurface,
letterSpacing: -0.02,
),
),
const SizedBox(width: 8),
Text(
'MPH',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 18,
fontWeight: FontWeight.w800,
color: colors.cyberCyan,
),
),
],
),
),
),
const SizedBox(height: 16),
_buildSectionHeader('ENDURANCE & ROUTE'),
_buildRecordItem('Longest Ride', records.longestRideMiles, 'Miles', isTime: false),
_buildRecordItem('Greatest Elevation Gain', records.greatestElevationGain, 'm', isTime: false),
],
),
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
title,
style: const TextStyle(
color: Colors.cyanAccent,
fontSize: 14,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
Widget _buildPerformanceTimesCard(
BuildContext context,
PersonalRecords records,
MotorSignalThemeColors colors,
) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outlineVariant, width: 1.2),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: _buildBenchmarkCell('0-30', records.fastest0To30, 3.0, colors, context),
),
Container(width: 1, height: 56, color: colors.outlineVariant),
Expanded(
child: _buildBenchmarkCell('0-60', records.fastest0To60, 6.0, colors, context),
),
],
),
Divider(color: colors.outlineVariant, height: 20),
Row(
children: [
Expanded(
child: _buildBenchmarkCell('0-100', records.fastest0To100, 12.0, colors, context),
),
Container(width: 1, height: 56, color: colors.outlineVariant),
Expanded(
child: _buildBenchmarkCell('60-100', records.fastest60To100, 8.0, colors, context),
),
],
),
],
),
);
}
Widget _buildRecordItem(String label, double value, String unit, {required bool isTime}) {
// If it's a time record and it's infinity, it means no record yet.
// If it's a normal record and it's 0, it means no record yet.
bool hasRecord = isTime ? value != double.infinity : value > 0;
return Card(
color: Colors.white10,
margin: const EdgeInsets.only(bottom: 8, right: 8),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
hasRecord ? (isTime ? value.toStringAsFixed(2) : value.toStringAsFixed(1)) : '--',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
Widget _buildBenchmarkCell(
String label,
double value,
double maxScale,
MotorSignalThemeColors colors,
BuildContext context,
) {
final hasRecord = value != double.infinity && value > 0;
final frac = hasRecord ? (value / maxScale).clamp(0.1, 1.0) : 0.0;
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
hasRecord ? '${value.toStringAsFixed(2)} s' : '--',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 22,
fontWeight: FontWeight.w900,
color: theme.colorScheme.onSurface,
),
),
),
),
if (hasRecord) ...[
const SizedBox(width: 4),
Text(
unit,
style: const TextStyle(color: Colors.white54, fontSize: 14),
Container(
width: 6,
height: 12,
decoration: BoxDecoration(
color: colors.cyberCyan,
borderRadius: BorderRadius.circular(2),
),
),
],
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: frac,
backgroundColor: colors.outlineVariant,
valueColor: AlwaysStoppedAnimation<Color>(colors.cyberCyan),
minHeight: 2,
),
],
),
);
}
Widget _buildHandlingCard(
BuildContext context,
PersonalRecords records,
MotorSignalThemeColors colors,
) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outlineVariant, width: 1.2),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: _buildHandlingCell(
'MAX LEAN LEFT',
records.maxLeanLeft > 0 ? '${records.maxLeanLeft.toStringAsFixed(1)}°' : '--',
colors.leanAngleLeft,
context,
),
),
Container(width: 1, height: 50, color: colors.outlineVariant),
Expanded(
child: _buildHandlingCell(
'MAX LEAN RIGHT',
records.maxLeanRight > 0 ? '${records.maxLeanRight.toStringAsFixed(1)}°' : '--',
colors.leanAngleRight,
context,
),
),
],
),
Divider(color: colors.outlineVariant, height: 20),
Row(
children: [
Expanded(
child: _buildHandlingCell(
'MAX ACCEL',
records.maxAccelG > 0 ? '${records.maxAccelG.toStringAsFixed(2)} G' : '--',
colors.gForcePositive,
context,
),
),
Container(width: 1, height: 50, color: colors.outlineVariant),
Expanded(
child: _buildHandlingCell(
'MAX BRAKING',
records.maxBrakingG > 0 ? '${records.maxBrakingG.toStringAsFixed(2)} G' : '--',
colors.gForceNegative,
context,
),
),
],
),
],
),
);
}
Widget _buildHandlingCell(
String label,
String value,
Color accentColor,
BuildContext context,
) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
],
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 20,
fontWeight: FontWeight.w900,
color: theme.colorScheme.onSurface,
),
),
),
),
const SizedBox(width: 4),
Container(
width: 6,
height: 10,
decoration: BoxDecoration(
color: accentColor,
borderRadius: BorderRadius.circular(2),
),
),
],
),
],
),
);
}
Widget _buildEnduranceCard(
BuildContext context,
PersonalRecords records,
MotorSignalThemeColors colors,
) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colors.outlineVariant, width: 1.2),
),
child: Row(
children: [
Expanded(
child: _buildEnduranceCell(
'LONGEST RIDE',
records.longestRideMiles > 0 ? '${records.longestRideMiles.toStringAsFixed(1)} mi' : '--',
Icons.route,
colors,
context,
),
),
Container(width: 1, height: 50, color: colors.outlineVariant),
Expanded(
child: _buildEnduranceCell(
'MAX ELEVATION GAIN',
records.greatestElevationGain > 0 ? '${records.greatestElevationGain.toStringAsFixed(0)} m' : '--',
Icons.terrain,
colors,
context,
),
),
],
),
);
}
Widget _buildEnduranceCell(
String label,
String value,
IconData icon,
MotorSignalThemeColors colors,
BuildContext context,
) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 14, color: colors.cyberCyan),
const SizedBox(width: 4),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
),
],
),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 20,
fontWeight: FontWeight.w900,
color: theme.colorScheme.onSurface,
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,322 @@
import 'dart:convert';
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:motorcycle_dash/database/database.dart';
import 'package:motorcycle_dash/models/motorcycle_models.dart';
import 'package:motorcycle_dash/services/github/github_sync_service.dart';
import 'package:motorcycle_dash/services/motorcycle/motorcycle_repository.dart';
import 'package:motorcycle_dash/features/garage/garage_providers.dart';
import 'package:motorcycle_dash/features/history/history_screen.dart';
import 'package:motorcycle_dash/features/history/ride_detail_screen.dart';
import 'package:motorcycle_dash/features/records/records_screen.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/features/garage/garage_screen.dart';
import 'package:motorcycle_dash/features/garage/motorcycle_detail_screen.dart';
import 'package:motorcycle_dash/features/garage/add_motorcycle_screen.dart';
class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService {
@override
GitHubUploadState get state => GitHubUploadState.idle;
@override
String get statusMessage => '';
@override
Set<int> get queuedRideIds => const {};
@override
bool isRideUploaded(int rideId) => false;
@override
String? getRideCommitSha(int rideId) => null;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeMotorcycleRepository implements MotorcycleRepository {
@override
Future<List<MotorcycleYear>> getYears() async => [
MotorcycleYear(2024),
MotorcycleYear(2023),
];
@override
Future<List<MotorcycleMake>> getMakes(int year) async => [
MotorcycleMake(id: 'yamaha', name: 'Yamaha'),
MotorcycleMake(id: 'ducati', name: 'Ducati'),
];
@override
Future<List<MotorcycleModel>> getModels(int year, String makeId) async => [
MotorcycleModel(id: 'r1', name: 'YZF-R1', makeId: makeId),
MotorcycleModel(id: 'mt09', name: 'MT-09', makeId: makeId),
];
@override
Future<List<MotorcycleVariant>> getVariants(int year, String makeId, String modelId) async => [
MotorcycleVariant(id: 'std', name: 'Standard'),
];
@override
Future<MotorcycleSpecification?> getSpecifications(MotorcycleSelection selection) async =>
MotorcycleSpecification(
year: 2024,
manufacturer: 'Yamaha',
model: 'YZF-R1',
displacementCc: 998,
horsepower: 198,
torqueLbFt: 83.0,
wetWeightLbs: 448,
claimedTopSpeedMph: 186,
claimedZeroToSixtySec: 2.8,
);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late SharedPreferences prefs;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
SharedPreferences.setMockInitialValues({
'topSpeedMph': 152.0,
'fastest0To60': 3.1,
'fastest0To100': 6.8,
'maxLeanLeft': 44.0,
'maxLeanRight': 46.0,
'maxAccelG': 0.88,
'maxBrakingG': 0.94,
'longestRideMiles': 120.5,
'greatestElevationGain': 1450.0,
});
prefs = await SharedPreferences.getInstance();
// Seed test garage motorcycle
final spec = MotorcycleSpecification(
year: 2024,
manufacturer: 'Yamaha',
model: 'YZF-R1',
displacementCc: 998,
horsepower: 198,
torqueLbFt: 83.0,
wetWeightLbs: 448,
claimedTopSpeedMph: 186,
claimedZeroToSixtySec: 2.8,
);
await db.into(db.garageMotorcycles).insert(
GarageMotorcyclesCompanion.insert(
year: 2024,
makeId: 'yamaha',
makeName: 'Yamaha',
modelId: 'r1',
modelName: 'YZF-R1',
nickname: const drift.Value('Blue Thunder'),
isDefault: const drift.Value(true),
dateAdded: DateTime.now(),
lastUpdated: DateTime.now(),
specificationJson: drift.Value(json.encode(spec.toJson())),
),
);
// Seed test ride
await db.into(db.rides).insert(
RidesCompanion.insert(
startTime: DateTime(2026, 1, 1, 10, 0),
endTime: drift.Value(DateTime(2026, 1, 1, 10, 45)),
distanceMiles: const drift.Value(25.5),
topSpeedMph: const drift.Value(65.0),
movingTime: const drift.Value(2400),
stoppedTime: const drift.Value(300),
distance: const drift.Value(25.5),
topSpeed: const drift.Value(65.0),
averageSpeed: const drift.Value(42.0),
elevationGain: const drift.Value(350.0),
elevationLoss: const drift.Value(300.0),
maxLeanLeft: const drift.Value(34.0),
maxLeanRight: const drift.Value(36.0),
maxAccelG: const drift.Value(0.75),
maxBrakingG: const drift.Value(0.85),
motorcycleId: const drift.Value(1),
motorcycleYear: const drift.Value(2024),
motorcycleMake: const drift.Value('Yamaha'),
motorcycleModel: const drift.Value('YZF-R1'),
),
);
});
tearDown(() async {
await db.close();
});
final testViewports = <String, Size>{
'Compact Phone (320x640)': const Size(320, 640),
'Standard Phone (360x800)': const Size(360, 800),
'Modern Phone (390x844)': const Size(390, 844),
'Large Phone (412x915)': const Size(412, 915),
'Compact Landscape (640x320)': const Size(640, 320),
'Standard Landscape (800x360)': const Size(800, 360),
'Wide Landscape (844x390)': const Size(844, 390),
'Tablet Portrait (768x1024)': const Size(768, 1024),
'Tablet Landscape (1024x768)': const Size(1024, 768),
};
Widget buildTestApp(Widget child, {ThemeMode mode = ThemeMode.dark}) {
return ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
sharedPreferencesProvider.overrideWithValue(prefs),
githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()),
motorcycleRepositoryProvider.overrideWith((ref) => _FakeMotorcycleRepository()),
],
child: MaterialApp(
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: mode,
home: child,
),
);
}
group('UI-2D: 9-Viewport Matrix Tests', () {
for (final entry in testViewports.entries) {
final name = entry.key;
final size = entry.value;
testWidgets('HistoryScreen renders cleanly on $name', (tester) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestApp(const HistoryScreen()));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Ride History'), findsOneWidget);
expect(find.byKey(const ValueKey('history_ride_card_1')), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('RideDetailScreen renders cleanly on $name', (tester) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final ride = Ride(
id: 1,
startTime: DateTime(2026, 1, 1, 10, 0),
endTime: DateTime(2026, 1, 1, 10, 45),
distanceMiles: 25.5,
topSpeedMph: 65.0,
movingTime: 2400,
stoppedTime: 300,
distance: 25.5,
topSpeed: 65.0,
averageSpeed: 42.0,
elevationGain: 350.0,
elevationLoss: 300.0,
maxLeanLeft: 34.0,
maxLeanRight: 36.0,
maxAccelG: 0.75,
maxBrakingG: 0.85,
motorcycleYear: 2024,
motorcycleMake: 'Yamaha',
motorcycleModel: 'YZF-R1',
);
await tester.pumpWidget(buildTestApp(RideDetailScreen(ride: ride)));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('REPLAY RIDE'), findsOneWidget);
expect(find.text('EXPORT GPX'), findsOneWidget);
expect(find.text('EXPORT CSV'), findsOneWidget);
expect(find.text('SESSION OVERVIEW'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('RecordsScreen renders cleanly on $name', (tester) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestApp(const RecordsScreen()));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Personal Records'), findsOneWidget);
expect(find.text('TOP SPEED'), findsOneWidget);
expect(find.text('152'), findsOneWidget);
expect(find.text('0-60'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('GarageScreen renders cleanly on $name', (tester) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestApp(const GarageScreen()));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('My Garage'), findsOneWidget);
expect(find.textContaining('Yamaha YZF-R1'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('MotorcycleDetailScreen renders cleanly on $name', (tester) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestApp(const MotorcycleDetailScreen(motorcycleId: 1)));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('MY PERFORMANCE'), findsOneWidget);
expect(find.text('SPECIFICATIONS'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('AddMotorcycleScreen renders cleanly on $name', (tester) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(buildTestApp(const AddMotorcycleScreen()));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Select Year'), findsOneWidget);
expect(find.text('2024'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
}
});
}

View File

@ -5,7 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/core/theme/evolution_theme_config.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/features/dashboard/widgets/evolution_portrait_dashboard.dart';
import 'package:motorcycle_dash/features/records/evolution_records_screen.dart';
import 'package:motorcycle_dash/features/records/records_screen.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
void main() {
@ -78,12 +78,17 @@ void main() {
expect(find.text('START SESSION'), findsOneWidget);
});
testWidgets('EvolutionRecordsScreen renders speed records and performance times', (tester) async {
testWidgets('RecordsScreen renders speed records and performance times', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
SharedPreferences.setMockInitialValues({});
SharedPreferences.setMockInitialValues({
'topSpeedMph': 164.0,
'fastest0To60': 2.9,
'fastest0To100': 6.4,
'maxLeanLeft': 48.0,
});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
@ -92,15 +97,14 @@ void main() {
sharedPreferencesProvider.overrideWithValue(prefs),
],
child: const MaterialApp(
home: EvolutionRecordsScreen(),
home: RecordsScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('MOTOR SIGNAL'), findsOneWidget);
expect(find.text('PERSONAL RECORDS'), findsOneWidget);
expect(find.text('Personal Records'), findsOneWidget);
expect(find.text('TOP SPEED'), findsOneWidget);
expect(find.text('0-60'), findsOneWidget);
expect(find.text('0-100'), findsOneWidget);
@ -108,3 +112,4 @@ void main() {
});
});
}

View File

@ -1,8 +1,9 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:motorcycle_dash/database/database.dart';
import 'package:motorcycle_dash/features/history/widgets/evolution_ride_details_view.dart';
import 'package:motorcycle_dash/features/history/ride_detail_screen.dart';
import 'package:motorcycle_dash/services/github/github_sync_service.dart';
class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService {
@ -23,6 +24,16 @@ class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
});
tearDown(() async {
await db.close();
});
Ride makeTestRide() {
return Ride(
id: 1,
@ -54,12 +65,12 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()),
],
child: MaterialApp(
home: EvolutionRideDetailsView(
home: RideDetailScreen(
ride: ride,
events: const [],
),
),
),
@ -82,12 +93,41 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()),
],
child: MaterialApp(
home: EvolutionRideDetailsView(
home: RideDetailScreen(
ride: ride,
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('REPLAY RIDE'), findsOneWidget);
expect(find.text('EXPORT GPX'), findsOneWidget);
expect(find.text('EXPORT CSV'), findsOneWidget);
});
testWidgets('renders on landscape 800x360 screen without overflow', (tester) async {
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final ride = makeTestRide();
await tester.pumpWidget(
ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()),
],
child: MaterialApp(
home: RideDetailScreen(
ride: ride,
events: const [],
),
),
),
@ -101,3 +141,4 @@ void main() {
expect(find.text('EXPORT CSV'), findsOneWidget);
});
}