272 lines
8.0 KiB
Dart
272 lines
8.0 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
|
|
/// Individual service log entry for a motorcycle in the rider's garage.
|
|
class ServiceRecord {
|
|
final String id;
|
|
final DateTime date;
|
|
final double odometer;
|
|
final String serviceType;
|
|
final String? notes;
|
|
final double? cost;
|
|
|
|
const ServiceRecord({
|
|
required this.id,
|
|
required this.date,
|
|
required this.odometer,
|
|
required this.serviceType,
|
|
this.notes,
|
|
this.cost,
|
|
});
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'date': date.toIso8601String(),
|
|
'odometer': odometer,
|
|
'serviceType': serviceType,
|
|
if (notes != null && notes!.isNotEmpty) 'notes': notes,
|
|
if (cost != null) 'cost': cost,
|
|
};
|
|
|
|
factory ServiceRecord.fromJson(Map<String, dynamic> json) {
|
|
return ServiceRecord(
|
|
id: json['id']?.toString() ?? 'srv_${DateTime.now().millisecondsSinceEpoch}',
|
|
date: json['date'] != null
|
|
? DateTime.tryParse(json['date'].toString()) ?? DateTime.now()
|
|
: DateTime.now(),
|
|
odometer: (json['odometer'] as num?)?.toDouble() ?? 0.0,
|
|
serviceType: json['serviceType']?.toString() ?? 'General Maintenance',
|
|
notes: json['notes']?.toString(),
|
|
cost: (json['cost'] as num?)?.toDouble(),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum MaintenanceUrgency {
|
|
ok,
|
|
dueSoon,
|
|
overdue,
|
|
}
|
|
|
|
/// Dynamic countdown status for a specific scheduled maintenance item.
|
|
class MaintenanceItemStatus {
|
|
final String key;
|
|
final String title;
|
|
final IconData icon;
|
|
final int intervalMiles;
|
|
final double milesRemaining;
|
|
final double lastServicedOdometer;
|
|
final MaintenanceUrgency urgency;
|
|
|
|
const MaintenanceItemStatus({
|
|
required this.key,
|
|
required this.title,
|
|
required this.icon,
|
|
required this.intervalMiles,
|
|
required this.milesRemaining,
|
|
required this.lastServicedOdometer,
|
|
required this.urgency,
|
|
});
|
|
|
|
String get label {
|
|
if (milesRemaining <= 0) {
|
|
return 'OVERDUE (${(-milesRemaining).round()} mi)';
|
|
} else if (urgency == MaintenanceUrgency.dueSoon) {
|
|
return 'Due Soon (${milesRemaining.round()} mi)';
|
|
} else {
|
|
return '${milesRemaining.round()} mi remaining';
|
|
}
|
|
}
|
|
|
|
Color getBadgeColor(BuildContext context) {
|
|
switch (urgency) {
|
|
case MaintenanceUrgency.overdue:
|
|
return const Color(0xFFEF4444); // Red
|
|
case MaintenanceUrgency.dueSoon:
|
|
return const Color(0xFFF59E0B); // Amber
|
|
case MaintenanceUrgency.ok:
|
|
return const Color(0xFF10B981); // Emerald
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Helper to parse and calculate maintenance intervals and logs from specificationJson.
|
|
class MaintenanceData {
|
|
final double baseOdometer;
|
|
final List<ServiceRecord> serviceRecords;
|
|
final Map<String, int> customIntervals;
|
|
|
|
const MaintenanceData({
|
|
this.baseOdometer = 0.0,
|
|
this.serviceRecords = const [],
|
|
this.customIntervals = const {},
|
|
});
|
|
|
|
static const int defaultOilInterval = 3000;
|
|
static const int defaultChainInterval = 600;
|
|
static const int defaultTireInterval = 4000;
|
|
static const int defaultBrakeInterval = 6000;
|
|
|
|
static MaintenanceData fromSpecificationJson(String? specJson) {
|
|
if (specJson == null || specJson.trim().isEmpty) {
|
|
return const MaintenanceData();
|
|
}
|
|
try {
|
|
final decoded = json.decode(specJson);
|
|
if (decoded is! Map<String, dynamic>) return const MaintenanceData();
|
|
|
|
final baseOdo = (decoded['baseOdometer'] as num?)?.toDouble() ?? 0.0;
|
|
final rawRecords = decoded['serviceRecords'];
|
|
final List<ServiceRecord> records = [];
|
|
|
|
if (rawRecords is List) {
|
|
for (final r in rawRecords) {
|
|
if (r is Map<String, dynamic>) {
|
|
records.add(ServiceRecord.fromJson(r));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort service records descending by date and odometer
|
|
records.sort((a, b) => b.odometer.compareTo(a.odometer));
|
|
|
|
final rawIntervals = decoded['customIntervals'];
|
|
final Map<String, int> intervals = {};
|
|
if (rawIntervals is Map) {
|
|
for (final entry in rawIntervals.entries) {
|
|
if (entry.value is num) {
|
|
intervals[entry.key.toString()] = (entry.value as num).toInt();
|
|
}
|
|
}
|
|
}
|
|
|
|
return MaintenanceData(
|
|
baseOdometer: baseOdo,
|
|
serviceRecords: records,
|
|
customIntervals: intervals,
|
|
);
|
|
} catch (_) {
|
|
return const MaintenanceData();
|
|
}
|
|
}
|
|
|
|
/// Calculates real-time countdown pills for all standard maintenance categories.
|
|
List<MaintenanceItemStatus> calculatePills(double totalRiddenMiles) {
|
|
final currentOdometer = baseOdometer + totalRiddenMiles;
|
|
|
|
final oilInterval = customIntervals['oil'] ?? defaultOilInterval;
|
|
final chainInterval = customIntervals['chain'] ?? defaultChainInterval;
|
|
final tireInterval = customIntervals['tire'] ?? defaultTireInterval;
|
|
final brakeInterval = customIntervals['brake'] ?? defaultBrakeInterval;
|
|
|
|
return [
|
|
_calculateItem(
|
|
key: 'oil',
|
|
title: 'Engine Oil',
|
|
icon: Icons.oil_barrel,
|
|
interval: oilInterval,
|
|
currentOdometer: currentOdometer,
|
|
typeMatch: (t) => t.toLowerCase().contains('oil'),
|
|
dueSoonThreshold: 350,
|
|
),
|
|
_calculateItem(
|
|
key: 'chain',
|
|
title: 'Chain Service',
|
|
icon: Icons.link,
|
|
interval: chainInterval,
|
|
currentOdometer: currentOdometer,
|
|
typeMatch: (t) => t.toLowerCase().contains('chain'),
|
|
dueSoonThreshold: 80,
|
|
),
|
|
_calculateItem(
|
|
key: 'tire',
|
|
title: 'Tire Wear',
|
|
icon: Icons.tire_repair,
|
|
interval: tireInterval,
|
|
currentOdometer: currentOdometer,
|
|
typeMatch: (t) => t.toLowerCase().contains('tire') || t.toLowerCase().contains('tyre'),
|
|
dueSoonThreshold: 450,
|
|
),
|
|
_calculateItem(
|
|
key: 'brake',
|
|
title: 'Brake Fluid/Pads',
|
|
icon: Icons.speed,
|
|
interval: brakeInterval,
|
|
currentOdometer: currentOdometer,
|
|
typeMatch: (t) => t.toLowerCase().contains('brake'),
|
|
dueSoonThreshold: 600,
|
|
),
|
|
];
|
|
}
|
|
|
|
MaintenanceItemStatus _calculateItem({
|
|
required String key,
|
|
required String title,
|
|
required IconData icon,
|
|
required int interval,
|
|
required double currentOdometer,
|
|
required bool Function(String) typeMatch,
|
|
required int dueSoonThreshold,
|
|
}) {
|
|
// Find the latest service record for this category
|
|
ServiceRecord? lastService;
|
|
for (final r in serviceRecords) {
|
|
if (typeMatch(r.serviceType)) {
|
|
if (lastService == null || r.odometer > lastService.odometer) {
|
|
lastService = r;
|
|
}
|
|
}
|
|
}
|
|
|
|
final lastServicedOdo = lastService?.odometer ?? baseOdometer;
|
|
final milesSinceService = currentOdometer - lastServicedOdo;
|
|
final milesRemaining = interval - milesSinceService;
|
|
|
|
final urgency = milesRemaining <= 0
|
|
? MaintenanceUrgency.overdue
|
|
: (milesRemaining <= dueSoonThreshold
|
|
? MaintenanceUrgency.dueSoon
|
|
: MaintenanceUrgency.ok);
|
|
|
|
return MaintenanceItemStatus(
|
|
key: key,
|
|
title: title,
|
|
icon: icon,
|
|
intervalMiles: interval,
|
|
milesRemaining: milesRemaining,
|
|
lastServicedOdometer: lastServicedOdo,
|
|
urgency: urgency,
|
|
);
|
|
}
|
|
|
|
/// Injects or updates maintenance records and base odometer into the existing specification JSON string.
|
|
static String updateSpecJsonWithMaintenance({
|
|
required String? originalSpecJson,
|
|
double? baseOdometer,
|
|
List<ServiceRecord>? serviceRecords,
|
|
Map<String, int>? customIntervals,
|
|
}) {
|
|
Map<String, dynamic> map = {};
|
|
if (originalSpecJson != null && originalSpecJson.trim().isNotEmpty) {
|
|
try {
|
|
final decoded = json.decode(originalSpecJson);
|
|
if (decoded is Map<String, dynamic>) {
|
|
map = Map<String, dynamic>.from(decoded);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (baseOdometer != null) {
|
|
map['baseOdometer'] = baseOdometer;
|
|
}
|
|
if (serviceRecords != null) {
|
|
map['serviceRecords'] = serviceRecords.map((s) => s.toJson()).toList();
|
|
}
|
|
if (customIntervals != null) {
|
|
map['customIntervals'] = customIntervals;
|
|
}
|
|
|
|
return json.encode(map);
|
|
}
|
|
}
|