Release version 1.0.0
This commit is contained in:
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user