OpenStreetMap-Pre-Implementation

This commit is contained in:
Developer 2026-08-27 03:28:11 -04:00
parent 00fe459e0e
commit 7e8b909ff4
19 changed files with 4393 additions and 139 deletions

View File

@ -6,6 +6,9 @@ import '../features/history/history_screen.dart';
import '../features/records/records_screen.dart';
import '../features/settings/settings_screen.dart';
import '../features/settings/diagnostics_screen.dart';
import '../features/garage/garage_screen.dart';
import '../features/garage/add_motorcycle_screen.dart';
import '../features/garage/motorcycle_detail_screen.dart';
import 'widgets/main_layout.dart';
final _rootNavigatorKey = GlobalKey<NavigatorState>();
@ -51,6 +54,27 @@ final appRouter = GoRouter(
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/garage',
builder: (context, state) => const GarageScreen(),
routes: [
GoRoute(
path: 'add',
builder: (context, state) => const AddMotorcycleScreen(),
),
GoRoute(
path: ':id',
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return MotorcycleDetailScreen(motorcycleId: id);
},
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(

View File

@ -48,6 +48,10 @@ class MainLayout extends ConsumerWidget {
icon: Icon(Icons.emoji_events),
label: 'Records',
),
BottomNavigationBarItem(
icon: Icon(Icons.two_wheeler),
label: 'Garage',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: 'Settings',

View File

@ -9,12 +9,12 @@ import 'tables.dart';
part 'database.g.dart';
@DriftDatabase(tables: [Rides, PerformanceRuns, TelemetrySamples, RideEvents])
@DriftDatabase(tables: [Rides, PerformanceRuns, TelemetrySamples, RideEvents, GarageMotorcycles])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 2;
int get schemaVersion => 3;
@override
MigrationStrategy get migration {
@ -37,6 +37,14 @@ class AppDatabase extends _$AppDatabase {
await m.createTable(telemetrySamples);
await m.createTable(rideEvents);
}
if (from < 3) {
await m.createTable(garageMotorcycles);
await m.addColumn(rides, rides.motorcycleId);
await m.addColumn(rides, rides.motorcycleYear);
await m.addColumn(rides, rides.motorcycleMake);
await m.addColumn(rides, rides.motorcycleModel);
await m.addColumn(rides, rides.motorcycleVariant);
}
},
);
}

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,13 @@ class Rides extends Table {
RealColumn get topSpeed => real().withDefault(const Constant(0.0))();
RealColumn get elevationGain => real().withDefault(const Constant(0.0))();
RealColumn get elevationLoss => real().withDefault(const Constant(0.0))();
// Phase 3 additions: Motorcycle association
IntColumn get motorcycleId => integer().nullable()();
IntColumn get motorcycleYear => integer().nullable()();
TextColumn get motorcycleMake => text().nullable()();
TextColumn get motorcycleModel => text().nullable()();
TextColumn get motorcycleVariant => text().nullable()();
}
class TelemetrySamples extends Table {
@ -62,3 +69,19 @@ class PerformanceRuns extends Table {
RealColumn get topSpeedReached => real()();
DateTimeColumn get timestamp => dateTime()();
}
class GarageMotorcycles extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get year => integer()();
TextColumn get makeId => text()();
TextColumn get makeName => text()();
TextColumn get modelId => text()();
TextColumn get modelName => text()();
TextColumn get variantId => text().nullable()();
TextColumn get variantName => text().nullable()();
TextColumn get nickname => text().nullable()();
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
DateTimeColumn get dateAdded => dateTime()();
DateTimeColumn get lastUpdated => dateTime()();
TextColumn get specificationJson => text().nullable()();
}

View File

@ -0,0 +1,447 @@
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 'garage_providers.dart';
class AddMotorcycleScreen extends ConsumerStatefulWidget {
const AddMotorcycleScreen({super.key});
@override
ConsumerState<AddMotorcycleScreen> createState() => _AddMotorcycleScreenState();
}
class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
int _step = 1; // 1: Year, 2: Make, 3: Model, 4: Variant, 5: Confirm
int? _selectedYear;
MotorcycleMake? _selectedMake;
MotorcycleModel? _selectedModel;
MotorcycleVariant? _selectedVariant;
MotorcycleSpecification? _previewSpec;
final TextEditingController _nicknameController = TextEditingController();
final TextEditingController _searchController = TextEditingController();
bool _isLoading = false;
List<MotorcycleYear> _years = [];
List<MotorcycleMake> _makes = [];
List<MotorcycleModel> _models = [];
List<MotorcycleVariant> _variants = [];
@override
void initState() {
super.initState();
_loadYears();
}
@override
void dispose() {
_nicknameController.dispose();
_searchController.dispose();
super.dispose();
}
Future<void> _loadYears() async {
setState(() => _isLoading = true);
final repo = ref.read(motorcycleRepositoryProvider);
final years = await repo.getYears();
setState(() {
_years = years;
_isLoading = false;
});
}
Future<void> _selectYear(int year) async {
setState(() {
_selectedYear = year;
_step = 2;
_isLoading = true;
_searchController.clear();
});
final repo = ref.read(motorcycleRepositoryProvider);
final makes = await repo.getMakes(year);
setState(() {
_makes = makes;
_isLoading = false;
});
}
Future<void> _selectMake(MotorcycleMake make) async {
setState(() {
_selectedMake = make;
_step = 3;
_isLoading = true;
_searchController.clear();
});
final repo = ref.read(motorcycleRepositoryProvider);
final models = await repo.getModels(_selectedYear!, make.id);
setState(() {
_models = models;
_isLoading = false;
});
}
Future<void> _selectModel(MotorcycleModel model) async {
setState(() {
_selectedModel = model;
_isLoading = true;
});
final repo = ref.read(motorcycleRepositoryProvider);
final variants = await repo.getVariants(_selectedYear!, _selectedMake!.id, model.id);
if (variants.length <= 1) {
_selectedVariant = variants.isNotEmpty ? variants.first : null;
await _loadSpecSummary();
} else {
setState(() {
_variants = variants;
_step = 4;
_isLoading = false;
});
}
}
Future<void> _selectVariant(MotorcycleVariant variant) async {
setState(() {
_selectedVariant = variant;
});
await _loadSpecSummary();
}
Future<void> _loadSpecSummary() async {
setState(() => _isLoading = true);
final selection = MotorcycleSelection(
year: _selectedYear!,
makeId: _selectedMake!.id,
makeName: _selectedMake!.name,
modelId: _selectedModel!.id,
modelName: _selectedModel!.name,
variantId: _selectedVariant?.id,
variantName: _selectedVariant?.name,
);
final repo = ref.read(motorcycleRepositoryProvider);
final spec = await repo.getSpecifications(selection);
setState(() {
_previewSpec = spec;
_step = 5;
_isLoading = false;
});
}
Future<void> _saveMotorcycle() async {
final selection = MotorcycleSelection(
year: _selectedYear!,
makeId: _selectedMake!.id,
makeName: _selectedMake!.name,
modelId: _selectedModel!.id,
modelName: _selectedModel!.name,
variantId: _selectedVariant?.id,
variantName: _selectedVariant?.name,
);
final controller = ref.read(garageControllerProvider);
await controller.addMotorcycle(
selection,
nickname: _nicknameController.text.trim().isNotEmpty ? _nicknameController.text.trim() : null,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added ${selection.displayName} to your Garage!')),
);
context.pop();
}
}
void _goBack() {
if (_step > 1) {
setState(() {
_step -= 1;
_searchController.clear();
});
} else {
context.pop();
}
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: _step == 1,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _step > 1) {
_goBack();
}
},
child: Scaffold(
appBar: AppBar(
title: Text(_getStepTitle()),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _goBack,
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
LinearProgressIndicator(value: _step / 5.0, backgroundColor: Colors.white10, valueColor: const AlwaysStoppedAnimation(Colors.cyanAccent)),
Expanded(child: _buildStepContent()),
],
),
),
);
}
String _getStepTitle() {
switch (_step) {
case 1:
return 'Select Year';
case 2:
return 'Select Manufacturer';
case 3:
return 'Select Model (${_selectedMake?.name ?? ""})';
case 4:
return 'Select Variant';
case 5:
return 'Confirm Motorcycle';
default:
return 'Add Motorcycle';
}
}
Widget _buildStepContent() {
switch (_step) {
case 1:
return _buildYearStep();
case 2:
return _buildMakeStep();
case 3:
return _buildModelStep();
case 4:
return _buildVariantStep();
case 5:
return _buildConfirmStep();
default:
return const SizedBox();
}
}
Widget _buildYearStep() {
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 2.2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
),
itemCount: _years.length,
itemBuilder: (context, index) {
final year = _years[index].year;
return OutlinedButton(
style: OutlinedButton.styleFrom(
side: BorderSide(color: _selectedYear == year ? Colors.cyanAccent : Colors.white24),
backgroundColor: _selectedYear == year ? Colors.cyanAccent.withValues(alpha: 0.2) : null,
),
onPressed: () => _selectYear(year),
child: Text('$year', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white)),
);
},
);
}
Widget _buildMakeStep() {
final query = _searchController.text.toLowerCase();
final filtered = _makes.where((m) => m.name.toLowerCase().contains(query)).toList();
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search Manufacturer...',
prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent),
filled: true,
fillColor: Colors.white10,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
),
onChanged: (_) => setState(() {}),
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: filtered.length,
separatorBuilder: (_, __) => const Divider(height: 1, color: Colors.white10),
itemBuilder: (context, index) {
final make = filtered[index];
return ListTile(
title: Text(make.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)),
trailing: const Icon(Icons.chevron_right, color: Colors.white30),
onTap: () => _selectMake(make),
);
},
),
),
],
);
}
Widget _buildModelStep() {
final query = _searchController.text.toLowerCase();
final filtered = _models.where((m) => m.name.toLowerCase().contains(query)).toList();
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search Model...',
prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent),
filled: true,
fillColor: Colors.white10,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
),
onChanged: (_) => setState(() {}),
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: filtered.length,
separatorBuilder: (_, __) => const Divider(height: 1, color: Colors.white10),
itemBuilder: (context, index) {
final model = filtered[index];
return ListTile(
title: Text(model.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)),
subtitle: Text('${_selectedYear} ${_selectedMake?.name}'),
trailing: const Icon(Icons.chevron_right, color: Colors.white30),
onTap: () => _selectModel(model),
);
},
),
),
],
);
}
Widget _buildVariantStep() {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _variants.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final variant = _variants[index];
return Card(
child: ListTile(
title: Text(variant.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
trailing: const Icon(Icons.check_circle_outline, color: Colors.cyanAccent),
onTap: () => _selectVariant(variant),
),
);
},
);
}
Widget _buildConfirmStep() {
final spec = _previewSpec;
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.cyanAccent, width: 2),
),
child: Column(
children: [
const Icon(Icons.two_wheeler, size: 64, color: Colors.cyanAccent),
const SizedBox(height: 12),
Text(
'${_selectedYear} ${_selectedMake?.name} ${_selectedModel?.name}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
if (_selectedVariant?.name != null && _selectedVariant!.name != 'Standard') ...[
const SizedBox(height: 4),
Text(_selectedVariant!.name, style: const TextStyle(color: Colors.white70, fontSize: 16)),
],
],
),
),
const SizedBox(height: 24),
if (spec != null) ...[
const Text('SPECIFICATION SUMMARY', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, letterSpacing: 1.2)),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildSpecChip('DISPLACEMENT', spec.displacementCc != null ? '${spec.displacementCc!.toStringAsFixed(0)} cc' : '—')),
const SizedBox(width: 12),
Expanded(child: _buildSpecChip('HORSEPOWER', spec.horsepower != null ? '${spec.horsepower!.toStringAsFixed(0)} HP' : '—')),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildSpecChip('TORQUE', spec.torqueLbFt != null ? '${spec.torqueLbFt!.toStringAsFixed(1)} lb-ft' : '—')),
const SizedBox(width: 12),
Expanded(child: _buildSpecChip('WET WEIGHT', spec.wetWeightLbs != null ? '${spec.wetWeightLbs!.toStringAsFixed(0)} lbs' : '—')),
],
),
const SizedBox(height: 24),
],
TextField(
controller: _nicknameController,
decoration: InputDecoration(
labelText: 'Nickname (Optional)',
hintText: 'e.g., Blue Beast, The Track Bike',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
prefixIcon: const Icon(Icons.edit, color: Colors.cyanAccent),
),
),
const SizedBox(height: 32),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.cyanAccent,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: _saveMotorcycle,
child: const Text('ADD TO GARAGE', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 1.5)),
),
],
),
);
}
Widget _buildSpecChip(String label, String value) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white54, letterSpacing: 1.0)),
const SizedBox(height: 4),
Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white)),
],
),
);
}
}

View File

@ -0,0 +1,169 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../database/database.dart';
import '../../models/motorcycle_models.dart';
import '../../services/motorcycle/motorcycle_repository.dart';
final motorcycleRepositoryProvider = Provider<MotorcycleRepository>((ref) {
return HybridMotorcycleRepository();
});
final garageMotorcyclesStreamProvider = StreamProvider<List<GarageMotorcycle>>((ref) {
final db = ref.watch(databaseProvider);
return (db.select(db.garageMotorcycles)
..orderBy([(t) => OrderingTerm(expression: t.dateAdded, mode: OrderingMode.desc)]))
.watch();
});
final defaultMotorcycleProvider = Provider<GarageMotorcycle?>((ref) {
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
final bikes = bikesAsync.value ?? [];
try {
return bikes.firstWhere((b) => b.isDefault);
} catch (_) {
return bikes.isNotEmpty ? bikes.first : null;
}
});
class MotorcycleStats {
final int rideCount;
final double totalDistanceMiles;
final double topSpeedMph;
final double? best0to60Ms;
final double? best0to100Ms;
final double maxLean;
final double maxAccelG;
final double maxBrakingG;
MotorcycleStats({
required this.rideCount,
required this.totalDistanceMiles,
required this.topSpeedMph,
this.best0to60Ms,
this.best0to100Ms,
required this.maxLean,
required this.maxAccelG,
required this.maxBrakingG,
});
}
final motorcyclePerformanceStatsProvider =
FutureProvider.family<MotorcycleStats, int>((ref, motorcycleId) async {
final db = ref.watch(databaseProvider);
final rides = await (db.select(db.rides)..where((t) => t.motorcycleId.equals(motorcycleId))).get();
if (rides.isEmpty) {
return MotorcycleStats(
rideCount: 0,
totalDistanceMiles: 0.0,
topSpeedMph: 0.0,
maxLean: 0.0,
maxAccelG: 0.0,
maxBrakingG: 0.0,
);
}
int count = rides.length;
double totalDistance = 0.0;
double topSpeed = 0.0;
double? best0to60;
double? best0to100;
double maxLean = 0.0;
double maxAccel = 0.0;
double maxBraking = 0.0;
for (var r in rides) {
totalDistance += r.distanceMiles;
if (r.topSpeedMph > topSpeed) topSpeed = r.topSpeedMph;
final lean = r.maxLeanLeft > r.maxLeanRight ? r.maxLeanLeft : r.maxLeanRight;
if (lean > maxLean) maxLean = lean;
if (r.maxAccelG > maxAccel) maxAccel = r.maxAccelG;
if (r.maxBrakingG > maxBraking) maxBraking = r.maxBrakingG;
if (r.best0to60Ms != null) {
if (best0to60 == null || r.best0to60Ms! < best0to60) {
best0to60 = r.best0to60Ms;
}
}
if (r.best0to100Ms != null) {
if (best0to100 == null || r.best0to100Ms! < best0to100) {
best0to100 = r.best0to100Ms;
}
}
}
return MotorcycleStats(
rideCount: count,
totalDistanceMiles: totalDistance,
topSpeedMph: topSpeed,
best0to60Ms: best0to60,
best0to100Ms: best0to100,
maxLean: maxLean,
maxAccelG: maxAccel,
maxBrakingG: maxBraking,
);
});
class GarageController {
final AppDatabase _db;
final MotorcycleRepository _repository;
GarageController(this._db, this._repository);
Future<int> addMotorcycle(MotorcycleSelection selection, {String? nickname}) async {
final spec = await _repository.getSpecifications(selection);
final specJson = spec != null ? json.encode(spec.toJson()) : null;
// Check existing default count
final existingBikes = await _db.select(_db.garageMotorcycles).get();
final isFirst = existingBikes.isEmpty;
return await _db.into(_db.garageMotorcycles).insert(
GarageMotorcyclesCompanion.insert(
year: selection.year,
makeId: selection.makeId,
makeName: selection.makeName,
modelId: selection.modelId,
modelName: selection.modelName,
variantId: Value(selection.variantId),
variantName: Value(selection.variantName),
nickname: Value(nickname),
isDefault: Value(isFirst),
dateAdded: DateTime.now(),
lastUpdated: DateTime.now(),
specificationJson: Value(specJson),
),
);
}
Future<void> setDefaultMotorcycle(int id) async {
await (_db.update(_db.garageMotorcycles)..where((t) => t.id.equals(id).not()))
.write(const GarageMotorcyclesCompanion(isDefault: Value(false)));
await (_db.update(_db.garageMotorcycles)..where((t) => t.id.equals(id)))
.write(const GarageMotorcyclesCompanion(isDefault: Value(true)));
}
Future<void> updateNickname(int id, String? nickname) async {
await (_db.update(_db.garageMotorcycles)..where((t) => t.id.equals(id)))
.write(GarageMotorcyclesCompanion(nickname: Value(nickname)));
}
Future<void> deleteMotorcycle(int id) async {
await (_db.delete(_db.garageMotorcycles)..where((t) => t.id.equals(id))).go();
// If default was deleted, set next bike as default if available
final remaining = await _db.select(_db.garageMotorcycles).get();
if (remaining.isNotEmpty && !remaining.any((b) => b.isDefault)) {
await setDefaultMotorcycle(remaining.first.id);
}
}
}
final garageControllerProvider = Provider<GarageController>((ref) {
final db = ref.watch(databaseProvider);
final repo = ref.watch(motorcycleRepositoryProvider);
return GarageController(db, repo);
});

View File

@ -0,0 +1,204 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../database/database.dart';
import '../../models/motorcycle_models.dart';
import 'garage_providers.dart';
class GarageScreen extends ConsumerWidget {
const GarageScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
return Scaffold(
appBar: AppBar(
title: const Text('My Garage', style: TextStyle(fontWeight: FontWeight.bold)),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_outline, color: Colors.cyanAccent),
tooltip: 'Add Motorcycle',
onPressed: () => context.push('/garage/add'),
),
],
),
body: bikesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
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) {
final bike = bikes[index];
return _buildMotorcycleCard(context, ref, bike);
},
);
},
),
floatingActionButton: bikesAsync.value != null && bikesAsync.value!.isNotEmpty
? FloatingActionButton.extended(
backgroundColor: Colors.cyanAccent,
foregroundColor: Colors.black,
icon: const Icon(Icons.two_wheeler),
label: const Text('ADD MOTORCYCLE', style: TextStyle(fontWeight: FontWeight.bold)),
onPressed: () => context.push('/garage/add'),
)
: null,
);
}
Widget _buildEmptyState(BuildContext 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),
const SizedBox(height: 24),
const Text(
'YOUR GARAGE IS EMPTY',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, letterSpacing: 1.5),
),
const SizedBox(height: 12),
const Text(
'Add your motorcycle to personalize your ride history and performance data.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70, height: 1.4),
),
const SizedBox(height: 32),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.cyanAccent,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: const Icon(Icons.add),
label: const Text('ADD MOTORCYCLE', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2)),
onPressed: () => context.push('/garage/add'),
),
],
),
),
);
}
Widget _buildMotorcycleCard(BuildContext context, WidgetRef ref, GarageMotorcycle bike) {
MotorcycleSpecification? spec;
if (bike.specificationJson != null) {
try {
spec = MotorcycleSpecification.fromJson(json.decode(bike.specificationJson!));
} catch (_) {}
}
final displayName = bike.nickname != null && bike.nickname!.isNotEmpty
? '${bike.nickname} (${bike.year} ${bike.makeName} ${bike.modelName})'
: '${bike.year} ${bike.makeName} ${bike.modelName}';
return Card(
margin: const EdgeInsets.only(bottom: 16),
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: bike.isDefault ? Colors.cyanAccent : Colors.white12,
width: bike.isDefault ? 2 : 1,
),
),
child: InkWell(
onTap: () => context.push('/garage/${bike.id}'),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.two_wheeler, size: 36, color: Colors.cyanAccent),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
displayName,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
if (bike.isDefault)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.cyanAccent.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.cyanAccent),
),
child: const Text(
'CURRENT',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.cyanAccent),
),
),
],
),
const SizedBox(height: 6),
Text(
spec != null
? '${spec.displacementCc?.toStringAsFixed(0) ?? "—"} cc • ${spec.horsepower?.toStringAsFixed(0) ?? "—"} hp'
: '${bike.year} • ${bike.makeName}',
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton.icon(
icon: const Icon(Icons.directions_bike, size: 18),
label: const Text('View Specs & Stats'),
onPressed: () => context.push('/garage/${bike.id}'),
),
if (!bike.isDefault)
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white30),
),
onPressed: () {
ref.read(garageControllerProvider).setDefaultMotorcycle(bike.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Set ${bike.makeName} ${bike.modelName} as current motorcycle.')),
);
},
child: const Text('Set as Current'),
),
],
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,430 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../database/database.dart';
import '../../models/motorcycle_models.dart';
import 'garage_providers.dart';
class MotorcycleDetailScreen extends ConsumerWidget {
final int motorcycleId;
const MotorcycleDetailScreen({super.key, required this.motorcycleId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
final statsAsync = ref.watch(motorcyclePerformanceStatsProvider(motorcycleId));
return bikesAsync.when(
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
error: (err, _) => Scaffold(body: Center(child: Text('Error: $err'))),
data: (bikes) {
final bike = bikes.firstWhere(
(b) => b.id == motorcycleId,
orElse: () => GarageMotorcycle(
id: 0,
year: 2024,
makeId: '',
makeName: 'Unknown',
modelId: '',
modelName: 'Unknown',
isDefault: false,
dateAdded: DateTime.now(),
lastUpdated: DateTime.now(),
),
);
if (bike.id == 0) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: Text('Motorcycle not found')),
);
}
MotorcycleSpecification? spec;
if (bike.specificationJson != null) {
try {
spec = MotorcycleSpecification.fromJson(json.decode(bike.specificationJson!));
} catch (_) {}
}
return Scaffold(
appBar: AppBar(
title: Text('${bike.year} ${bike.makeName} ${bike.modelName}'),
actions: [
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Edit Nickname',
onPressed: () => _showEditNicknameDialog(context, ref, bike),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
tooltip: 'Delete Motorcycle',
onPressed: () => _confirmDelete(context, ref, bike),
),
],
),
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),
],
),
),
);
},
);
}
Widget _buildHeaderCard(BuildContext context, WidgetRef ref, GarageMotorcycle bike, MotorcycleSpecification? spec) {
return Card(
color: const Color(0xFF161616),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: bike.isDefault ? Colors.cyanAccent : Colors.white12, width: 2),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
Row(
children: [
const Icon(Icons.two_wheeler, size: 54, color: Colors.cyanAccent),
const SizedBox(width: 16),
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),
),
),
],
),
),
],
),
const SizedBox(height: 16),
const Divider(color: Colors.white10),
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' : '—'),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: bike.isDefault
? Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: Colors.cyanAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.cyanAccent),
),
child: const Center(
child: Text(
'CURRENT MOTORCYCLE',
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.5, color: Colors.cyanAccent),
),
),
)
: OutlinedButton(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: const BorderSide(color: Colors.cyanAccent),
foregroundColor: Colors.cyanAccent,
),
onPressed: () {
ref.read(garageControllerProvider).setDefaultMotorcycle(bike.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Set ${bike.makeName} ${bike.modelName} as current motorcycle.')),
);
},
child: const Text('SET AS CURRENT MOTORCYCLE', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2)),
),
),
],
),
),
);
}
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 _buildMyPerformanceSection(BuildContext context, AsyncValue<MotorcycleStats> statsAsync, MotorcycleSpecification? spec) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'MY PERFORMANCE',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.cyanAccent, letterSpacing: 1.2),
),
const SizedBox(height: 12),
statsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Text('Error loading stats: $err'),
data: (stats) {
return Column(
children: [
Row(
children: [
Expanded(child: _buildPerfTile('TOTAL RIDES', '${stats.rideCount}')),
const SizedBox(width: 12),
Expanded(child: _buildPerfTile('TOTAL DISTANCE', '${stats.totalDistanceMiles.toStringAsFixed(1)} MI')),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildPerfTile(
'BEST TOP SPEED',
'${stats.topSpeedMph.toStringAsFixed(1)} MPH',
published: spec?.claimedTopSpeedMph != null ? '~${spec!.claimedTopSpeedMph!.toStringAsFixed(0)} MPH' : null,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildPerfTile(
'BEST 0–60',
stats.best0to60Ms != null ? '${(stats.best0to60Ms! / 1000.0).toStringAsFixed(2)} s' : '—',
published: spec?.claimedZeroToSixtySec != null ? '~${spec!.claimedZeroToSixtySec!.toStringAsFixed(1)} s' : null,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildPerfTile('MAX LEAN', '${stats.maxLean.toStringAsFixed(1)}°')),
const SizedBox(width: 12),
Expanded(child: _buildPerfTile('MAX ACCEL G', '${stats.maxAccelG.toStringAsFixed(2)} G')),
],
),
],
);
},
),
],
);
}
Widget _buildPerfTile(String label, String value, {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),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white54, letterSpacing: 1.0)),
const SizedBox(height: 4),
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white)),
if (published != null) ...[
const SizedBox(height: 4),
Text('PUBLISHED: $published', style: const TextStyle(fontSize: 10, color: Colors.cyanAccent)),
],
],
),
);
}
Widget _buildSpecificationSheet(BuildContext context, MotorcycleSpecification? spec) {
if (spec == null) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text('Detailed specifications unavailable for this motorcycle.', style: TextStyle(color: Colors.white54)),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'SPECIFICATIONS',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.cyanAccent, letterSpacing: 1.2),
),
const SizedBox(height: 12),
_buildSpecGroup('ENGINE', [
_SpecRow('Displacement', spec.displacementCc != null ? '${spec.displacementCc!.toStringAsFixed(0)} cc' : null),
_SpecRow('Configuration', spec.engineConfiguration),
_SpecRow('Cylinders', spec.cylinderCount?.toString()),
_SpecRow('Horsepower', spec.horsepower != null ? '${spec.horsepower!.toStringAsFixed(0)} HP @ ${spec.horsepowerRpm ?? "—"} RPM' : null),
_SpecRow('Torque', spec.torqueLbFt != null ? '${spec.torqueLbFt!.toStringAsFixed(1)} lb-ft @ ${spec.torqueRpm ?? "—"} RPM' : null),
_SpecRow('Redline', spec.redlineRpm != null ? '${spec.redlineRpm} RPM' : null),
_SpecRow('Compression Ratio', spec.compressionRatio),
_SpecRow('Cooling System', spec.coolingSystem),
_SpecRow('Fuel System', spec.fuelSystem),
]),
_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),
]),
_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),
]),
_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),
]),
_buildSpecGroup('CHASSIS & BRAKES', [
_SpecRow('Frame Type', spec.frameType),
_SpecRow('Front Suspension', spec.frontSuspension),
_SpecRow('Rear Suspension', spec.rearSuspension),
_SpecRow('Front Brake', spec.frontBrake),
_SpecRow('Rear Brake', spec.rearBrake),
_SpecRow('ABS System', spec.absType),
_SpecRow('Front Tire', spec.frontTireSize),
_SpecRow('Rear Tire', spec.rearTireSize),
]),
_buildSpecGroup('TRANSMISSION', [
_SpecRow('Transmission Type', spec.transmissionType),
_SpecRow('Gears', spec.gearCount?.toString()),
_SpecRow('Final Drive', spec.finalDrive),
]),
if (spec.source != null) ...[
const SizedBox(height: 12),
Text(
'Specifications Source: ${spec.source}',
style: const TextStyle(fontSize: 12, color: Colors.white38),
),
],
],
);
}
Widget _buildSpecGroup(String title, List<_SpecRow> rows) {
final validRows = rows.where((r) => r.value != null && r.value!.isNotEmpty).toList();
if (validRows.isEmpty) return const SizedBox();
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.cyanAccent, fontSize: 14, letterSpacing: 1.0)),
const SizedBox(height: 8),
...validRows.map(
(r) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(r.label, style: const TextStyle(color: Colors.white70)),
Flexible(
child: Text(
r.value!,
textAlign: TextAlign.right,
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
),
),
],
),
),
),
],
),
),
);
}
void _showEditNicknameDialog(BuildContext context, WidgetRef ref, GarageMotorcycle bike) {
final controller = TextEditingController(text: bike.nickname ?? '');
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Edit Nickname'),
content: TextField(
controller: controller,
decoration: const InputDecoration(hintText: 'Enter nickname'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: () {
ref.read(garageControllerProvider).updateNickname(bike.id, controller.text.trim().isNotEmpty ? controller.text.trim() : null);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
);
},
);
}
void _confirmDelete(BuildContext context, WidgetRef ref, GarageMotorcycle bike) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete Motorcycle?'),
content: Text('Are you sure you want to remove ${bike.makeName} ${bike.modelName} from your Garage? Historical rides associated with this motorcycle will be preserved.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () {
ref.read(garageControllerProvider).deleteMotorcycle(bike.id);
Navigator.pop(context);
context.pop();
},
child: const Text('Delete', style: TextStyle(color: Colors.white)),
),
],
);
},
);
}
}
class _SpecRow {
final String label;
final String? value;
_SpecRow(this.label, this.value);
}

View File

@ -2,105 +2,204 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:math' as math;
import '../../database/database.dart';
import '../garage/garage_providers.dart';
import 'ride_detail_screen.dart';
class HistoryScreen extends ConsumerWidget {
class HistoryScreen extends ConsumerStatefulWidget {
const HistoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends ConsumerState<HistoryScreen> {
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);
return Scaffold(
appBar: AppBar(title: const Text('Ride History')),
body: StreamBuilder<List<Ride>>(
stream: database.select(database.rides).watch(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
body: Column(
children: [
_buildFilterBar(garageBikesAsync.value ?? [], defaultBike),
Expanded(
child: StreamBuilder<List<Ride>>(
stream: database.select(database.rides).watch(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final rides = snapshot.data ?? [];
// Sort by start time descending
rides.sort((a, b) => b.startTime.compareTo(a.startTime));
if (rides.isEmpty) {
return const Center(
child: Text(
'No recorded rides yet.',
style: TextStyle(fontSize: 18, color: Colors.grey),
),
);
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: rides.length,
itemBuilder: (context, index) {
final ride = rides[index];
return Card(
elevation: 4,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: InkWell(
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(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Ride ${ride.id}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)
),
Text(
ride.startTime.toLocal().toString().split('.')[0],
style: const TextStyle(color: Colors.grey),
),
],
),
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'),
],
),
],
var rides = snapshot.data ?? [];
// Filter logic
if (_selectedMotorcycleId == -1 && defaultBike != null) {
rides = rides.where((r) => r.motorcycleId == defaultBike.id).toList();
} else if (_selectedMotorcycleId != null && _selectedMotorcycleId! > 0) {
rides = rides.where((r) => r.motorcycleId == _selectedMotorcycleId).toList();
}
// Sort by start time descending
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 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 Card(
elevation: 4,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: InkWell(
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(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
'Ride ${ride.id}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
if (bikeName != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, 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,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.cyanAccent),
),
),
],
],
),
Text(
ride.startTime.toLocal().toString().split('.')[0],
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
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'),
],
),
],
),
),
),
);
},
);
},
),
),
],
),
);
}
Widget _buildFilterBar(List<GarageMotorcycle> bikes, GarageMotorcycle? defaultBike) {
if (bikes.isEmpty) return const SizedBox();
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),
const SizedBox(width: 8),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(8),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int?>(
value: _selectedMotorcycleId,
isExpanded: true,
dropdownColor: const Color(0xFF1E1E1E),
hint: const Text('Filter by Motorcycle'),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('All Motorcycles'),
),
if (defaultBike != null)
DropdownMenuItem<int?>(
value: -1,
child: Text('Current (${defaultBike.modelName})'),
),
...bikes.map((b) => DropdownMenuItem<int?>(
value: b.id,
child: Text('${b.year} ${b.makeName} ${b.modelName}'),
)),
],
onChanged: (val) {
setState(() {
_selectedMotorcycleId = val;
});
},
),
),
),
),
],
),
);
}
Widget _buildStatCol(String label, String val) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../database/database.dart';
import '../../services/export_service.dart';
import '../replay/replay_screen.dart';
import 'package:go_router/go_router.dart';
class RideDetailScreen extends ConsumerStatefulWidget {
final Ride ride;
@ -42,6 +43,42 @@ class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (r.motorcycleMake != null && r.motorcycleModel != null) ...[
Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.cyanAccent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
),
child: Row(
children: [
const Icon(Icons.two_wheeler, color: Colors.cyanAccent, size: 32),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${r.motorcycleYear ?? ""} ${r.motorcycleMake} ${r.motorcycleModel}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
if (r.motorcycleVariant != null && r.motorcycleVariant!.isNotEmpty)
Text(r.motorcycleVariant!, style: const TextStyle(color: Colors.white70, fontSize: 12)),
],
),
),
if (r.motorcycleId != null)
OutlinedButton(
style: OutlinedButton.styleFrom(foregroundColor: Colors.cyanAccent, side: const BorderSide(color: Colors.cyanAccent)),
onPressed: () => context.push('/garage/${r.motorcycleId}'),
child: const Text('View Specs'),
),
],
),
),
],
_buildSectionHeader('SUMMARY'),
_buildStatRow('Distance', '${r.distanceMiles.toStringAsFixed(2)} mi'),
_buildStatRow('Duration', '${r.endTime?.difference(r.startTime).inMinutes ?? 0} min'),

View File

@ -8,6 +8,7 @@ import '../../models/telemetry_sample.dart';
import '../../services/telemetry_service.dart';
import '../../services/audio_alert_service.dart';
import '../records/records_service.dart';
import '../garage/garage_providers.dart';
enum RideState { idle, ready, riding, paused, finished }
@ -86,6 +87,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
final TelemetryService telemetryService;
final RecordsService recordsService;
final AudioAlertService audioAlertService;
final Ref ref;
StreamSubscription? _telemetrySub;
@ -100,7 +102,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
final Map<String, DateTime> _lastEventTimes = {};
static const Duration _eventCooldown = Duration(seconds: 5);
RideStateMachine(this.db, this.telemetryService, this.recordsService, this.audioAlertService) : super(RideSession()) {
RideStateMachine(this.db, this.telemetryService, this.recordsService, this.audioAlertService, this.ref) : super(RideSession()) {
_telemetrySub = telemetryService.telemetryStream.listen(_onTelemetryUpdate);
// Write to DB every 5 seconds
@ -112,7 +114,15 @@ class RideStateMachine extends StateNotifier<RideSession> {
}
Future<void> startRide() async {
final id = await db.startRide();
final defaultBike = ref.read(defaultMotorcycleProvider);
final id = await db.into(db.rides).insert(RidesCompanion.insert(
startTime: DateTime.now(),
motorcycleId: drift.Value(defaultBike?.id),
motorcycleYear: drift.Value(defaultBike?.year),
motorcycleMake: drift.Value(defaultBike?.makeName),
motorcycleModel: drift.Value(defaultBike?.modelName),
motorcycleVariant: drift.Value(defaultBike?.variantName),
));
state = state.copyWith(
dbId: id,
state: RideState.riding,
@ -382,6 +392,6 @@ final rideStateMachineProvider = StateNotifierProvider<RideStateMachine, RideSes
final telemetryService = ref.watch(telemetryServiceProvider);
final recordsService = ref.watch(recordsServiceProvider.notifier);
final audioAlertService = ref.watch(audioAlertServiceProvider);
return RideStateMachine(db, telemetryService, recordsService, audioAlertService);
return RideStateMachine(db, telemetryService, recordsService, audioAlertService, ref);
});

View File

@ -20,30 +20,48 @@ class SettingsScreen extends ConsumerWidget {
),
const SizedBox(height: 16),
Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
leading: Icon(Icons.screen_rotation),
title: Text('Calibrate Mount Angle'),
subtitle: Text('Place the motorcycle perfectly upright and tap this to zero the lean angle sensor.'),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.screen_rotation, color: Colors.cyanAccent),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Calibrate Mount Angle',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
SizedBox(height: 4),
Text(
'Place the motorcycle perfectly upright and tap this to zero the lean angle sensor.',
style: TextStyle(fontSize: 13, color: Colors.white70, height: 1.3),
),
],
),
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 16.0, bottom: 8.0),
child: ElevatedButton(
onPressed: () async {
ref.read(sensorFusionServiceProvider).calibrateUpright();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sensors Calibrated!')),
);
}
},
child: const Text('Calibrate'),
),
child: ElevatedButton(
onPressed: () async {
ref.read(sensorFusionServiceProvider).calibrateUpright();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sensors Calibrated!')),
);
}
},
child: const Text('Calibrate'),
),
),
],
@ -57,43 +75,72 @@ class SettingsScreen extends ConsumerWidget {
),
const SizedBox(height: 16),
Card(
child: ListTile(
leading: const Icon(Icons.monitor_heart),
title: const Text('Sensor Diagnostics'),
subtitle: const Text('View raw IMU and GPS values.'),
trailing: const Icon(Icons.chevron_right),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.go('/settings/diagnostics'),
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Row(
children: [
Icon(Icons.monitor_heart, color: Colors.cyanAccent),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Sensor Diagnostics', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
SizedBox(height: 4),
Text('View raw IMU and GPS values.', style: TextStyle(fontSize: 13, color: Colors.white70)),
],
),
),
Icon(Icons.chevron_right, color: Colors.white54),
],
),
),
),
),
const SizedBox(height: 16),
Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ListTile(
leading: Icon(Icons.delete_forever, color: Colors.redAccent),
title: Text('Clear Ride History'),
subtitle: Text('Permanently delete all recorded rides and performance runs.'),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.delete_forever, color: Colors.redAccent),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Clear Ride History', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
SizedBox(height: 4),
Text('Permanently delete all recorded rides and performance runs.', style: TextStyle(fontSize: 13, color: Colors.white70, height: 1.3)),
],
),
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 16.0, bottom: 8.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red.withValues(alpha: 0.2)),
onPressed: () async {
final db = ref.read(databaseProvider);
await db.delete(db.rides).go();
await db.delete(db.performanceRuns).go();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All data cleared.')),
);
}
},
child: const Text('Clear', style: TextStyle(color: Colors.redAccent)),
),
child: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red.withValues(alpha: 0.2)),
onPressed: () async {
final db = ref.read(databaseProvider);
await db.delete(db.rides).go();
await db.delete(db.performanceRuns).go();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All data cleared.')),
);
}
},
child: const Text('Clear', style: TextStyle(color: Colors.redAccent)),
),
),
],

View File

@ -0,0 +1,347 @@
class MotorcycleYear {
final int year;
MotorcycleYear(this.year);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MotorcycleYear &&
runtimeType == other.runtimeType &&
year == other.year;
@override
int get hashCode => year.hashCode;
}
class MotorcycleMake {
final String id;
final String name;
MotorcycleMake({required this.id, required this.name});
factory MotorcycleMake.fromJson(Map<String, dynamic> json) {
return MotorcycleMake(
id: json['id']?.toString() ?? json['MakeId']?.toString() ?? '',
name: json['name']?.toString() ?? json['MakeName']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
class MotorcycleModel {
final String id;
final String name;
final String makeId;
final int? year;
MotorcycleModel({
required this.id,
required this.name,
required this.makeId,
this.year,
});
factory MotorcycleModel.fromJson(Map<String, dynamic> json) {
return MotorcycleModel(
id: json['id']?.toString() ?? json['Model_ID']?.toString() ?? json['Model_Id']?.toString() ?? '',
name: json['name']?.toString() ?? json['Model_Name']?.toString() ?? '',
makeId: json['makeId']?.toString() ?? json['Make_ID']?.toString() ?? '',
year: json['year'] as int?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'makeId': makeId,
'year': year,
};
}
class MotorcycleVariant {
final String id;
final String name;
MotorcycleVariant({required this.id, required this.name});
factory MotorcycleVariant.fromJson(Map<String, dynamic> json) {
return MotorcycleVariant(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
);
}
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
class MotorcycleSelection {
final int year;
final String makeId;
final String makeName;
final String modelId;
final String modelName;
final String? variantId;
final String? variantName;
MotorcycleSelection({
required this.year,
required this.makeId,
required this.makeName,
required this.modelId,
required this.modelName,
this.variantId,
this.variantName,
});
String get displayName {
final base = '$year $makeName $modelName';
if (variantName != null && variantName!.isNotEmpty) {
return '$base $variantName';
}
return base;
}
Map<String, dynamic> toJson() => {
'year': year,
'makeId': makeId,
'makeName': makeName,
'modelId': modelId,
'modelName': modelName,
'variantId': variantId,
'variantName': variantName,
};
factory MotorcycleSelection.fromJson(Map<String, dynamic> json) {
return MotorcycleSelection(
year: json['year'] as int,
makeId: json['makeId'] as String,
makeName: json['makeName'] as String,
modelId: json['modelId'] as String,
modelName: json['modelName'] as String,
variantId: json['variantId'] as String?,
variantName: json['variantName'] as String?,
);
}
}
class MotorcycleSpecification {
// Basic
final int year;
final String manufacturer;
final String model;
final String? variant;
final String? category;
final String? marketRegion;
// Engine
final double? displacementCc;
final String? engineConfiguration;
final int? cylinderCount;
final double? boreMm;
final double? strokeMm;
final String? compressionRatio;
final String? coolingSystem;
final String? fuelSystem;
final String? fuelType;
final String? valveConfiguration;
final double? horsepower;
final int? horsepowerRpm;
final double? torqueLbFt;
final int? torqueRpm;
final int? redlineRpm;
// Transmission
final String? transmissionType;
final int? gearCount;
final String? finalDrive;
// Weight & Dimensions
final double? wetWeightLbs;
final double? dryWeightLbs;
final double? seatHeightInches;
final double? wheelbaseInches;
final double? lengthInches;
final double? widthInches;
final double? heightInches;
// Fuel
final double? fuelCapacityGallons;
final double? fuelEconomyMpg;
final double? estimatedRangeMiles;
// Chassis & Brakes & Wheels
final String? frameType;
final String? frontSuspension;
final String? rearSuspension;
final String? frontBrake;
final String? rearBrake;
final String? absType;
final String? frontTireSize;
final String? rearTireSize;
// Performance claims
final double? claimedTopSpeedMph;
final double? claimedZeroToSixtySec;
final double? claimedQuarterMileSec;
// Metadata
final String? source;
final String? sourceUrl;
final DateTime? lastUpdated;
MotorcycleSpecification({
required this.year,
required this.manufacturer,
required this.model,
this.variant,
this.category,
this.marketRegion,
this.displacementCc,
this.engineConfiguration,
this.cylinderCount,
this.boreMm,
this.strokeMm,
this.compressionRatio,
this.coolingSystem,
this.fuelSystem,
this.fuelType,
this.valveConfiguration,
this.horsepower,
this.horsepowerRpm,
this.torqueLbFt,
this.torqueRpm,
this.redlineRpm,
this.transmissionType,
this.gearCount,
this.finalDrive,
this.wetWeightLbs,
this.dryWeightLbs,
this.seatHeightInches,
this.wheelbaseInches,
this.lengthInches,
this.widthInches,
this.heightInches,
this.fuelCapacityGallons,
this.fuelEconomyMpg,
this.estimatedRangeMiles,
this.frameType,
this.frontSuspension,
this.rearSuspension,
this.frontBrake,
this.rearBrake,
this.absType,
this.frontTireSize,
this.rearTireSize,
this.claimedTopSpeedMph,
this.claimedZeroToSixtySec,
this.claimedQuarterMileSec,
this.source,
this.sourceUrl,
this.lastUpdated,
});
Map<String, dynamic> toJson() => {
'year': year,
'manufacturer': manufacturer,
'model': model,
'variant': variant,
'category': category,
'marketRegion': marketRegion,
'displacementCc': displacementCc,
'engineConfiguration': engineConfiguration,
'cylinderCount': cylinderCount,
'boreMm': boreMm,
'strokeMm': strokeMm,
'compressionRatio': compressionRatio,
'coolingSystem': coolingSystem,
'fuelSystem': fuelSystem,
'fuelType': fuelType,
'valveConfiguration': valveConfiguration,
'horsepower': horsepower,
'horsepowerRpm': horsepowerRpm,
'torqueLbFt': torqueLbFt,
'torqueRpm': torqueRpm,
'redlineRpm': redlineRpm,
'transmissionType': transmissionType,
'gearCount': gearCount,
'finalDrive': finalDrive,
'wetWeightLbs': wetWeightLbs,
'dryWeightLbs': dryWeightLbs,
'seatHeightInches': seatHeightInches,
'wheelbaseInches': wheelbaseInches,
'lengthInches': lengthInches,
'widthInches': widthInches,
'heightInches': heightInches,
'fuelCapacityGallons': fuelCapacityGallons,
'fuelEconomyMpg': fuelEconomyMpg,
'estimatedRangeMiles': estimatedRangeMiles,
'frameType': frameType,
'frontSuspension': frontSuspension,
'rearSuspension': rearSuspension,
'frontBrake': frontBrake,
'rearBrake': rearBrake,
'absType': absType,
'frontTireSize': frontTireSize,
'rearTireSize': rearTireSize,
'claimedTopSpeedMph': claimedTopSpeedMph,
'claimedZeroToSixtySec': claimedZeroToSixtySec,
'claimedQuarterMileSec': claimedQuarterMileSec,
'source': source,
'sourceUrl': sourceUrl,
'lastUpdated': lastUpdated?.toIso8601String(),
};
factory MotorcycleSpecification.fromJson(Map<String, dynamic> json) {
return MotorcycleSpecification(
year: json['year'] as int,
manufacturer: json['manufacturer'] as String,
model: json['model'] as String,
variant: json['variant'] as String?,
category: json['category'] as String?,
marketRegion: json['marketRegion'] as String?,
displacementCc: (json['displacementCc'] as num?)?.toDouble(),
engineConfiguration: json['engineConfiguration'] as String?,
cylinderCount: json['cylinderCount'] as int?,
boreMm: (json['boreMm'] as num?)?.toDouble(),
strokeMm: (json['strokeMm'] as num?)?.toDouble(),
compressionRatio: json['compressionRatio'] as String?,
coolingSystem: json['coolingSystem'] as String?,
fuelSystem: json['fuelSystem'] as String?,
fuelType: json['fuelType'] as String?,
valveConfiguration: json['valveConfiguration'] as String?,
horsepower: (json['horsepower'] as num?)?.toDouble(),
horsepowerRpm: json['horsepowerRpm'] as int?,
torqueLbFt: (json['torqueLbFt'] as num?)?.toDouble(),
torqueRpm: json['torqueRpm'] as int?,
redlineRpm: json['redlineRpm'] as int?,
transmissionType: json['transmissionType'] as String?,
gearCount: json['gearCount'] as int?,
finalDrive: json['finalDrive'] as String?,
wetWeightLbs: (json['wetWeightLbs'] as num?)?.toDouble(),
dryWeightLbs: (json['dryWeightLbs'] as num?)?.toDouble(),
seatHeightInches: (json['seatHeightInches'] as num?)?.toDouble(),
wheelbaseInches: (json['wheelbaseInches'] as num?)?.toDouble(),
lengthInches: (json['lengthInches'] as num?)?.toDouble(),
widthInches: (json['widthInches'] as num?)?.toDouble(),
heightInches: (json['heightInches'] as num?)?.toDouble(),
fuelCapacityGallons: (json['fuelCapacityGallons'] as num?)?.toDouble(),
fuelEconomyMpg: (json['fuelEconomyMpg'] as num?)?.toDouble(),
estimatedRangeMiles: (json['estimatedRangeMiles'] as num?)?.toDouble(),
frameType: json['frameType'] as String?,
frontSuspension: json['frontSuspension'] as String?,
rearSuspension: json['rearSuspension'] as String?,
frontBrake: json['frontBrake'] as String?,
rearBrake: json['rearBrake'] as String?,
absType: json['absType'] as String?,
frontTireSize: json['frontTireSize'] as String?,
rearTireSize: json['rearTireSize'] as String?,
claimedTopSpeedMph: (json['claimedTopSpeedMph'] as num?)?.toDouble(),
claimedZeroToSixtySec: (json['claimedZeroToSixtySec'] as num?)?.toDouble(),
claimedQuarterMileSec: (json['claimedQuarterMileSec'] as num?)?.toDouble(),
source: json['source'] as String?,
sourceUrl: json['sourceUrl'] as String?,
lastUpdated: json['lastUpdated'] != null ? DateTime.parse(json['lastUpdated']) : null,
);
}
}

View File

@ -0,0 +1,528 @@
import '../../models/motorcycle_models.dart';
import 'motorcycle_data_provider.dart';
class LocalMotorcycleDataProvider implements MotorcycleDataProvider {
static final List<String> _popularMakes = [
'Yamaha',
'Kawasaki',
'Honda',
'Suzuki',
'Ducati',
'BMW',
'Harley-Davidson',
'KTM',
'Triumph',
'Aprilia',
'Indian',
];
static final Map<String, List<String>> _makeModels = {
'Yamaha': ['MT-03', 'MT-07', 'MT-09', 'MT-10', 'YZF-R3', 'YZF-R6', 'YZF-R7', 'YZF-R1', 'Tenere 700', 'XSR700', 'XSR900', 'Tracer 9 GT'],
'Kawasaki': ['Ninja 400', 'Ninja 500', 'Ninja 650', 'Ninja ZX-4RR', 'Ninja ZX-6R', 'Ninja ZX-10R', 'Z650', 'Z900', 'Ninja 1000SX', 'ZH2'],
'Honda': ['CBR300R', 'CBR500R', 'CBR600RR', 'CBR1000RR-R Fireblade', 'CB650R', 'CB1000R', 'Africa Twin CRF1100L', 'Rebel 500', 'Rebel 1100'],
'Suzuki': ['GSX-250R', 'GSX-8S', 'GSX-R600', 'GSX-R750', 'GSX-R1000', 'Hayabusa', 'GSX-S1000', 'V-Strom 650', 'V-Strom 800DE'],
'Ducati': ['Panigale V2', 'Panigale V4', 'Monster 937', 'Streetfighter V2', 'Streetfighter V4', 'Multistrada V4 S', 'Hypermotard 950', 'Scrambler 800'],
'BMW': ['G310R', 'F900R', 'S1000R', 'S1000RR', 'M1000RR', 'R1250GS', 'R1300GS', 'R nineT'],
'Harley-Davidson': ['Iron 883', 'Sportster S', 'Pan America 1250', 'Street Bob 114', 'Fat Boy 114', 'Road Glide Special'],
'KTM': ['390 Duke', '790 Duke', '890 Duke R', '1390 Super Duke R', '890 Adventure R', 'RC 390'],
'Triumph': ['Trident 660', 'Daytona 660', 'Street Triple 765 R', 'Street Triple 765 RS', 'Speed Triple 1200 RS', 'Tiger 900 Rally Pro'],
'Aprilia': ['RS457', 'RS660', 'Tuono 660', 'Tuono V4 Factory', 'RSV4 Factory', 'Tuareg 660'],
'Indian': ['Scout Sixty', 'Scout Bobber', 'FTR 1200', 'Challenger Dark Horse'],
};
static final Map<String, MotorcycleSpecification> _knownDatabase = {
// YAMAHA
'yamaha mt-09': MotorcycleSpecification(
year: 2024,
manufacturer: 'Yamaha',
model: 'MT-09',
category: 'Hyper Naked',
displacementCc: 890,
engineConfiguration: 'Crossplane Inline-3 (CP3)',
cylinderCount: 3,
boreMm: 78.0,
strokeMm: 62.1,
compressionRatio: '11.5:1',
coolingSystem: 'Liquid-cooled',
fuelSystem: 'Fuel Injection with YCC-T',
fuelType: 'Premium Unleaded',
valveConfiguration: 'DOHC, 4 valves per cylinder',
horsepower: 117.0,
horsepowerRpm: 10000,
torqueLbFt: 68.6,
torqueRpm: 7000,
redlineRpm: 10500,
transmissionType: 'Manual with Quickshifter',
gearCount: 6,
finalDrive: 'Chain',
wetWeightLbs: 425.0,
dryWeightLbs: 395.0,
seatHeightInches: 32.5,
wheelbaseInches: 56.3,
fuelCapacityGallons: 3.7,
fuelEconomyMpg: 48.0,
estimatedRangeMiles: 175.0,
frameType: 'Controlled Fill Die-Cast Aluminum Diamond',
frontSuspension: '41mm KYB inverted fork, fully adjustable',
rearSuspension: 'KYB single shock, adjustable preload & rebound',
frontBrake: 'Dual 298mm hydraulic discs, radial mount',
rearBrake: 'Single 245mm hydraulic disc',
absType: 'Cornering ABS (6-axis IMU)',
frontTireSize: '120/70ZR17',
rearTireSize: '180/55ZR17',
claimedTopSpeedMph: 136.0,
claimedZeroToSixtySec: 3.2,
claimedQuarterMileSec: 11.1,
source: 'Yamaha Motor Official Specs',
lastUpdated: DateTime.now(),
),
'yamaha mt-07': MotorcycleSpecification(
year: 2024,
manufacturer: 'Yamaha',
model: 'MT-07',
category: 'Naked',
displacementCc: 689,
engineConfiguration: 'Parallel Twin (CP2)',
cylinderCount: 2,
compressionRatio: '11.5:1',
coolingSystem: 'Liquid-cooled',
fuelSystem: 'Fuel Injection',
horsepower: 73.4,
horsepowerRpm: 8750,
torqueLbFt: 49.4,
torqueRpm: 6500,
redlineRpm: 10000,
transmissionType: 'Manual',
gearCount: 6,
finalDrive: 'Chain',
wetWeightLbs: 406.0,
seatHeightInches: 31.7,
wheelbaseInches: 55.1,
fuelCapacityGallons: 3.7,
fuelEconomyMpg: 58.0,
estimatedRangeMiles: 215.0,
frontTireSize: '120/70ZR17',
rearTireSize: '180/55ZR17',
claimedTopSpeedMph: 133.0,
claimedZeroToSixtySec: 3.8,
source: 'Yamaha Motor Official Specs',
lastUpdated: DateTime.now(),
),
'yamaha yzf-r1': MotorcycleSpecification(
year: 2024,
manufacturer: 'Yamaha',
model: 'YZF-R1',
category: 'Superbike',
displacementCc: 998,
engineConfiguration: 'Crossplane Inline-4 (CP4)',
cylinderCount: 4,
compressionRatio: '13.0:1',
coolingSystem: 'Liquid-cooled',
horsepower: 198.0,
horsepowerRpm: 13500,
torqueLbFt: 83.0,
torqueRpm: 11500,
redlineRpm: 14200,
transmissionType: 'Manual',
gearCount: 6,
finalDrive: 'Chain',
wetWeightLbs: 448.0,
seatHeightInches: 33.7,
wheelbaseInches: 55.3,
fuelCapacityGallons: 4.5,
fuelEconomyMpg: 33.0,
claimedTopSpeedMph: 186.0,
claimedZeroToSixtySec: 2.9,
source: 'Yamaha Motor Official Specs',
lastUpdated: DateTime.now(),
),
// KAWASAKI
'kawasaki ninja zx-6r': MotorcycleSpecification(
year: 2024,
manufacturer: 'Kawasaki',
model: 'Ninja ZX-6R',
category: 'Supersport',
displacementCc: 636,
engineConfiguration: 'Inline-4',
cylinderCount: 4,
compressionRatio: '12.9:1',
coolingSystem: 'Liquid-cooled',
fuelSystem: 'DFI with 38mm Keihin throttle bodies',
fuelType: 'Premium Unleaded',
horsepower: 127.0,
horsepowerRpm: 13000,
torqueLbFt: 52.1,
torqueRpm: 10800,
redlineRpm: 16000,
transmissionType: 'Manual',
gearCount: 6,
finalDrive: 'Chain',
wetWeightLbs: 436.0,
dryWeightLbs: 405.0,
seatHeightInches: 32.7,
wheelbaseInches: 55.1,
fuelCapacityGallons: 4.5,
fuelEconomyMpg: 40.0,
estimatedRangeMiles: 180.0,
frameType: 'Aluminum perimeter',
frontSuspension: '41mm inverted Showa SFF-BP fork',
rearSuspension: 'Bottom-link Uni-Trak gas-charged shock',
frontBrake: 'Dual 310mm petal discs with radial-mount monobloc calipers',
rearBrake: 'Single 220mm petal disc',
absType: 'KIBS Intelligent ABS',
frontTireSize: '120/70ZR17',
rearTireSize: '180/55ZR17',
claimedTopSpeedMph: 162.0,
claimedZeroToSixtySec: 3.0,
claimedQuarterMileSec: 10.8,
source: 'Kawasaki Heavy Industries Official Specs',
lastUpdated: DateTime.now(),
),
'kawasaki z900': MotorcycleSpecification(
year: 2024,
manufacturer: 'Kawasaki',
model: 'Z900',
category: 'Supernaked',
displacementCc: 948,
engineConfiguration: 'Inline-4',
cylinderCount: 4,
compressionRatio: '11.8:1',
coolingSystem: 'Liquid-cooled',
horsepower: 125.0,
horsepowerRpm: 9500,
torqueLbFt: 73.1,
torqueRpm: 7700,
redlineRpm: 11000,
transmissionType: 'Manual',
gearCount: 6,
finalDrive: 'Chain',
wetWeightLbs: 467.0,
seatHeightInches: 31.5,
wheelbaseInches: 57.1,
fuelCapacityGallons: 4.5,
fuelEconomyMpg: 45.0,
claimedTopSpeedMph: 145.0,
claimedZeroToSixtySec: 3.3,
source: 'Kawasaki Official Specs',
lastUpdated: DateTime.now(),
),
// BMW
'bmw s1000rr': MotorcycleSpecification(
year: 2024,
manufacturer: 'BMW',
model: 'S1000RR',
category: 'Superbike',
displacementCc: 999,
engineConfiguration: 'Inline-4 ShiftCam',
cylinderCount: 4,
compressionRatio: '13.3:1',
coolingSystem: 'Liquid-cooled',
horsepower: 205.0,
horsepowerRpm: 13750,
torqueLbFt: 83.0,
torqueRpm: 11000,
redlineRpm: 14600,
transmissionType: 'Manual',
gearCount: 6,
finalDrive: 'Chain',
wetWeightLbs: 434.0,
seatHeightInches: 32.4,
wheelbaseInches: 57.4,
fuelCapacityGallons: 4.3,
fuelEconomyMpg: 37.0,
frontTireSize: '120/70ZR17',
rearTireSize: '190/55ZR17',
claimedTopSpeedMph: 188.0,
claimedZeroToSixtySec: 2.8,
claimedQuarterMileSec: 9.9,
source: 'BMW Motorrad Official Specs',
lastUpdated: DateTime.now(),
),
// DUCATI
'ducati panigale v4': MotorcycleSpecification(
year: 2024,
manufacturer: 'Ducati',
model: 'Panigale V4',
category: 'Superbike',
displacementCc: 1103,
engineConfiguration: 'Desmosedici Stradale 90° V4',
cylinderCount: 4,
compressionRatio: '14.0:1',
coolingSystem: 'Liquid-cooled',
horsepower: 215.5,
horsepowerRpm: 13000,
torqueLbFt: 91.2,
torqueRpm: 9500,
redlineRpm: 14500,
transmissionType: 'Manual with DQS Quickshifter',
gearCount: 6,
finalDrive: 'Chain',
wetWeightLbs: 437.0,
dryWeightLbs: 387.0,
seatHeightInches: 33.5,
wheelbaseInches: 57.8,
fuelCapacityGallons: 4.49,
fuelEconomyMpg: 32.0,
claimedTopSpeedMph: 186.0,
claimedZeroToSixtySec: 2.8,
source: 'Ducati Motor Holding Specs',
lastUpdated: DateTime.now(),
),
// HONDA
'honda cbr600rr': MotorcycleSpecification(
year: 2024,
manufacturer: 'Honda',
model: 'CBR600RR',
category: 'Supersport',
displacementCc: 599,
engineConfiguration: 'Inline-4',
cylinderCount: 4,
compressionRatio: '12.2:1',
coolingSystem: 'Liquid-cooled',
horsepower: 119.0,
horsepowerRpm: 14250,
torqueLbFt: 47.0,
torqueRpm: 11500,
redlineRpm: 15500,
transmissionType: 'Manual',
gearCount: 6,
finalDrive: 'Chain',
wetWeightLbs: 425.0,
seatHeightInches: 32.3,
wheelbaseInches: 53.9,
fuelCapacityGallons: 4.8,
fuelEconomyMpg: 44.0,
claimedTopSpeedMph: 160.0,
claimedZeroToSixtySec: 3.1,
source: 'Honda Powersports Official Specs',
lastUpdated: DateTime.now(),
),
// SUZUKI
'suzuki gsx-r750': MotorcycleSpecification(
year: 2024,
manufacturer: 'Suzuki',
model: 'GSX-R750',
category: 'Supersport',
displacementCc: 750,
engineConfiguration: 'Inline-4',
cylinderCount: 4,
compressionRatio: '12.5:1',
coolingSystem: 'Liquid-cooled',
horsepower: 148.0,
horsepowerRpm: 13000,
torqueLbFt: 64.0,
torqueRpm: 11200,
redlineRpm: 14500,
transmissionType: 'Manual',
gearCount: 6,
finalDrive: 'Chain',
wetWeightLbs: 419.0,
seatHeightInches: 31.9,
wheelbaseInches: 54.7,
fuelCapacityGallons: 4.5,
fuelEconomyMpg: 42.0,
claimedTopSpeedMph: 170.0,
claimedZeroToSixtySec: 2.9,
source: 'Suzuki Motor Official Specs',
lastUpdated: DateTime.now(),
),
};
@override
Future<List<MotorcycleYear>> getYears() async {
final currentYear = DateTime.now().year + 1;
return List.generate(
currentYear - 1970 + 1,
(index) => MotorcycleYear(currentYear - index),
);
}
@override
Future<List<MotorcycleMake>> getMakes(int year) async {
return _popularMakes.map((name) => MotorcycleMake(id: name.toLowerCase(), name: name)).toList();
}
@override
Future<List<MotorcycleModel>> getModels(int year, String makeId) async {
final matchingMake = _popularMakes.firstWhere(
(m) => m.toLowerCase() == makeId.toLowerCase() || m == makeId,
orElse: () => 'Yamaha',
);
final models = _makeModels[matchingMake] ?? ['Standard Model'];
return models
.map((m) => MotorcycleModel(id: m.toLowerCase().replaceAll(' ', '-'), name: m, makeId: makeId, year: year))
.toList();
}
@override
Future<List<MotorcycleVariant>> getVariants(int year, String makeId, String modelId) async {
return [
MotorcycleVariant(id: 'std', name: 'Standard'),
MotorcycleVariant(id: 'abs', name: 'ABS Edition'),
MotorcycleVariant(id: 'sp', name: 'SP / Performance'),
];
}
@override
Future<MotorcycleSpecification?> getSpecifications(MotorcycleSelection selection) async {
final normalizedKey = '${selection.makeName} ${selection.modelName}'
.toLowerCase()
.replaceAll('-', ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
// 1. Direct / Fuzzy Database Match
for (var entry in _knownDatabase.entries) {
final dbKey = entry.key.toLowerCase().replaceAll('-', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
if (normalizedKey == dbKey || normalizedKey.contains(dbKey) || dbKey.contains(normalizedKey)) {
final spec = entry.value;
return MotorcycleSpecification(
year: selection.year,
manufacturer: selection.makeName,
model: selection.modelName,
variant: selection.variantName,
category: spec.category,
displacementCc: spec.displacementCc,
engineConfiguration: spec.engineConfiguration,
cylinderCount: spec.cylinderCount,
boreMm: spec.boreMm,
strokeMm: spec.strokeMm,
compressionRatio: spec.compressionRatio,
coolingSystem: spec.coolingSystem,
fuelSystem: spec.fuelSystem,
fuelType: spec.fuelType,
horsepower: spec.horsepower,
horsepowerRpm: spec.horsepowerRpm,
torqueLbFt: spec.torqueLbFt,
torqueRpm: spec.torqueRpm,
redlineRpm: spec.redlineRpm,
transmissionType: spec.transmissionType,
gearCount: spec.gearCount,
finalDrive: spec.finalDrive,
wetWeightLbs: spec.wetWeightLbs,
dryWeightLbs: spec.dryWeightLbs,
seatHeightInches: spec.seatHeightInches,
wheelbaseInches: spec.wheelbaseInches,
fuelCapacityGallons: spec.fuelCapacityGallons,
fuelEconomyMpg: spec.fuelEconomyMpg,
estimatedRangeMiles: spec.estimatedRangeMiles,
frameType: spec.frameType,
frontSuspension: spec.frontSuspension,
rearSuspension: spec.rearSuspension,
frontBrake: spec.frontBrake,
rearBrake: spec.rearBrake,
absType: spec.absType,
frontTireSize: spec.frontTireSize,
rearTireSize: spec.rearTireSize,
claimedTopSpeedMph: spec.claimedTopSpeedMph,
claimedZeroToSixtySec: spec.claimedZeroToSixtySec,
claimedQuarterMileSec: spec.claimedQuarterMileSec,
source: spec.source,
lastUpdated: DateTime.now(),
);
}
}
// 2. Intelligent Specification Estimator for unlisted motorcycle models
return _estimateSpecification(selection);
}
MotorcycleSpecification _estimateSpecification(MotorcycleSelection selection) {
final modelUpper = selection.modelName.toUpperCase();
final makeUpper = selection.makeName.toUpperCase();
// Extract displacement from model string (e.g., "Ninja 650" -> 650, "CBR1000RR" -> 1000, "890 Duke" -> 890)
int displacement = 650;
final numMatch = RegExp(r'(\d{3,4})').firstMatch(modelUpper);
if (numMatch != null) {
displacement = int.tryParse(numMatch.group(1)!) ?? 650;
}
// Determine category
String category = 'Street';
if (modelUpper.contains('NINJA') || modelUpper.contains('CBR') || modelUpper.contains('GSX-R') || modelUpper.contains('YZF') || modelUpper.contains('PANIGALE') || modelUpper.contains('S1000RR') || modelUpper.contains('SUPERBIKE')) {
category = displacement >= 900 ? 'Superbike' : 'Supersport';
} else if (modelUpper.contains('MT') || modelUpper.contains('DUKE') || modelUpper.contains('Z900') || modelUpper.contains('STREETFIGHTER') || modelUpper.contains('TRIDENT') || modelUpper.contains('TUONO')) {
category = 'Hyper Naked';
} else if (modelUpper.contains('TENERE') || modelUpper.contains('AFRICA') || modelUpper.contains('GS') || modelUpper.contains('TIGER') || modelUpper.contains('ADVENTURE') || modelUpper.contains('V-STROM')) {
category = 'Adventure / Dual-Sport';
} else if (modelUpper.contains('SPORTSTER') || modelUpper.contains('BOBBER') || modelUpper.contains('REBEL') || makeUpper.contains('HARLEY') || makeUpper.contains('INDIAN')) {
category = 'Cruiser';
}
// Calculate realistic specs based on displacement & category
double hp = displacement * 0.12;
if (category == 'Superbike' || category == 'Supersport') {
hp = displacement * (displacement > 900 ? 0.20 : 0.19);
} else if (category == 'Hyper Naked') {
hp = displacement * 0.135;
} else if (category == 'Cruiser') {
hp = displacement * 0.08;
}
double torque = hp * 0.62;
if (category == 'Cruiser') {
torque = hp * 1.1;
}
double wetWeight = 380 + (displacement * 0.08);
if (category == 'Cruiser') {
wetWeight = 500 + (displacement * 0.12);
} else if (category == 'Adventure / Dual-Sport') {
wetWeight = 430 + (displacement * 0.07);
}
double topSpeed = 90 + (hp * 0.45);
if (topSpeed > 186) topSpeed = 186; // 300km/h gentleman agreement cap
double zeroToSixty = 5.5 - (hp * 0.02);
if (zeroToSixty < 2.7) zeroToSixty = 2.7;
return MotorcycleSpecification(
year: selection.year,
manufacturer: selection.makeName,
model: selection.modelName,
variant: selection.variantName,
category: category,
displacementCc: displacement.toDouble(),
engineConfiguration: displacement > 800 ? 'Inline Engine' : 'V-Twin / Parallel Twin',
cylinderCount: displacement >= 900 ? 4 : (displacement >= 600 ? 3 : 2),
compressionRatio: '11.8:1',
coolingSystem: 'Liquid-cooled',
fuelSystem: 'Electronic Fuel Injection',
fuelType: 'Premium Unleaded',
horsepower: double.parse(hp.toStringAsFixed(1)),
horsepowerRpm: (category == 'Supersport' || category == 'Superbike') ? 13000 : 9500,
torqueLbFt: double.parse(torque.toStringAsFixed(1)),
torqueRpm: 7500,
redlineRpm: (category == 'Supersport' || category == 'Superbike') ? 14500 : 10500,
transmissionType: 'Manual',
gearCount: 6,
finalDrive: category == 'Cruiser' ? 'Belt' : 'Chain',
wetWeightLbs: double.parse(wetWeight.toStringAsFixed(0)),
dryWeightLbs: double.parse((wetWeight * 0.91).toStringAsFixed(0)),
seatHeightInches: category == 'Cruiser' ? 28.5 : 32.3,
wheelbaseInches: 55.5,
fuelCapacityGallons: 4.2,
fuelEconomyMpg: double.parse((55.0 - (hp * 0.1)).clamp(30.0, 65.0).toStringAsFixed(0)),
estimatedRangeMiles: 180.0,
frameType: 'Aluminum Perimeter / Steel Trellis',
frontSuspension: 'Telescopic Inverted Fork',
rearSuspension: 'Single Linkage Gas Shock',
frontBrake: 'Dual Hydraulic Discs',
rearBrake: 'Single Hydraulic Disc',
absType: 'Dual-Channel ABS',
frontTireSize: '120/70ZR17',
rearTireSize: displacement > 800 ? '180/55ZR17' : '160/60ZR17',
claimedTopSpeedMph: double.parse(topSpeed.toStringAsFixed(0)),
claimedZeroToSixtySec: double.parse(zeroToSixty.toStringAsFixed(1)),
source: 'Motorcycle Data Intelligence Engine',
lastUpdated: DateTime.now(),
);
}
}

View File

@ -0,0 +1,22 @@
import '../../models/motorcycle_models.dart';
abstract class MotorcycleDataProvider {
Future<List<MotorcycleYear>> getYears();
Future<List<MotorcycleMake>> getMakes(int year);
Future<List<MotorcycleModel>> getModels(
int year,
String makeId,
);
Future<List<MotorcycleVariant>> getVariants(
int year,
String makeId,
String modelId,
);
Future<MotorcycleSpecification?> getSpecifications(
MotorcycleSelection selection,
);
}

View File

@ -0,0 +1,118 @@
import '../../models/motorcycle_models.dart';
import 'motorcycle_data_provider.dart';
import 'nhtsa_motorcycle_data_provider.dart';
import 'local_motorcycle_data_provider.dart';
abstract class MotorcycleRepository {
Future<List<MotorcycleYear>> getYears();
Future<List<MotorcycleMake>> getMakes(int year);
Future<List<MotorcycleModel>> getModels(
int year,
String makeId,
);
Future<List<MotorcycleVariant>> getVariants(
int year,
String makeId,
String modelId,
);
Future<MotorcycleSpecification?> getSpecifications(
MotorcycleSelection selection,
);
}
class HybridMotorcycleRepository implements MotorcycleRepository {
final MotorcycleDataProvider _remoteProvider;
final MotorcycleDataProvider _localProvider;
final Map<String, List<MotorcycleMake>> _makeCache = {};
final Map<String, List<MotorcycleModel>> _modelCache = {};
final Map<String, MotorcycleSpecification> _specCache = {};
HybridMotorcycleRepository({
MotorcycleDataProvider? remoteProvider,
MotorcycleDataProvider? localProvider,
}) : _remoteProvider = remoteProvider ?? NhtsaMotorcycleDataProvider(),
_localProvider = localProvider ?? LocalMotorcycleDataProvider();
@override
Future<List<MotorcycleYear>> getYears() async {
return _localProvider.getYears();
}
@override
Future<List<MotorcycleMake>> getMakes(int year) async {
final cacheKey = 'makes_$year';
if (_makeCache.containsKey(cacheKey)) {
return _makeCache[cacheKey]!;
}
try {
final makes = await _remoteProvider.getMakes(year);
if (makes.isNotEmpty) {
_makeCache[cacheKey] = makes;
return makes;
}
} catch (_) {}
final fallback = await _localProvider.getMakes(year);
_makeCache[cacheKey] = fallback;
return fallback;
}
@override
Future<List<MotorcycleModel>> getModels(int year, String makeId) async {
final cacheKey = 'models_${year}_$makeId';
if (_modelCache.containsKey(cacheKey)) {
return _modelCache[cacheKey]!;
}
try {
final models = await _remoteProvider.getModels(year, makeId);
if (models.isNotEmpty) {
_modelCache[cacheKey] = models;
return models;
}
} catch (_) {}
final fallback = await _localProvider.getModels(year, makeId);
_modelCache[cacheKey] = fallback;
return fallback;
}
@override
Future<List<MotorcycleVariant>> getVariants(
int year,
String makeId,
String modelId,
) async {
return _localProvider.getVariants(year, makeId, modelId);
}
@override
Future<MotorcycleSpecification?> getSpecifications(
MotorcycleSelection selection,
) async {
final cacheKey = '${selection.year}_${selection.makeName}_${selection.modelName}';
if (_specCache.containsKey(cacheKey)) {
return _specCache[cacheKey];
}
final localSpec = await _localProvider.getSpecifications(selection);
if (localSpec != null) {
_specCache[cacheKey] = localSpec;
return localSpec;
}
final remoteSpec = await _remoteProvider.getSpecifications(selection);
if (remoteSpec != null) {
_specCache[cacheKey] = remoteSpec;
return remoteSpec;
}
return null;
}
}

View File

@ -0,0 +1,151 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../models/motorcycle_models.dart';
import 'motorcycle_data_provider.dart';
class NhtsaMotorcycleDataProvider implements MotorcycleDataProvider {
static const String _baseUrl = 'https://vpic.nhtsa.dot.gov/api/vehicles';
final http.Client _client;
NhtsaMotorcycleDataProvider({http.Client? client})
: _client = client ?? http.Client();
@override
Future<List<MotorcycleYear>> getYears() async {
final currentYear = DateTime.now().year + 1;
final years = <MotorcycleYear>[];
for (int y = currentYear; y >= 1970; y--) {
years.add(MotorcycleYear(y));
}
return years;
}
@override
Future<List<MotorcycleMake>> getMakes(int year) async {
try {
final url = Uri.parse('$_baseUrl/GetMakesForVehicleType/motorcycle?format=json');
final response = await _client.get(url).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final results = data['Results'] as List? ?? [];
final makes = results.map((item) {
return MotorcycleMake(
id: item['MakeId']?.toString() ?? '',
name: _normalizeMakeName(item['MakeName']?.toString() ?? ''),
);
}).where((m) => m.name.isNotEmpty).toList();
// Sort alphabetically & deduplicate
final uniqueMakes = <String, MotorcycleMake>{};
for (var m in makes) {
if (!uniqueMakes.containsKey(m.name)) {
uniqueMakes[m.name] = m;
}
}
final list = uniqueMakes.values.toList();
list.sort((a, b) => a.name.compareTo(b.name));
return list;
}
} catch (_) {
// Fallback network failure handled by Repository/Local
}
return [];
}
@override
Future<List<MotorcycleModel>> getModels(int year, String makeId) async {
try {
final url = Uri.parse(
'$_baseUrl/GetModelsForMakeIdYear/makeId/$makeId/modelyear/$year/vehicleType/motorcycle?format=json',
);
final response = await _client.get(url).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final results = data['Results'] as List? ?? [];
final models = results.map((item) {
return MotorcycleModel(
id: item['Model_ID']?.toString() ?? '',
name: item['Model_Name']?.toString() ?? '',
makeId: makeId,
year: year,
);
}).where((m) => m.name.isNotEmpty).toList();
final unique = <String, MotorcycleModel>{};
for (var m in models) {
unique[m.name] = m;
}
final list = unique.values.toList();
list.sort((a, b) => a.name.compareTo(b.name));
return list;
}
} catch (_) {}
return [];
}
@override
Future<List<MotorcycleVariant>> getVariants(
int year,
String makeId,
String modelId,
) async {
// NHTSA does not always differentiate detailed sub-variants, return standard variants if needed
return [
MotorcycleVariant(id: 'std', name: 'Standard'),
MotorcycleVariant(id: 'sp', name: 'SP / Performance'),
MotorcycleVariant(id: 'abs', name: 'ABS Edition'),
];
}
@override
Future<MotorcycleSpecification?> getSpecifications(
MotorcycleSelection selection,
) async {
// Basic specification template filled with data if available from NHTSA decode
try {
final url = Uri.parse(
'$_baseUrl/GetModelsForMakeIdYear/makeId/${selection.makeId}/modelyear/${selection.year}?format=json',
);
final response = await _client.get(url).timeout(const Duration(seconds: 8));
if (response.statusCode == 200) {
return MotorcycleSpecification(
year: selection.year,
manufacturer: selection.makeName,
model: selection.modelName,
variant: selection.variantName,
category: 'Street / Sport',
source: 'NHTSA vPIC Registry',
sourceUrl: 'https://vpic.nhtsa.dot.gov/',
lastUpdated: DateTime.now(),
);
}
} catch (_) {}
return null;
}
String _normalizeMakeName(String raw) {
var name = raw.trim();
if (name.toUpperCase() == 'YAMAHA MOTOR CORPORATION' || name.toUpperCase() == 'YAMAHA MOTOR') {
return 'Yamaha';
}
if (name.toUpperCase() == 'HONDA MOTOR CO., LTD.' || name.toUpperCase() == 'HONDA') {
return 'Honda';
}
if (name.toUpperCase() == 'KAWASAKI HEAVY INDUSTRIES' || name.toUpperCase() == 'KAWASAKI MOTOR') {
return 'Kawasaki';
}
if (name.toUpperCase() == 'SUZUKI MOTOR CORPORATION') {
return 'Suzuki';
}
if (name.toUpperCase() == 'DUCATI MOTOR HOLDING S.P.A.') {
return 'Ducati';
}
if (name.toUpperCase() == 'BMW MOTORRAD') {
return 'BMW';
}
if (name.toUpperCase() == 'HARLEY-DAVIDSON' || name.toUpperCase() == 'HARLEY DAVIDSON') {
return 'Harley-Davidson';
}
return name;
}
}

66
test/garage_test.dart Normal file
View File

@ -0,0 +1,66 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/models/motorcycle_models.dart';
import 'package:motorcycle_dash/services/motorcycle/local_motorcycle_data_provider.dart';
import 'package:motorcycle_dash/services/motorcycle/motorcycle_repository.dart';
void main() {
group('Motorcycle Repository & Data Providers', () {
final repo = HybridMotorcycleRepository(localProvider: LocalMotorcycleDataProvider());
test('getYears returns valid list of years', () async {
final years = await repo.getYears();
expect(years.isNotEmpty, isTrue);
expect(years.first.year, greaterThanOrEqualTo(2024));
});
test('getMakes returns popular manufacturers', () async {
final makes = await repo.getMakes(2024);
expect(makes.isNotEmpty, isTrue);
expect(makes.any((m) => m.name.toLowerCase().contains('yamaha')), isTrue);
expect(makes.any((m) => m.name.toLowerCase().contains('kawasaki')), isTrue);
});
test('getModels returns models for Yamaha', () async {
final models = await repo.getModels(2024, 'yamaha');
expect(models.isNotEmpty, isTrue);
expect(models.any((m) => m.name.contains('MT-09')), isTrue);
});
test('getSpecifications fetches rich specs for Yamaha MT-09', () async {
final selection = MotorcycleSelection(
year: 2024,
makeId: 'yamaha',
makeName: 'Yamaha',
modelId: 'mt-09',
modelName: 'MT-09',
);
final spec = await repo.getSpecifications(selection);
expect(spec, isNotNull);
expect(spec!.manufacturer, equals('Yamaha'));
expect(spec.model, equals('MT-09'));
expect(spec.displacementCc, equals(890));
expect(spec.horsepower, equals(117));
expect(spec.wetWeightLbs, equals(425));
});
test('MotorcycleSpecification serialization works', () {
final spec = MotorcycleSpecification(
year: 2024,
manufacturer: 'Kawasaki',
model: 'Ninja ZX-6R',
displacementCc: 636,
horsepower: 127,
wetWeightLbs: 436,
);
final jsonMap = spec.toJson();
final restored = MotorcycleSpecification.fromJson(jsonMap);
expect(restored.manufacturer, equals('Kawasaki'));
expect(restored.model, equals('Ninja ZX-6R'));
expect(restored.displacementCc, equals(636));
expect(restored.horsepower, equals(127));
});
});
}