Release version 1.0.0

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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