From 7e8b909ff4ff18a3f5aa07912f5908ab73e00b3c Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 27 Aug 2026 03:28:11 -0400 Subject: [PATCH] OpenStreetMap-Pre-Implementation --- lib/core/router.dart | 24 + lib/core/widgets/main_layout.dart | 4 + lib/database/database.dart | 12 +- lib/database/database.g.dart | 1530 ++++++++++++++++- lib/database/tables.dart | 23 + .../garage/add_motorcycle_screen.dart | 447 +++++ lib/features/garage/garage_providers.dart | 169 ++ lib/features/garage/garage_screen.dart | 204 +++ .../garage/motorcycle_detail_screen.dart | 430 +++++ lib/features/history/history_screen.dart | 269 ++- lib/features/history/ride_detail_screen.dart | 37 + lib/features/rides/ride_state_machine.dart | 16 +- lib/features/settings/settings_screen.dart | 135 +- lib/models/motorcycle_models.dart | 347 ++++ .../local_motorcycle_data_provider.dart | 528 ++++++ .../motorcycle/motorcycle_data_provider.dart | 22 + .../motorcycle/motorcycle_repository.dart | 118 ++ .../nhtsa_motorcycle_data_provider.dart | 151 ++ test/garage_test.dart | 66 + 19 files changed, 4393 insertions(+), 139 deletions(-) create mode 100644 lib/features/garage/add_motorcycle_screen.dart create mode 100644 lib/features/garage/garage_providers.dart create mode 100644 lib/features/garage/garage_screen.dart create mode 100644 lib/features/garage/motorcycle_detail_screen.dart create mode 100644 lib/models/motorcycle_models.dart create mode 100644 lib/services/motorcycle/local_motorcycle_data_provider.dart create mode 100644 lib/services/motorcycle/motorcycle_data_provider.dart create mode 100644 lib/services/motorcycle/motorcycle_repository.dart create mode 100644 lib/services/motorcycle/nhtsa_motorcycle_data_provider.dart create mode 100644 test/garage_test.dart diff --git a/lib/core/router.dart b/lib/core/router.dart index 162a97e..049f2ef 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -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(); @@ -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( diff --git a/lib/core/widgets/main_layout.dart b/lib/core/widgets/main_layout.dart index 11e5fa4..c799f08 100644 --- a/lib/core/widgets/main_layout.dart +++ b/lib/core/widgets/main_layout.dart @@ -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', diff --git a/lib/database/database.dart b/lib/database/database.dart index 1098ad0..56bc056 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -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); + } }, ); } diff --git a/lib/database/database.g.dart b/lib/database/database.g.dart index 6209f5c..b297622 100644 --- a/lib/database/database.g.dart +++ b/lib/database/database.g.dart @@ -221,6 +221,62 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> { requiredDuringInsert: false, defaultValue: const Constant(0.0), ); + static const VerificationMeta _motorcycleIdMeta = const VerificationMeta( + 'motorcycleId', + ); + @override + late final GeneratedColumn motorcycleId = GeneratedColumn( + 'motorcycle_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _motorcycleYearMeta = const VerificationMeta( + 'motorcycleYear', + ); + @override + late final GeneratedColumn motorcycleYear = GeneratedColumn( + 'motorcycle_year', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _motorcycleMakeMeta = const VerificationMeta( + 'motorcycleMake', + ); + @override + late final GeneratedColumn motorcycleMake = GeneratedColumn( + 'motorcycle_make', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _motorcycleModelMeta = const VerificationMeta( + 'motorcycleModel', + ); + @override + late final GeneratedColumn motorcycleModel = GeneratedColumn( + 'motorcycle_model', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _motorcycleVariantMeta = const VerificationMeta( + 'motorcycleVariant', + ); + @override + late final GeneratedColumn motorcycleVariant = + GeneratedColumn( + 'motorcycle_variant', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -241,6 +297,11 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> { topSpeed, elevationGain, elevationLoss, + motorcycleId, + motorcycleYear, + motorcycleMake, + motorcycleModel, + motorcycleVariant, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -394,6 +455,51 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> { ), ); } + if (data.containsKey('motorcycle_id')) { + context.handle( + _motorcycleIdMeta, + motorcycleId.isAcceptableOrUnknown( + data['motorcycle_id']!, + _motorcycleIdMeta, + ), + ); + } + if (data.containsKey('motorcycle_year')) { + context.handle( + _motorcycleYearMeta, + motorcycleYear.isAcceptableOrUnknown( + data['motorcycle_year']!, + _motorcycleYearMeta, + ), + ); + } + if (data.containsKey('motorcycle_make')) { + context.handle( + _motorcycleMakeMeta, + motorcycleMake.isAcceptableOrUnknown( + data['motorcycle_make']!, + _motorcycleMakeMeta, + ), + ); + } + if (data.containsKey('motorcycle_model')) { + context.handle( + _motorcycleModelMeta, + motorcycleModel.isAcceptableOrUnknown( + data['motorcycle_model']!, + _motorcycleModelMeta, + ), + ); + } + if (data.containsKey('motorcycle_variant')) { + context.handle( + _motorcycleVariantMeta, + motorcycleVariant.isAcceptableOrUnknown( + data['motorcycle_variant']!, + _motorcycleVariantMeta, + ), + ); + } return context; } @@ -475,6 +581,26 @@ class $RidesTable extends Rides with TableInfo<$RidesTable, Ride> { DriftSqlType.double, data['${effectivePrefix}elevation_loss'], )!, + motorcycleId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}motorcycle_id'], + ), + motorcycleYear: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}motorcycle_year'], + ), + motorcycleMake: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}motorcycle_make'], + ), + motorcycleModel: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}motorcycle_model'], + ), + motorcycleVariant: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}motorcycle_variant'], + ), ); } @@ -503,6 +629,11 @@ class Ride extends DataClass implements Insertable { final double topSpeed; final double elevationGain; final double elevationLoss; + final int? motorcycleId; + final int? motorcycleYear; + final String? motorcycleMake; + final String? motorcycleModel; + final String? motorcycleVariant; const Ride({ required this.id, required this.startTime, @@ -522,6 +653,11 @@ class Ride extends DataClass implements Insertable { required this.topSpeed, required this.elevationGain, required this.elevationLoss, + this.motorcycleId, + this.motorcycleYear, + this.motorcycleMake, + this.motorcycleModel, + this.motorcycleVariant, }); @override Map toColumns(bool nullToAbsent) { @@ -550,6 +686,21 @@ class Ride extends DataClass implements Insertable { map['top_speed'] = Variable(topSpeed); map['elevation_gain'] = Variable(elevationGain); map['elevation_loss'] = Variable(elevationLoss); + if (!nullToAbsent || motorcycleId != null) { + map['motorcycle_id'] = Variable(motorcycleId); + } + if (!nullToAbsent || motorcycleYear != null) { + map['motorcycle_year'] = Variable(motorcycleYear); + } + if (!nullToAbsent || motorcycleMake != null) { + map['motorcycle_make'] = Variable(motorcycleMake); + } + if (!nullToAbsent || motorcycleModel != null) { + map['motorcycle_model'] = Variable(motorcycleModel); + } + if (!nullToAbsent || motorcycleVariant != null) { + map['motorcycle_variant'] = Variable(motorcycleVariant); + } return map; } @@ -579,6 +730,21 @@ class Ride extends DataClass implements Insertable { topSpeed: Value(topSpeed), elevationGain: Value(elevationGain), elevationLoss: Value(elevationLoss), + motorcycleId: motorcycleId == null && nullToAbsent + ? const Value.absent() + : Value(motorcycleId), + motorcycleYear: motorcycleYear == null && nullToAbsent + ? const Value.absent() + : Value(motorcycleYear), + motorcycleMake: motorcycleMake == null && nullToAbsent + ? const Value.absent() + : Value(motorcycleMake), + motorcycleModel: motorcycleModel == null && nullToAbsent + ? const Value.absent() + : Value(motorcycleModel), + motorcycleVariant: motorcycleVariant == null && nullToAbsent + ? const Value.absent() + : Value(motorcycleVariant), ); } @@ -606,6 +772,13 @@ class Ride extends DataClass implements Insertable { topSpeed: serializer.fromJson(json['topSpeed']), elevationGain: serializer.fromJson(json['elevationGain']), elevationLoss: serializer.fromJson(json['elevationLoss']), + motorcycleId: serializer.fromJson(json['motorcycleId']), + motorcycleYear: serializer.fromJson(json['motorcycleYear']), + motorcycleMake: serializer.fromJson(json['motorcycleMake']), + motorcycleModel: serializer.fromJson(json['motorcycleModel']), + motorcycleVariant: serializer.fromJson( + json['motorcycleVariant'], + ), ); } @override @@ -630,6 +803,11 @@ class Ride extends DataClass implements Insertable { 'topSpeed': serializer.toJson(topSpeed), 'elevationGain': serializer.toJson(elevationGain), 'elevationLoss': serializer.toJson(elevationLoss), + 'motorcycleId': serializer.toJson(motorcycleId), + 'motorcycleYear': serializer.toJson(motorcycleYear), + 'motorcycleMake': serializer.toJson(motorcycleMake), + 'motorcycleModel': serializer.toJson(motorcycleModel), + 'motorcycleVariant': serializer.toJson(motorcycleVariant), }; } @@ -652,6 +830,11 @@ class Ride extends DataClass implements Insertable { double? topSpeed, double? elevationGain, double? elevationLoss, + Value motorcycleId = const Value.absent(), + Value motorcycleYear = const Value.absent(), + Value motorcycleMake = const Value.absent(), + Value motorcycleModel = const Value.absent(), + Value motorcycleVariant = const Value.absent(), }) => Ride( id: id ?? this.id, startTime: startTime ?? this.startTime, @@ -671,6 +854,19 @@ class Ride extends DataClass implements Insertable { topSpeed: topSpeed ?? this.topSpeed, elevationGain: elevationGain ?? this.elevationGain, elevationLoss: elevationLoss ?? this.elevationLoss, + motorcycleId: motorcycleId.present ? motorcycleId.value : this.motorcycleId, + motorcycleYear: motorcycleYear.present + ? motorcycleYear.value + : this.motorcycleYear, + motorcycleMake: motorcycleMake.present + ? motorcycleMake.value + : this.motorcycleMake, + motorcycleModel: motorcycleModel.present + ? motorcycleModel.value + : this.motorcycleModel, + motorcycleVariant: motorcycleVariant.present + ? motorcycleVariant.value + : this.motorcycleVariant, ); Ride copyWithCompanion(RidesCompanion data) { return Ride( @@ -716,6 +912,21 @@ class Ride extends DataClass implements Insertable { elevationLoss: data.elevationLoss.present ? data.elevationLoss.value : this.elevationLoss, + motorcycleId: data.motorcycleId.present + ? data.motorcycleId.value + : this.motorcycleId, + motorcycleYear: data.motorcycleYear.present + ? data.motorcycleYear.value + : this.motorcycleYear, + motorcycleMake: data.motorcycleMake.present + ? data.motorcycleMake.value + : this.motorcycleMake, + motorcycleModel: data.motorcycleModel.present + ? data.motorcycleModel.value + : this.motorcycleModel, + motorcycleVariant: data.motorcycleVariant.present + ? data.motorcycleVariant.value + : this.motorcycleVariant, ); } @@ -739,13 +950,18 @@ class Ride extends DataClass implements Insertable { ..write('averageSpeed: $averageSpeed, ') ..write('topSpeed: $topSpeed, ') ..write('elevationGain: $elevationGain, ') - ..write('elevationLoss: $elevationLoss') + ..write('elevationLoss: $elevationLoss, ') + ..write('motorcycleId: $motorcycleId, ') + ..write('motorcycleYear: $motorcycleYear, ') + ..write('motorcycleMake: $motorcycleMake, ') + ..write('motorcycleModel: $motorcycleModel, ') + ..write('motorcycleVariant: $motorcycleVariant') ..write(')')) .toString(); } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ id, startTime, endTime, @@ -764,7 +980,12 @@ class Ride extends DataClass implements Insertable { topSpeed, elevationGain, elevationLoss, - ); + motorcycleId, + motorcycleYear, + motorcycleMake, + motorcycleModel, + motorcycleVariant, + ]); @override bool operator ==(Object other) => identical(this, other) || @@ -786,7 +1007,12 @@ class Ride extends DataClass implements Insertable { other.averageSpeed == this.averageSpeed && other.topSpeed == this.topSpeed && other.elevationGain == this.elevationGain && - other.elevationLoss == this.elevationLoss); + other.elevationLoss == this.elevationLoss && + other.motorcycleId == this.motorcycleId && + other.motorcycleYear == this.motorcycleYear && + other.motorcycleMake == this.motorcycleMake && + other.motorcycleModel == this.motorcycleModel && + other.motorcycleVariant == this.motorcycleVariant); } class RidesCompanion extends UpdateCompanion { @@ -808,6 +1034,11 @@ class RidesCompanion extends UpdateCompanion { final Value topSpeed; final Value elevationGain; final Value elevationLoss; + final Value motorcycleId; + final Value motorcycleYear; + final Value motorcycleMake; + final Value motorcycleModel; + final Value motorcycleVariant; const RidesCompanion({ this.id = const Value.absent(), this.startTime = const Value.absent(), @@ -827,6 +1058,11 @@ class RidesCompanion extends UpdateCompanion { this.topSpeed = const Value.absent(), this.elevationGain = const Value.absent(), this.elevationLoss = const Value.absent(), + this.motorcycleId = const Value.absent(), + this.motorcycleYear = const Value.absent(), + this.motorcycleMake = const Value.absent(), + this.motorcycleModel = const Value.absent(), + this.motorcycleVariant = const Value.absent(), }); RidesCompanion.insert({ this.id = const Value.absent(), @@ -847,6 +1083,11 @@ class RidesCompanion extends UpdateCompanion { this.topSpeed = const Value.absent(), this.elevationGain = const Value.absent(), this.elevationLoss = const Value.absent(), + this.motorcycleId = const Value.absent(), + this.motorcycleYear = const Value.absent(), + this.motorcycleMake = const Value.absent(), + this.motorcycleModel = const Value.absent(), + this.motorcycleVariant = const Value.absent(), }) : startTime = Value(startTime); static Insertable custom({ Expression? id, @@ -867,6 +1108,11 @@ class RidesCompanion extends UpdateCompanion { Expression? topSpeed, Expression? elevationGain, Expression? elevationLoss, + Expression? motorcycleId, + Expression? motorcycleYear, + Expression? motorcycleMake, + Expression? motorcycleModel, + Expression? motorcycleVariant, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -887,6 +1133,11 @@ class RidesCompanion extends UpdateCompanion { if (topSpeed != null) 'top_speed': topSpeed, if (elevationGain != null) 'elevation_gain': elevationGain, if (elevationLoss != null) 'elevation_loss': elevationLoss, + if (motorcycleId != null) 'motorcycle_id': motorcycleId, + if (motorcycleYear != null) 'motorcycle_year': motorcycleYear, + if (motorcycleMake != null) 'motorcycle_make': motorcycleMake, + if (motorcycleModel != null) 'motorcycle_model': motorcycleModel, + if (motorcycleVariant != null) 'motorcycle_variant': motorcycleVariant, }); } @@ -909,6 +1160,11 @@ class RidesCompanion extends UpdateCompanion { Value? topSpeed, Value? elevationGain, Value? elevationLoss, + Value? motorcycleId, + Value? motorcycleYear, + Value? motorcycleMake, + Value? motorcycleModel, + Value? motorcycleVariant, }) { return RidesCompanion( id: id ?? this.id, @@ -929,6 +1185,11 @@ class RidesCompanion extends UpdateCompanion { topSpeed: topSpeed ?? this.topSpeed, elevationGain: elevationGain ?? this.elevationGain, elevationLoss: elevationLoss ?? this.elevationLoss, + motorcycleId: motorcycleId ?? this.motorcycleId, + motorcycleYear: motorcycleYear ?? this.motorcycleYear, + motorcycleMake: motorcycleMake ?? this.motorcycleMake, + motorcycleModel: motorcycleModel ?? this.motorcycleModel, + motorcycleVariant: motorcycleVariant ?? this.motorcycleVariant, ); } @@ -989,6 +1250,21 @@ class RidesCompanion extends UpdateCompanion { if (elevationLoss.present) { map['elevation_loss'] = Variable(elevationLoss.value); } + if (motorcycleId.present) { + map['motorcycle_id'] = Variable(motorcycleId.value); + } + if (motorcycleYear.present) { + map['motorcycle_year'] = Variable(motorcycleYear.value); + } + if (motorcycleMake.present) { + map['motorcycle_make'] = Variable(motorcycleMake.value); + } + if (motorcycleModel.present) { + map['motorcycle_model'] = Variable(motorcycleModel.value); + } + if (motorcycleVariant.present) { + map['motorcycle_variant'] = Variable(motorcycleVariant.value); + } return map; } @@ -1012,7 +1288,12 @@ class RidesCompanion extends UpdateCompanion { ..write('averageSpeed: $averageSpeed, ') ..write('topSpeed: $topSpeed, ') ..write('elevationGain: $elevationGain, ') - ..write('elevationLoss: $elevationLoss') + ..write('elevationLoss: $elevationLoss, ') + ..write('motorcycleId: $motorcycleId, ') + ..write('motorcycleYear: $motorcycleYear, ') + ..write('motorcycleMake: $motorcycleMake, ') + ..write('motorcycleModel: $motorcycleModel, ') + ..write('motorcycleVariant: $motorcycleVariant') ..write(')')) .toString(); } @@ -2775,6 +3056,770 @@ class RideEventsCompanion extends UpdateCompanion { } } +class $GarageMotorcyclesTable extends GarageMotorcycles + with TableInfo<$GarageMotorcyclesTable, GarageMotorcycle> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $GarageMotorcyclesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _yearMeta = const VerificationMeta('year'); + @override + late final GeneratedColumn year = GeneratedColumn( + 'year', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _makeIdMeta = const VerificationMeta('makeId'); + @override + late final GeneratedColumn makeId = GeneratedColumn( + 'make_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _makeNameMeta = const VerificationMeta( + 'makeName', + ); + @override + late final GeneratedColumn makeName = GeneratedColumn( + 'make_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _modelIdMeta = const VerificationMeta( + 'modelId', + ); + @override + late final GeneratedColumn modelId = GeneratedColumn( + 'model_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _modelNameMeta = const VerificationMeta( + 'modelName', + ); + @override + late final GeneratedColumn modelName = GeneratedColumn( + 'model_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _variantIdMeta = const VerificationMeta( + 'variantId', + ); + @override + late final GeneratedColumn variantId = GeneratedColumn( + 'variant_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _variantNameMeta = const VerificationMeta( + 'variantName', + ); + @override + late final GeneratedColumn variantName = GeneratedColumn( + 'variant_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _nicknameMeta = const VerificationMeta( + 'nickname', + ); + @override + late final GeneratedColumn nickname = GeneratedColumn( + 'nickname', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _isDefaultMeta = const VerificationMeta( + 'isDefault', + ); + @override + late final GeneratedColumn isDefault = GeneratedColumn( + 'is_default', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_default" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _dateAddedMeta = const VerificationMeta( + 'dateAdded', + ); + @override + late final GeneratedColumn dateAdded = GeneratedColumn( + 'date_added', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastUpdatedMeta = const VerificationMeta( + 'lastUpdated', + ); + @override + late final GeneratedColumn lastUpdated = GeneratedColumn( + 'last_updated', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _specificationJsonMeta = const VerificationMeta( + 'specificationJson', + ); + @override + late final GeneratedColumn specificationJson = + GeneratedColumn( + 'specification_json', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + year, + makeId, + makeName, + modelId, + modelName, + variantId, + variantName, + nickname, + isDefault, + dateAdded, + lastUpdated, + specificationJson, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'garage_motorcycles'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('year')) { + context.handle( + _yearMeta, + year.isAcceptableOrUnknown(data['year']!, _yearMeta), + ); + } else if (isInserting) { + context.missing(_yearMeta); + } + if (data.containsKey('make_id')) { + context.handle( + _makeIdMeta, + makeId.isAcceptableOrUnknown(data['make_id']!, _makeIdMeta), + ); + } else if (isInserting) { + context.missing(_makeIdMeta); + } + if (data.containsKey('make_name')) { + context.handle( + _makeNameMeta, + makeName.isAcceptableOrUnknown(data['make_name']!, _makeNameMeta), + ); + } else if (isInserting) { + context.missing(_makeNameMeta); + } + if (data.containsKey('model_id')) { + context.handle( + _modelIdMeta, + modelId.isAcceptableOrUnknown(data['model_id']!, _modelIdMeta), + ); + } else if (isInserting) { + context.missing(_modelIdMeta); + } + if (data.containsKey('model_name')) { + context.handle( + _modelNameMeta, + modelName.isAcceptableOrUnknown(data['model_name']!, _modelNameMeta), + ); + } else if (isInserting) { + context.missing(_modelNameMeta); + } + if (data.containsKey('variant_id')) { + context.handle( + _variantIdMeta, + variantId.isAcceptableOrUnknown(data['variant_id']!, _variantIdMeta), + ); + } + if (data.containsKey('variant_name')) { + context.handle( + _variantNameMeta, + variantName.isAcceptableOrUnknown( + data['variant_name']!, + _variantNameMeta, + ), + ); + } + if (data.containsKey('nickname')) { + context.handle( + _nicknameMeta, + nickname.isAcceptableOrUnknown(data['nickname']!, _nicknameMeta), + ); + } + if (data.containsKey('is_default')) { + context.handle( + _isDefaultMeta, + isDefault.isAcceptableOrUnknown(data['is_default']!, _isDefaultMeta), + ); + } + if (data.containsKey('date_added')) { + context.handle( + _dateAddedMeta, + dateAdded.isAcceptableOrUnknown(data['date_added']!, _dateAddedMeta), + ); + } else if (isInserting) { + context.missing(_dateAddedMeta); + } + if (data.containsKey('last_updated')) { + context.handle( + _lastUpdatedMeta, + lastUpdated.isAcceptableOrUnknown( + data['last_updated']!, + _lastUpdatedMeta, + ), + ); + } else if (isInserting) { + context.missing(_lastUpdatedMeta); + } + if (data.containsKey('specification_json')) { + context.handle( + _specificationJsonMeta, + specificationJson.isAcceptableOrUnknown( + data['specification_json']!, + _specificationJsonMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + GarageMotorcycle map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GarageMotorcycle( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + year: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}year'], + )!, + makeId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}make_id'], + )!, + makeName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}make_name'], + )!, + modelId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}model_id'], + )!, + modelName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}model_name'], + )!, + variantId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}variant_id'], + ), + variantName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}variant_name'], + ), + nickname: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}nickname'], + ), + isDefault: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_default'], + )!, + dateAdded: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}date_added'], + )!, + lastUpdated: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_updated'], + )!, + specificationJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}specification_json'], + ), + ); + } + + @override + $GarageMotorcyclesTable createAlias(String alias) { + return $GarageMotorcyclesTable(attachedDatabase, alias); + } +} + +class GarageMotorcycle extends DataClass + implements Insertable { + final int id; + final int year; + final String makeId; + final String makeName; + final String modelId; + final String modelName; + final String? variantId; + final String? variantName; + final String? nickname; + final bool isDefault; + final DateTime dateAdded; + final DateTime lastUpdated; + final String? specificationJson; + const GarageMotorcycle({ + required this.id, + required this.year, + required this.makeId, + required this.makeName, + required this.modelId, + required this.modelName, + this.variantId, + this.variantName, + this.nickname, + required this.isDefault, + required this.dateAdded, + required this.lastUpdated, + this.specificationJson, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['year'] = Variable(year); + map['make_id'] = Variable(makeId); + map['make_name'] = Variable(makeName); + map['model_id'] = Variable(modelId); + map['model_name'] = Variable(modelName); + if (!nullToAbsent || variantId != null) { + map['variant_id'] = Variable(variantId); + } + if (!nullToAbsent || variantName != null) { + map['variant_name'] = Variable(variantName); + } + if (!nullToAbsent || nickname != null) { + map['nickname'] = Variable(nickname); + } + map['is_default'] = Variable(isDefault); + map['date_added'] = Variable(dateAdded); + map['last_updated'] = Variable(lastUpdated); + if (!nullToAbsent || specificationJson != null) { + map['specification_json'] = Variable(specificationJson); + } + return map; + } + + GarageMotorcyclesCompanion toCompanion(bool nullToAbsent) { + return GarageMotorcyclesCompanion( + id: Value(id), + year: Value(year), + makeId: Value(makeId), + makeName: Value(makeName), + modelId: Value(modelId), + modelName: Value(modelName), + variantId: variantId == null && nullToAbsent + ? const Value.absent() + : Value(variantId), + variantName: variantName == null && nullToAbsent + ? const Value.absent() + : Value(variantName), + nickname: nickname == null && nullToAbsent + ? const Value.absent() + : Value(nickname), + isDefault: Value(isDefault), + dateAdded: Value(dateAdded), + lastUpdated: Value(lastUpdated), + specificationJson: specificationJson == null && nullToAbsent + ? const Value.absent() + : Value(specificationJson), + ); + } + + factory GarageMotorcycle.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GarageMotorcycle( + id: serializer.fromJson(json['id']), + year: serializer.fromJson(json['year']), + makeId: serializer.fromJson(json['makeId']), + makeName: serializer.fromJson(json['makeName']), + modelId: serializer.fromJson(json['modelId']), + modelName: serializer.fromJson(json['modelName']), + variantId: serializer.fromJson(json['variantId']), + variantName: serializer.fromJson(json['variantName']), + nickname: serializer.fromJson(json['nickname']), + isDefault: serializer.fromJson(json['isDefault']), + dateAdded: serializer.fromJson(json['dateAdded']), + lastUpdated: serializer.fromJson(json['lastUpdated']), + specificationJson: serializer.fromJson( + json['specificationJson'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'year': serializer.toJson(year), + 'makeId': serializer.toJson(makeId), + 'makeName': serializer.toJson(makeName), + 'modelId': serializer.toJson(modelId), + 'modelName': serializer.toJson(modelName), + 'variantId': serializer.toJson(variantId), + 'variantName': serializer.toJson(variantName), + 'nickname': serializer.toJson(nickname), + 'isDefault': serializer.toJson(isDefault), + 'dateAdded': serializer.toJson(dateAdded), + 'lastUpdated': serializer.toJson(lastUpdated), + 'specificationJson': serializer.toJson(specificationJson), + }; + } + + GarageMotorcycle copyWith({ + int? id, + int? year, + String? makeId, + String? makeName, + String? modelId, + String? modelName, + Value variantId = const Value.absent(), + Value variantName = const Value.absent(), + Value nickname = const Value.absent(), + bool? isDefault, + DateTime? dateAdded, + DateTime? lastUpdated, + Value specificationJson = const Value.absent(), + }) => GarageMotorcycle( + id: id ?? this.id, + year: year ?? this.year, + makeId: makeId ?? this.makeId, + makeName: makeName ?? this.makeName, + modelId: modelId ?? this.modelId, + modelName: modelName ?? this.modelName, + variantId: variantId.present ? variantId.value : this.variantId, + variantName: variantName.present ? variantName.value : this.variantName, + nickname: nickname.present ? nickname.value : this.nickname, + isDefault: isDefault ?? this.isDefault, + dateAdded: dateAdded ?? this.dateAdded, + lastUpdated: lastUpdated ?? this.lastUpdated, + specificationJson: specificationJson.present + ? specificationJson.value + : this.specificationJson, + ); + GarageMotorcycle copyWithCompanion(GarageMotorcyclesCompanion data) { + return GarageMotorcycle( + id: data.id.present ? data.id.value : this.id, + year: data.year.present ? data.year.value : this.year, + makeId: data.makeId.present ? data.makeId.value : this.makeId, + makeName: data.makeName.present ? data.makeName.value : this.makeName, + modelId: data.modelId.present ? data.modelId.value : this.modelId, + modelName: data.modelName.present ? data.modelName.value : this.modelName, + variantId: data.variantId.present ? data.variantId.value : this.variantId, + variantName: data.variantName.present + ? data.variantName.value + : this.variantName, + nickname: data.nickname.present ? data.nickname.value : this.nickname, + isDefault: data.isDefault.present ? data.isDefault.value : this.isDefault, + dateAdded: data.dateAdded.present ? data.dateAdded.value : this.dateAdded, + lastUpdated: data.lastUpdated.present + ? data.lastUpdated.value + : this.lastUpdated, + specificationJson: data.specificationJson.present + ? data.specificationJson.value + : this.specificationJson, + ); + } + + @override + String toString() { + return (StringBuffer('GarageMotorcycle(') + ..write('id: $id, ') + ..write('year: $year, ') + ..write('makeId: $makeId, ') + ..write('makeName: $makeName, ') + ..write('modelId: $modelId, ') + ..write('modelName: $modelName, ') + ..write('variantId: $variantId, ') + ..write('variantName: $variantName, ') + ..write('nickname: $nickname, ') + ..write('isDefault: $isDefault, ') + ..write('dateAdded: $dateAdded, ') + ..write('lastUpdated: $lastUpdated, ') + ..write('specificationJson: $specificationJson') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + year, + makeId, + makeName, + modelId, + modelName, + variantId, + variantName, + nickname, + isDefault, + dateAdded, + lastUpdated, + specificationJson, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GarageMotorcycle && + other.id == this.id && + other.year == this.year && + other.makeId == this.makeId && + other.makeName == this.makeName && + other.modelId == this.modelId && + other.modelName == this.modelName && + other.variantId == this.variantId && + other.variantName == this.variantName && + other.nickname == this.nickname && + other.isDefault == this.isDefault && + other.dateAdded == this.dateAdded && + other.lastUpdated == this.lastUpdated && + other.specificationJson == this.specificationJson); +} + +class GarageMotorcyclesCompanion extends UpdateCompanion { + final Value id; + final Value year; + final Value makeId; + final Value makeName; + final Value modelId; + final Value modelName; + final Value variantId; + final Value variantName; + final Value nickname; + final Value isDefault; + final Value dateAdded; + final Value lastUpdated; + final Value specificationJson; + const GarageMotorcyclesCompanion({ + this.id = const Value.absent(), + this.year = const Value.absent(), + this.makeId = const Value.absent(), + this.makeName = const Value.absent(), + this.modelId = const Value.absent(), + this.modelName = const Value.absent(), + this.variantId = const Value.absent(), + this.variantName = const Value.absent(), + this.nickname = const Value.absent(), + this.isDefault = const Value.absent(), + this.dateAdded = const Value.absent(), + this.lastUpdated = const Value.absent(), + this.specificationJson = const Value.absent(), + }); + GarageMotorcyclesCompanion.insert({ + this.id = const Value.absent(), + required int year, + required String makeId, + required String makeName, + required String modelId, + required String modelName, + this.variantId = const Value.absent(), + this.variantName = const Value.absent(), + this.nickname = const Value.absent(), + this.isDefault = const Value.absent(), + required DateTime dateAdded, + required DateTime lastUpdated, + this.specificationJson = const Value.absent(), + }) : year = Value(year), + makeId = Value(makeId), + makeName = Value(makeName), + modelId = Value(modelId), + modelName = Value(modelName), + dateAdded = Value(dateAdded), + lastUpdated = Value(lastUpdated); + static Insertable custom({ + Expression? id, + Expression? year, + Expression? makeId, + Expression? makeName, + Expression? modelId, + Expression? modelName, + Expression? variantId, + Expression? variantName, + Expression? nickname, + Expression? isDefault, + Expression? dateAdded, + Expression? lastUpdated, + Expression? specificationJson, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (year != null) 'year': year, + if (makeId != null) 'make_id': makeId, + if (makeName != null) 'make_name': makeName, + if (modelId != null) 'model_id': modelId, + if (modelName != null) 'model_name': modelName, + if (variantId != null) 'variant_id': variantId, + if (variantName != null) 'variant_name': variantName, + if (nickname != null) 'nickname': nickname, + if (isDefault != null) 'is_default': isDefault, + if (dateAdded != null) 'date_added': dateAdded, + if (lastUpdated != null) 'last_updated': lastUpdated, + if (specificationJson != null) 'specification_json': specificationJson, + }); + } + + GarageMotorcyclesCompanion copyWith({ + Value? id, + Value? year, + Value? makeId, + Value? makeName, + Value? modelId, + Value? modelName, + Value? variantId, + Value? variantName, + Value? nickname, + Value? isDefault, + Value? dateAdded, + Value? lastUpdated, + Value? specificationJson, + }) { + return GarageMotorcyclesCompanion( + id: id ?? this.id, + year: year ?? this.year, + makeId: makeId ?? this.makeId, + makeName: makeName ?? this.makeName, + modelId: modelId ?? this.modelId, + modelName: modelName ?? this.modelName, + variantId: variantId ?? this.variantId, + variantName: variantName ?? this.variantName, + nickname: nickname ?? this.nickname, + isDefault: isDefault ?? this.isDefault, + dateAdded: dateAdded ?? this.dateAdded, + lastUpdated: lastUpdated ?? this.lastUpdated, + specificationJson: specificationJson ?? this.specificationJson, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (year.present) { + map['year'] = Variable(year.value); + } + if (makeId.present) { + map['make_id'] = Variable(makeId.value); + } + if (makeName.present) { + map['make_name'] = Variable(makeName.value); + } + if (modelId.present) { + map['model_id'] = Variable(modelId.value); + } + if (modelName.present) { + map['model_name'] = Variable(modelName.value); + } + if (variantId.present) { + map['variant_id'] = Variable(variantId.value); + } + if (variantName.present) { + map['variant_name'] = Variable(variantName.value); + } + if (nickname.present) { + map['nickname'] = Variable(nickname.value); + } + if (isDefault.present) { + map['is_default'] = Variable(isDefault.value); + } + if (dateAdded.present) { + map['date_added'] = Variable(dateAdded.value); + } + if (lastUpdated.present) { + map['last_updated'] = Variable(lastUpdated.value); + } + if (specificationJson.present) { + map['specification_json'] = Variable(specificationJson.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GarageMotorcyclesCompanion(') + ..write('id: $id, ') + ..write('year: $year, ') + ..write('makeId: $makeId, ') + ..write('makeName: $makeName, ') + ..write('modelId: $modelId, ') + ..write('modelName: $modelName, ') + ..write('variantId: $variantId, ') + ..write('variantName: $variantName, ') + ..write('nickname: $nickname, ') + ..write('isDefault: $isDefault, ') + ..write('dateAdded: $dateAdded, ') + ..write('lastUpdated: $lastUpdated, ') + ..write('specificationJson: $specificationJson') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -2786,6 +3831,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { this, ); late final $RideEventsTable rideEvents = $RideEventsTable(this); + late final $GarageMotorcyclesTable garageMotorcycles = + $GarageMotorcyclesTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -2795,6 +3842,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { performanceRuns, telemetrySamples, rideEvents, + garageMotorcycles, ]; } @@ -2818,6 +3866,11 @@ typedef $$RidesTableCreateCompanionBuilder = Value topSpeed, Value elevationGain, Value elevationLoss, + Value motorcycleId, + Value motorcycleYear, + Value motorcycleMake, + Value motorcycleModel, + Value motorcycleVariant, }); typedef $$RidesTableUpdateCompanionBuilder = RidesCompanion Function({ @@ -2839,6 +3892,11 @@ typedef $$RidesTableUpdateCompanionBuilder = Value topSpeed, Value elevationGain, Value elevationLoss, + Value motorcycleId, + Value motorcycleYear, + Value motorcycleMake, + Value motorcycleModel, + Value motorcycleVariant, }); final class $$RidesTableReferences @@ -3002,6 +4060,31 @@ class $$RidesTableFilterComposer extends Composer<_$AppDatabase, $RidesTable> { builder: (column) => ColumnFilters(column), ); + ColumnFilters get motorcycleId => $composableBuilder( + column: $table.motorcycleId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get motorcycleYear => $composableBuilder( + column: $table.motorcycleYear, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get motorcycleMake => $composableBuilder( + column: $table.motorcycleMake, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get motorcycleModel => $composableBuilder( + column: $table.motorcycleModel, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get motorcycleVariant => $composableBuilder( + column: $table.motorcycleVariant, + builder: (column) => ColumnFilters(column), + ); + Expression performanceRunsRefs( Expression Function($$PerformanceRunsTableFilterComposer f) f, ) { @@ -3176,6 +4259,31 @@ class $$RidesTableOrderingComposer column: $table.elevationLoss, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get motorcycleId => $composableBuilder( + column: $table.motorcycleId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get motorcycleYear => $composableBuilder( + column: $table.motorcycleYear, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get motorcycleMake => $composableBuilder( + column: $table.motorcycleMake, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get motorcycleModel => $composableBuilder( + column: $table.motorcycleModel, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get motorcycleVariant => $composableBuilder( + column: $table.motorcycleVariant, + builder: (column) => ColumnOrderings(column), + ); } class $$RidesTableAnnotationComposer @@ -3265,6 +4373,31 @@ class $$RidesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get motorcycleId => $composableBuilder( + column: $table.motorcycleId, + builder: (column) => column, + ); + + GeneratedColumn get motorcycleYear => $composableBuilder( + column: $table.motorcycleYear, + builder: (column) => column, + ); + + GeneratedColumn get motorcycleMake => $composableBuilder( + column: $table.motorcycleMake, + builder: (column) => column, + ); + + GeneratedColumn get motorcycleModel => $composableBuilder( + column: $table.motorcycleModel, + builder: (column) => column, + ); + + GeneratedColumn get motorcycleVariant => $composableBuilder( + column: $table.motorcycleVariant, + builder: (column) => column, + ); + Expression performanceRunsRefs( Expression Function($$PerformanceRunsTableAnnotationComposer a) f, ) { @@ -3391,6 +4524,11 @@ class $$RidesTableTableManager Value topSpeed = const Value.absent(), Value elevationGain = const Value.absent(), Value elevationLoss = const Value.absent(), + Value motorcycleId = const Value.absent(), + Value motorcycleYear = const Value.absent(), + Value motorcycleMake = const Value.absent(), + Value motorcycleModel = const Value.absent(), + Value motorcycleVariant = const Value.absent(), }) => RidesCompanion( id: id, startTime: startTime, @@ -3410,6 +4548,11 @@ class $$RidesTableTableManager topSpeed: topSpeed, elevationGain: elevationGain, elevationLoss: elevationLoss, + motorcycleId: motorcycleId, + motorcycleYear: motorcycleYear, + motorcycleMake: motorcycleMake, + motorcycleModel: motorcycleModel, + motorcycleVariant: motorcycleVariant, ), createCompanionCallback: ({ @@ -3431,6 +4574,11 @@ class $$RidesTableTableManager Value topSpeed = const Value.absent(), Value elevationGain = const Value.absent(), Value elevationLoss = const Value.absent(), + Value motorcycleId = const Value.absent(), + Value motorcycleYear = const Value.absent(), + Value motorcycleMake = const Value.absent(), + Value motorcycleModel = const Value.absent(), + Value motorcycleVariant = const Value.absent(), }) => RidesCompanion.insert( id: id, startTime: startTime, @@ -3450,6 +4598,11 @@ class $$RidesTableTableManager topSpeed: topSpeed, elevationGain: elevationGain, elevationLoss: elevationLoss, + motorcycleId: motorcycleId, + motorcycleYear: motorcycleYear, + motorcycleMake: motorcycleMake, + motorcycleModel: motorcycleModel, + motorcycleVariant: motorcycleVariant, ), withReferenceMapper: (p0) => p0 .map( @@ -4783,6 +5936,371 @@ typedef $$RideEventsTableProcessedTableManager = RideEvent, PrefetchHooks Function({bool rideId}) >; +typedef $$GarageMotorcyclesTableCreateCompanionBuilder = + GarageMotorcyclesCompanion Function({ + Value id, + required int year, + required String makeId, + required String makeName, + required String modelId, + required String modelName, + Value variantId, + Value variantName, + Value nickname, + Value isDefault, + required DateTime dateAdded, + required DateTime lastUpdated, + Value specificationJson, + }); +typedef $$GarageMotorcyclesTableUpdateCompanionBuilder = + GarageMotorcyclesCompanion Function({ + Value id, + Value year, + Value makeId, + Value makeName, + Value modelId, + Value modelName, + Value variantId, + Value variantName, + Value nickname, + Value isDefault, + Value dateAdded, + Value lastUpdated, + Value specificationJson, + }); + +class $$GarageMotorcyclesTableFilterComposer + extends Composer<_$AppDatabase, $GarageMotorcyclesTable> { + $$GarageMotorcyclesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get year => $composableBuilder( + column: $table.year, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get makeId => $composableBuilder( + column: $table.makeId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get makeName => $composableBuilder( + column: $table.makeName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get modelId => $composableBuilder( + column: $table.modelId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get modelName => $composableBuilder( + column: $table.modelName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get variantId => $composableBuilder( + column: $table.variantId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get variantName => $composableBuilder( + column: $table.variantName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get nickname => $composableBuilder( + column: $table.nickname, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get isDefault => $composableBuilder( + column: $table.isDefault, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dateAdded => $composableBuilder( + column: $table.dateAdded, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastUpdated => $composableBuilder( + column: $table.lastUpdated, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get specificationJson => $composableBuilder( + column: $table.specificationJson, + builder: (column) => ColumnFilters(column), + ); +} + +class $$GarageMotorcyclesTableOrderingComposer + extends Composer<_$AppDatabase, $GarageMotorcyclesTable> { + $$GarageMotorcyclesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get year => $composableBuilder( + column: $table.year, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get makeId => $composableBuilder( + column: $table.makeId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get makeName => $composableBuilder( + column: $table.makeName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get modelId => $composableBuilder( + column: $table.modelId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get modelName => $composableBuilder( + column: $table.modelName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get variantId => $composableBuilder( + column: $table.variantId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get variantName => $composableBuilder( + column: $table.variantName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get nickname => $composableBuilder( + column: $table.nickname, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get isDefault => $composableBuilder( + column: $table.isDefault, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dateAdded => $composableBuilder( + column: $table.dateAdded, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastUpdated => $composableBuilder( + column: $table.lastUpdated, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get specificationJson => $composableBuilder( + column: $table.specificationJson, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$GarageMotorcyclesTableAnnotationComposer + extends Composer<_$AppDatabase, $GarageMotorcyclesTable> { + $$GarageMotorcyclesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get year => + $composableBuilder(column: $table.year, builder: (column) => column); + + GeneratedColumn get makeId => + $composableBuilder(column: $table.makeId, builder: (column) => column); + + GeneratedColumn get makeName => + $composableBuilder(column: $table.makeName, builder: (column) => column); + + GeneratedColumn get modelId => + $composableBuilder(column: $table.modelId, builder: (column) => column); + + GeneratedColumn get modelName => + $composableBuilder(column: $table.modelName, builder: (column) => column); + + GeneratedColumn get variantId => + $composableBuilder(column: $table.variantId, builder: (column) => column); + + GeneratedColumn get variantName => $composableBuilder( + column: $table.variantName, + builder: (column) => column, + ); + + GeneratedColumn get nickname => + $composableBuilder(column: $table.nickname, builder: (column) => column); + + GeneratedColumn get isDefault => + $composableBuilder(column: $table.isDefault, builder: (column) => column); + + GeneratedColumn get dateAdded => + $composableBuilder(column: $table.dateAdded, builder: (column) => column); + + GeneratedColumn get lastUpdated => $composableBuilder( + column: $table.lastUpdated, + builder: (column) => column, + ); + + GeneratedColumn get specificationJson => $composableBuilder( + column: $table.specificationJson, + builder: (column) => column, + ); +} + +class $$GarageMotorcyclesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $GarageMotorcyclesTable, + GarageMotorcycle, + $$GarageMotorcyclesTableFilterComposer, + $$GarageMotorcyclesTableOrderingComposer, + $$GarageMotorcyclesTableAnnotationComposer, + $$GarageMotorcyclesTableCreateCompanionBuilder, + $$GarageMotorcyclesTableUpdateCompanionBuilder, + ( + GarageMotorcycle, + BaseReferences< + _$AppDatabase, + $GarageMotorcyclesTable, + GarageMotorcycle + >, + ), + GarageMotorcycle, + PrefetchHooks Function() + > { + $$GarageMotorcyclesTableTableManager( + _$AppDatabase db, + $GarageMotorcyclesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$GarageMotorcyclesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$GarageMotorcyclesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$GarageMotorcyclesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value year = const Value.absent(), + Value makeId = const Value.absent(), + Value makeName = const Value.absent(), + Value modelId = const Value.absent(), + Value modelName = const Value.absent(), + Value variantId = const Value.absent(), + Value variantName = const Value.absent(), + Value nickname = const Value.absent(), + Value isDefault = const Value.absent(), + Value dateAdded = const Value.absent(), + Value lastUpdated = const Value.absent(), + Value specificationJson = const Value.absent(), + }) => GarageMotorcyclesCompanion( + id: id, + year: year, + makeId: makeId, + makeName: makeName, + modelId: modelId, + modelName: modelName, + variantId: variantId, + variantName: variantName, + nickname: nickname, + isDefault: isDefault, + dateAdded: dateAdded, + lastUpdated: lastUpdated, + specificationJson: specificationJson, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required int year, + required String makeId, + required String makeName, + required String modelId, + required String modelName, + Value variantId = const Value.absent(), + Value variantName = const Value.absent(), + Value nickname = const Value.absent(), + Value isDefault = const Value.absent(), + required DateTime dateAdded, + required DateTime lastUpdated, + Value specificationJson = const Value.absent(), + }) => GarageMotorcyclesCompanion.insert( + id: id, + year: year, + makeId: makeId, + makeName: makeName, + modelId: modelId, + modelName: modelName, + variantId: variantId, + variantName: variantName, + nickname: nickname, + isDefault: isDefault, + dateAdded: dateAdded, + lastUpdated: lastUpdated, + specificationJson: specificationJson, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$GarageMotorcyclesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $GarageMotorcyclesTable, + GarageMotorcycle, + $$GarageMotorcyclesTableFilterComposer, + $$GarageMotorcyclesTableOrderingComposer, + $$GarageMotorcyclesTableAnnotationComposer, + $$GarageMotorcyclesTableCreateCompanionBuilder, + $$GarageMotorcyclesTableUpdateCompanionBuilder, + ( + GarageMotorcycle, + BaseReferences< + _$AppDatabase, + $GarageMotorcyclesTable, + GarageMotorcycle + >, + ), + GarageMotorcycle, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -4795,4 +6313,6 @@ class $AppDatabaseManager { $$TelemetrySamplesTableTableManager(_db, _db.telemetrySamples); $$RideEventsTableTableManager get rideEvents => $$RideEventsTableTableManager(_db, _db.rideEvents); + $$GarageMotorcyclesTableTableManager get garageMotorcycles => + $$GarageMotorcyclesTableTableManager(_db, _db.garageMotorcycles); } diff --git a/lib/database/tables.dart b/lib/database/tables.dart index 162e251..e9a3355 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -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()(); +} diff --git a/lib/features/garage/add_motorcycle_screen.dart b/lib/features/garage/add_motorcycle_screen.dart new file mode 100644 index 0000000..9735b3e --- /dev/null +++ b/lib/features/garage/add_motorcycle_screen.dart @@ -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 createState() => _AddMotorcycleScreenState(); +} + +class _AddMotorcycleScreenState extends ConsumerState { + 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 _years = []; + List _makes = []; + List _models = []; + List _variants = []; + + @override + void initState() { + super.initState(); + _loadYears(); + } + + @override + void dispose() { + _nicknameController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + Future _loadYears() async { + setState(() => _isLoading = true); + final repo = ref.read(motorcycleRepositoryProvider); + final years = await repo.getYears(); + setState(() { + _years = years; + _isLoading = false; + }); + } + + Future _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 _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 _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 _selectVariant(MotorcycleVariant variant) async { + setState(() { + _selectedVariant = variant; + }); + await _loadSpecSummary(); + } + + Future _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 _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)), + ], + ), + ); + } +} diff --git a/lib/features/garage/garage_providers.dart b/lib/features/garage/garage_providers.dart new file mode 100644 index 0000000..d77d81d --- /dev/null +++ b/lib/features/garage/garage_providers.dart @@ -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((ref) { + return HybridMotorcycleRepository(); +}); + +final garageMotorcyclesStreamProvider = StreamProvider>((ref) { + final db = ref.watch(databaseProvider); + return (db.select(db.garageMotorcycles) + ..orderBy([(t) => OrderingTerm(expression: t.dateAdded, mode: OrderingMode.desc)])) + .watch(); +}); + +final defaultMotorcycleProvider = Provider((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((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 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 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 updateNickname(int id, String? nickname) async { + await (_db.update(_db.garageMotorcycles)..where((t) => t.id.equals(id))) + .write(GarageMotorcyclesCompanion(nickname: Value(nickname))); + } + + Future 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((ref) { + final db = ref.watch(databaseProvider); + final repo = ref.watch(motorcycleRepositoryProvider); + return GarageController(db, repo); +}); diff --git a/lib/features/garage/garage_screen.dart b/lib/features/garage/garage_screen.dart new file mode 100644 index 0000000..976681d --- /dev/null +++ b/lib/features/garage/garage_screen.dart @@ -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'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/garage/motorcycle_detail_screen.dart b/lib/features/garage/motorcycle_detail_screen.dart new file mode 100644 index 0000000..82e6c7d --- /dev/null +++ b/lib/features/garage/motorcycle_detail_screen.dart @@ -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 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); +} diff --git a/lib/features/history/history_screen.dart b/lib/features/history/history_screen.dart index fa1bc21..948ba0e 100644 --- a/lib/features/history/history_screen.dart +++ b/lib/features/history/history_screen.dart @@ -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 createState() => _HistoryScreenState(); +} + +class _HistoryScreenState extends ConsumerState { + 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>( - 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>( + 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 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( + value: _selectedMotorcycleId, + isExpanded: true, + dropdownColor: const Color(0xFF1E1E1E), + hint: const Text('Filter by Motorcycle'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All Motorcycles'), + ), + if (defaultBike != null) + DropdownMenuItem( + value: -1, + child: Text('Current (${defaultBike.modelName})'), + ), + ...bikes.map((b) => DropdownMenuItem( + 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, diff --git a/lib/features/history/ride_detail_screen.dart b/lib/features/history/ride_detail_screen.dart index abf84e6..95af67d 100644 --- a/lib/features/history/ride_detail_screen.dart +++ b/lib/features/history/ride_detail_screen.dart @@ -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 { 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'), diff --git a/lib/features/rides/ride_state_machine.dart b/lib/features/rides/ride_state_machine.dart index b5cb53f..207db20 100644 --- a/lib/features/rides/ride_state_machine.dart +++ b/lib/features/rides/ride_state_machine.dart @@ -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 { final TelemetryService telemetryService; final RecordsService recordsService; final AudioAlertService audioAlertService; + final Ref ref; StreamSubscription? _telemetrySub; @@ -100,7 +102,7 @@ class RideStateMachine extends StateNotifier { final Map _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 { } Future 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 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)), ), ), ], diff --git a/lib/models/motorcycle_models.dart b/lib/models/motorcycle_models.dart new file mode 100644 index 0000000..af6e8fa --- /dev/null +++ b/lib/models/motorcycle_models.dart @@ -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 json) { + return MotorcycleMake( + id: json['id']?.toString() ?? json['MakeId']?.toString() ?? '', + name: json['name']?.toString() ?? json['MakeName']?.toString() ?? '', + ); + } + + Map 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 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 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 json) { + return MotorcycleVariant( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + ); + } + + Map 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 toJson() => { + 'year': year, + 'makeId': makeId, + 'makeName': makeName, + 'modelId': modelId, + 'modelName': modelName, + 'variantId': variantId, + 'variantName': variantName, + }; + + factory MotorcycleSelection.fromJson(Map 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 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 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, + ); + } +} diff --git a/lib/services/motorcycle/local_motorcycle_data_provider.dart b/lib/services/motorcycle/local_motorcycle_data_provider.dart new file mode 100644 index 0000000..697a8be --- /dev/null +++ b/lib/services/motorcycle/local_motorcycle_data_provider.dart @@ -0,0 +1,528 @@ +import '../../models/motorcycle_models.dart'; +import 'motorcycle_data_provider.dart'; + +class LocalMotorcycleDataProvider implements MotorcycleDataProvider { + static final List _popularMakes = [ + 'Yamaha', + 'Kawasaki', + 'Honda', + 'Suzuki', + 'Ducati', + 'BMW', + 'Harley-Davidson', + 'KTM', + 'Triumph', + 'Aprilia', + 'Indian', + ]; + + static final Map> _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 _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> getYears() async { + final currentYear = DateTime.now().year + 1; + return List.generate( + currentYear - 1970 + 1, + (index) => MotorcycleYear(currentYear - index), + ); + } + + @override + Future> getMakes(int year) async { + return _popularMakes.map((name) => MotorcycleMake(id: name.toLowerCase(), name: name)).toList(); + } + + @override + Future> 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> 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 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(), + ); + } +} diff --git a/lib/services/motorcycle/motorcycle_data_provider.dart b/lib/services/motorcycle/motorcycle_data_provider.dart new file mode 100644 index 0000000..bfac02d --- /dev/null +++ b/lib/services/motorcycle/motorcycle_data_provider.dart @@ -0,0 +1,22 @@ +import '../../models/motorcycle_models.dart'; + +abstract class MotorcycleDataProvider { + Future> getYears(); + + Future> getMakes(int year); + + Future> getModels( + int year, + String makeId, + ); + + Future> getVariants( + int year, + String makeId, + String modelId, + ); + + Future getSpecifications( + MotorcycleSelection selection, + ); +} diff --git a/lib/services/motorcycle/motorcycle_repository.dart b/lib/services/motorcycle/motorcycle_repository.dart new file mode 100644 index 0000000..c1d4e33 --- /dev/null +++ b/lib/services/motorcycle/motorcycle_repository.dart @@ -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> getYears(); + + Future> getMakes(int year); + + Future> getModels( + int year, + String makeId, + ); + + Future> getVariants( + int year, + String makeId, + String modelId, + ); + + Future getSpecifications( + MotorcycleSelection selection, + ); +} + +class HybridMotorcycleRepository implements MotorcycleRepository { + final MotorcycleDataProvider _remoteProvider; + final MotorcycleDataProvider _localProvider; + + final Map> _makeCache = {}; + final Map> _modelCache = {}; + final Map _specCache = {}; + + HybridMotorcycleRepository({ + MotorcycleDataProvider? remoteProvider, + MotorcycleDataProvider? localProvider, + }) : _remoteProvider = remoteProvider ?? NhtsaMotorcycleDataProvider(), + _localProvider = localProvider ?? LocalMotorcycleDataProvider(); + + @override + Future> getYears() async { + return _localProvider.getYears(); + } + + @override + Future> 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> 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> getVariants( + int year, + String makeId, + String modelId, + ) async { + return _localProvider.getVariants(year, makeId, modelId); + } + + @override + Future 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; + } +} diff --git a/lib/services/motorcycle/nhtsa_motorcycle_data_provider.dart b/lib/services/motorcycle/nhtsa_motorcycle_data_provider.dart new file mode 100644 index 0000000..5b5f4c1 --- /dev/null +++ b/lib/services/motorcycle/nhtsa_motorcycle_data_provider.dart @@ -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> getYears() async { + final currentYear = DateTime.now().year + 1; + final years = []; + for (int y = currentYear; y >= 1970; y--) { + years.add(MotorcycleYear(y)); + } + return years; + } + + @override + Future> 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 = {}; + 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> 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 = {}; + 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> 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 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; + } +} diff --git a/test/garage_test.dart b/test/garage_test.dart new file mode 100644 index 0000000..4933fe7 --- /dev/null +++ b/test/garage_test.dart @@ -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)); + }); + }); +}