OpenStreetMap-Pre-Implementation
This commit is contained in:
parent
00fe459e0e
commit
7e8b909ff4
|
|
@ -6,6 +6,9 @@ import '../features/history/history_screen.dart';
|
|||
import '../features/records/records_screen.dart';
|
||||
import '../features/settings/settings_screen.dart';
|
||||
import '../features/settings/diagnostics_screen.dart';
|
||||
import '../features/garage/garage_screen.dart';
|
||||
import '../features/garage/add_motorcycle_screen.dart';
|
||||
import '../features/garage/motorcycle_detail_screen.dart';
|
||||
import 'widgets/main_layout.dart';
|
||||
|
||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
|
@ -51,6 +54,27 @@ final appRouter = GoRouter(
|
|||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/garage',
|
||||
builder: (context, state) => const GarageScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'add',
|
||||
builder: (context, state) => const AddMotorcycleScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
builder: (context, state) {
|
||||
final id = int.parse(state.pathParameters['id']!);
|
||||
return MotorcycleDetailScreen(motorcycleId: id);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ import 'tables.dart';
|
|||
|
||||
part 'database.g.dart';
|
||||
|
||||
@DriftDatabase(tables: [Rides, PerformanceRuns, TelemetrySamples, RideEvents])
|
||||
@DriftDatabase(tables: [Rides, PerformanceRuns, TelemetrySamples, RideEvents, GarageMotorcycles])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
int get schemaVersion => 3;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
|
|
@ -37,6 +37,14 @@ class AppDatabase extends _$AppDatabase {
|
|||
await m.createTable(telemetrySamples);
|
||||
await m.createTable(rideEvents);
|
||||
}
|
||||
if (from < 3) {
|
||||
await m.createTable(garageMotorcycles);
|
||||
await m.addColumn(rides, rides.motorcycleId);
|
||||
await m.addColumn(rides, rides.motorcycleYear);
|
||||
await m.addColumn(rides, rides.motorcycleMake);
|
||||
await m.addColumn(rides, rides.motorcycleModel);
|
||||
await m.addColumn(rides, rides.motorcycleVariant);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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()();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,447 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../models/motorcycle_models.dart';
|
||||
import 'garage_providers.dart';
|
||||
|
||||
class AddMotorcycleScreen extends ConsumerStatefulWidget {
|
||||
const AddMotorcycleScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AddMotorcycleScreen> createState() => _AddMotorcycleScreenState();
|
||||
}
|
||||
|
||||
class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
||||
int _step = 1; // 1: Year, 2: Make, 3: Model, 4: Variant, 5: Confirm
|
||||
|
||||
int? _selectedYear;
|
||||
MotorcycleMake? _selectedMake;
|
||||
MotorcycleModel? _selectedModel;
|
||||
MotorcycleVariant? _selectedVariant;
|
||||
MotorcycleSpecification? _previewSpec;
|
||||
|
||||
final TextEditingController _nicknameController = TextEditingController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
bool _isLoading = false;
|
||||
List<MotorcycleYear> _years = [];
|
||||
List<MotorcycleMake> _makes = [];
|
||||
List<MotorcycleModel> _models = [];
|
||||
List<MotorcycleVariant> _variants = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadYears();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nicknameController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadYears() async {
|
||||
setState(() => _isLoading = true);
|
||||
final repo = ref.read(motorcycleRepositoryProvider);
|
||||
final years = await repo.getYears();
|
||||
setState(() {
|
||||
_years = years;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _selectYear(int year) async {
|
||||
setState(() {
|
||||
_selectedYear = year;
|
||||
_step = 2;
|
||||
_isLoading = true;
|
||||
_searchController.clear();
|
||||
});
|
||||
final repo = ref.read(motorcycleRepositoryProvider);
|
||||
final makes = await repo.getMakes(year);
|
||||
setState(() {
|
||||
_makes = makes;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _selectMake(MotorcycleMake make) async {
|
||||
setState(() {
|
||||
_selectedMake = make;
|
||||
_step = 3;
|
||||
_isLoading = true;
|
||||
_searchController.clear();
|
||||
});
|
||||
final repo = ref.read(motorcycleRepositoryProvider);
|
||||
final models = await repo.getModels(_selectedYear!, make.id);
|
||||
setState(() {
|
||||
_models = models;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _selectModel(MotorcycleModel model) async {
|
||||
setState(() {
|
||||
_selectedModel = model;
|
||||
_isLoading = true;
|
||||
});
|
||||
final repo = ref.read(motorcycleRepositoryProvider);
|
||||
final variants = await repo.getVariants(_selectedYear!, _selectedMake!.id, model.id);
|
||||
|
||||
if (variants.length <= 1) {
|
||||
_selectedVariant = variants.isNotEmpty ? variants.first : null;
|
||||
await _loadSpecSummary();
|
||||
} else {
|
||||
setState(() {
|
||||
_variants = variants;
|
||||
_step = 4;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectVariant(MotorcycleVariant variant) async {
|
||||
setState(() {
|
||||
_selectedVariant = variant;
|
||||
});
|
||||
await _loadSpecSummary();
|
||||
}
|
||||
|
||||
Future<void> _loadSpecSummary() async {
|
||||
setState(() => _isLoading = true);
|
||||
final selection = MotorcycleSelection(
|
||||
year: _selectedYear!,
|
||||
makeId: _selectedMake!.id,
|
||||
makeName: _selectedMake!.name,
|
||||
modelId: _selectedModel!.id,
|
||||
modelName: _selectedModel!.name,
|
||||
variantId: _selectedVariant?.id,
|
||||
variantName: _selectedVariant?.name,
|
||||
);
|
||||
|
||||
final repo = ref.read(motorcycleRepositoryProvider);
|
||||
final spec = await repo.getSpecifications(selection);
|
||||
setState(() {
|
||||
_previewSpec = spec;
|
||||
_step = 5;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveMotorcycle() async {
|
||||
final selection = MotorcycleSelection(
|
||||
year: _selectedYear!,
|
||||
makeId: _selectedMake!.id,
|
||||
makeName: _selectedMake!.name,
|
||||
modelId: _selectedModel!.id,
|
||||
modelName: _selectedModel!.name,
|
||||
variantId: _selectedVariant?.id,
|
||||
variantName: _selectedVariant?.name,
|
||||
);
|
||||
|
||||
final controller = ref.read(garageControllerProvider);
|
||||
await controller.addMotorcycle(
|
||||
selection,
|
||||
nickname: _nicknameController.text.trim().isNotEmpty ? _nicknameController.text.trim() : null,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added ${selection.displayName} to your Garage!')),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
void _goBack() {
|
||||
if (_step > 1) {
|
||||
setState(() {
|
||||
_step -= 1;
|
||||
_searchController.clear();
|
||||
});
|
||||
} else {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: _step == 1,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _step > 1) {
|
||||
_goBack();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_getStepTitle()),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _goBack,
|
||||
),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
children: [
|
||||
LinearProgressIndicator(value: _step / 5.0, backgroundColor: Colors.white10, valueColor: const AlwaysStoppedAnimation(Colors.cyanAccent)),
|
||||
Expanded(child: _buildStepContent()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getStepTitle() {
|
||||
switch (_step) {
|
||||
case 1:
|
||||
return 'Select Year';
|
||||
case 2:
|
||||
return 'Select Manufacturer';
|
||||
case 3:
|
||||
return 'Select Model (${_selectedMake?.name ?? ""})';
|
||||
case 4:
|
||||
return 'Select Variant';
|
||||
case 5:
|
||||
return 'Confirm Motorcycle';
|
||||
default:
|
||||
return 'Add Motorcycle';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStepContent() {
|
||||
switch (_step) {
|
||||
case 1:
|
||||
return _buildYearStep();
|
||||
case 2:
|
||||
return _buildMakeStep();
|
||||
case 3:
|
||||
return _buildModelStep();
|
||||
case 4:
|
||||
return _buildVariantStep();
|
||||
case 5:
|
||||
return _buildConfirmStep();
|
||||
default:
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildYearStep() {
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 2.2,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
),
|
||||
itemCount: _years.length,
|
||||
itemBuilder: (context, index) {
|
||||
final year = _years[index].year;
|
||||
return OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: _selectedYear == year ? Colors.cyanAccent : Colors.white24),
|
||||
backgroundColor: _selectedYear == year ? Colors.cyanAccent.withValues(alpha: 0.2) : null,
|
||||
),
|
||||
onPressed: () => _selectYear(year),
|
||||
child: Text('$year', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMakeStep() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
final filtered = _makes.where((m) => m.name.toLowerCase().contains(query)).toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search Manufacturer...',
|
||||
prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent),
|
||||
filled: true,
|
||||
fillColor: Colors.white10,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1, color: Colors.white10),
|
||||
itemBuilder: (context, index) {
|
||||
final make = filtered[index];
|
||||
return ListTile(
|
||||
title: Text(make.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)),
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.white30),
|
||||
onTap: () => _selectMake(make),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModelStep() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
final filtered = _models.where((m) => m.name.toLowerCase().contains(query)).toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search Model...',
|
||||
prefixIcon: const Icon(Icons.search, color: Colors.cyanAccent),
|
||||
filled: true,
|
||||
fillColor: Colors.white10,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1, color: Colors.white10),
|
||||
itemBuilder: (context, index) {
|
||||
final model = filtered[index];
|
||||
return ListTile(
|
||||
title: Text(model.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)),
|
||||
subtitle: Text('${_selectedYear} ${_selectedMake?.name}'),
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.white30),
|
||||
onTap: () => _selectModel(model),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVariantStep() {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _variants.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final variant = _variants[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Text(variant.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
||||
trailing: const Icon(Icons.check_circle_outline, color: Colors.cyanAccent),
|
||||
onTap: () => _selectVariant(variant),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfirmStep() {
|
||||
final spec = _previewSpec;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.cyanAccent, width: 2),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.two_wheeler, size: 64, color: Colors.cyanAccent),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${_selectedYear} ${_selectedMake?.name} ${_selectedModel?.name}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (_selectedVariant?.name != null && _selectedVariant!.name != 'Standard') ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(_selectedVariant!.name, style: const TextStyle(color: Colors.white70, fontSize: 16)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (spec != null) ...[
|
||||
const Text('SPECIFICATION SUMMARY', style: TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold, letterSpacing: 1.2)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildSpecChip('DISPLACEMENT', spec.displacementCc != null ? '${spec.displacementCc!.toStringAsFixed(0)} cc' : '—')),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildSpecChip('HORSEPOWER', spec.horsepower != null ? '${spec.horsepower!.toStringAsFixed(0)} HP' : '—')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildSpecChip('TORQUE', spec.torqueLbFt != null ? '${spec.torqueLbFt!.toStringAsFixed(1)} lb-ft' : '—')),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildSpecChip('WET WEIGHT', spec.wetWeightLbs != null ? '${spec.wetWeightLbs!.toStringAsFixed(0)} lbs' : '—')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
TextField(
|
||||
controller: _nicknameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nickname (Optional)',
|
||||
hintText: 'e.g., Blue Beast, The Track Bike',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
prefixIcon: const Icon(Icons.edit, color: Colors.cyanAccent),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.cyanAccent,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
onPressed: _saveMotorcycle,
|
||||
child: const Text('ADD TO GARAGE', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 1.5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpecChip(String label, String value) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white54, letterSpacing: 1.0)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import 'dart:convert';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../database/database.dart';
|
||||
import '../../models/motorcycle_models.dart';
|
||||
import '../../services/motorcycle/motorcycle_repository.dart';
|
||||
|
||||
final motorcycleRepositoryProvider = Provider<MotorcycleRepository>((ref) {
|
||||
return HybridMotorcycleRepository();
|
||||
});
|
||||
|
||||
final garageMotorcyclesStreamProvider = StreamProvider<List<GarageMotorcycle>>((ref) {
|
||||
final db = ref.watch(databaseProvider);
|
||||
return (db.select(db.garageMotorcycles)
|
||||
..orderBy([(t) => OrderingTerm(expression: t.dateAdded, mode: OrderingMode.desc)]))
|
||||
.watch();
|
||||
});
|
||||
|
||||
final defaultMotorcycleProvider = Provider<GarageMotorcycle?>((ref) {
|
||||
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
|
||||
final bikes = bikesAsync.value ?? [];
|
||||
try {
|
||||
return bikes.firstWhere((b) => b.isDefault);
|
||||
} catch (_) {
|
||||
return bikes.isNotEmpty ? bikes.first : null;
|
||||
}
|
||||
});
|
||||
|
||||
class MotorcycleStats {
|
||||
final int rideCount;
|
||||
final double totalDistanceMiles;
|
||||
final double topSpeedMph;
|
||||
final double? best0to60Ms;
|
||||
final double? best0to100Ms;
|
||||
final double maxLean;
|
||||
final double maxAccelG;
|
||||
final double maxBrakingG;
|
||||
|
||||
MotorcycleStats({
|
||||
required this.rideCount,
|
||||
required this.totalDistanceMiles,
|
||||
required this.topSpeedMph,
|
||||
this.best0to60Ms,
|
||||
this.best0to100Ms,
|
||||
required this.maxLean,
|
||||
required this.maxAccelG,
|
||||
required this.maxBrakingG,
|
||||
});
|
||||
}
|
||||
|
||||
final motorcyclePerformanceStatsProvider =
|
||||
FutureProvider.family<MotorcycleStats, int>((ref, motorcycleId) async {
|
||||
final db = ref.watch(databaseProvider);
|
||||
final rides = await (db.select(db.rides)..where((t) => t.motorcycleId.equals(motorcycleId))).get();
|
||||
|
||||
if (rides.isEmpty) {
|
||||
return MotorcycleStats(
|
||||
rideCount: 0,
|
||||
totalDistanceMiles: 0.0,
|
||||
topSpeedMph: 0.0,
|
||||
maxLean: 0.0,
|
||||
maxAccelG: 0.0,
|
||||
maxBrakingG: 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
int count = rides.length;
|
||||
double totalDistance = 0.0;
|
||||
double topSpeed = 0.0;
|
||||
double? best0to60;
|
||||
double? best0to100;
|
||||
double maxLean = 0.0;
|
||||
double maxAccel = 0.0;
|
||||
double maxBraking = 0.0;
|
||||
|
||||
for (var r in rides) {
|
||||
totalDistance += r.distanceMiles;
|
||||
if (r.topSpeedMph > topSpeed) topSpeed = r.topSpeedMph;
|
||||
|
||||
final lean = r.maxLeanLeft > r.maxLeanRight ? r.maxLeanLeft : r.maxLeanRight;
|
||||
if (lean > maxLean) maxLean = lean;
|
||||
|
||||
if (r.maxAccelG > maxAccel) maxAccel = r.maxAccelG;
|
||||
if (r.maxBrakingG > maxBraking) maxBraking = r.maxBrakingG;
|
||||
|
||||
if (r.best0to60Ms != null) {
|
||||
if (best0to60 == null || r.best0to60Ms! < best0to60) {
|
||||
best0to60 = r.best0to60Ms;
|
||||
}
|
||||
}
|
||||
if (r.best0to100Ms != null) {
|
||||
if (best0to100 == null || r.best0to100Ms! < best0to100) {
|
||||
best0to100 = r.best0to100Ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MotorcycleStats(
|
||||
rideCount: count,
|
||||
totalDistanceMiles: totalDistance,
|
||||
topSpeedMph: topSpeed,
|
||||
best0to60Ms: best0to60,
|
||||
best0to100Ms: best0to100,
|
||||
maxLean: maxLean,
|
||||
maxAccelG: maxAccel,
|
||||
maxBrakingG: maxBraking,
|
||||
);
|
||||
});
|
||||
|
||||
class GarageController {
|
||||
final AppDatabase _db;
|
||||
final MotorcycleRepository _repository;
|
||||
|
||||
GarageController(this._db, this._repository);
|
||||
|
||||
Future<int> addMotorcycle(MotorcycleSelection selection, {String? nickname}) async {
|
||||
final spec = await _repository.getSpecifications(selection);
|
||||
final specJson = spec != null ? json.encode(spec.toJson()) : null;
|
||||
|
||||
// Check existing default count
|
||||
final existingBikes = await _db.select(_db.garageMotorcycles).get();
|
||||
final isFirst = existingBikes.isEmpty;
|
||||
|
||||
return await _db.into(_db.garageMotorcycles).insert(
|
||||
GarageMotorcyclesCompanion.insert(
|
||||
year: selection.year,
|
||||
makeId: selection.makeId,
|
||||
makeName: selection.makeName,
|
||||
modelId: selection.modelId,
|
||||
modelName: selection.modelName,
|
||||
variantId: Value(selection.variantId),
|
||||
variantName: Value(selection.variantName),
|
||||
nickname: Value(nickname),
|
||||
isDefault: Value(isFirst),
|
||||
dateAdded: DateTime.now(),
|
||||
lastUpdated: DateTime.now(),
|
||||
specificationJson: Value(specJson),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setDefaultMotorcycle(int id) async {
|
||||
await (_db.update(_db.garageMotorcycles)..where((t) => t.id.equals(id).not()))
|
||||
.write(const GarageMotorcyclesCompanion(isDefault: Value(false)));
|
||||
await (_db.update(_db.garageMotorcycles)..where((t) => t.id.equals(id)))
|
||||
.write(const GarageMotorcyclesCompanion(isDefault: Value(true)));
|
||||
}
|
||||
|
||||
Future<void> updateNickname(int id, String? nickname) async {
|
||||
await (_db.update(_db.garageMotorcycles)..where((t) => t.id.equals(id)))
|
||||
.write(GarageMotorcyclesCompanion(nickname: Value(nickname)));
|
||||
}
|
||||
|
||||
Future<void> deleteMotorcycle(int id) async {
|
||||
await (_db.delete(_db.garageMotorcycles)..where((t) => t.id.equals(id))).go();
|
||||
|
||||
// If default was deleted, set next bike as default if available
|
||||
final remaining = await _db.select(_db.garageMotorcycles).get();
|
||||
if (remaining.isNotEmpty && !remaining.any((b) => b.isDefault)) {
|
||||
await setDefaultMotorcycle(remaining.first.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final garageControllerProvider = Provider<GarageController>((ref) {
|
||||
final db = ref.watch(databaseProvider);
|
||||
final repo = ref.watch(motorcycleRepositoryProvider);
|
||||
return GarageController(db, repo);
|
||||
});
|
||||
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,430 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../database/database.dart';
|
||||
import '../../models/motorcycle_models.dart';
|
||||
import 'garage_providers.dart';
|
||||
|
||||
class MotorcycleDetailScreen extends ConsumerWidget {
|
||||
final int motorcycleId;
|
||||
|
||||
const MotorcycleDetailScreen({super.key, required this.motorcycleId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bikesAsync = ref.watch(garageMotorcyclesStreamProvider);
|
||||
final statsAsync = ref.watch(motorcyclePerformanceStatsProvider(motorcycleId));
|
||||
|
||||
return bikesAsync.when(
|
||||
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
error: (err, _) => Scaffold(body: Center(child: Text('Error: $err'))),
|
||||
data: (bikes) {
|
||||
final bike = bikes.firstWhere(
|
||||
(b) => b.id == motorcycleId,
|
||||
orElse: () => GarageMotorcycle(
|
||||
id: 0,
|
||||
year: 2024,
|
||||
makeId: '',
|
||||
makeName: 'Unknown',
|
||||
modelId: '',
|
||||
modelName: 'Unknown',
|
||||
isDefault: false,
|
||||
dateAdded: DateTime.now(),
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
if (bike.id == 0) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: const Center(child: Text('Motorcycle not found')),
|
||||
);
|
||||
}
|
||||
|
||||
MotorcycleSpecification? spec;
|
||||
if (bike.specificationJson != null) {
|
||||
try {
|
||||
spec = MotorcycleSpecification.fromJson(json.decode(bike.specificationJson!));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('${bike.year} ${bike.makeName} ${bike.modelName}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: 'Edit Nickname',
|
||||
onPressed: () => _showEditNicknameDialog(context, ref, bike),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||
tooltip: 'Delete Motorcycle',
|
||||
onPressed: () => _confirmDelete(context, ref, bike),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeaderCard(context, ref, bike, spec),
|
||||
const SizedBox(height: 24),
|
||||
_buildMyPerformanceSection(context, statsAsync, spec),
|
||||
const SizedBox(height: 24),
|
||||
_buildSpecificationSheet(context, spec),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderCard(BuildContext context, WidgetRef ref, GarageMotorcycle bike, MotorcycleSpecification? spec) {
|
||||
return Card(
|
||||
color: const Color(0xFF161616),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: bike.isDefault ? Colors.cyanAccent : Colors.white12, width: 2),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.two_wheeler, size: 54, color: Colors.cyanAccent),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${bike.year}',
|
||||
style: const TextStyle(fontSize: 16, color: Colors.cyanAccent, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'${bike.makeName} ${bike.modelName}'.toUpperCase(),
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w900, letterSpacing: 0.5),
|
||||
),
|
||||
if (bike.nickname != null && bike.nickname!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
'"${bike.nickname}"',
|
||||
style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.amberAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(color: Colors.white10),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildHeaderStat('DISPLACEMENT', spec?.displacementCc != null ? '${spec!.displacementCc!.toStringAsFixed(0)} cc' : '—'),
|
||||
_buildHeaderStat('POWER', spec?.horsepower != null ? '${spec!.horsepower!.toStringAsFixed(0)} HP' : '—'),
|
||||
_buildHeaderStat('TORQUE', spec?.torqueLbFt != null ? '${spec!.torqueLbFt!.toStringAsFixed(1)} lb-ft' : '—'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: bike.isDefault
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.cyanAccent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.cyanAccent),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'CURRENT MOTORCYCLE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.5, color: Colors.cyanAccent),
|
||||
),
|
||||
),
|
||||
)
|
||||
: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: const BorderSide(color: Colors.cyanAccent),
|
||||
foregroundColor: Colors.cyanAccent,
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(garageControllerProvider).setDefaultMotorcycle(bike.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Set ${bike.makeName} ${bike.modelName} as current motorcycle.')),
|
||||
);
|
||||
},
|
||||
child: const Text('SET AS CURRENT MOTORCYCLE', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderStat(String label, String value) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white54, letterSpacing: 1.2)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMyPerformanceSection(BuildContext context, AsyncValue<MotorcycleStats> statsAsync, MotorcycleSpecification? spec) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'MY PERFORMANCE',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.cyanAccent, letterSpacing: 1.2),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
statsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Text('Error loading stats: $err'),
|
||||
data: (stats) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildPerfTile('TOTAL RIDES', '${stats.rideCount}')),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildPerfTile('TOTAL DISTANCE', '${stats.totalDistanceMiles.toStringAsFixed(1)} MI')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildPerfTile(
|
||||
'BEST TOP SPEED',
|
||||
'${stats.topSpeedMph.toStringAsFixed(1)} MPH',
|
||||
published: spec?.claimedTopSpeedMph != null ? '~${spec!.claimedTopSpeedMph!.toStringAsFixed(0)} MPH' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildPerfTile(
|
||||
'BEST 0–60',
|
||||
stats.best0to60Ms != null ? '${(stats.best0to60Ms! / 1000.0).toStringAsFixed(2)} s' : '—',
|
||||
published: spec?.claimedZeroToSixtySec != null ? '~${spec!.claimedZeroToSixtySec!.toStringAsFixed(1)} s' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildPerfTile('MAX LEAN', '${stats.maxLean.toStringAsFixed(1)}°')),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildPerfTile('MAX ACCEL G', '${stats.maxAccelG.toStringAsFixed(2)} G')),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPerfTile(String label, String value, {String? published}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white54, letterSpacing: 1.0)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
if (published != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('PUBLISHED: $published', style: const TextStyle(fontSize: 10, color: Colors.cyanAccent)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpecificationSheet(BuildContext context, MotorcycleSpecification? spec) {
|
||||
if (spec == null) {
|
||||
return const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('Detailed specifications unavailable for this motorcycle.', style: TextStyle(color: Colors.white54)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'SPECIFICATIONS',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.cyanAccent, letterSpacing: 1.2),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildSpecGroup('ENGINE', [
|
||||
_SpecRow('Displacement', spec.displacementCc != null ? '${spec.displacementCc!.toStringAsFixed(0)} cc' : null),
|
||||
_SpecRow('Configuration', spec.engineConfiguration),
|
||||
_SpecRow('Cylinders', spec.cylinderCount?.toString()),
|
||||
_SpecRow('Horsepower', spec.horsepower != null ? '${spec.horsepower!.toStringAsFixed(0)} HP @ ${spec.horsepowerRpm ?? "—"} RPM' : null),
|
||||
_SpecRow('Torque', spec.torqueLbFt != null ? '${spec.torqueLbFt!.toStringAsFixed(1)} lb-ft @ ${spec.torqueRpm ?? "—"} RPM' : null),
|
||||
_SpecRow('Redline', spec.redlineRpm != null ? '${spec.redlineRpm} RPM' : null),
|
||||
_SpecRow('Compression Ratio', spec.compressionRatio),
|
||||
_SpecRow('Cooling System', spec.coolingSystem),
|
||||
_SpecRow('Fuel System', spec.fuelSystem),
|
||||
]),
|
||||
_buildSpecGroup('PERFORMANCE (PUBLISHED)', [
|
||||
_SpecRow('Claimed Top Speed', spec.claimedTopSpeedMph != null ? '~${spec.claimedTopSpeedMph!.toStringAsFixed(0)} MPH' : null),
|
||||
_SpecRow('Claimed 0–60 MPH', spec.claimedZeroToSixtySec != null ? '~${spec.claimedZeroToSixtySec!.toStringAsFixed(1)} sec' : null),
|
||||
_SpecRow('Claimed 1/4 Mile', spec.claimedQuarterMileSec != null ? '~${spec.claimedQuarterMileSec!.toStringAsFixed(1)} sec' : null),
|
||||
]),
|
||||
_buildSpecGroup('WEIGHT & DIMENSIONS', [
|
||||
_SpecRow('Wet Weight', spec.wetWeightLbs != null ? '${spec.wetWeightLbs!.toStringAsFixed(0)} lbs' : null),
|
||||
_SpecRow('Dry Weight', spec.dryWeightLbs != null ? '${spec.dryWeightLbs!.toStringAsFixed(0)} lbs' : null),
|
||||
_SpecRow('Seat Height', spec.seatHeightInches != null ? '${spec.seatHeightInches!.toStringAsFixed(1)} in' : null),
|
||||
_SpecRow('Wheelbase', spec.wheelbaseInches != null ? '${spec.wheelbaseInches!.toStringAsFixed(1)} in' : null),
|
||||
]),
|
||||
_buildSpecGroup('FUEL', [
|
||||
_SpecRow('Tank Capacity', spec.fuelCapacityGallons != null ? '${spec.fuelCapacityGallons!.toStringAsFixed(1)} gal' : null),
|
||||
_SpecRow('Fuel Economy', spec.fuelEconomyMpg != null ? '${spec.fuelEconomyMpg!.toStringAsFixed(0)} MPG' : null),
|
||||
_SpecRow('Estimated Range', spec.estimatedRangeMiles != null ? '~${spec.estimatedRangeMiles!.toStringAsFixed(0)} miles' : null),
|
||||
]),
|
||||
_buildSpecGroup('CHASSIS & BRAKES', [
|
||||
_SpecRow('Frame Type', spec.frameType),
|
||||
_SpecRow('Front Suspension', spec.frontSuspension),
|
||||
_SpecRow('Rear Suspension', spec.rearSuspension),
|
||||
_SpecRow('Front Brake', spec.frontBrake),
|
||||
_SpecRow('Rear Brake', spec.rearBrake),
|
||||
_SpecRow('ABS System', spec.absType),
|
||||
_SpecRow('Front Tire', spec.frontTireSize),
|
||||
_SpecRow('Rear Tire', spec.rearTireSize),
|
||||
]),
|
||||
_buildSpecGroup('TRANSMISSION', [
|
||||
_SpecRow('Transmission Type', spec.transmissionType),
|
||||
_SpecRow('Gears', spec.gearCount?.toString()),
|
||||
_SpecRow('Final Drive', spec.finalDrive),
|
||||
]),
|
||||
if (spec.source != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Specifications Source: ${spec.source}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white38),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpecGroup(String title, List<_SpecRow> rows) {
|
||||
final validRows = rows.where((r) => r.value != null && r.value!.isNotEmpty).toList();
|
||||
if (validRows.isEmpty) return const SizedBox();
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.cyanAccent, fontSize: 14, letterSpacing: 1.0)),
|
||||
const SizedBox(height: 8),
|
||||
...validRows.map(
|
||||
(r) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(r.label, style: const TextStyle(color: Colors.white70)),
|
||||
Flexible(
|
||||
child: Text(
|
||||
r.value!,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditNicknameDialog(BuildContext context, WidgetRef ref, GarageMotorcycle bike) {
|
||||
final controller = TextEditingController(text: bike.nickname ?? '');
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Edit Nickname'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(hintText: 'Enter nickname'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.read(garageControllerProvider).updateNickname(bike.id, controller.text.trim().isNotEmpty ? controller.text.trim() : null);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, WidgetRef ref, GarageMotorcycle bike) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Delete Motorcycle?'),
|
||||
content: Text('Are you sure you want to remove ${bike.makeName} ${bike.modelName} from your Garage? Historical rides associated with this motorcycle will be preserved.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
|
||||
onPressed: () {
|
||||
ref.read(garageControllerProvider).deleteMotorcycle(bike.id);
|
||||
Navigator.pop(context);
|
||||
context.pop();
|
||||
},
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpecRow {
|
||||
final String label;
|
||||
final String? value;
|
||||
_SpecRow(this.label, this.value);
|
||||
}
|
||||
|
|
@ -2,105 +2,204 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../../database/database.dart';
|
||||
import '../garage/garage_providers.dart';
|
||||
import 'ride_detail_screen.dart';
|
||||
|
||||
class HistoryScreen extends ConsumerWidget {
|
||||
class HistoryScreen extends ConsumerStatefulWidget {
|
||||
const HistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<HistoryScreen> createState() => _HistoryScreenState();
|
||||
}
|
||||
|
||||
class _HistoryScreenState extends ConsumerState<HistoryScreen> {
|
||||
int? _selectedMotorcycleId; // null = All Motorcycles, -1 = Current, else specific ID
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final database = ref.watch(databaseProvider);
|
||||
|
||||
final garageBikesAsync = ref.watch(garageMotorcyclesStreamProvider);
|
||||
final defaultBike = ref.watch(defaultMotorcycleProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Ride History')),
|
||||
body: StreamBuilder<List<Ride>>(
|
||||
stream: database.select(database.rides).watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
}
|
||||
body: Column(
|
||||
children: [
|
||||
_buildFilterBar(garageBikesAsync.value ?? [], defaultBike),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<Ride>>(
|
||||
stream: database.select(database.rides).watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final rides = snapshot.data ?? [];
|
||||
// Sort by start time descending
|
||||
rides.sort((a, b) => b.startTime.compareTo(a.startTime));
|
||||
|
||||
if (rides.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No recorded rides yet.',
|
||||
style: TextStyle(fontSize: 18, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: rides.length,
|
||||
itemBuilder: (context, index) {
|
||||
final ride = rides[index];
|
||||
return Card(
|
||||
elevation: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => RideDetailScreen(ride: ride),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Ride ${ride.id}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)
|
||||
),
|
||||
Text(
|
||||
ride.startTime.toLocal().toString().split('.')[0],
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildStatCol('TRIP', '${ride.distanceMiles.toStringAsFixed(1)} mi'),
|
||||
_buildStatCol('TIME', '${(ride.movingTime / 60).toStringAsFixed(0)} min'),
|
||||
_buildStatCol('TOP SPD', '${ride.topSpeedMph.toStringAsFixed(0)} mph'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildStatCol('MAX LEAN', '${math.max(ride.maxLeanLeft, ride.maxLeanRight).toStringAsFixed(0)}°'),
|
||||
_buildStatCol('HARD BRK', '${ride.maxBrakingG.toStringAsFixed(2)}G'),
|
||||
_buildStatCol('ELEV GAIN', '${ride.elevationGain.toStringAsFixed(0)}m'),
|
||||
],
|
||||
),
|
||||
],
|
||||
var rides = snapshot.data ?? [];
|
||||
|
||||
// Filter logic
|
||||
if (_selectedMotorcycleId == -1 && defaultBike != null) {
|
||||
rides = rides.where((r) => r.motorcycleId == defaultBike.id).toList();
|
||||
} else if (_selectedMotorcycleId != null && _selectedMotorcycleId! > 0) {
|
||||
rides = rides.where((r) => r.motorcycleId == _selectedMotorcycleId).toList();
|
||||
}
|
||||
|
||||
// Sort by start time descending
|
||||
rides.sort((a, b) => b.startTime.compareTo(a.startTime));
|
||||
|
||||
if (rides.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No recorded rides matching filter.',
|
||||
style: TextStyle(fontSize: 18, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: rides.length,
|
||||
itemBuilder: (context, index) {
|
||||
final ride = rides[index];
|
||||
final bikeName = (ride.motorcycleYear != null && ride.motorcycleMake != null && ride.motorcycleModel != null)
|
||||
? '${ride.motorcycleYear} ${ride.motorcycleMake} ${ride.motorcycleModel}'
|
||||
: null;
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => RideDetailScreen(ride: ride),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Ride ${ride.id}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
||||
),
|
||||
if (bikeName != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.cyanAccent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Text(
|
||||
bikeName,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.cyanAccent),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
ride.startTime.toLocal().toString().split('.')[0],
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildStatCol('TRIP', '${ride.distanceMiles.toStringAsFixed(1)} mi'),
|
||||
_buildStatCol('TIME', '${(ride.movingTime / 60).toStringAsFixed(0)} min'),
|
||||
_buildStatCol('TOP SPD', '${ride.topSpeedMph.toStringAsFixed(0)} mph'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildStatCol('MAX LEAN', '${math.max(ride.maxLeanLeft, ride.maxLeanRight).toStringAsFixed(0)}°'),
|
||||
_buildStatCol('HARD BRK', '${ride.maxBrakingG.toStringAsFixed(2)}G'),
|
||||
_buildStatCol('ELEV GAIN', '${ride.elevationGain.toStringAsFixed(0)}m'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildFilterBar(List<GarageMotorcycle> bikes, GarageMotorcycle? defaultBike) {
|
||||
if (bikes.isEmpty) return const SizedBox();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.filter_alt, color: Colors.cyanAccent, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int?>(
|
||||
value: _selectedMotorcycleId,
|
||||
isExpanded: true,
|
||||
dropdownColor: const Color(0xFF1E1E1E),
|
||||
hint: const Text('Filter by Motorcycle'),
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('All Motorcycles'),
|
||||
),
|
||||
if (defaultBike != null)
|
||||
DropdownMenuItem<int?>(
|
||||
value: -1,
|
||||
child: Text('Current (${defaultBike.modelName})'),
|
||||
),
|
||||
...bikes.map((b) => DropdownMenuItem<int?>(
|
||||
value: b.id,
|
||||
child: Text('${b.year} ${b.makeName} ${b.modelName}'),
|
||||
)),
|
||||
],
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_selectedMotorcycleId = val;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatCol(String label, String val) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import '../../database/database.dart';
|
||||
import '../../services/export_service.dart';
|
||||
import '../replay/replay_screen.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class RideDetailScreen extends ConsumerStatefulWidget {
|
||||
final Ride ride;
|
||||
|
|
@ -42,6 +43,42 @@ class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
|
|||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (r.motorcycleMake != null && r.motorcycleModel != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.cyanAccent.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.cyanAccent.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.two_wheeler, color: Colors.cyanAccent, size: 32),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${r.motorcycleYear ?? ""} ${r.motorcycleMake} ${r.motorcycleModel}',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (r.motorcycleVariant != null && r.motorcycleVariant!.isNotEmpty)
|
||||
Text(r.motorcycleVariant!, style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (r.motorcycleId != null)
|
||||
OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(foregroundColor: Colors.cyanAccent, side: const BorderSide(color: Colors.cyanAccent)),
|
||||
onPressed: () => context.push('/garage/${r.motorcycleId}'),
|
||||
child: const Text('View Specs'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
_buildSectionHeader('SUMMARY'),
|
||||
_buildStatRow('Distance', '${r.distanceMiles.toStringAsFixed(2)} mi'),
|
||||
_buildStatRow('Duration', '${r.endTime?.difference(r.startTime).inMinutes ?? 0} min'),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import '../../models/telemetry_sample.dart';
|
|||
import '../../services/telemetry_service.dart';
|
||||
import '../../services/audio_alert_service.dart';
|
||||
import '../records/records_service.dart';
|
||||
import '../garage/garage_providers.dart';
|
||||
|
||||
enum RideState { idle, ready, riding, paused, finished }
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
final TelemetryService telemetryService;
|
||||
final RecordsService recordsService;
|
||||
final AudioAlertService audioAlertService;
|
||||
final Ref ref;
|
||||
|
||||
StreamSubscription? _telemetrySub;
|
||||
|
||||
|
|
@ -100,7 +102,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
final Map<String, DateTime> _lastEventTimes = {};
|
||||
static const Duration _eventCooldown = Duration(seconds: 5);
|
||||
|
||||
RideStateMachine(this.db, this.telemetryService, this.recordsService, this.audioAlertService) : super(RideSession()) {
|
||||
RideStateMachine(this.db, this.telemetryService, this.recordsService, this.audioAlertService, this.ref) : super(RideSession()) {
|
||||
_telemetrySub = telemetryService.telemetryStream.listen(_onTelemetryUpdate);
|
||||
|
||||
// Write to DB every 5 seconds
|
||||
|
|
@ -112,7 +114,15 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
}
|
||||
|
||||
Future<void> startRide() async {
|
||||
final id = await db.startRide();
|
||||
final defaultBike = ref.read(defaultMotorcycleProvider);
|
||||
final id = await db.into(db.rides).insert(RidesCompanion.insert(
|
||||
startTime: DateTime.now(),
|
||||
motorcycleId: drift.Value(defaultBike?.id),
|
||||
motorcycleYear: drift.Value(defaultBike?.year),
|
||||
motorcycleMake: drift.Value(defaultBike?.makeName),
|
||||
motorcycleModel: drift.Value(defaultBike?.modelName),
|
||||
motorcycleVariant: drift.Value(defaultBike?.variantName),
|
||||
));
|
||||
state = state.copyWith(
|
||||
dbId: id,
|
||||
state: RideState.riding,
|
||||
|
|
@ -382,6 +392,6 @@ final rideStateMachineProvider = StateNotifierProvider<RideStateMachine, RideSes
|
|||
final telemetryService = ref.watch(telemetryServiceProvider);
|
||||
final recordsService = ref.watch(recordsServiceProvider.notifier);
|
||||
final audioAlertService = ref.watch(audioAlertServiceProvider);
|
||||
return RideStateMachine(db, telemetryService, recordsService, audioAlertService);
|
||||
return RideStateMachine(db, telemetryService, recordsService, audioAlertService, ref);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -20,30 +20,48 @@ class SettingsScreen extends ConsumerWidget {
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ListTile(
|
||||
leading: Icon(Icons.screen_rotation),
|
||||
title: Text('Calibrate Mount Angle'),
|
||||
subtitle: Text('Place the motorcycle perfectly upright and tap this to zero the lean angle sensor.'),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.screen_rotation, color: Colors.cyanAccent),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Calibrate Mount Angle',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Place the motorcycle perfectly upright and tap this to zero the lean angle sensor.',
|
||||
style: TextStyle(fontSize: 13, color: Colors.white70, height: 1.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0, bottom: 8.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
ref.read(sensorFusionServiceProvider).calibrateUpright();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Sensors Calibrated!')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Calibrate'),
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
ref.read(sensorFusionServiceProvider).calibrateUpright();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Sensors Calibrated!')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Calibrate'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -57,43 +75,72 @@ class SettingsScreen extends ConsumerWidget {
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.monitor_heart),
|
||||
title: const Text('Sensor Diagnostics'),
|
||||
subtitle: const Text('View raw IMU and GPS values.'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => context.go('/settings/diagnostics'),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.monitor_heart, color: Colors.cyanAccent),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Sensor Diagnostics', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 4),
|
||||
Text('View raw IMU and GPS values.', style: TextStyle(fontSize: 13, color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: Colors.white54),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ListTile(
|
||||
leading: Icon(Icons.delete_forever, color: Colors.redAccent),
|
||||
title: Text('Clear Ride History'),
|
||||
subtitle: Text('Permanently delete all recorded rides and performance runs.'),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.delete_forever, color: Colors.redAccent),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Clear Ride History', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 4),
|
||||
Text('Permanently delete all recorded rides and performance runs.', style: TextStyle(fontSize: 13, color: Colors.white70, height: 1.3)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0, bottom: 8.0),
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red.withValues(alpha: 0.2)),
|
||||
onPressed: () async {
|
||||
final db = ref.read(databaseProvider);
|
||||
await db.delete(db.rides).go();
|
||||
await db.delete(db.performanceRuns).go();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('All data cleared.')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Clear', style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red.withValues(alpha: 0.2)),
|
||||
onPressed: () async {
|
||||
final db = ref.read(databaseProvider);
|
||||
await db.delete(db.rides).go();
|
||||
await db.delete(db.performanceRuns).go();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('All data cleared.')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Clear', style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,347 @@
|
|||
class MotorcycleYear {
|
||||
final int year;
|
||||
MotorcycleYear(this.year);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is MotorcycleYear &&
|
||||
runtimeType == other.runtimeType &&
|
||||
year == other.year;
|
||||
|
||||
@override
|
||||
int get hashCode => year.hashCode;
|
||||
}
|
||||
|
||||
class MotorcycleMake {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
MotorcycleMake({required this.id, required this.name});
|
||||
|
||||
factory MotorcycleMake.fromJson(Map<String, dynamic> json) {
|
||||
return MotorcycleMake(
|
||||
id: json['id']?.toString() ?? json['MakeId']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? json['MakeName']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {'id': id, 'name': name};
|
||||
}
|
||||
|
||||
class MotorcycleModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final String makeId;
|
||||
final int? year;
|
||||
|
||||
MotorcycleModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.makeId,
|
||||
this.year,
|
||||
});
|
||||
|
||||
factory MotorcycleModel.fromJson(Map<String, dynamic> json) {
|
||||
return MotorcycleModel(
|
||||
id: json['id']?.toString() ?? json['Model_ID']?.toString() ?? json['Model_Id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? json['Model_Name']?.toString() ?? '',
|
||||
makeId: json['makeId']?.toString() ?? json['Make_ID']?.toString() ?? '',
|
||||
year: json['year'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'makeId': makeId,
|
||||
'year': year,
|
||||
};
|
||||
}
|
||||
|
||||
class MotorcycleVariant {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
MotorcycleVariant({required this.id, required this.name});
|
||||
|
||||
factory MotorcycleVariant.fromJson(Map<String, dynamic> json) {
|
||||
return MotorcycleVariant(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {'id': id, 'name': name};
|
||||
}
|
||||
|
||||
class MotorcycleSelection {
|
||||
final int year;
|
||||
final String makeId;
|
||||
final String makeName;
|
||||
final String modelId;
|
||||
final String modelName;
|
||||
final String? variantId;
|
||||
final String? variantName;
|
||||
|
||||
MotorcycleSelection({
|
||||
required this.year,
|
||||
required this.makeId,
|
||||
required this.makeName,
|
||||
required this.modelId,
|
||||
required this.modelName,
|
||||
this.variantId,
|
||||
this.variantName,
|
||||
});
|
||||
|
||||
String get displayName {
|
||||
final base = '$year $makeName $modelName';
|
||||
if (variantName != null && variantName!.isNotEmpty) {
|
||||
return '$base $variantName';
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'year': year,
|
||||
'makeId': makeId,
|
||||
'makeName': makeName,
|
||||
'modelId': modelId,
|
||||
'modelName': modelName,
|
||||
'variantId': variantId,
|
||||
'variantName': variantName,
|
||||
};
|
||||
|
||||
factory MotorcycleSelection.fromJson(Map<String, dynamic> json) {
|
||||
return MotorcycleSelection(
|
||||
year: json['year'] as int,
|
||||
makeId: json['makeId'] as String,
|
||||
makeName: json['makeName'] as String,
|
||||
modelId: json['modelId'] as String,
|
||||
modelName: json['modelName'] as String,
|
||||
variantId: json['variantId'] as String?,
|
||||
variantName: json['variantName'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MotorcycleSpecification {
|
||||
// Basic
|
||||
final int year;
|
||||
final String manufacturer;
|
||||
final String model;
|
||||
final String? variant;
|
||||
final String? category;
|
||||
final String? marketRegion;
|
||||
|
||||
// Engine
|
||||
final double? displacementCc;
|
||||
final String? engineConfiguration;
|
||||
final int? cylinderCount;
|
||||
final double? boreMm;
|
||||
final double? strokeMm;
|
||||
final String? compressionRatio;
|
||||
final String? coolingSystem;
|
||||
final String? fuelSystem;
|
||||
final String? fuelType;
|
||||
final String? valveConfiguration;
|
||||
final double? horsepower;
|
||||
final int? horsepowerRpm;
|
||||
final double? torqueLbFt;
|
||||
final int? torqueRpm;
|
||||
final int? redlineRpm;
|
||||
|
||||
// Transmission
|
||||
final String? transmissionType;
|
||||
final int? gearCount;
|
||||
final String? finalDrive;
|
||||
|
||||
// Weight & Dimensions
|
||||
final double? wetWeightLbs;
|
||||
final double? dryWeightLbs;
|
||||
final double? seatHeightInches;
|
||||
final double? wheelbaseInches;
|
||||
final double? lengthInches;
|
||||
final double? widthInches;
|
||||
final double? heightInches;
|
||||
|
||||
// Fuel
|
||||
final double? fuelCapacityGallons;
|
||||
final double? fuelEconomyMpg;
|
||||
final double? estimatedRangeMiles;
|
||||
|
||||
// Chassis & Brakes & Wheels
|
||||
final String? frameType;
|
||||
final String? frontSuspension;
|
||||
final String? rearSuspension;
|
||||
final String? frontBrake;
|
||||
final String? rearBrake;
|
||||
final String? absType;
|
||||
final String? frontTireSize;
|
||||
final String? rearTireSize;
|
||||
|
||||
// Performance claims
|
||||
final double? claimedTopSpeedMph;
|
||||
final double? claimedZeroToSixtySec;
|
||||
final double? claimedQuarterMileSec;
|
||||
|
||||
// Metadata
|
||||
final String? source;
|
||||
final String? sourceUrl;
|
||||
final DateTime? lastUpdated;
|
||||
|
||||
MotorcycleSpecification({
|
||||
required this.year,
|
||||
required this.manufacturer,
|
||||
required this.model,
|
||||
this.variant,
|
||||
this.category,
|
||||
this.marketRegion,
|
||||
this.displacementCc,
|
||||
this.engineConfiguration,
|
||||
this.cylinderCount,
|
||||
this.boreMm,
|
||||
this.strokeMm,
|
||||
this.compressionRatio,
|
||||
this.coolingSystem,
|
||||
this.fuelSystem,
|
||||
this.fuelType,
|
||||
this.valveConfiguration,
|
||||
this.horsepower,
|
||||
this.horsepowerRpm,
|
||||
this.torqueLbFt,
|
||||
this.torqueRpm,
|
||||
this.redlineRpm,
|
||||
this.transmissionType,
|
||||
this.gearCount,
|
||||
this.finalDrive,
|
||||
this.wetWeightLbs,
|
||||
this.dryWeightLbs,
|
||||
this.seatHeightInches,
|
||||
this.wheelbaseInches,
|
||||
this.lengthInches,
|
||||
this.widthInches,
|
||||
this.heightInches,
|
||||
this.fuelCapacityGallons,
|
||||
this.fuelEconomyMpg,
|
||||
this.estimatedRangeMiles,
|
||||
this.frameType,
|
||||
this.frontSuspension,
|
||||
this.rearSuspension,
|
||||
this.frontBrake,
|
||||
this.rearBrake,
|
||||
this.absType,
|
||||
this.frontTireSize,
|
||||
this.rearTireSize,
|
||||
this.claimedTopSpeedMph,
|
||||
this.claimedZeroToSixtySec,
|
||||
this.claimedQuarterMileSec,
|
||||
this.source,
|
||||
this.sourceUrl,
|
||||
this.lastUpdated,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'year': year,
|
||||
'manufacturer': manufacturer,
|
||||
'model': model,
|
||||
'variant': variant,
|
||||
'category': category,
|
||||
'marketRegion': marketRegion,
|
||||
'displacementCc': displacementCc,
|
||||
'engineConfiguration': engineConfiguration,
|
||||
'cylinderCount': cylinderCount,
|
||||
'boreMm': boreMm,
|
||||
'strokeMm': strokeMm,
|
||||
'compressionRatio': compressionRatio,
|
||||
'coolingSystem': coolingSystem,
|
||||
'fuelSystem': fuelSystem,
|
||||
'fuelType': fuelType,
|
||||
'valveConfiguration': valveConfiguration,
|
||||
'horsepower': horsepower,
|
||||
'horsepowerRpm': horsepowerRpm,
|
||||
'torqueLbFt': torqueLbFt,
|
||||
'torqueRpm': torqueRpm,
|
||||
'redlineRpm': redlineRpm,
|
||||
'transmissionType': transmissionType,
|
||||
'gearCount': gearCount,
|
||||
'finalDrive': finalDrive,
|
||||
'wetWeightLbs': wetWeightLbs,
|
||||
'dryWeightLbs': dryWeightLbs,
|
||||
'seatHeightInches': seatHeightInches,
|
||||
'wheelbaseInches': wheelbaseInches,
|
||||
'lengthInches': lengthInches,
|
||||
'widthInches': widthInches,
|
||||
'heightInches': heightInches,
|
||||
'fuelCapacityGallons': fuelCapacityGallons,
|
||||
'fuelEconomyMpg': fuelEconomyMpg,
|
||||
'estimatedRangeMiles': estimatedRangeMiles,
|
||||
'frameType': frameType,
|
||||
'frontSuspension': frontSuspension,
|
||||
'rearSuspension': rearSuspension,
|
||||
'frontBrake': frontBrake,
|
||||
'rearBrake': rearBrake,
|
||||
'absType': absType,
|
||||
'frontTireSize': frontTireSize,
|
||||
'rearTireSize': rearTireSize,
|
||||
'claimedTopSpeedMph': claimedTopSpeedMph,
|
||||
'claimedZeroToSixtySec': claimedZeroToSixtySec,
|
||||
'claimedQuarterMileSec': claimedQuarterMileSec,
|
||||
'source': source,
|
||||
'sourceUrl': sourceUrl,
|
||||
'lastUpdated': lastUpdated?.toIso8601String(),
|
||||
};
|
||||
|
||||
factory MotorcycleSpecification.fromJson(Map<String, dynamic> json) {
|
||||
return MotorcycleSpecification(
|
||||
year: json['year'] as int,
|
||||
manufacturer: json['manufacturer'] as String,
|
||||
model: json['model'] as String,
|
||||
variant: json['variant'] as String?,
|
||||
category: json['category'] as String?,
|
||||
marketRegion: json['marketRegion'] as String?,
|
||||
displacementCc: (json['displacementCc'] as num?)?.toDouble(),
|
||||
engineConfiguration: json['engineConfiguration'] as String?,
|
||||
cylinderCount: json['cylinderCount'] as int?,
|
||||
boreMm: (json['boreMm'] as num?)?.toDouble(),
|
||||
strokeMm: (json['strokeMm'] as num?)?.toDouble(),
|
||||
compressionRatio: json['compressionRatio'] as String?,
|
||||
coolingSystem: json['coolingSystem'] as String?,
|
||||
fuelSystem: json['fuelSystem'] as String?,
|
||||
fuelType: json['fuelType'] as String?,
|
||||
valveConfiguration: json['valveConfiguration'] as String?,
|
||||
horsepower: (json['horsepower'] as num?)?.toDouble(),
|
||||
horsepowerRpm: json['horsepowerRpm'] as int?,
|
||||
torqueLbFt: (json['torqueLbFt'] as num?)?.toDouble(),
|
||||
torqueRpm: json['torqueRpm'] as int?,
|
||||
redlineRpm: json['redlineRpm'] as int?,
|
||||
transmissionType: json['transmissionType'] as String?,
|
||||
gearCount: json['gearCount'] as int?,
|
||||
finalDrive: json['finalDrive'] as String?,
|
||||
wetWeightLbs: (json['wetWeightLbs'] as num?)?.toDouble(),
|
||||
dryWeightLbs: (json['dryWeightLbs'] as num?)?.toDouble(),
|
||||
seatHeightInches: (json['seatHeightInches'] as num?)?.toDouble(),
|
||||
wheelbaseInches: (json['wheelbaseInches'] as num?)?.toDouble(),
|
||||
lengthInches: (json['lengthInches'] as num?)?.toDouble(),
|
||||
widthInches: (json['widthInches'] as num?)?.toDouble(),
|
||||
heightInches: (json['heightInches'] as num?)?.toDouble(),
|
||||
fuelCapacityGallons: (json['fuelCapacityGallons'] as num?)?.toDouble(),
|
||||
fuelEconomyMpg: (json['fuelEconomyMpg'] as num?)?.toDouble(),
|
||||
estimatedRangeMiles: (json['estimatedRangeMiles'] as num?)?.toDouble(),
|
||||
frameType: json['frameType'] as String?,
|
||||
frontSuspension: json['frontSuspension'] as String?,
|
||||
rearSuspension: json['rearSuspension'] as String?,
|
||||
frontBrake: json['frontBrake'] as String?,
|
||||
rearBrake: json['rearBrake'] as String?,
|
||||
absType: json['absType'] as String?,
|
||||
frontTireSize: json['frontTireSize'] as String?,
|
||||
rearTireSize: json['rearTireSize'] as String?,
|
||||
claimedTopSpeedMph: (json['claimedTopSpeedMph'] as num?)?.toDouble(),
|
||||
claimedZeroToSixtySec: (json['claimedZeroToSixtySec'] as num?)?.toDouble(),
|
||||
claimedQuarterMileSec: (json['claimedQuarterMileSec'] as num?)?.toDouble(),
|
||||
source: json['source'] as String?,
|
||||
sourceUrl: json['sourceUrl'] as String?,
|
||||
lastUpdated: json['lastUpdated'] != null ? DateTime.parse(json['lastUpdated']) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,528 @@
|
|||
import '../../models/motorcycle_models.dart';
|
||||
import 'motorcycle_data_provider.dart';
|
||||
|
||||
class LocalMotorcycleDataProvider implements MotorcycleDataProvider {
|
||||
static final List<String> _popularMakes = [
|
||||
'Yamaha',
|
||||
'Kawasaki',
|
||||
'Honda',
|
||||
'Suzuki',
|
||||
'Ducati',
|
||||
'BMW',
|
||||
'Harley-Davidson',
|
||||
'KTM',
|
||||
'Triumph',
|
||||
'Aprilia',
|
||||
'Indian',
|
||||
];
|
||||
|
||||
static final Map<String, List<String>> _makeModels = {
|
||||
'Yamaha': ['MT-03', 'MT-07', 'MT-09', 'MT-10', 'YZF-R3', 'YZF-R6', 'YZF-R7', 'YZF-R1', 'Tenere 700', 'XSR700', 'XSR900', 'Tracer 9 GT'],
|
||||
'Kawasaki': ['Ninja 400', 'Ninja 500', 'Ninja 650', 'Ninja ZX-4RR', 'Ninja ZX-6R', 'Ninja ZX-10R', 'Z650', 'Z900', 'Ninja 1000SX', 'ZH2'],
|
||||
'Honda': ['CBR300R', 'CBR500R', 'CBR600RR', 'CBR1000RR-R Fireblade', 'CB650R', 'CB1000R', 'Africa Twin CRF1100L', 'Rebel 500', 'Rebel 1100'],
|
||||
'Suzuki': ['GSX-250R', 'GSX-8S', 'GSX-R600', 'GSX-R750', 'GSX-R1000', 'Hayabusa', 'GSX-S1000', 'V-Strom 650', 'V-Strom 800DE'],
|
||||
'Ducati': ['Panigale V2', 'Panigale V4', 'Monster 937', 'Streetfighter V2', 'Streetfighter V4', 'Multistrada V4 S', 'Hypermotard 950', 'Scrambler 800'],
|
||||
'BMW': ['G310R', 'F900R', 'S1000R', 'S1000RR', 'M1000RR', 'R1250GS', 'R1300GS', 'R nineT'],
|
||||
'Harley-Davidson': ['Iron 883', 'Sportster S', 'Pan America 1250', 'Street Bob 114', 'Fat Boy 114', 'Road Glide Special'],
|
||||
'KTM': ['390 Duke', '790 Duke', '890 Duke R', '1390 Super Duke R', '890 Adventure R', 'RC 390'],
|
||||
'Triumph': ['Trident 660', 'Daytona 660', 'Street Triple 765 R', 'Street Triple 765 RS', 'Speed Triple 1200 RS', 'Tiger 900 Rally Pro'],
|
||||
'Aprilia': ['RS457', 'RS660', 'Tuono 660', 'Tuono V4 Factory', 'RSV4 Factory', 'Tuareg 660'],
|
||||
'Indian': ['Scout Sixty', 'Scout Bobber', 'FTR 1200', 'Challenger Dark Horse'],
|
||||
};
|
||||
|
||||
static final Map<String, MotorcycleSpecification> _knownDatabase = {
|
||||
// YAMAHA
|
||||
'yamaha mt-09': MotorcycleSpecification(
|
||||
year: 2024,
|
||||
manufacturer: 'Yamaha',
|
||||
model: 'MT-09',
|
||||
category: 'Hyper Naked',
|
||||
displacementCc: 890,
|
||||
engineConfiguration: 'Crossplane Inline-3 (CP3)',
|
||||
cylinderCount: 3,
|
||||
boreMm: 78.0,
|
||||
strokeMm: 62.1,
|
||||
compressionRatio: '11.5:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
fuelSystem: 'Fuel Injection with YCC-T',
|
||||
fuelType: 'Premium Unleaded',
|
||||
valveConfiguration: 'DOHC, 4 valves per cylinder',
|
||||
horsepower: 117.0,
|
||||
horsepowerRpm: 10000,
|
||||
torqueLbFt: 68.6,
|
||||
torqueRpm: 7000,
|
||||
redlineRpm: 10500,
|
||||
transmissionType: 'Manual with Quickshifter',
|
||||
gearCount: 6,
|
||||
finalDrive: 'Chain',
|
||||
wetWeightLbs: 425.0,
|
||||
dryWeightLbs: 395.0,
|
||||
seatHeightInches: 32.5,
|
||||
wheelbaseInches: 56.3,
|
||||
fuelCapacityGallons: 3.7,
|
||||
fuelEconomyMpg: 48.0,
|
||||
estimatedRangeMiles: 175.0,
|
||||
frameType: 'Controlled Fill Die-Cast Aluminum Diamond',
|
||||
frontSuspension: '41mm KYB inverted fork, fully adjustable',
|
||||
rearSuspension: 'KYB single shock, adjustable preload & rebound',
|
||||
frontBrake: 'Dual 298mm hydraulic discs, radial mount',
|
||||
rearBrake: 'Single 245mm hydraulic disc',
|
||||
absType: 'Cornering ABS (6-axis IMU)',
|
||||
frontTireSize: '120/70ZR17',
|
||||
rearTireSize: '180/55ZR17',
|
||||
claimedTopSpeedMph: 136.0,
|
||||
claimedZeroToSixtySec: 3.2,
|
||||
claimedQuarterMileSec: 11.1,
|
||||
source: 'Yamaha Motor Official Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
'yamaha mt-07': MotorcycleSpecification(
|
||||
year: 2024,
|
||||
manufacturer: 'Yamaha',
|
||||
model: 'MT-07',
|
||||
category: 'Naked',
|
||||
displacementCc: 689,
|
||||
engineConfiguration: 'Parallel Twin (CP2)',
|
||||
cylinderCount: 2,
|
||||
compressionRatio: '11.5:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
fuelSystem: 'Fuel Injection',
|
||||
horsepower: 73.4,
|
||||
horsepowerRpm: 8750,
|
||||
torqueLbFt: 49.4,
|
||||
torqueRpm: 6500,
|
||||
redlineRpm: 10000,
|
||||
transmissionType: 'Manual',
|
||||
gearCount: 6,
|
||||
finalDrive: 'Chain',
|
||||
wetWeightLbs: 406.0,
|
||||
seatHeightInches: 31.7,
|
||||
wheelbaseInches: 55.1,
|
||||
fuelCapacityGallons: 3.7,
|
||||
fuelEconomyMpg: 58.0,
|
||||
estimatedRangeMiles: 215.0,
|
||||
frontTireSize: '120/70ZR17',
|
||||
rearTireSize: '180/55ZR17',
|
||||
claimedTopSpeedMph: 133.0,
|
||||
claimedZeroToSixtySec: 3.8,
|
||||
source: 'Yamaha Motor Official Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
'yamaha yzf-r1': MotorcycleSpecification(
|
||||
year: 2024,
|
||||
manufacturer: 'Yamaha',
|
||||
model: 'YZF-R1',
|
||||
category: 'Superbike',
|
||||
displacementCc: 998,
|
||||
engineConfiguration: 'Crossplane Inline-4 (CP4)',
|
||||
cylinderCount: 4,
|
||||
compressionRatio: '13.0:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
horsepower: 198.0,
|
||||
horsepowerRpm: 13500,
|
||||
torqueLbFt: 83.0,
|
||||
torqueRpm: 11500,
|
||||
redlineRpm: 14200,
|
||||
transmissionType: 'Manual',
|
||||
gearCount: 6,
|
||||
finalDrive: 'Chain',
|
||||
wetWeightLbs: 448.0,
|
||||
seatHeightInches: 33.7,
|
||||
wheelbaseInches: 55.3,
|
||||
fuelCapacityGallons: 4.5,
|
||||
fuelEconomyMpg: 33.0,
|
||||
claimedTopSpeedMph: 186.0,
|
||||
claimedZeroToSixtySec: 2.9,
|
||||
source: 'Yamaha Motor Official Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
|
||||
// KAWASAKI
|
||||
'kawasaki ninja zx-6r': MotorcycleSpecification(
|
||||
year: 2024,
|
||||
manufacturer: 'Kawasaki',
|
||||
model: 'Ninja ZX-6R',
|
||||
category: 'Supersport',
|
||||
displacementCc: 636,
|
||||
engineConfiguration: 'Inline-4',
|
||||
cylinderCount: 4,
|
||||
compressionRatio: '12.9:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
fuelSystem: 'DFI with 38mm Keihin throttle bodies',
|
||||
fuelType: 'Premium Unleaded',
|
||||
horsepower: 127.0,
|
||||
horsepowerRpm: 13000,
|
||||
torqueLbFt: 52.1,
|
||||
torqueRpm: 10800,
|
||||
redlineRpm: 16000,
|
||||
transmissionType: 'Manual',
|
||||
gearCount: 6,
|
||||
finalDrive: 'Chain',
|
||||
wetWeightLbs: 436.0,
|
||||
dryWeightLbs: 405.0,
|
||||
seatHeightInches: 32.7,
|
||||
wheelbaseInches: 55.1,
|
||||
fuelCapacityGallons: 4.5,
|
||||
fuelEconomyMpg: 40.0,
|
||||
estimatedRangeMiles: 180.0,
|
||||
frameType: 'Aluminum perimeter',
|
||||
frontSuspension: '41mm inverted Showa SFF-BP fork',
|
||||
rearSuspension: 'Bottom-link Uni-Trak gas-charged shock',
|
||||
frontBrake: 'Dual 310mm petal discs with radial-mount monobloc calipers',
|
||||
rearBrake: 'Single 220mm petal disc',
|
||||
absType: 'KIBS Intelligent ABS',
|
||||
frontTireSize: '120/70ZR17',
|
||||
rearTireSize: '180/55ZR17',
|
||||
claimedTopSpeedMph: 162.0,
|
||||
claimedZeroToSixtySec: 3.0,
|
||||
claimedQuarterMileSec: 10.8,
|
||||
source: 'Kawasaki Heavy Industries Official Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
'kawasaki z900': MotorcycleSpecification(
|
||||
year: 2024,
|
||||
manufacturer: 'Kawasaki',
|
||||
model: 'Z900',
|
||||
category: 'Supernaked',
|
||||
displacementCc: 948,
|
||||
engineConfiguration: 'Inline-4',
|
||||
cylinderCount: 4,
|
||||
compressionRatio: '11.8:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
horsepower: 125.0,
|
||||
horsepowerRpm: 9500,
|
||||
torqueLbFt: 73.1,
|
||||
torqueRpm: 7700,
|
||||
redlineRpm: 11000,
|
||||
transmissionType: 'Manual',
|
||||
gearCount: 6,
|
||||
finalDrive: 'Chain',
|
||||
wetWeightLbs: 467.0,
|
||||
seatHeightInches: 31.5,
|
||||
wheelbaseInches: 57.1,
|
||||
fuelCapacityGallons: 4.5,
|
||||
fuelEconomyMpg: 45.0,
|
||||
claimedTopSpeedMph: 145.0,
|
||||
claimedZeroToSixtySec: 3.3,
|
||||
source: 'Kawasaki Official Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
|
||||
// BMW
|
||||
'bmw s1000rr': MotorcycleSpecification(
|
||||
year: 2024,
|
||||
manufacturer: 'BMW',
|
||||
model: 'S1000RR',
|
||||
category: 'Superbike',
|
||||
displacementCc: 999,
|
||||
engineConfiguration: 'Inline-4 ShiftCam',
|
||||
cylinderCount: 4,
|
||||
compressionRatio: '13.3:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
horsepower: 205.0,
|
||||
horsepowerRpm: 13750,
|
||||
torqueLbFt: 83.0,
|
||||
torqueRpm: 11000,
|
||||
redlineRpm: 14600,
|
||||
transmissionType: 'Manual',
|
||||
gearCount: 6,
|
||||
finalDrive: 'Chain',
|
||||
wetWeightLbs: 434.0,
|
||||
seatHeightInches: 32.4,
|
||||
wheelbaseInches: 57.4,
|
||||
fuelCapacityGallons: 4.3,
|
||||
fuelEconomyMpg: 37.0,
|
||||
frontTireSize: '120/70ZR17',
|
||||
rearTireSize: '190/55ZR17',
|
||||
claimedTopSpeedMph: 188.0,
|
||||
claimedZeroToSixtySec: 2.8,
|
||||
claimedQuarterMileSec: 9.9,
|
||||
source: 'BMW Motorrad Official Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
|
||||
// DUCATI
|
||||
'ducati panigale v4': MotorcycleSpecification(
|
||||
year: 2024,
|
||||
manufacturer: 'Ducati',
|
||||
model: 'Panigale V4',
|
||||
category: 'Superbike',
|
||||
displacementCc: 1103,
|
||||
engineConfiguration: 'Desmosedici Stradale 90° V4',
|
||||
cylinderCount: 4,
|
||||
compressionRatio: '14.0:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
horsepower: 215.5,
|
||||
horsepowerRpm: 13000,
|
||||
torqueLbFt: 91.2,
|
||||
torqueRpm: 9500,
|
||||
redlineRpm: 14500,
|
||||
transmissionType: 'Manual with DQS Quickshifter',
|
||||
gearCount: 6,
|
||||
finalDrive: 'Chain',
|
||||
wetWeightLbs: 437.0,
|
||||
dryWeightLbs: 387.0,
|
||||
seatHeightInches: 33.5,
|
||||
wheelbaseInches: 57.8,
|
||||
fuelCapacityGallons: 4.49,
|
||||
fuelEconomyMpg: 32.0,
|
||||
claimedTopSpeedMph: 186.0,
|
||||
claimedZeroToSixtySec: 2.8,
|
||||
source: 'Ducati Motor Holding Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
|
||||
// HONDA
|
||||
'honda cbr600rr': MotorcycleSpecification(
|
||||
year: 2024,
|
||||
manufacturer: 'Honda',
|
||||
model: 'CBR600RR',
|
||||
category: 'Supersport',
|
||||
displacementCc: 599,
|
||||
engineConfiguration: 'Inline-4',
|
||||
cylinderCount: 4,
|
||||
compressionRatio: '12.2:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
horsepower: 119.0,
|
||||
horsepowerRpm: 14250,
|
||||
torqueLbFt: 47.0,
|
||||
torqueRpm: 11500,
|
||||
redlineRpm: 15500,
|
||||
transmissionType: 'Manual',
|
||||
gearCount: 6,
|
||||
finalDrive: 'Chain',
|
||||
wetWeightLbs: 425.0,
|
||||
seatHeightInches: 32.3,
|
||||
wheelbaseInches: 53.9,
|
||||
fuelCapacityGallons: 4.8,
|
||||
fuelEconomyMpg: 44.0,
|
||||
claimedTopSpeedMph: 160.0,
|
||||
claimedZeroToSixtySec: 3.1,
|
||||
source: 'Honda Powersports Official Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
|
||||
// SUZUKI
|
||||
'suzuki gsx-r750': MotorcycleSpecification(
|
||||
year: 2024,
|
||||
manufacturer: 'Suzuki',
|
||||
model: 'GSX-R750',
|
||||
category: 'Supersport',
|
||||
displacementCc: 750,
|
||||
engineConfiguration: 'Inline-4',
|
||||
cylinderCount: 4,
|
||||
compressionRatio: '12.5:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
horsepower: 148.0,
|
||||
horsepowerRpm: 13000,
|
||||
torqueLbFt: 64.0,
|
||||
torqueRpm: 11200,
|
||||
redlineRpm: 14500,
|
||||
transmissionType: 'Manual',
|
||||
gearCount: 6,
|
||||
finalDrive: 'Chain',
|
||||
wetWeightLbs: 419.0,
|
||||
seatHeightInches: 31.9,
|
||||
wheelbaseInches: 54.7,
|
||||
fuelCapacityGallons: 4.5,
|
||||
fuelEconomyMpg: 42.0,
|
||||
claimedTopSpeedMph: 170.0,
|
||||
claimedZeroToSixtySec: 2.9,
|
||||
source: 'Suzuki Motor Official Specs',
|
||||
lastUpdated: DateTime.now(),
|
||||
),
|
||||
};
|
||||
|
||||
@override
|
||||
Future<List<MotorcycleYear>> getYears() async {
|
||||
final currentYear = DateTime.now().year + 1;
|
||||
return List.generate(
|
||||
currentYear - 1970 + 1,
|
||||
(index) => MotorcycleYear(currentYear - index),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MotorcycleMake>> getMakes(int year) async {
|
||||
return _popularMakes.map((name) => MotorcycleMake(id: name.toLowerCase(), name: name)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MotorcycleModel>> getModels(int year, String makeId) async {
|
||||
final matchingMake = _popularMakes.firstWhere(
|
||||
(m) => m.toLowerCase() == makeId.toLowerCase() || m == makeId,
|
||||
orElse: () => 'Yamaha',
|
||||
);
|
||||
final models = _makeModels[matchingMake] ?? ['Standard Model'];
|
||||
return models
|
||||
.map((m) => MotorcycleModel(id: m.toLowerCase().replaceAll(' ', '-'), name: m, makeId: makeId, year: year))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MotorcycleVariant>> getVariants(int year, String makeId, String modelId) async {
|
||||
return [
|
||||
MotorcycleVariant(id: 'std', name: 'Standard'),
|
||||
MotorcycleVariant(id: 'abs', name: 'ABS Edition'),
|
||||
MotorcycleVariant(id: 'sp', name: 'SP / Performance'),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<MotorcycleSpecification?> getSpecifications(MotorcycleSelection selection) async {
|
||||
final normalizedKey = '${selection.makeName} ${selection.modelName}'
|
||||
.toLowerCase()
|
||||
.replaceAll('-', ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
// 1. Direct / Fuzzy Database Match
|
||||
for (var entry in _knownDatabase.entries) {
|
||||
final dbKey = entry.key.toLowerCase().replaceAll('-', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
if (normalizedKey == dbKey || normalizedKey.contains(dbKey) || dbKey.contains(normalizedKey)) {
|
||||
final spec = entry.value;
|
||||
return MotorcycleSpecification(
|
||||
year: selection.year,
|
||||
manufacturer: selection.makeName,
|
||||
model: selection.modelName,
|
||||
variant: selection.variantName,
|
||||
category: spec.category,
|
||||
displacementCc: spec.displacementCc,
|
||||
engineConfiguration: spec.engineConfiguration,
|
||||
cylinderCount: spec.cylinderCount,
|
||||
boreMm: spec.boreMm,
|
||||
strokeMm: spec.strokeMm,
|
||||
compressionRatio: spec.compressionRatio,
|
||||
coolingSystem: spec.coolingSystem,
|
||||
fuelSystem: spec.fuelSystem,
|
||||
fuelType: spec.fuelType,
|
||||
horsepower: spec.horsepower,
|
||||
horsepowerRpm: spec.horsepowerRpm,
|
||||
torqueLbFt: spec.torqueLbFt,
|
||||
torqueRpm: spec.torqueRpm,
|
||||
redlineRpm: spec.redlineRpm,
|
||||
transmissionType: spec.transmissionType,
|
||||
gearCount: spec.gearCount,
|
||||
finalDrive: spec.finalDrive,
|
||||
wetWeightLbs: spec.wetWeightLbs,
|
||||
dryWeightLbs: spec.dryWeightLbs,
|
||||
seatHeightInches: spec.seatHeightInches,
|
||||
wheelbaseInches: spec.wheelbaseInches,
|
||||
fuelCapacityGallons: spec.fuelCapacityGallons,
|
||||
fuelEconomyMpg: spec.fuelEconomyMpg,
|
||||
estimatedRangeMiles: spec.estimatedRangeMiles,
|
||||
frameType: spec.frameType,
|
||||
frontSuspension: spec.frontSuspension,
|
||||
rearSuspension: spec.rearSuspension,
|
||||
frontBrake: spec.frontBrake,
|
||||
rearBrake: spec.rearBrake,
|
||||
absType: spec.absType,
|
||||
frontTireSize: spec.frontTireSize,
|
||||
rearTireSize: spec.rearTireSize,
|
||||
claimedTopSpeedMph: spec.claimedTopSpeedMph,
|
||||
claimedZeroToSixtySec: spec.claimedZeroToSixtySec,
|
||||
claimedQuarterMileSec: spec.claimedQuarterMileSec,
|
||||
source: spec.source,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Intelligent Specification Estimator for unlisted motorcycle models
|
||||
return _estimateSpecification(selection);
|
||||
}
|
||||
|
||||
MotorcycleSpecification _estimateSpecification(MotorcycleSelection selection) {
|
||||
final modelUpper = selection.modelName.toUpperCase();
|
||||
final makeUpper = selection.makeName.toUpperCase();
|
||||
|
||||
// Extract displacement from model string (e.g., "Ninja 650" -> 650, "CBR1000RR" -> 1000, "890 Duke" -> 890)
|
||||
int displacement = 650;
|
||||
final numMatch = RegExp(r'(\d{3,4})').firstMatch(modelUpper);
|
||||
if (numMatch != null) {
|
||||
displacement = int.tryParse(numMatch.group(1)!) ?? 650;
|
||||
}
|
||||
|
||||
// Determine category
|
||||
String category = 'Street';
|
||||
if (modelUpper.contains('NINJA') || modelUpper.contains('CBR') || modelUpper.contains('GSX-R') || modelUpper.contains('YZF') || modelUpper.contains('PANIGALE') || modelUpper.contains('S1000RR') || modelUpper.contains('SUPERBIKE')) {
|
||||
category = displacement >= 900 ? 'Superbike' : 'Supersport';
|
||||
} else if (modelUpper.contains('MT') || modelUpper.contains('DUKE') || modelUpper.contains('Z900') || modelUpper.contains('STREETFIGHTER') || modelUpper.contains('TRIDENT') || modelUpper.contains('TUONO')) {
|
||||
category = 'Hyper Naked';
|
||||
} else if (modelUpper.contains('TENERE') || modelUpper.contains('AFRICA') || modelUpper.contains('GS') || modelUpper.contains('TIGER') || modelUpper.contains('ADVENTURE') || modelUpper.contains('V-STROM')) {
|
||||
category = 'Adventure / Dual-Sport';
|
||||
} else if (modelUpper.contains('SPORTSTER') || modelUpper.contains('BOBBER') || modelUpper.contains('REBEL') || makeUpper.contains('HARLEY') || makeUpper.contains('INDIAN')) {
|
||||
category = 'Cruiser';
|
||||
}
|
||||
|
||||
// Calculate realistic specs based on displacement & category
|
||||
double hp = displacement * 0.12;
|
||||
if (category == 'Superbike' || category == 'Supersport') {
|
||||
hp = displacement * (displacement > 900 ? 0.20 : 0.19);
|
||||
} else if (category == 'Hyper Naked') {
|
||||
hp = displacement * 0.135;
|
||||
} else if (category == 'Cruiser') {
|
||||
hp = displacement * 0.08;
|
||||
}
|
||||
|
||||
double torque = hp * 0.62;
|
||||
if (category == 'Cruiser') {
|
||||
torque = hp * 1.1;
|
||||
}
|
||||
|
||||
double wetWeight = 380 + (displacement * 0.08);
|
||||
if (category == 'Cruiser') {
|
||||
wetWeight = 500 + (displacement * 0.12);
|
||||
} else if (category == 'Adventure / Dual-Sport') {
|
||||
wetWeight = 430 + (displacement * 0.07);
|
||||
}
|
||||
|
||||
double topSpeed = 90 + (hp * 0.45);
|
||||
if (topSpeed > 186) topSpeed = 186; // 300km/h gentleman agreement cap
|
||||
|
||||
double zeroToSixty = 5.5 - (hp * 0.02);
|
||||
if (zeroToSixty < 2.7) zeroToSixty = 2.7;
|
||||
|
||||
return MotorcycleSpecification(
|
||||
year: selection.year,
|
||||
manufacturer: selection.makeName,
|
||||
model: selection.modelName,
|
||||
variant: selection.variantName,
|
||||
category: category,
|
||||
displacementCc: displacement.toDouble(),
|
||||
engineConfiguration: displacement > 800 ? 'Inline Engine' : 'V-Twin / Parallel Twin',
|
||||
cylinderCount: displacement >= 900 ? 4 : (displacement >= 600 ? 3 : 2),
|
||||
compressionRatio: '11.8:1',
|
||||
coolingSystem: 'Liquid-cooled',
|
||||
fuelSystem: 'Electronic Fuel Injection',
|
||||
fuelType: 'Premium Unleaded',
|
||||
horsepower: double.parse(hp.toStringAsFixed(1)),
|
||||
horsepowerRpm: (category == 'Supersport' || category == 'Superbike') ? 13000 : 9500,
|
||||
torqueLbFt: double.parse(torque.toStringAsFixed(1)),
|
||||
torqueRpm: 7500,
|
||||
redlineRpm: (category == 'Supersport' || category == 'Superbike') ? 14500 : 10500,
|
||||
transmissionType: 'Manual',
|
||||
gearCount: 6,
|
||||
finalDrive: category == 'Cruiser' ? 'Belt' : 'Chain',
|
||||
wetWeightLbs: double.parse(wetWeight.toStringAsFixed(0)),
|
||||
dryWeightLbs: double.parse((wetWeight * 0.91).toStringAsFixed(0)),
|
||||
seatHeightInches: category == 'Cruiser' ? 28.5 : 32.3,
|
||||
wheelbaseInches: 55.5,
|
||||
fuelCapacityGallons: 4.2,
|
||||
fuelEconomyMpg: double.parse((55.0 - (hp * 0.1)).clamp(30.0, 65.0).toStringAsFixed(0)),
|
||||
estimatedRangeMiles: 180.0,
|
||||
frameType: 'Aluminum Perimeter / Steel Trellis',
|
||||
frontSuspension: 'Telescopic Inverted Fork',
|
||||
rearSuspension: 'Single Linkage Gas Shock',
|
||||
frontBrake: 'Dual Hydraulic Discs',
|
||||
rearBrake: 'Single Hydraulic Disc',
|
||||
absType: 'Dual-Channel ABS',
|
||||
frontTireSize: '120/70ZR17',
|
||||
rearTireSize: displacement > 800 ? '180/55ZR17' : '160/60ZR17',
|
||||
claimedTopSpeedMph: double.parse(topSpeed.toStringAsFixed(0)),
|
||||
claimedZeroToSixtySec: double.parse(zeroToSixty.toStringAsFixed(1)),
|
||||
source: 'Motorcycle Data Intelligence Engine',
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import '../../models/motorcycle_models.dart';
|
||||
|
||||
abstract class MotorcycleDataProvider {
|
||||
Future<List<MotorcycleYear>> getYears();
|
||||
|
||||
Future<List<MotorcycleMake>> getMakes(int year);
|
||||
|
||||
Future<List<MotorcycleModel>> getModels(
|
||||
int year,
|
||||
String makeId,
|
||||
);
|
||||
|
||||
Future<List<MotorcycleVariant>> getVariants(
|
||||
int year,
|
||||
String makeId,
|
||||
String modelId,
|
||||
);
|
||||
|
||||
Future<MotorcycleSpecification?> getSpecifications(
|
||||
MotorcycleSelection selection,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import '../../models/motorcycle_models.dart';
|
||||
import 'motorcycle_data_provider.dart';
|
||||
import 'nhtsa_motorcycle_data_provider.dart';
|
||||
import 'local_motorcycle_data_provider.dart';
|
||||
|
||||
abstract class MotorcycleRepository {
|
||||
Future<List<MotorcycleYear>> getYears();
|
||||
|
||||
Future<List<MotorcycleMake>> getMakes(int year);
|
||||
|
||||
Future<List<MotorcycleModel>> getModels(
|
||||
int year,
|
||||
String makeId,
|
||||
);
|
||||
|
||||
Future<List<MotorcycleVariant>> getVariants(
|
||||
int year,
|
||||
String makeId,
|
||||
String modelId,
|
||||
);
|
||||
|
||||
Future<MotorcycleSpecification?> getSpecifications(
|
||||
MotorcycleSelection selection,
|
||||
);
|
||||
}
|
||||
|
||||
class HybridMotorcycleRepository implements MotorcycleRepository {
|
||||
final MotorcycleDataProvider _remoteProvider;
|
||||
final MotorcycleDataProvider _localProvider;
|
||||
|
||||
final Map<String, List<MotorcycleMake>> _makeCache = {};
|
||||
final Map<String, List<MotorcycleModel>> _modelCache = {};
|
||||
final Map<String, MotorcycleSpecification> _specCache = {};
|
||||
|
||||
HybridMotorcycleRepository({
|
||||
MotorcycleDataProvider? remoteProvider,
|
||||
MotorcycleDataProvider? localProvider,
|
||||
}) : _remoteProvider = remoteProvider ?? NhtsaMotorcycleDataProvider(),
|
||||
_localProvider = localProvider ?? LocalMotorcycleDataProvider();
|
||||
|
||||
@override
|
||||
Future<List<MotorcycleYear>> getYears() async {
|
||||
return _localProvider.getYears();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MotorcycleMake>> getMakes(int year) async {
|
||||
final cacheKey = 'makes_$year';
|
||||
if (_makeCache.containsKey(cacheKey)) {
|
||||
return _makeCache[cacheKey]!;
|
||||
}
|
||||
|
||||
try {
|
||||
final makes = await _remoteProvider.getMakes(year);
|
||||
if (makes.isNotEmpty) {
|
||||
_makeCache[cacheKey] = makes;
|
||||
return makes;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final fallback = await _localProvider.getMakes(year);
|
||||
_makeCache[cacheKey] = fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MotorcycleModel>> getModels(int year, String makeId) async {
|
||||
final cacheKey = 'models_${year}_$makeId';
|
||||
if (_modelCache.containsKey(cacheKey)) {
|
||||
return _modelCache[cacheKey]!;
|
||||
}
|
||||
|
||||
try {
|
||||
final models = await _remoteProvider.getModels(year, makeId);
|
||||
if (models.isNotEmpty) {
|
||||
_modelCache[cacheKey] = models;
|
||||
return models;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final fallback = await _localProvider.getModels(year, makeId);
|
||||
_modelCache[cacheKey] = fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MotorcycleVariant>> getVariants(
|
||||
int year,
|
||||
String makeId,
|
||||
String modelId,
|
||||
) async {
|
||||
return _localProvider.getVariants(year, makeId, modelId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<MotorcycleSpecification?> getSpecifications(
|
||||
MotorcycleSelection selection,
|
||||
) async {
|
||||
final cacheKey = '${selection.year}_${selection.makeName}_${selection.modelName}';
|
||||
if (_specCache.containsKey(cacheKey)) {
|
||||
return _specCache[cacheKey];
|
||||
}
|
||||
|
||||
final localSpec = await _localProvider.getSpecifications(selection);
|
||||
if (localSpec != null) {
|
||||
_specCache[cacheKey] = localSpec;
|
||||
return localSpec;
|
||||
}
|
||||
|
||||
final remoteSpec = await _remoteProvider.getSpecifications(selection);
|
||||
if (remoteSpec != null) {
|
||||
_specCache[cacheKey] = remoteSpec;
|
||||
return remoteSpec;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../models/motorcycle_models.dart';
|
||||
import 'motorcycle_data_provider.dart';
|
||||
|
||||
class NhtsaMotorcycleDataProvider implements MotorcycleDataProvider {
|
||||
static const String _baseUrl = 'https://vpic.nhtsa.dot.gov/api/vehicles';
|
||||
final http.Client _client;
|
||||
|
||||
NhtsaMotorcycleDataProvider({http.Client? client})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
@override
|
||||
Future<List<MotorcycleYear>> getYears() async {
|
||||
final currentYear = DateTime.now().year + 1;
|
||||
final years = <MotorcycleYear>[];
|
||||
for (int y = currentYear; y >= 1970; y--) {
|
||||
years.add(MotorcycleYear(y));
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MotorcycleMake>> getMakes(int year) async {
|
||||
try {
|
||||
final url = Uri.parse('$_baseUrl/GetMakesForVehicleType/motorcycle?format=json');
|
||||
final response = await _client.get(url).timeout(const Duration(seconds: 10));
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final results = data['Results'] as List? ?? [];
|
||||
final makes = results.map((item) {
|
||||
return MotorcycleMake(
|
||||
id: item['MakeId']?.toString() ?? '',
|
||||
name: _normalizeMakeName(item['MakeName']?.toString() ?? ''),
|
||||
);
|
||||
}).where((m) => m.name.isNotEmpty).toList();
|
||||
|
||||
// Sort alphabetically & deduplicate
|
||||
final uniqueMakes = <String, MotorcycleMake>{};
|
||||
for (var m in makes) {
|
||||
if (!uniqueMakes.containsKey(m.name)) {
|
||||
uniqueMakes[m.name] = m;
|
||||
}
|
||||
}
|
||||
final list = uniqueMakes.values.toList();
|
||||
list.sort((a, b) => a.name.compareTo(b.name));
|
||||
return list;
|
||||
}
|
||||
} catch (_) {
|
||||
// Fallback network failure handled by Repository/Local
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MotorcycleModel>> getModels(int year, String makeId) async {
|
||||
try {
|
||||
final url = Uri.parse(
|
||||
'$_baseUrl/GetModelsForMakeIdYear/makeId/$makeId/modelyear/$year/vehicleType/motorcycle?format=json',
|
||||
);
|
||||
final response = await _client.get(url).timeout(const Duration(seconds: 10));
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
final results = data['Results'] as List? ?? [];
|
||||
final models = results.map((item) {
|
||||
return MotorcycleModel(
|
||||
id: item['Model_ID']?.toString() ?? '',
|
||||
name: item['Model_Name']?.toString() ?? '',
|
||||
makeId: makeId,
|
||||
year: year,
|
||||
);
|
||||
}).where((m) => m.name.isNotEmpty).toList();
|
||||
|
||||
final unique = <String, MotorcycleModel>{};
|
||||
for (var m in models) {
|
||||
unique[m.name] = m;
|
||||
}
|
||||
final list = unique.values.toList();
|
||||
list.sort((a, b) => a.name.compareTo(b.name));
|
||||
return list;
|
||||
}
|
||||
} catch (_) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MotorcycleVariant>> getVariants(
|
||||
int year,
|
||||
String makeId,
|
||||
String modelId,
|
||||
) async {
|
||||
// NHTSA does not always differentiate detailed sub-variants, return standard variants if needed
|
||||
return [
|
||||
MotorcycleVariant(id: 'std', name: 'Standard'),
|
||||
MotorcycleVariant(id: 'sp', name: 'SP / Performance'),
|
||||
MotorcycleVariant(id: 'abs', name: 'ABS Edition'),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<MotorcycleSpecification?> getSpecifications(
|
||||
MotorcycleSelection selection,
|
||||
) async {
|
||||
// Basic specification template filled with data if available from NHTSA decode
|
||||
try {
|
||||
final url = Uri.parse(
|
||||
'$_baseUrl/GetModelsForMakeIdYear/makeId/${selection.makeId}/modelyear/${selection.year}?format=json',
|
||||
);
|
||||
final response = await _client.get(url).timeout(const Duration(seconds: 8));
|
||||
if (response.statusCode == 200) {
|
||||
return MotorcycleSpecification(
|
||||
year: selection.year,
|
||||
manufacturer: selection.makeName,
|
||||
model: selection.modelName,
|
||||
variant: selection.variantName,
|
||||
category: 'Street / Sport',
|
||||
source: 'NHTSA vPIC Registry',
|
||||
sourceUrl: 'https://vpic.nhtsa.dot.gov/',
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _normalizeMakeName(String raw) {
|
||||
var name = raw.trim();
|
||||
if (name.toUpperCase() == 'YAMAHA MOTOR CORPORATION' || name.toUpperCase() == 'YAMAHA MOTOR') {
|
||||
return 'Yamaha';
|
||||
}
|
||||
if (name.toUpperCase() == 'HONDA MOTOR CO., LTD.' || name.toUpperCase() == 'HONDA') {
|
||||
return 'Honda';
|
||||
}
|
||||
if (name.toUpperCase() == 'KAWASAKI HEAVY INDUSTRIES' || name.toUpperCase() == 'KAWASAKI MOTOR') {
|
||||
return 'Kawasaki';
|
||||
}
|
||||
if (name.toUpperCase() == 'SUZUKI MOTOR CORPORATION') {
|
||||
return 'Suzuki';
|
||||
}
|
||||
if (name.toUpperCase() == 'DUCATI MOTOR HOLDING S.P.A.') {
|
||||
return 'Ducati';
|
||||
}
|
||||
if (name.toUpperCase() == 'BMW MOTORRAD') {
|
||||
return 'BMW';
|
||||
}
|
||||
if (name.toUpperCase() == 'HARLEY-DAVIDSON' || name.toUpperCase() == 'HARLEY DAVIDSON') {
|
||||
return 'Harley-Davidson';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue