Release version 1.0.0

This commit is contained in:
2025-09-16 21:48:33 +02:00
commit a6d27c5f21
165 changed files with 8385 additions and 0 deletions

2
lib/config.dart Normal file
View File

@@ -0,0 +1,2 @@
const String apiBaseUrl =
String.fromEnvironment('API_BASE_URL', defaultValue: 'https://fuelstats.filiprojek.cz');

164
lib/main.dart Normal file
View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'services/session_manager.dart';
import 'screens/home_screen.dart';
import 'screens/add_screen.dart';
import 'screens/vehicles_screen.dart';
import 'screens/history_screen.dart';
import 'screens/user_settings.dart';
import 'screens/login.dart';
import 'screens/signup.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await SessionManager().init();
runApp(
ChangeNotifierProvider(
create: (_) => SessionManager(),
child: FuelStatsApp(),
),
);
}
class FuelStatsApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark(),
themeMode: ThemeMode.dark,
debugShowCheckedModeBanner: false,
home: MainNavigation(),
);
}
}
class MainNavigation extends StatefulWidget {
const MainNavigation({super.key});
@override
_MainNavigationState createState() => _MainNavigationState();
}
class _MainNavigationState extends State<MainNavigation> {
int _currentIndex = 0;
bool get isAuthScreen => _currentIndex == 5 || _currentIndex == 6;
Widget get currentTitle {
switch (_currentIndex) {
case 0:
return Text("Fuel Stats");
case 1:
return Text("Add record");
case 2:
return Text("Vehicles");
case 3:
return Text("History");
case 4:
return Text("Settings");
case 5:
return Text(""); // Empty title on login
case 6:
return Text(""); // Empty title on signup
default:
return Text("Fuel Stats");
}
}
@override
Widget build(BuildContext context) {
final session = Provider.of<SessionManager>(context);
// Auto-redirect to login if not logged in and not already on auth screens
Future.delayed(Duration(milliseconds: 100), () {
if (!session.isLoggedIn && !isAuthScreen) {
setState(() => _currentIndex = 5);
}
});
final screens = [
HomeScreen(),
AddScreen(
onSaved: () {
setState(() => _currentIndex = 0);
},
),
VehiclesScreen(),
HistoryScreen(),
UserSettingsScreen(
onLogout: () {
setState(() => _currentIndex = 5); // Go to login
},
),
LoginScreen(
onSwitchToSignup: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() => _currentIndex = 6);
});
},
onLoginSuccess: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() => _currentIndex = 0); // Go to Home
});
},
),
SignupScreen(
onSwitchToLogin: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() => _currentIndex = 5);
});
},
onSignupSuccess: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() => _currentIndex = 0); // Go to Home
});
},
),
];
return Scaffold(
appBar: AppBar(
title: currentTitle,
actions: !isAuthScreen
? [
IconButton(
icon: const Icon(Icons.person),
tooltip: "User settings",
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UserSettingsScreen(
onLogout: () {
Navigator.pop(context); // Close settings
setState(() => _currentIndex = 5); // Go to Login
},
),
),
);
},
),
]
: null,
),
body: screens[_currentIndex],
bottomNavigationBar: !isAuthScreen
? BottomNavigationBar(
currentIndex: _currentIndex <= 3 ? _currentIndex : 0,
onTap: (index) => setState(() => _currentIndex = index),
backgroundColor: Colors.grey[900],
selectedItemColor: Colors.white,
unselectedItemColor: Colors.grey,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.add), label: 'Add'),
BottomNavigationBarItem(icon: Icon(Icons.directions_car), label: 'Vehicles'),
BottomNavigationBarItem(icon: Icon(Icons.history), label: 'History'),
],
)
: null,
);
}
}

20
lib/main.dart.old2 Normal file
View File

@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
void main() {
runApp(MaterialApp(home: Home()));
}
class Home extends StatelessWidget {
const Home({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Fuel Stats"),
backgroundColor: Colors.blue[700],
),
body: Column(children: [Text("vim")]),
);
}
}

113
lib/models/refuel.dart Normal file
View File

@@ -0,0 +1,113 @@
import 'dart:convert';
import 'vehicle.dart';
class Refuel {
final String? id;
final String vehicleId;
final FuelType fuelType;
final double liters;
final double pricePerLiter;
final double totalPrice;
final int mileage;
final String? note;
final DateTime? createdAt;
Refuel({
this.id,
required this.vehicleId,
required this.fuelType,
required this.liters,
required this.pricePerLiter,
required this.totalPrice,
required this.mileage,
this.note,
this.createdAt,
});
Refuel copyWith({
String? id,
String? vehicleId,
FuelType? fuelType,
double? liters,
double? pricePerLiter,
double? totalPrice,
int? mileage,
String? note,
DateTime? createdAt,
}) {
return Refuel(
id: id ?? this.id,
vehicleId: vehicleId ?? this.vehicleId,
fuelType: fuelType ?? this.fuelType,
liters: liters ?? this.liters,
pricePerLiter: pricePerLiter ?? this.pricePerLiter,
totalPrice: totalPrice ?? this.totalPrice,
mileage: mileage ?? this.mileage,
note: note ?? this.note,
createdAt: createdAt ?? this.createdAt,
);
}
Map<String, dynamic> toApiMap() {
return {
'vehicleId': vehicleId,
'fuelType': fuelType.name,
'note': note,
'liters': liters,
'pricePerLiter': pricePerLiter,
'totalPrice': totalPrice,
'mileage': mileage,
};
}
factory Refuel.fromApi(Map<String, dynamic> map) {
return Refuel(
id: map['id'] as String?,
vehicleId: map['vehicleId'] as String,
fuelType:
FuelType.values.firstWhere((e) => e.name == map['fuelType'] as String),
note: map['note'] as String?,
liters: (map['liters'] as num).toDouble(),
pricePerLiter: (map['pricePerLiter'] as num).toDouble(),
totalPrice: (map['totalPrice'] as num).toDouble(),
mileage: (map['mileage'] as num).toInt(),
createdAt: map['createdAt'] != null
? DateTime.tryParse(map['createdAt'] as String)
: null,
);
}
Map<String, dynamic> toMap() => {
'id': id,
'vehicleId': vehicleId,
'fuelType': fuelType.name,
'note': note,
'liters': liters,
'pricePerLiter': pricePerLiter,
'totalPrice': totalPrice,
'mileage': mileage,
'createdAt': createdAt?.toIso8601String(),
};
factory Refuel.fromMap(Map<String, dynamic> map) {
return Refuel(
id: map['id'] as String?,
vehicleId: map['vehicleId'] as String,
fuelType:
FuelType.values.firstWhere((e) => e.name == map['fuelType'] as String),
note: map['note'] as String?,
liters: (map['liters'] as num).toDouble(),
pricePerLiter: (map['pricePerLiter'] as num).toDouble(),
totalPrice: (map['totalPrice'] as num).toDouble(),
mileage: (map['mileage'] as num).toInt(),
createdAt: map['createdAt'] != null
? DateTime.tryParse(map['createdAt'] as String)
: null,
);
}
String toJson() => jsonEncode(toMap());
factory Refuel.fromJson(String source) =>
Refuel.fromMap(jsonDecode(source) as Map<String, dynamic>);
}

235
lib/models/service.dart Normal file
View File

@@ -0,0 +1,235 @@
import 'dart:convert';
class ServiceRecord {
final String? id;
final String vehicleId;
final ServiceType serviceType;
final String? customType;
final String? itemName;
final double cost;
final int mileage;
final String? shop;
final bool selfService;
final String? note;
final List<String> photos;
final DateTime? date;
final DateTime? createdAt;
ServiceRecord({
this.id,
required this.vehicleId,
required this.serviceType,
this.customType,
this.itemName,
required this.cost,
required this.mileage,
this.shop,
this.selfService = false,
this.note,
List<String>? photos,
this.date,
this.createdAt,
}) : photos = photos ?? [];
String get displayType =>
serviceType == ServiceType.other && customType != null && customType!.isNotEmpty
? customType!
: serviceType.label;
ServiceRecord copyWith({
String? id,
String? vehicleId,
ServiceType? serviceType,
String? customType,
String? itemName,
double? cost,
int? mileage,
String? shop,
bool? selfService,
String? note,
List<String>? photos,
DateTime? date,
DateTime? createdAt,
}) {
return ServiceRecord(
id: id ?? this.id,
vehicleId: vehicleId ?? this.vehicleId,
serviceType: serviceType ?? this.serviceType,
customType: customType ?? this.customType,
itemName: itemName ?? this.itemName,
cost: cost ?? this.cost,
mileage: mileage ?? this.mileage,
shop: shop ?? this.shop,
selfService: selfService ?? this.selfService,
note: note ?? this.note,
photos: photos ?? List.of(this.photos),
date: date ?? this.date,
createdAt: createdAt ?? this.createdAt,
);
}
Map<String, dynamic> toApiMap() {
return {
'vehicleId': vehicleId,
'serviceType': serviceType.apiValue,
if (customType != null && customType!.isNotEmpty) 'customType': customType,
if (itemName != null) 'itemName': itemName,
'cost': cost,
'mileage': mileage,
if (shop != null && shop!.isNotEmpty) 'shop': shop,
if (selfService) 'selfService': true,
if (note != null) 'note': note,
'photos': photos,
if (date != null) 'date': date!.toUtc().toIso8601String(),
};
}
factory ServiceRecord.fromApi(Map<String, dynamic> map) {
return ServiceRecord(
id: map['id'] as String?,
vehicleId: map['vehicleId'] as String,
serviceType: ServiceTypeX.fromApi(map['serviceType'] as String),
customType: map['customType'] as String?,
itemName: map['itemName'] as String?,
cost: (map['cost'] as num).toDouble(),
mileage: (map['mileage'] as num).toInt(),
shop: map['shop'] as String?,
selfService: map['selfService'] as bool? ?? false,
note: map['note'] as String?,
photos: map['photos'] != null
? List<String>.from(map['photos'] as List)
: [],
date: map['date'] != null
? DateTime.tryParse(map['date'] as String)
: null,
createdAt: map['createdAt'] != null
? DateTime.tryParse(map['createdAt'] as String)
: null,
);
}
Map<String, dynamic> toMap() => {
'id': id,
'vehicleId': vehicleId,
'serviceType': serviceType.apiValue,
'customType': customType,
'itemName': itemName,
'cost': cost,
'mileage': mileage,
'shop': shop,
'selfService': selfService,
'note': note,
'photos': photos,
'date': date?.toIso8601String(),
'createdAt': createdAt?.toIso8601String(),
};
factory ServiceRecord.fromMap(Map<String, dynamic> map) {
return ServiceRecord(
id: map['id'] as String?,
vehicleId: map['vehicleId'] as String,
serviceType: ServiceTypeX.fromApi(map['serviceType'] as String),
customType: map['customType'] as String?,
itemName: map['itemName'] as String?,
cost: (map['cost'] as num).toDouble(),
mileage: (map['mileage'] as num).toInt(),
shop: map['shop'] as String?,
selfService: map['selfService'] as bool? ?? false,
note: map['note'] as String?,
photos: map['photos'] != null
? List<String>.from(map['photos'] as List)
: [],
date: map['date'] != null
? DateTime.tryParse(map['date'] as String)
: null,
createdAt: map['createdAt'] != null
? DateTime.tryParse(map['createdAt'] as String)
: null,
);
}
String toJson() => jsonEncode(toMap());
factory ServiceRecord.fromJson(String source) =>
ServiceRecord.fromMap(jsonDecode(source) as Map<String, dynamic>);
}
enum ServiceType {
airFilter,
oilFilter,
fuelFilter,
cabinFilter,
motorOil,
brakePadFront,
brakePadRear,
sparkPlug,
coolant,
tireChange,
battery,
other,
}
extension ServiceTypeX on ServiceType {
String get label {
switch (this) {
case ServiceType.airFilter:
return 'Air Filter';
case ServiceType.oilFilter:
return 'Oil Filter';
case ServiceType.fuelFilter:
return 'Fuel Filter';
case ServiceType.cabinFilter:
return 'Cabin Filter';
case ServiceType.motorOil:
return 'Motor Oil';
case ServiceType.brakePadFront:
return 'Brake Pads (Front)';
case ServiceType.brakePadRear:
return 'Brake Pads (Rear)';
case ServiceType.sparkPlug:
return 'Spark Plugs';
case ServiceType.coolant:
return 'Coolant';
case ServiceType.tireChange:
return 'Tire Change';
case ServiceType.battery:
return 'Battery';
case ServiceType.other:
return 'Other';
}
}
String get apiValue {
switch (this) {
case ServiceType.airFilter:
return 'air_filter';
case ServiceType.oilFilter:
return 'oil_filter';
case ServiceType.fuelFilter:
return 'fuel_filter';
case ServiceType.cabinFilter:
return 'cabin_filter';
case ServiceType.motorOil:
return 'motor_oil';
case ServiceType.brakePadFront:
return 'brake_pad_front';
case ServiceType.brakePadRear:
return 'brake_pad_rear';
case ServiceType.sparkPlug:
return 'spark_plug';
case ServiceType.coolant:
return 'coolant';
case ServiceType.tireChange:
return 'tire_change';
case ServiceType.battery:
return 'battery';
case ServiceType.other:
return 'other';
}
}
static ServiceType fromApi(String value) {
return ServiceType.values.firstWhere(
(e) => e.apiValue == value,
orElse: () => ServiceType.other);
}
}

104
lib/models/vehicle.dart Normal file
View File

@@ -0,0 +1,104 @@
import 'dart:convert';
enum FuelType { diesel, gasoline95, gasoline98, other }
extension FuelTypeX on FuelType {
String get label {
switch (this) {
case FuelType.diesel:
return 'Diesel';
case FuelType.gasoline95:
return 'Gasoline 95';
case FuelType.gasoline98:
return 'Gasoline 98';
case FuelType.other:
return 'Other';
}
}
static FuelType fromIndex(int index) => FuelType.values[index];
}
class Vehicle {
final String? id;
final String name;
final String registrationPlate;
final FuelType fuelType;
final String? note;
final bool isDefault;
Vehicle({
this.id,
required this.name,
required this.registrationPlate,
required this.fuelType,
this.note,
this.isDefault = false,
});
Vehicle copyWith({
String? id,
String? name,
String? registrationPlate,
FuelType? fuelType,
String? note,
bool? isDefault,
}) {
return Vehicle(
id: id ?? this.id,
name: name ?? this.name,
registrationPlate: registrationPlate ?? this.registrationPlate,
fuelType: fuelType ?? this.fuelType,
note: note ?? this.note,
isDefault: isDefault ?? this.isDefault,
);
}
Map<String, dynamic> toApiMap() {
return {
'name': name,
'registrationPlate': registrationPlate,
'fuelType': fuelType.name,
'note': note,
'isDefault': isDefault,
};
}
factory Vehicle.fromApi(Map<String, dynamic> map) {
return Vehicle(
id: map['id'] as String?,
name: map['name'] as String,
registrationPlate: map['registrationPlate'] as String,
fuelType: FuelType.values
.firstWhere((e) => e.name == map['fuelType'] as String),
note: map['note'] as String?,
isDefault: map['isDefault'] as bool? ?? false,
);
}
Map<String, dynamic> toMap() => {
'id': id,
'name': name,
'registrationPlate': registrationPlate,
'fuelType': fuelType.index,
'note': note,
'isDefault': isDefault,
};
factory Vehicle.fromMap(Map<String, dynamic> map) {
return Vehicle(
id: map['id'] as String?,
name: map['name'] as String,
registrationPlate: map['registrationPlate'] as String,
fuelType: map['fuelType'] is int
? FuelTypeX.fromIndex(map['fuelType'] as int)
: FuelType.values
.firstWhere((e) => e.name == map['fuelType'] as String),
note: map['note'] as String?,
isDefault: map['isDefault'] as bool? ?? false,
);
}
String toJson() => jsonEncode(toMap());
factory Vehicle.fromJson(String source) => Vehicle.fromMap(jsonDecode(source));
}

279
lib/screens/add_screen.dart Normal file
View File

@@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../models/refuel.dart';
import '../services/session_manager.dart';
import 'add_service_screen.dart';
class AddScreen extends StatefulWidget {
final Refuel? refuel;
final VoidCallback? onSaved;
final bool standalone;
AddScreen({this.refuel, this.onSaved, this.standalone = false});
@override
_AddScreenState createState() => _AddScreenState();
}
class _AddScreenState extends State<AddScreen> {
final _formKey = GlobalKey<FormState>();
String? _selectedVehicleId;
FuelType? _selectedFuelType;
final TextEditingController _litersController = TextEditingController();
final TextEditingController _pricePerLiterController =
TextEditingController();
final TextEditingController _totalPriceController = TextEditingController();
final TextEditingController _mileageController = TextEditingController();
final TextEditingController _noteController = TextEditingController();
final FocusNode _litersFocus = FocusNode();
final FocusNode _pricePerLiterFocus = FocusNode();
final FocusNode _totalPriceFocus = FocusNode();
bool _initialized = false;
bool _isRecalculating = false;
@override
void initState() {
super.initState();
_litersController.addListener(_recalculate);
_pricePerLiterController.addListener(_recalculate);
_totalPriceController.addListener(_recalculate);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialized) return;
final session = Provider.of<SessionManager>(context);
if (widget.refuel != null) {
final r = widget.refuel!;
_selectedVehicleId = r.vehicleId;
_selectedFuelType = r.fuelType;
_litersController.text = r.liters.toString();
_pricePerLiterController.text = r.pricePerLiter.toString();
_totalPriceController.text = r.totalPrice.toString();
_mileageController.text = r.mileage.toString();
if (r.note != null) _noteController.text = r.note!;
} else {
_selectedVehicleId = session.defaultVehicle?.id;
_selectedFuelType = session.defaultVehicle?.fuelType;
}
_initialized = true;
}
@override
Widget build(BuildContext context) {
final session = Provider.of<SessionManager>(context);
final vehicles = session.vehicles;
final form = Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
decoration: InputDecoration(labelText: 'Vehicle'),
value: _selectedVehicleId,
items: vehicles
.map(
(vehicle) => DropdownMenuItem(
value: vehicle.id,
child: Text(vehicle.name),
),
)
.toList(),
onChanged: (value) {
setState(() {
_selectedVehicleId = value;
final vehicle = vehicles.firstWhere((v) => v.id == value);
_selectedFuelType = vehicle.fuelType;
});
},
validator:
(value) => value == null ? 'Please select a vehicle' : null,
),
SizedBox(height: 16),
DropdownButtonFormField<FuelType>(
decoration: InputDecoration(labelText: 'Fuel Type'),
value: _selectedFuelType,
items: FuelType.values
.map((fuel) => DropdownMenuItem(
value: fuel,
child: Text(fuel.label),
))
.toList(),
onChanged: (value) => setState(() => _selectedFuelType = value),
validator:
(value) => value == null ? 'Please select a fuel type' : null,
),
SizedBox(height: 16),
TextFormField(
controller: _litersController,
focusNode: _litersFocus,
decoration: InputDecoration(labelText: 'Liters'),
keyboardType: TextInputType.number,
validator: _numberValidator,
),
SizedBox(height: 16),
TextFormField(
controller: _pricePerLiterController,
focusNode: _pricePerLiterFocus,
decoration: InputDecoration(labelText: 'Price per Liter'),
keyboardType: TextInputType.number,
validator: _numberValidator,
),
SizedBox(height: 16),
TextFormField(
controller: _totalPriceController,
focusNode: _totalPriceFocus,
decoration: InputDecoration(labelText: 'Total Price'),
keyboardType: TextInputType.number,
validator: _numberValidator,
),
SizedBox(height: 16),
TextFormField(
controller: _mileageController,
decoration: InputDecoration(labelText: 'Mileage'),
keyboardType: TextInputType.number,
validator: _numberValidator,
),
SizedBox(height: 16),
TextFormField(
controller: _noteController,
decoration: InputDecoration(labelText: 'Note'),
keyboardType: TextInputType.text,
),
SizedBox(height: 24),
ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
),
child: Text(widget.refuel == null
? 'Create Fuel Record'
: 'Update Fuel Record'),
),
if (!widget.standalone) ...[
SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AddServiceScreen(standalone: true),
),
);
},
icon: Icon(Icons.build),
label: Text('Add Service Record'),
),
],
],
),
),
),
);
if (widget.standalone) {
return Scaffold(
appBar: AppBar(
title: Text(widget.refuel == null
? 'Add Refuel Record'
: 'Edit Refuel Record'),
),
body: form,
);
}
return form;
}
String? _numberValidator(String? value) {
if (value == null || value.isEmpty) return 'This field cannot be empty';
if (double.tryParse(value) == null) return 'Enter a valid number';
return null;
}
void _recalculate() {
if (_isRecalculating) return;
final liters = double.tryParse(_litersController.text);
final price = double.tryParse(_pricePerLiterController.text);
final total = double.tryParse(_totalPriceController.text);
_isRecalculating = true;
if (!_totalPriceFocus.hasFocus && liters != null && price != null) {
_totalPriceController.text = (liters * price).toStringAsFixed(2);
} else if (!_pricePerLiterFocus.hasFocus && liters != null && total != null) {
_pricePerLiterController.text = (total / liters).toStringAsFixed(2);
} else if (!_litersFocus.hasFocus && price != null && total != null) {
_litersController.text = (total / price).toStringAsFixed(2);
}
_isRecalculating = false;
}
void _submitForm() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final session = Provider.of<SessionManager>(context, listen: false);
final refuel = Refuel(
id: widget.refuel?.id,
vehicleId: _selectedVehicleId!,
fuelType: _selectedFuelType!,
liters: double.parse(_litersController.text),
pricePerLiter: double.parse(_pricePerLiterController.text),
totalPrice: double.parse(_totalPriceController.text),
mileage: int.parse(_mileageController.text),
note: _noteController.text.isEmpty ? null : _noteController.text,
);
if (widget.refuel == null) {
await session.addRefuel(refuel);
} else if (widget.refuel!.id != null) {
await session.updateRefuel(widget.refuel!.id!, refuel);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fuel record saved successfully!')),
);
_formKey.currentState?.reset();
setState(() {
_selectedVehicleId = session.defaultVehicle?.id;
_selectedFuelType = session.defaultVehicle?.fuelType;
});
_litersController.clear();
_pricePerLiterController.clear();
_totalPriceController.clear();
_mileageController.clear();
_noteController.clear();
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
widget.onSaved?.call();
}
}
@override
void dispose() {
_litersController.dispose();
_pricePerLiterController.dispose();
_totalPriceController.dispose();
_mileageController.dispose();
_noteController.dispose();
_litersFocus.dispose();
_pricePerLiterFocus.dispose();
_totalPriceFocus.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,371 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '../models/service.dart';
import '../models/vehicle.dart';
import '../services/session_manager.dart';
enum _ServicePerformer { shop, self }
class AddServiceScreen extends StatefulWidget {
final ServiceRecord? service;
final VoidCallback? onSaved;
final bool standalone;
AddServiceScreen({this.service, this.onSaved, this.standalone = false});
@override
_AddServiceScreenState createState() => _AddServiceScreenState();
}
class _AddServiceScreenState extends State<AddServiceScreen> {
final _formKey = GlobalKey<FormState>();
String? _selectedVehicleId;
ServiceType? _selectedType;
final TextEditingController _customTypeController = TextEditingController();
final TextEditingController _itemNameController = TextEditingController();
final TextEditingController _costController = TextEditingController();
final TextEditingController _mileageController = TextEditingController();
final TextEditingController _shopController = TextEditingController();
_ServicePerformer _performer = _ServicePerformer.shop;
final TextEditingController _noteController = TextEditingController();
final List<Uint8List> _photos = [];
DateTime? _selectedDate;
final TextEditingController _dateController = TextEditingController();
bool _initialized = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialized) return;
final session = Provider.of<SessionManager>(context);
if (widget.service != null) {
final s = widget.service!;
_selectedVehicleId = s.vehicleId;
_selectedType = s.serviceType;
if (s.customType != null) _customTypeController.text = s.customType!;
if (s.itemName != null) _itemNameController.text = s.itemName!;
_costController.text = s.cost.toString();
_mileageController.text = s.mileage.toString();
if (s.selfService) {
_performer = _ServicePerformer.self;
} else {
_performer = _ServicePerformer.shop;
if (s.shop != null) _shopController.text = s.shop!;
}
if (s.note != null) _noteController.text = s.note!;
for (final p in s.photos) {
try {
final data = p.contains(',') ? p.split(',').last : p;
_photos.add(base64Decode(data));
} catch (_) {}
}
_selectedDate = s.date;
if (s.date != null) {
_dateController.text = _formatDate(s.date!);
}
} else {
_selectedVehicleId = session.defaultVehicle?.id;
}
_initialized = true;
}
String _formatDate(DateTime date) {
final d = date.toLocal();
return '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final session = Provider.of<SessionManager>(context);
final vehicles = session.vehicles;
final form = Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
decoration: InputDecoration(labelText: 'Vehicle'),
value: _selectedVehicleId,
items: vehicles
.map((vehicle) => DropdownMenuItem(
value: vehicle.id,
child: Text(vehicle.name),
))
.toList(),
onChanged: (value) => setState(() => _selectedVehicleId = value),
validator: (value) =>
value == null ? 'Please select a vehicle' : null,
),
SizedBox(height: 16),
DropdownButtonFormField<ServiceType>(
decoration: InputDecoration(labelText: 'Service Type'),
value: _selectedType,
items: ServiceType.values
.map((t) => DropdownMenuItem(
value: t,
child: Text(t.label),
))
.toList(),
onChanged: (value) => setState(() => _selectedType = value),
validator: (value) =>
value == null ? 'Please select a service type' : null,
),
if (_selectedType == ServiceType.other) ...[
SizedBox(height: 16),
TextFormField(
controller: _customTypeController,
decoration: InputDecoration(labelText: 'Custom Type'),
validator: (v) =>
v == null || v.isEmpty ? 'Enter custom type' : null,
),
],
SizedBox(height: 16),
TextFormField(
controller: _itemNameController,
decoration: InputDecoration(labelText: 'Item Name'),
),
SizedBox(height: 16),
TextFormField(
controller: _costController,
decoration: InputDecoration(labelText: 'Cost'),
keyboardType: TextInputType.number,
validator: _numberValidator,
),
SizedBox(height: 16),
TextFormField(
controller: _mileageController,
decoration: InputDecoration(labelText: 'Mileage'),
keyboardType: TextInputType.number,
validator: _numberValidator,
),
SizedBox(height: 16),
RadioListTile<_ServicePerformer>(
value: _ServicePerformer.shop,
groupValue: _performer,
title: Text('Performed at shop'),
onChanged: (v) => setState(() => _performer = v!),
),
RadioListTile<_ServicePerformer>(
value: _ServicePerformer.self,
groupValue: _performer,
title: Text('Self Service'),
onChanged: (v) => setState(() {
_performer = v!;
_shopController.clear();
}),
),
if (_performer == _ServicePerformer.shop) ...[
SizedBox(height: 16),
TextFormField(
controller: _shopController,
decoration: InputDecoration(labelText: 'Shop'),
validator: (v) =>
v == null || v.isEmpty ? 'Enter shop name' : null,
),
],
SizedBox(height: 16),
TextFormField(
controller: _noteController,
decoration: InputDecoration(labelText: 'Note'),
),
if (_photos.isNotEmpty) ...[
SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(_photos.length, (i) {
return Stack(
children: [
Image.memory(
_photos[i],
width: 80,
height: 80,
fit: BoxFit.cover,
),
Positioned(
right: 0,
top: 0,
child: GestureDetector(
onTap: () => setState(() => _photos.removeAt(i)),
child: Container(
color: Colors.black54,
child: Icon(Icons.close, color: Colors.white, size: 20),
),
),
),
],
);
}),
),
],
SizedBox(height: 16),
TextButton.icon(
onPressed: _pickPhoto,
icon: Icon(Icons.add_a_photo),
label: Text('Add Photo'),
),
SizedBox(height: 16),
TextFormField(
controller: _dateController,
decoration: InputDecoration(labelText: 'Date'),
readOnly: true,
onTap: () async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate ?? now,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
_selectedDate = picked;
_dateController.text = _formatDate(picked);
});
}
},
validator: (v) => _selectedDate == null ? 'Select a date' : null,
),
SizedBox(height: 24),
ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
child: Text(widget.service == null
? 'Create Service Record'
: 'Update Service Record'),
),
],
),
),
),
);
if (widget.standalone) {
return Scaffold(
appBar: AppBar(
title: Text(widget.service == null
? 'Add Service Record'
: 'Edit Service Record'),
),
body: form,
);
}
return form;
}
Future<void> _pickPhoto() async {
final picker = ImagePicker();
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (context) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: Icon(Icons.camera_alt),
title: Text('Take Photo'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: Icon(Icons.photo_library),
title: Text('Choose from Gallery'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
),
);
if (source == null) return;
final file = await picker.pickImage(source: source);
if (file == null) return;
var bytes = await file.readAsBytes();
final decoded = img.decodeImage(bytes);
if (decoded != null) {
const maxDim = 800;
img.Image resized;
if (decoded.width > decoded.height) {
resized = img.copyResize(decoded, width: maxDim);
} else {
resized = img.copyResize(decoded, height: maxDim);
}
bytes = Uint8List.fromList(img.encodeJpg(resized, quality: 70));
}
setState(() => _photos.add(bytes));
}
String? _numberValidator(String? value) {
if (value == null || value.isEmpty) return 'This field cannot be empty';
if (double.tryParse(value) == null) return 'Enter a valid number';
return null;
}
Future<void> _submitForm() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final session = Provider.of<SessionManager>(context, listen: false);
final service = ServiceRecord(
id: widget.service?.id,
vehicleId: _selectedVehicleId!,
serviceType: _selectedType!,
customType: _selectedType == ServiceType.other
? _customTypeController.text
: null,
itemName: _itemNameController.text.isEmpty
? null
: _itemNameController.text,
cost: double.parse(_costController.text),
mileage: int.parse(_mileageController.text),
shop: _performer == _ServicePerformer.shop ? _shopController.text : null,
selfService: _performer == _ServicePerformer.self,
note: _noteController.text.isEmpty ? null : _noteController.text,
photos: _photos.map((b) => base64Encode(b)).toList(),
date: _selectedDate,
);
bool success = false;
if (widget.service == null) {
success = await session.addService(service);
} else if (widget.service!.id != null) {
success = await session.updateService(widget.service!.id!, service);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Service record saved successfully!'
: 'Failed to save service record.')),
);
if (success) {
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
widget.onSaved?.call();
}
}
}
@override
void dispose() {
_customTypeController.dispose();
_itemNameController.dispose();
_costController.dispose();
_mileageController.dispose();
_shopController.dispose();
_noteController.dispose();
_dateController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,428 @@
import 'dart:ui';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/refuel.dart';
import '../models/service.dart';
import '../models/vehicle.dart';
import '../services/session_manager.dart';
import 'add_screen.dart';
import 'add_service_screen.dart';
import '../config.dart';
class HistoryScreen extends StatefulWidget {
@override
_HistoryScreenState createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
final TextEditingController _searchController = TextEditingController();
String _filter = 'all';
String _search = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
String _formatNumber(num value) {
if (value % 1 == 0) {
return value.toInt().toString();
}
return value
.toStringAsFixed(2)
.replaceFirst(RegExp(r'0+$'), '')
.replaceFirst(RegExp(r'[.]$'), '');
}
String _formatCurrency(num value) => '${_formatNumber(value)},-';
String _formatDateTime(DateTime? date) {
if (date == null) return '';
final d = date.toLocal();
final day = d.day.toString().padLeft(2, '0');
final month = d.month.toString().padLeft(2, '0');
final hour = d.hour.toString().padLeft(2, '0');
final minute = d.minute.toString().padLeft(2, '0');
return '$day.$month.${d.year} $hour:$minute';
}
TableRow _detailRow(String label, String value) {
return TableRow(
children: [
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 2, right: 16),
child: Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(value),
),
],
);
}
void _showFullImage(BuildContext context, String p) {
Widget img;
if (p.startsWith('http') || p.startsWith('/')) {
final url = p.startsWith('http') ? p : '$apiBaseUrl$p';
img = Image.network(url);
} else {
try {
img = Image.memory(base64Decode(p));
} catch (_) {
return;
}
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
onTap: () => Navigator.pop(context),
child: Center(child: InteractiveViewer(child: img)),
),
),
),
);
}
bool _matchesSearch(Refuel? r, ServiceRecord? s) {
final q = _search.toLowerCase();
if (q.isEmpty) return true;
if (r != null) {
return (r.note ?? '').toLowerCase().contains(q) ||
r.fuelType.label.toLowerCase().contains(q);
} else if (s != null) {
return s.displayType.toLowerCase().contains(q) ||
(s.note ?? '').toLowerCase().contains(q) ||
(s.shop ?? '').toLowerCase().contains(q);
}
return false;
}
@override
Widget build(BuildContext context) {
return Consumer<SessionManager>(
builder: (context, session, _) {
final items = <_HistoryItem>[];
for (final r in session.refuels) {
items.add(_HistoryItem(date: r.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0), refuel: r));
}
for (final s in session.services) {
items.add(_HistoryItem(date: s.date ?? s.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0), service: s));
}
items.sort((a, b) => b.date.compareTo(a.date));
final filtered = items.where((i) {
if (_filter == 'refuel' && i.refuel == null) return false;
if (_filter == 'service' && i.service == null) return false;
return _matchesSearch(i.refuel, i.service);
}).toList();
return Scaffold(
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Search...',
),
onChanged: (v) => setState(() => _search = v),
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
const SizedBox(width: 8),
ChoiceChip(
label: Text('All'),
selected: _filter == 'all',
onSelected: (_) => setState(() => _filter = 'all'),
),
const SizedBox(width: 8),
ChoiceChip(
label: Text('Refuels'),
selected: _filter == 'refuel',
onSelected: (_) => setState(() => _filter = 'refuel'),
),
const SizedBox(width: 8),
ChoiceChip(
label: Text('Services'),
selected: _filter == 'service',
onSelected: (_) => setState(() => _filter = 'service'),
),
const SizedBox(width: 8),
],
),
),
Expanded(
child: filtered.isEmpty
? Center(child: Text('No history yet'))
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, index) {
final item = filtered[index];
if (item.refuel != null) {
final r = item.refuel!;
final textStyle = const TextStyle(
fontFeatures: [FontFeature.tabularFigures()]);
return ListTile(
leading: Icon(Icons.local_gas_station, color: Colors.green),
title: Row(
children: [
Expanded(
child: Text(
'${_formatNumber(r.liters)} L',
style: textStyle,
),
),
Expanded(
child: Text(
'${_formatCurrency(r.pricePerLiter)}/L',
textAlign: TextAlign.center,
style: textStyle,
),
),
Expanded(
child: Text(
_formatCurrency(r.totalPrice),
textAlign: TextAlign.end,
style: textStyle,
),
),
],
),
subtitle: Text(_formatDateTime(r.createdAt), style: textStyle),
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Refuel Details'),
content: Table(
columnWidths: {0: IntrinsicColumnWidth()},
children: [
_detailRow('Total', _formatCurrency(r.totalPrice)),
_detailRow('Price/L', '${_formatCurrency(r.pricePerLiter)}'),
_detailRow('Liters', '${_formatNumber(r.liters)} L'),
_detailRow('Mileage', '${_formatNumber(r.mileage)} km'),
if (r.note != null && r.note!.isNotEmpty)
_detailRow('Note', r.note!),
_detailRow('Date', _formatDateTime(r.createdAt)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Close'),
),
],
),
);
},
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.edit, color: Colors.blue),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AddScreen(refuel: r, standalone: true),
),
);
},
),
if (r.id != null)
IconButton(
icon: Icon(Icons.delete, color: Colors.red),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Confirm Deletion'),
content: Text('Are you sure you want to delete this record?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'),
),
],
),
);
if (confirm == true) {
await session.removeRefuel(r.id!);
}
},
),
],
),
);
} else {
final s = item.service!;
final textStyle = const TextStyle(
fontFeatures: [FontFeature.tabularFigures()]);
return ListTile(
leading: Icon(Icons.build, color: Colors.orangeAccent),
title: Row(
children: [
Expanded(
child: Text(
s.displayType,
style: textStyle,
),
),
Expanded(
child: Text(
_formatCurrency(s.cost),
textAlign: TextAlign.end,
style: textStyle,
),
),
],
),
subtitle: Text(_formatDateTime(s.date), style: textStyle),
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Service Details'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Table(
columnWidths: {0: IntrinsicColumnWidth()},
children: [
_detailRow('Type', s.displayType),
_detailRow('Cost', _formatCurrency(s.cost)),
_detailRow('Mileage', '${_formatNumber(s.mileage)} km'),
if (s.shop != null && s.shop!.isNotEmpty)
_detailRow('Shop', s.shop!),
_detailRow('Self service', s.selfService ? 'Yes' : 'No'),
if (s.note != null && s.note!.isNotEmpty)
_detailRow('Note', s.note!),
_detailRow('Date', _formatDateTime(s.date)),
],
),
if (s.photos.isNotEmpty) ...[
SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: s.photos.map((p) {
Widget img;
if (p.startsWith('http') || p.startsWith('/')) {
final url =
p.startsWith('http') ? p : '$apiBaseUrl$p';
img = Image.network(
url,
width: 100,
height: 100,
fit: BoxFit.cover,
);
} else {
try {
img = Image.memory(
base64Decode(p),
width: 100,
height: 100,
fit: BoxFit.cover,
);
} catch (_) {
img = SizedBox.shrink();
}
}
return GestureDetector(
onTap: () => _showFullImage(context, p),
child: img,
);
}).toList(),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Close'),
),
],
),
);
},
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.edit, color: Colors.blue),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AddServiceScreen(service: s, standalone: true),
),
);
},
),
if (s.id != null)
IconButton(
icon: Icon(Icons.delete, color: Colors.red),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Confirm Deletion'),
content: Text('Are you sure you want to delete this record?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'),
),
],
),
);
if (confirm == true) {
await session.removeService(s.id!);
}
},
),
],
),
);
}
},
),
),
],
),
);
},
);
}
}
class _HistoryItem {
final DateTime date;
final Refuel? refuel;
final ServiceRecord? service;
_HistoryItem({required this.date, this.refuel, this.service});
}

View File

@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/refuel.dart';
import '../services/session_manager.dart';
import '../widgets/stat_card.dart';
import '../widgets/gas_price_chart.dart';
import '../widgets/consumption_chart.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
String _formatDouble(double value) {
var s = value.toStringAsFixed(2);
if (s.endsWith('.00')) {
s = s.substring(0, s.length - 3);
} else if (s.endsWith('0')) {
s = s.substring(0, s.length - 1);
}
return s;
}
double? _allTimeConsumption(List<Refuel> refuels) {
if (refuels.length < 2) return null;
final distance = refuels.last.mileage - refuels.first.mileage;
if (distance <= 0) return null;
final liters =
refuels.skip(1).fold<double>(0.0, (sum, r) => sum + r.liters);
return liters / distance * 100;
}
double? _lastConsumption(List<Refuel> refuels) {
if (refuels.length < 2) return null;
final last = refuels[refuels.length - 1];
final prev = refuels[refuels.length - 2];
final distance = last.mileage - prev.mileage;
if (distance <= 0) return null;
return last.liters / distance * 100;
}
int? _kmSinceLast(List<Refuel> refuels) {
if (refuels.length < 2) return null;
return refuels.last.mileage - refuels[refuels.length - 2].mileage;
}
int _kmForPeriod(List<Refuel> refuels, DateTime from) {
final list = refuels
.where((r) => r.createdAt != null && r.createdAt!.isAfter(from))
.toList();
if (list.length < 2) return 0;
return list.last.mileage - list.first.mileage;
}
int _kmAllTime(List<Refuel> refuels) {
if (refuels.length < 2) return 0;
return refuels.last.mileage - refuels.first.mileage;
}
@override
Widget build(BuildContext context) {
return Consumer<SessionManager>(
builder: (context, session, _) {
final vehicle = session.defaultVehicle;
if (vehicle == null) {
return const Scaffold(
body: Center(child: Text('No default vehicle selected')),
);
}
final refuels = session.refuels
.where((r) => r.vehicleId == vehicle.id)
.toList();
refuels.sort((a, b) => a.mileage.compareTo(b.mileage));
final allCons = _allTimeConsumption(refuels);
final lastCons = _lastConsumption(refuels);
final kmLast = _kmSinceLast(refuels);
final now = DateTime.now();
final km1m = _kmForPeriod(refuels, now.subtract(const Duration(days: 30)));
final km6m =
_kmForPeriod(refuels, now.subtract(const Duration(days: 182)));
final km1y =
_kmForPeriod(refuels, now.subtract(const Duration(days: 365)));
final kmAll = _kmAllTime(refuels);
final lastRefuels =
refuels.length > 14 ? refuels.sublist(refuels.length - 14) : refuels;
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
color: Theme.of(context).colorScheme.secondaryContainer,
child: ListTile(
leading: const Icon(Icons.directions_car),
title: Text(vehicle.name,
style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(vehicle.registrationPlate),
trailing:
const Icon(Icons.star, color: Colors.amber),
),
),
const SizedBox(height: 16),
const Text('Refuel stats',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 2,
children: [
StatCard(
title: 'Avg consumption (all time)',
value: allCons != null
? '${_formatDouble(allCons)} L/100km'
: '-',
),
StatCard(
title: 'Since last refuel',
value: lastCons != null
? '${_formatDouble(lastCons)} L/100km'
: '-',
),
],
),
const SizedBox(height: 24),
const Text('Kilometers driven',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Builder(builder: (context) {
final kmCards = <Widget>[
StatCard(
title: 'Since last refuel',
value: kmLast != null ? '$kmLast km' : '-',
),
StatCard(title: 'Past month', value: '$km1m km'),
StatCard(title: 'Past 6 months', value: '$km6m km'),
StatCard(title: 'Past year', value: '$km1y km'),
StatCard(title: 'All time', value: '$kmAll km'),
];
if (kmCards.length.isOdd) {
kmCards.add(const SizedBox.shrink());
}
return GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 2,
children: kmCards,
);
}),
if (lastRefuels.isNotEmpty) ...[
const SizedBox(height: 24),
Text('Gas price (last ${lastRefuels.length} refuels)',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
SizedBox(
height: 220,
child: GasPriceChart(refuels: lastRefuels),
),
if (lastRefuels.length > 1) ...[
const SizedBox(height: 24),
const Text('Consumption trend',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
SizedBox(
height: 220,
child: ConsumptionChart(refuels: lastRefuels),
),
],
],
const SizedBox(height: 8),
],
),
),
),
),
);
},
);
}
}

152
lib/screens/login.dart Normal file
View File

@@ -0,0 +1,152 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import '../config.dart';
import '../services/session_manager.dart';
class LoginScreen extends StatefulWidget {
final VoidCallback onSwitchToSignup;
final VoidCallback onLoginSuccess;
const LoginScreen({
required this.onSwitchToSignup,
required this.onLoginSuccess,
super.key,
});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
Future<void> _login() async {
if (_formKey.currentState!.validate()) {
final email = _emailController.text;
final password = _passwordController.text;
try {
final response = await http.post(
Uri.parse('$apiBaseUrl/api/v1/auth/signin'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final token = data['token'];
final name = data['user']?['username'] ?? data['username'];
await Provider.of<SessionManager>(context, listen: false).login(
token: token,
email: email,
name: name,
);
widget.onLoginSuccess();
} else {
final data = jsonDecode(response.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(data['message'] ?? 'Login failed')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Login error: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 40),
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/icon/app_icon.png',
width: 100,
height: 100,
),
),
const SizedBox(height: 16),
Text(
'Log in to Fuel Stats',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty)
return 'Please enter your email';
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value))
return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty)
return 'Please enter your password';
if (value.length < 6)
return 'Password must be at least 6 characters';
return null;
},
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _login,
icon: Icon(Icons.login),
label: Text('Log In'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 32, vertical: 12),
),
),
const SizedBox(height: 12),
TextButton(
onPressed: widget.onSwitchToSignup,
child: Text("Don't have an account? Sign up"),
),
],
),
),
),
),
);
}
}

186
lib/screens/signup.dart Normal file
View File

@@ -0,0 +1,186 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import '../config.dart';
import '../services/session_manager.dart';
class SignupScreen extends StatefulWidget {
final VoidCallback onSwitchToLogin;
final VoidCallback onSignupSuccess;
const SignupScreen({
required this.onSwitchToLogin,
required this.onSignupSuccess,
super.key,
});
@override
State<SignupScreen> createState() => _SignupScreenState();
}
class _SignupScreenState extends State<SignupScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
Future<void> _signup() async {
if (_formKey.currentState!.validate()) {
final username = _usernameController.text;
final email = _emailController.text;
final password = _passwordController.text;
try {
final signupResponse = await http.post(
Uri.parse('$apiBaseUrl/api/v1/auth/signup'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'username': username,
'email': email,
'password': password,
}),
);
if (signupResponse.statusCode == 200 || signupResponse.statusCode == 201) {
final signinResponse = await http.post(
Uri.parse('$apiBaseUrl/api/v1/auth/signin'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email, 'password': password}),
);
if (signinResponse.statusCode == 200) {
final data = jsonDecode(signinResponse.body);
final token = data['token'];
final name = data['user']?['username'] ?? data['username'] ?? username;
await Provider.of<SessionManager>(context, listen: false).login(
token: token,
email: email,
name: name,
);
widget.onSignupSuccess();
} else {
final data = jsonDecode(signinResponse.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(data['message'] ?? 'Sign in failed')),
);
}
} else {
final data = jsonDecode(signupResponse.body);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(data['message'] ?? 'Signup failed')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Signup error: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
//appBar: AppBar(title: Text('Sign Up')),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 40),
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/icon/app_icon.png',
width: 100,
height: 100,
),
),
const SizedBox(height: 16),
Text(
'Create your Fuel Stats account',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32),
TextFormField(
controller: _usernameController,
decoration: InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty)
return 'Please enter a username';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty)
return 'Please enter an email';
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value))
return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty)
return 'Please enter a password';
if (value.length < 6)
return 'Password must be at least 6 characters';
return null;
},
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _signup,
icon: Icon(Icons.person_add),
label: Text('Sign Up'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 32, vertical: 12),
),
),
const SizedBox(height: 12),
TextButton(
onPressed: widget.onSwitchToLogin,
child: Text("Already have an account? Log in"),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
class UserSettingsScreen extends StatelessWidget {
final VoidCallback onLogout;
const UserSettingsScreen({required this.onLogout, super.key});
Future<String> _getVersion() async {
final info = await PackageInfo.fromPlatform();
return 'Version: ${info.version}+${info.buildNumber}';
}
@override
Widget build(BuildContext context) {
final session = Provider.of<SessionManager>(context);
final userName = session.name ?? "Unknown User"; // fallback just in case
final userEmail = session.email ?? '';
return Scaffold(
appBar: AppBar(title: Text('User settings')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
userName,
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
if (userEmail.isNotEmpty)
Text(
userEmail,
style: TextStyle(fontSize: 16),
),
SizedBox(height: 20),
ElevatedButton.icon(
onPressed: () async {
await session.logout();
onLogout();
},
icon: Icon(Icons.logout),
label: Text("Sign Out"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
foregroundColor: Colors.white,
),
),
SizedBox(height: 8),
FutureBuilder<String>(
future: _getVersion(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return Text(
snapshot.data ?? '',
style: TextStyle(color: Colors.grey),
);
} else {
return SizedBox.shrink();
}
},
),
],
),
),
);
}
}

View File

@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../services/session_manager.dart';
class VehiclesScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final session = Provider.of<SessionManager>(context);
final vehicles = session.vehicles;
return Scaffold(
body: vehicles.isEmpty
? Center(child: Text('No vehicles added yet.'))
: ListView.builder(
itemCount: vehicles.length,
itemBuilder: (context, index) {
final vehicle = vehicles[index];
final isDefault = vehicle.isDefault;
return ListTile(
tileColor: isDefault
? Theme.of(context).colorScheme.secondaryContainer
: null,
leading: IconButton(
icon: Icon(Icons.star,
color: isDefault ? Colors.amber : Colors.grey),
tooltip: isDefault ? 'Unset default' : 'Set as default',
onPressed: () => session.setDefaultVehicle(
isDefault ? null : vehicle.id),
),
title: Text(vehicle.name),
subtitle: Text(
'${vehicle.registrationPlate}${vehicle.fuelType.label}'
'${vehicle.note != null ? '${vehicle.note}' : ''}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.edit, color: Colors.blue),
onPressed: () => _editVehicle(context, session, index, vehicle),
),
IconButton(
icon: Icon(Icons.delete, color: Colors.red),
onPressed: () => _removeVehicle(context, session, index),
),
],
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _addVehicle(context, session),
child: Icon(Icons.add),
tooltip: 'Add Vehicle',
),
);
}
void _addVehicle(BuildContext context, SessionManager session) async {
final newVehicle = await showDialog<Vehicle>(
context: context,
builder: (context) => _VehicleDialog(),
);
if (newVehicle != null) {
await session.addVehicle(newVehicle);
}
}
void _editVehicle(BuildContext context, SessionManager session, int index,
Vehicle vehicle) async {
final updatedVehicle = await showDialog<Vehicle>(
context: context,
builder: (context) => _VehicleDialog(vehicle: vehicle),
);
if (updatedVehicle != null) {
await session.updateVehicle(index, updatedVehicle);
}
}
void _removeVehicle(
BuildContext context, SessionManager session, int index) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Confirm Removal'),
content: Text(
'Are you sure you want to delete this vehicle? This action cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'),
),
],
),
);
if (confirm == true) {
await session.removeVehicle(index);
}
}
}
class _VehicleDialog extends StatefulWidget {
final Vehicle? vehicle;
const _VehicleDialog({this.vehicle});
@override
_VehicleDialogState createState() => _VehicleDialogState();
}
class _VehicleDialogState extends State<_VehicleDialog> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameController;
late TextEditingController _plateController;
late TextEditingController _noteController;
FuelType? _selectedFuelType;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.vehicle?.name ?? '');
_plateController =
TextEditingController(text: widget.vehicle?.registrationPlate ?? '');
_noteController = TextEditingController(text: widget.vehicle?.note ?? '');
_selectedFuelType = widget.vehicle?.fuelType;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.vehicle == null ? 'Add New Vehicle' : 'Edit Vehicle'),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nameController,
decoration: InputDecoration(labelText: 'Vehicle Name'),
validator: (value) =>
value == null || value.isEmpty ? 'Enter a vehicle name' : null,
),
TextFormField(
controller: _plateController,
decoration: InputDecoration(labelText: 'Registration Plate'),
validator: (value) =>
value == null || value.isEmpty ? 'Enter a plate number' : null,
),
DropdownButtonFormField<FuelType>(
decoration: InputDecoration(labelText: 'Fuel Type'),
value: _selectedFuelType,
items: FuelType.values
.map((fuelType) => DropdownMenuItem(
value: fuelType,
child: Text(fuelType.label),
))
.toList(),
onChanged: (value) => setState(() => _selectedFuelType = value),
validator: (value) =>
value == null ? 'Please select a fuel type' : null,
),
TextFormField(
controller: _noteController,
decoration: InputDecoration(labelText: 'Note (optional)'),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'),
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState?.validate() ?? false) {
final vehicle = Vehicle(
id: widget.vehicle?.id,
name: _nameController.text,
registrationPlate: _plateController.text,
fuelType: _selectedFuelType!,
note:
_noteController.text.isEmpty ? null : _noteController.text,
isDefault: widget.vehicle?.isDefault ?? false,
);
Navigator.pop(context, vehicle);
}
},
child: Text(widget.vehicle == null ? 'Add' : 'Save'),
),
],
);
}
}

View File

@@ -0,0 +1,342 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:http/http.dart' as http;
import '../config.dart';
import '../models/vehicle.dart';
import '../models/refuel.dart';
import '../models/service.dart';
class SessionManager extends ChangeNotifier {
static final SessionManager _instance = SessionManager._internal();
factory SessionManager() => _instance;
SessionManager._internal();
bool _loggedIn = false;
String? _token;
String? _email;
String? _name;
List<Vehicle> _vehicles = [];
List<Refuel> _refuels = [];
List<ServiceRecord> _services = [];
bool get isLoggedIn => _loggedIn;
String? get token => _token;
String? get email => _email;
String? get name => _name;
List<Vehicle> get vehicles => List.unmodifiable(_vehicles);
List<Refuel> get refuels => List.unmodifiable(_refuels);
List<ServiceRecord> get services => List.unmodifiable(_services);
Vehicle? get defaultVehicle {
try {
return _vehicles.firstWhere((v) => v.isDefault);
} catch (_) {
return null;
}
}
final _prefs = SharedPreferencesAsync(); // ✅ New API
Future<void> init() async {
_token = await _prefs.getString('token');
_email = await _prefs.getString('email');
_name = await _prefs.getString('name');
if (_token != null) {
final valid = await _validateToken();
if (valid) {
_loggedIn = true;
await fetchVehicles();
await fetchRefuels();
await fetchServices();
notifyListeners();
} else {
await logout();
}
} else {
_loggedIn = false;
notifyListeners();
}
}
Future<void> login({
required String token,
required String email,
String? name,
}) async {
await _prefs.setString('token', token);
await _prefs.setString('email', email);
if (name != null) await _prefs.setString('name', name);
_token = token;
_email = email;
_name = name;
_loggedIn = true;
// Ensure we have the latest user info and that the token is valid
await _validateToken();
await fetchVehicles();
await fetchRefuels();
await fetchServices();
notifyListeners();
}
Future<void> logout() async {
await _prefs.remove('token');
await _prefs.remove('email');
await _prefs.remove('name');
_token = null;
_email = null;
_name = null;
_vehicles.clear();
_refuels.clear();
_services.clear();
_loggedIn = false;
notifyListeners();
}
Map<String, String> _authHeaders() => {
'Content-Type': 'application/json',
if (_token != null) 'Authorization': 'Bearer $_token',
};
Future<bool> _validateToken() async {
if (_token == null) return false;
try {
final response = await http.get(
Uri.parse('$apiBaseUrl/api/v1/user/me'),
headers: _authHeaders(),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_email = data['email'] ?? _email;
_name = data['username'] ?? data['name'] ?? _name;
if (_email != null) await _prefs.setString('email', _email!);
if (_name != null) await _prefs.setString('name', _name!);
return true;
}
} catch (_) {}
return false;
}
Future<void> fetchVehicles() async {
try {
final response = await http.get(
Uri.parse('$apiBaseUrl/api/v1/vehicles'),
headers: _authHeaders(),
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
_vehicles = data.map((e) => Vehicle.fromApi(e)).toList();
notifyListeners();
}
} catch (_) {
// ignore for now
}
}
Future<void> addVehicle(Vehicle vehicle) async {
try {
final response = await http.post(
Uri.parse('$apiBaseUrl/api/v1/vehicles'),
headers: _authHeaders(),
body: jsonEncode(vehicle.toApiMap()),
);
if (response.statusCode == 200 || response.statusCode == 201) {
final data = jsonDecode(response.body);
_vehicles.add(Vehicle.fromApi(data));
notifyListeners();
}
} catch (_) {}
}
Future<void> updateVehicle(int index, Vehicle vehicle) async {
final id = _vehicles[index].id;
if (id == null) return;
try {
final response = await http.put(
Uri.parse('$apiBaseUrl/api/v1/vehicles/$id'),
headers: _authHeaders(),
body: jsonEncode(vehicle.toApiMap()),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
_vehicles[index] = Vehicle.fromApi(data);
notifyListeners();
}
} catch (_) {}
}
Future<void> removeVehicle(int index) async {
final id = _vehicles[index].id;
if (id == null) return;
try {
final response = await http.delete(
Uri.parse('$apiBaseUrl/api/v1/vehicles/$id'),
headers: _authHeaders(),
);
if (response.statusCode == 200 || response.statusCode == 204) {
_vehicles.removeAt(index);
notifyListeners();
}
} catch (_) {}
}
Future<void> setDefaultVehicle(String? id) async {
if (id == null) {
// Unset default from any vehicle currently marked as default
for (var i = 0; i < _vehicles.length; i++) {
if (_vehicles[i].isDefault) {
await updateVehicle(i, _vehicles[i].copyWith(isDefault: false));
}
}
} else {
// Clear default flag on all other vehicles first
for (var i = 0; i < _vehicles.length; i++) {
final vehicle = _vehicles[i];
if (vehicle.isDefault && vehicle.id != id) {
await updateVehicle(i, vehicle.copyWith(isDefault: false));
}
}
// Finally mark the selected vehicle as default
final idx = _vehicles.indexWhere((v) => v.id == id);
if (idx != -1) {
await updateVehicle(idx, _vehicles[idx].copyWith(isDefault: true));
}
}
await fetchVehicles();
}
// Refuel records
Future<void> fetchRefuels() async {
try {
final response = await http.get(
Uri.parse('$apiBaseUrl/api/v1/refuels'),
headers: _authHeaders(),
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
_refuels = data.map((e) => Refuel.fromApi(e)).toList();
notifyListeners();
}
} catch (_) {}
}
Future<void> addRefuel(Refuel refuel) async {
try {
final response = await http.post(
Uri.parse('$apiBaseUrl/api/v1/refuels'),
headers: _authHeaders(),
body: jsonEncode(refuel.toApiMap()),
);
if (response.statusCode == 200 || response.statusCode == 201) {
final data = jsonDecode(response.body);
_refuels.add(Refuel.fromApi(data));
notifyListeners();
}
} catch (_) {}
}
Future<void> updateRefuel(String id, Refuel refuel) async {
try {
final response = await http.put(
Uri.parse('$apiBaseUrl/api/v1/refuels/$id'),
headers: _authHeaders(),
body: jsonEncode(refuel.toApiMap()),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final idx = _refuels.indexWhere((r) => r.id == id);
if (idx != -1) {
_refuels[idx] = Refuel.fromApi(data);
notifyListeners();
}
}
} catch (_) {}
}
Future<void> removeRefuel(String id) async {
try {
final response = await http.delete(
Uri.parse('$apiBaseUrl/api/v1/refuels/$id'),
headers: _authHeaders(),
);
if (response.statusCode == 200 || response.statusCode == 204) {
_refuels.removeWhere((r) => r.id == id);
notifyListeners();
}
} catch (_) {}
}
// Service records
Future<void> fetchServices() async {
try {
final response = await http.get(
Uri.parse('$apiBaseUrl/api/v1/services'),
headers: _authHeaders(),
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
_services = data.map((e) => ServiceRecord.fromApi(e)).toList();
notifyListeners();
}
} catch (_) {}
}
Future<bool> addService(ServiceRecord service) async {
try {
final response = await http.post(
Uri.parse('$apiBaseUrl/api/v1/services'),
headers: _authHeaders(),
body: jsonEncode(service.toApiMap()),
);
if (response.statusCode == 200 || response.statusCode == 201) {
final data = jsonDecode(response.body);
_services.add(ServiceRecord.fromApi(data));
notifyListeners();
return true;
}
} catch (_) {}
return false;
}
Future<bool> updateService(String id, ServiceRecord service) async {
try {
final response = await http.put(
Uri.parse('$apiBaseUrl/api/v1/services/$id'),
headers: _authHeaders(),
body: jsonEncode(service.toApiMap()),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final idx = _services.indexWhere((s) => s.id == id);
if (idx != -1) {
_services[idx] = ServiceRecord.fromApi(data);
notifyListeners();
}
return true;
}
} catch (_) {}
return false;
}
Future<void> removeService(String id) async {
try {
final response = await http.delete(
Uri.parse('$apiBaseUrl/api/v1/services/$id'),
headers: _authHeaders(),
);
if (response.statusCode == 200 || response.statusCode == 204) {
_services.removeWhere((s) => s.id == id);
notifyListeners();
}
} catch (_) {}
}
}

View File

@@ -0,0 +1,98 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../models/refuel.dart';
class ConsumptionChart extends StatelessWidget {
final List<Refuel> refuels;
const ConsumptionChart({super.key, required this.refuels});
@override
Widget build(BuildContext context) {
final spots = <FlSpot>[];
final labels = <String>[];
for (var i = 1; i < refuels.length; i++) {
final prev = refuels[i - 1];
final curr = refuels[i];
final distance = curr.mileage - prev.mileage;
if (distance <= 0) continue;
final consumption =
double.parse((curr.liters / distance * 100).toStringAsFixed(2));
spots.add(FlSpot(spots.length.toDouble(), consumption));
final date = curr.createdAt;
labels.add(date != null ? '${date.month}/${date.day}' : '');
}
return LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
barWidth: 3,
color: Theme.of(context).colorScheme.secondary,
dotData: const FlDotData(show: true),
)
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: Colors.black87,
tooltipMargin: 40,
fitInsideHorizontally: true,
fitInsideVertically: true,
getTooltipItems: (spots) => spots
.map((s) => LineTooltipItem(
'${s.y.toStringAsFixed(2)} L/100km',
const TextStyle(color: Colors.white)))
.toList(),
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
axisNameSize: 28,
axisNameWidget: const Padding(
padding: EdgeInsets.only(right: 8),
child: Text('L/100km'),
),
sideTitles: SideTitles(
showTitles: true,
reservedSize: 50,
getTitlesWidget: (value, meta) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(value.toStringAsFixed(1),
style: const TextStyle(fontSize: 10)),
),
),
),
bottomTitles: AxisTitles(
axisNameSize: 24,
axisNameWidget: const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('Date'),
),
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= labels.length) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(labels[index],
style: const TextStyle(fontSize: 10)),
);
},
),
),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
),
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
class DataSection extends StatelessWidget {
final String title;
const DataSection({required this.title});
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.all(16),
child: Container(
height: 200,
padding: EdgeInsets.all(16),
child: Center(child: Text(title, style: TextStyle(fontSize: 18))),
),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../models/refuel.dart';
class GasPriceChart extends StatelessWidget {
final List<Refuel> refuels;
const GasPriceChart({super.key, required this.refuels});
@override
Widget build(BuildContext context) {
final spots = <FlSpot>[];
final labels = <String>[];
for (var i = 0; i < refuels.length; i++) {
final refuel = refuels[i];
spots.add(FlSpot(spots.length.toDouble(), refuel.pricePerLiter));
final date = refuel.createdAt;
labels.add(date != null ? '${date.month}/${date.day}' : '');
}
return LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
barWidth: 3,
color: Theme.of(context).colorScheme.primary,
dotData: const FlDotData(show: true),
)
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: Colors.black87,
tooltipMargin: 40,
fitInsideHorizontally: true,
fitInsideVertically: true,
getTooltipItems: (spots) => spots
.map((s) => LineTooltipItem(
s.y.toStringAsFixed(2),
const TextStyle(color: Colors.white)))
.toList(),
),
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
axisNameSize: 28,
axisNameWidget: const Padding(
padding: EdgeInsets.only(right: 8),
child: Text('Price/L'),
),
sideTitles: SideTitles(
showTitles: true,
reservedSize: 50,
getTitlesWidget: (value, meta) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(value.toStringAsFixed(1),
style: const TextStyle(fontSize: 10)),
),
),
),
bottomTitles: AxisTitles(
axisNameSize: 24,
axisNameWidget: const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('Date'),
),
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= labels.length) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(labels[index],
style: const TextStyle(fontSize: 10)),
);
},
),
),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
),
);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class StatCard extends StatelessWidget {
final String title;
final String value;
const StatCard({super.key, required this.title, required this.value});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
value,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 2),
Text(title, textAlign: TextAlign.center),
],
),
),
);
}
}