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/records/records_screen.dart';
|
||||||
import '../features/settings/settings_screen.dart';
|
import '../features/settings/settings_screen.dart';
|
||||||
import '../features/settings/diagnostics_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';
|
import 'widgets/main_layout.dart';
|
||||||
|
|
||||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
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(
|
StatefulShellBranch(
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ class MainLayout extends ConsumerWidget {
|
||||||
icon: Icon(Icons.emoji_events),
|
icon: Icon(Icons.emoji_events),
|
||||||
label: 'Records',
|
label: 'Records',
|
||||||
),
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.two_wheeler),
|
||||||
|
label: 'Garage',
|
||||||
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.settings),
|
icon: Icon(Icons.settings),
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ import 'tables.dart';
|
||||||
|
|
||||||
part 'database.g.dart';
|
part 'database.g.dart';
|
||||||
|
|
||||||
@DriftDatabase(tables: [Rides, PerformanceRuns, TelemetrySamples, RideEvents])
|
@DriftDatabase(tables: [Rides, PerformanceRuns, TelemetrySamples, RideEvents, GarageMotorcycles])
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
AppDatabase() : super(_openConnection());
|
AppDatabase() : super(_openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 2;
|
int get schemaVersion => 3;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
|
|
@ -37,6 +37,14 @@ class AppDatabase extends _$AppDatabase {
|
||||||
await m.createTable(telemetrySamples);
|
await m.createTable(telemetrySamples);
|
||||||
await m.createTable(rideEvents);
|
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 topSpeed => real().withDefault(const Constant(0.0))();
|
||||||
RealColumn get elevationGain => real().withDefault(const Constant(0.0))();
|
RealColumn get elevationGain => real().withDefault(const Constant(0.0))();
|
||||||
RealColumn get elevationLoss => 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 {
|
class TelemetrySamples extends Table {
|
||||||
|
|
@ -62,3 +69,19 @@ class PerformanceRuns extends Table {
|
||||||
RealColumn get topSpeedReached => real()();
|
RealColumn get topSpeedReached => real()();
|
||||||
DateTimeColumn get timestamp => dateTime()();
|
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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
|
import '../garage/garage_providers.dart';
|
||||||
import 'ride_detail_screen.dart';
|
import 'ride_detail_screen.dart';
|
||||||
|
|
||||||
class HistoryScreen extends ConsumerWidget {
|
class HistoryScreen extends ConsumerStatefulWidget {
|
||||||
const HistoryScreen({super.key});
|
const HistoryScreen({super.key});
|
||||||
|
|
||||||
@override
|
@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 database = ref.watch(databaseProvider);
|
||||||
|
final garageBikesAsync = ref.watch(garageMotorcyclesStreamProvider);
|
||||||
|
final defaultBike = ref.watch(defaultMotorcycleProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Ride History')),
|
appBar: AppBar(title: const Text('Ride History')),
|
||||||
body: StreamBuilder<List<Ride>>(
|
body: Column(
|
||||||
stream: database.select(database.rides).watch(),
|
children: [
|
||||||
builder: (context, snapshot) {
|
_buildFilterBar(garageBikesAsync.value ?? [], defaultBike),
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
Expanded(
|
||||||
return const Center(child: CircularProgressIndicator());
|
child: StreamBuilder<List<Ride>>(
|
||||||
}
|
stream: database.select(database.rides).watch(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasError) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return Center(child: Text('Error: ${snapshot.error}'));
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
final rides = snapshot.data ?? [];
|
if (snapshot.hasError) {
|
||||||
// Sort by start time descending
|
return Center(child: Text('Error: ${snapshot.error}'));
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
var rides = snapshot.data ?? [];
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
itemCount: rides.length,
|
// Filter logic
|
||||||
itemBuilder: (context, index) {
|
if (_selectedMotorcycleId == -1 && defaultBike != null) {
|
||||||
final ride = rides[index];
|
rides = rides.where((r) => r.motorcycleId == defaultBike.id).toList();
|
||||||
return Card(
|
} else if (_selectedMotorcycleId != null && _selectedMotorcycleId! > 0) {
|
||||||
elevation: 4,
|
rides = rides.where((r) => r.motorcycleId == _selectedMotorcycleId).toList();
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
}
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
// Sort by start time descending
|
||||||
Navigator.of(context).push(
|
rides.sort((a, b) => b.startTime.compareTo(a.startTime));
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => RideDetailScreen(ride: ride),
|
if (rides.isEmpty) {
|
||||||
),
|
return const Center(
|
||||||
);
|
child: Text(
|
||||||
},
|
'No recorded rides matching filter.',
|
||||||
child: Padding(
|
style: TextStyle(fontSize: 18, color: Colors.grey),
|
||||||
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'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
},
|
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) {
|
Widget _buildStatCol(String label, String val) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
import '../../services/export_service.dart';
|
import '../../services/export_service.dart';
|
||||||
import '../replay/replay_screen.dart';
|
import '../replay/replay_screen.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class RideDetailScreen extends ConsumerStatefulWidget {
|
class RideDetailScreen extends ConsumerStatefulWidget {
|
||||||
final Ride ride;
|
final Ride ride;
|
||||||
|
|
@ -42,6 +43,42 @@ class _RideDetailScreenState extends ConsumerState<RideDetailScreen> {
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
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'),
|
_buildSectionHeader('SUMMARY'),
|
||||||
_buildStatRow('Distance', '${r.distanceMiles.toStringAsFixed(2)} mi'),
|
_buildStatRow('Distance', '${r.distanceMiles.toStringAsFixed(2)} mi'),
|
||||||
_buildStatRow('Duration', '${r.endTime?.difference(r.startTime).inMinutes ?? 0} min'),
|
_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/telemetry_service.dart';
|
||||||
import '../../services/audio_alert_service.dart';
|
import '../../services/audio_alert_service.dart';
|
||||||
import '../records/records_service.dart';
|
import '../records/records_service.dart';
|
||||||
|
import '../garage/garage_providers.dart';
|
||||||
|
|
||||||
enum RideState { idle, ready, riding, paused, finished }
|
enum RideState { idle, ready, riding, paused, finished }
|
||||||
|
|
||||||
|
|
@ -86,6 +87,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
final TelemetryService telemetryService;
|
final TelemetryService telemetryService;
|
||||||
final RecordsService recordsService;
|
final RecordsService recordsService;
|
||||||
final AudioAlertService audioAlertService;
|
final AudioAlertService audioAlertService;
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
StreamSubscription? _telemetrySub;
|
StreamSubscription? _telemetrySub;
|
||||||
|
|
||||||
|
|
@ -100,7 +102,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
final Map<String, DateTime> _lastEventTimes = {};
|
final Map<String, DateTime> _lastEventTimes = {};
|
||||||
static const Duration _eventCooldown = Duration(seconds: 5);
|
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);
|
_telemetrySub = telemetryService.telemetryStream.listen(_onTelemetryUpdate);
|
||||||
|
|
||||||
// Write to DB every 5 seconds
|
// Write to DB every 5 seconds
|
||||||
|
|
@ -112,7 +114,15 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startRide() async {
|
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(
|
state = state.copyWith(
|
||||||
dbId: id,
|
dbId: id,
|
||||||
state: RideState.riding,
|
state: RideState.riding,
|
||||||
|
|
@ -382,6 +392,6 @@ final rideStateMachineProvider = StateNotifierProvider<RideStateMachine, RideSes
|
||||||
final telemetryService = ref.watch(telemetryServiceProvider);
|
final telemetryService = ref.watch(telemetryServiceProvider);
|
||||||
final recordsService = ref.watch(recordsServiceProvider.notifier);
|
final recordsService = ref.watch(recordsServiceProvider.notifier);
|
||||||
final audioAlertService = ref.watch(audioAlertServiceProvider);
|
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),
|
const SizedBox(height: 16),
|
||||||
Card(
|
Card(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const ListTile(
|
Row(
|
||||||
leading: Icon(Icons.screen_rotation),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
title: Text('Calibrate Mount Angle'),
|
children: [
|
||||||
subtitle: Text('Place the motorcycle perfectly upright and tap this to zero the lean angle sensor.'),
|
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(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: Padding(
|
child: ElevatedButton(
|
||||||
padding: const EdgeInsets.only(right: 16.0, bottom: 8.0),
|
onPressed: () async {
|
||||||
child: ElevatedButton(
|
ref.read(sensorFusionServiceProvider).calibrateUpright();
|
||||||
onPressed: () async {
|
if (context.mounted) {
|
||||||
ref.read(sensorFusionServiceProvider).calibrateUpright();
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
if (context.mounted) {
|
const SnackBar(content: Text('Sensors Calibrated!')),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
);
|
||||||
const SnackBar(content: Text('Sensors Calibrated!')),
|
}
|
||||||
);
|
},
|
||||||
}
|
child: const Text('Calibrate'),
|
||||||
},
|
|
||||||
child: const Text('Calibrate'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -57,43 +75,72 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Card(
|
Card(
|
||||||
child: ListTile(
|
clipBehavior: Clip.antiAlias,
|
||||||
leading: const Icon(Icons.monitor_heart),
|
child: InkWell(
|
||||||
title: const Text('Sensor Diagnostics'),
|
|
||||||
subtitle: const Text('View raw IMU and GPS values.'),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () => context.go('/settings/diagnostics'),
|
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),
|
const SizedBox(height: 16),
|
||||||
Card(
|
Card(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const ListTile(
|
Row(
|
||||||
leading: Icon(Icons.delete_forever, color: Colors.redAccent),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
title: Text('Clear Ride History'),
|
children: [
|
||||||
subtitle: Text('Permanently delete all recorded rides and performance runs.'),
|
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(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: Padding(
|
child: ElevatedButton(
|
||||||
padding: const EdgeInsets.only(right: 16.0, bottom: 8.0),
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red.withValues(alpha: 0.2)),
|
||||||
child: ElevatedButton(
|
onPressed: () async {
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red.withValues(alpha: 0.2)),
|
final db = ref.read(databaseProvider);
|
||||||
onPressed: () async {
|
await db.delete(db.rides).go();
|
||||||
final db = ref.read(databaseProvider);
|
await db.delete(db.performanceRuns).go();
|
||||||
await db.delete(db.rides).go();
|
if (context.mounted) {
|
||||||
await db.delete(db.performanceRuns).go();
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
if (context.mounted) {
|
const SnackBar(content: Text('All data cleared.')),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
);
|
||||||
const SnackBar(content: Text('All data cleared.')),
|
}
|
||||||
);
|
},
|
||||||
}
|
child: const Text('Clear', style: TextStyle(color: Colors.redAccent)),
|
||||||
},
|
|
||||||
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