Release version 1.0.0
This commit is contained in:
2
lib/config.dart
Normal file
2
lib/config.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
const String apiBaseUrl =
|
||||
String.fromEnvironment('API_BASE_URL', defaultValue: 'https://fuelstats.filiprojek.cz');
|
164
lib/main.dart
Normal file
164
lib/main.dart
Normal 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
20
lib/main.dart.old2
Normal 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
113
lib/models/refuel.dart
Normal 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
235
lib/models/service.dart
Normal 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
104
lib/models/vehicle.dart
Normal 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
279
lib/screens/add_screen.dart
Normal 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();
|
||||
}
|
||||
}
|
371
lib/screens/add_service_screen.dart
Normal file
371
lib/screens/add_service_screen.dart
Normal 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();
|
||||
}
|
||||
}
|
428
lib/screens/history_screen.dart
Normal file
428
lib/screens/history_screen.dart
Normal 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});
|
||||
}
|
196
lib/screens/home_screen.dart
Normal file
196
lib/screens/home_screen.dart
Normal 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
152
lib/screens/login.dart
Normal 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
186
lib/screens/signup.dart
Normal 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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
71
lib/screens/user_settings.dart
Normal file
71
lib/screens/user_settings.dart
Normal 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();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
201
lib/screens/vehicles_screen.dart
Normal file
201
lib/screens/vehicles_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
342
lib/services/session_manager.dart
Normal file
342
lib/services/session_manager.dart
Normal 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 (_) {}
|
||||
}
|
||||
}
|
||||
|
98
lib/widgets/consumption_chart.dart
Normal file
98
lib/widgets/consumption_chart.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
19
lib/widgets/data_section.dart
Normal file
19
lib/widgets/data_section.dart
Normal 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))),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
93
lib/widgets/gas_price_chart.dart
Normal file
93
lib/widgets/gas_price_chart.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
29
lib/widgets/stat_card.dart
Normal file
29
lib/widgets/stat_card.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user