A whole bunch of changes

This commit is contained in:
Courtney Arnold 2026-08-15 17:52:25 -05:00
parent 901ec678a3
commit 956231048e
122 changed files with 5442 additions and 914 deletions

View file

@ -12,11 +12,19 @@ import '../widgets/image_source_sheet.dart';
import 'vehicle_detail_screen.dart';
/// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or
/// omit it to create a new one.
/// omit it to create a new one. [promptToCreateForReceipt] is set when this
/// screen was reached because the user tried to log a receipt with no
/// vehicle to attach it to yet — it shows an explanatory dialog on arrival
/// so that's clear, rather than silently landing on the add-vehicle form.
class AddEditVehicleScreen extends StatefulWidget {
final Vehicle? vehicle;
final bool promptToCreateForReceipt;
const AddEditVehicleScreen({super.key, this.vehicle});
const AddEditVehicleScreen({
super.key,
this.vehicle,
this.promptToCreateForReceipt = false,
});
@override
State<AddEditVehicleScreen> createState() => _AddEditVehicleScreenState();
@ -39,6 +47,24 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
super.initState();
_nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? '');
_vinController = TextEditingController(text: widget.vehicle?.vin ?? '');
if (widget.promptToCreateForReceipt) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
content: const Text('Create a vehicle to attach receipt'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
});
}
}
@override
@ -48,8 +74,46 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
super.dispose();
}
void _showVinLocationHelp() {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Where to find the VIN'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"It's usually on a sticker in the driver's side door jamb, or on a "
'small plate at the base of the windshield.',
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset('assets/vin_help/door_jamb.jpg'),
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset('assets/vin_help/windshield.jpg'),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
],
),
);
}
Future<void> _scanVin() async {
final source = await chooseImageSource(context);
final source = await chooseImageSource(
context,
heading: 'VIN From',
onInfoTap: _showVinLocationHelp,
);
if (source == null || !mounted) return;
final picker = ImagePicker();
@ -89,13 +153,35 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
await _checkForExistingVin();
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Couldn't read a VIN from that photo. Please enter it manually."),
SnackBar(
content: const Text("Couldn't read a VIN from that photo. Please enter it manually."),
duration: const Duration(seconds: 8),
action: recognizedText.trim().isEmpty
? null
// Lets you see exactly what the on-device OCR read, rather
// than only knowing "no VIN was found in it" — the
// difference between "OCR misread a character" and "OCR
// read it fine but the VIN pattern itself needs a fix"
// isn't visible any other way.
: SnackBarAction(label: 'Show Text', onPressed: () => _showRecognizedText(recognizedText)),
),
);
}
}
void _showRecognizedText(String text) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Recognized Text'),
content: SingleChildScrollView(child: SelectableText(text)),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
],
),
);
}
/// Field-level counterpart to [_checkForExistingVin]'s dialog: catches
/// "Required" as before, plus flags the field itself as invalid when the
/// VIN belongs to another active vehicle — excluding this vehicle's own
@ -160,24 +246,30 @@ class _AddEditVehicleScreenState extends State<AddEditVehicleScreen> {
_error = null;
});
final vin = _vinController.text.trim();
final nickname = _nicknameController.text.trim();
final appState = context.read<AppState>();
try {
if (_isEditing) {
await appState.updateVehicle(widget.vehicle!.copyWith(
vin: _vinController.text.trim(),
vin: vin,
nickname: nickname.isEmpty ? null : nickname,
clearNickname: nickname.isEmpty,
));
} else {
await appState.addVehicle(
vin: _vinController.text.trim(),
nickname: nickname.isEmpty ? null : nickname,
);
await appState.addVehicle(vin: vin, nickname: nickname.isEmpty ? null : nickname);
}
if (mounted) Navigator.of(context).pop();
if (mounted) {
// Reached from "log a receipt with no vehicle yet" — once the
// vehicle exists, continue straight into the receipt capture flow
// the user was actually trying to do, rather than just landing
// back on an (again) empty Receipts tab. The caller does the
// continuing; this just hands back which vehicle to do it for.
final continueToReceipt = !_isEditing && widget.promptToCreateForReceipt;
Navigator.of(context).pop(continueToReceipt ? appState.vehicleByVin(vin)?.id : null);
}
} on DuplicateVinException catch (e) {
setState(() => _error = e.toString());
} finally {

View file

@ -0,0 +1,550 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/cloud/cloud_storage_provider.dart';
import 'cloud_folder_browser_screen.dart';
/// "Data" settings submenu, reached from [SettingsScreen]: cloud storage
/// connection, the two photo-handling switches, and an Advanced section
/// (stale sync lock timeout, plus the destructive purge actions — grouped
/// there rather than given their own top-level "Danger Zone" so they sit
/// behind the same disclosure as other rarely-touched settings).
class DataSettingsScreen extends StatefulWidget {
const DataSettingsScreen({super.key});
@override
State<DataSettingsScreen> createState() => _DataSettingsScreenState();
}
class _DataSettingsScreenState extends State<DataSettingsScreen> {
static final _dateFormat = DateFormat.yMMMd();
bool _busy = false;
String? _error;
DateTime? _purgeRangeStart;
DateTime? _purgeRangeEnd;
String? _purgeRangeError;
Future<void> _connect(CloudProviderId id) async {
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().connectProvider(id);
} catch (e) {
setState(() => _error = 'Could not sign in: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _connectManual(CloudProviderId id, String providerName) async {
final credentials = await showDialog<_WebDavCredentials>(
context: context,
builder: (_) => _WebDavCredentialsDialog(providerName: providerName),
);
if (credentials == null || !mounted) return;
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().connectProviderWithCredentials(
id,
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
);
} catch (e) {
setState(() => _error = 'Could not connect: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _disconnect() async {
setState(() => _busy = true);
try {
await context.read<AppState>().disconnectCloud();
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _chooseFolder() {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
);
}
Future<void> _syncNow() async {
setState(() => _busy = true);
try {
await context.read<AppState>().syncNow();
} finally {
if (mounted) setState(() => _busy = false);
}
}
/// Shared warning-dialog shell for both purge actions — [message] is the
/// caller's job to make specific and unambiguous, since this is the only
/// thing standing between the user and an unrecoverable delete.
Future<bool> _confirmPurge({required String title, required String message}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext).colorScheme.error,
),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Delete'),
),
],
),
);
return confirmed ?? false;
}
/// Extra context appended to a purge warning when a cloud account is
/// connected — a purely local delete would otherwise just get silently
/// re-imported from the remote copy on the very next sync, which isn't
/// obvious from the app's normal behavior.
String _cloudSyncCaveat(AppState appState) {
if (!appState.isCloudConnected) return '';
return '\n\nThis device is connected to ${appState.activeProvider!.displayName}. The '
'deletion will be pushed there on the next sync, same as any other delete.';
}
Future<void> _purgeAllData() async {
final appState = context.read<AppState>();
final confirmed = await _confirmPurge(
title: 'Purge all data?',
message: 'This permanently deletes every vehicle, fuel entry, and receipt photo on '
"this device — starting completely fresh. This can't be undone."
'${_cloudSyncCaveat(appState)}',
);
if (!confirmed || !mounted) return;
setState(() => _busy = true);
try {
await appState.purgeAllData();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All data purged.')),
);
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _pickPurgeRangeDate({required bool isStart}) async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: (isStart ? _purgeRangeStart : _purgeRangeEnd) ?? now,
firstDate: DateTime(2000),
lastDate: now,
);
if (picked == null) return;
setState(() {
_purgeRangeError = null;
if (isStart) {
_purgeRangeStart = picked;
} else {
_purgeRangeEnd = picked;
}
});
}
Future<void> _purgeDataInRange() async {
final start = _purgeRangeStart;
final end = _purgeRangeEnd;
if (start == null || end == null) return;
if (end.isBefore(start)) {
setState(() => _purgeRangeError = 'End date must be on or after the start date.');
return;
}
final appState = context.read<AppState>();
final confirmed = await _confirmPurge(
title: 'Purge data in range?',
message: 'This permanently deletes every fuel entry and receipt photo dated from '
'${_dateFormat.format(start)} to ${_dateFormat.format(end)}, across all '
"vehicles. Vehicles themselves aren't affected. This can't be undone."
'${_cloudSyncCaveat(appState)}',
);
if (!confirmed || !mounted) return;
setState(() => _busy = true);
try {
await appState.purgeFuelEntriesInRange(start, end);
if (mounted) {
setState(() {
_purgeRangeStart = null;
_purgeRangeEnd = null;
_purgeRangeError = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fuel entries in range purged.')),
);
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return Scaffold(
appBar: AppBar(title: const Text('Data')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (!appState.isCloudConnected) ...[
Text(
'Connect a cloud storage account to share vehicles and fuel '
'receipts with other people, and to keep a backup off this device.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 8),
],
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final provider in appState.availableProviders)
FilledButton.icon(
onPressed: _busy
? null
: () => provider is ManualCredentialCloudStorageProvider
? _connectManual(provider.id, provider.displayName)
: _connect(provider.id),
icon: const Icon(Icons.login),
label: Text('Connect ${provider.displayName}'),
),
],
),
] else ...[
Text('${appState.activeProvider!.displayName}: ${appState.cloudAccountLabel}'),
const SizedBox(height: 4),
Text(
appState.cloudFolderPath == null
? 'No folder selected yet.'
: 'Folder: ${appState.cloudFolderPath}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: _busy ? null : _chooseFolder,
icon: const Icon(Icons.folder_open),
label: Text(appState.cloudFolderPath == null
? 'Choose Folder'
: 'Change Folder'),
),
OutlinedButton.icon(
onPressed: (_busy || appState.cloudFolderPath == null)
? null
: _syncNow,
icon: appState.isSyncing
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.sync),
label: const Text('Sync Now'),
),
TextButton.icon(
onPressed: _busy ? null : _disconnect,
icon: const Icon(Icons.logout),
label: const Text('Disconnect'),
),
],
),
const SizedBox(height: 8),
if (appState.lastSyncedAt != null)
Text(
'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}',
style: Theme.of(context).textTheme.bodySmall,
),
if (appState.lastSyncError != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Last sync failed: ${appState.lastSyncError}',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
],
),
),
),
const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Keep photos on this phone after syncing'),
subtitle: const Text(
'Otherwise, a receipt photo is removed from this device once '
"it's safely uploaded to the cloud.",
),
value: appState.keepReceiptPhotosLocally,
onChanged: (value) => context.read<AppState>().setKeepReceiptPhotosLocally(value),
),
),
const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Keep max quality images'),
subtitle: const Text(
'Otherwise, receipt photos are downscaled to a reasonable size '
'before storing — smaller cloud storage and faster syncs, with '
'no loss of legibility for a printed receipt.',
),
value: appState.keepMaxQualityReceiptPhotos,
onChanged: (value) =>
context.read<AppState>().setKeepMaxQualityReceiptPhotos(value),
),
),
const SizedBox(height: 16),
Card(
child: ExpansionTile(
title: const Text('Advanced'),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
Align(
alignment: Alignment.centerLeft,
child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
),
const SizedBox(height: 4),
Text(
"If another device disconnects mid-sync without releasing its lock, "
"this is how long to wait before treating it as abandoned and clearing "
"it so sync can continue.",
style: Theme.of(context).textTheme.bodySmall,
),
Slider(
value: appState.staleLockMinutes.toDouble(),
min: minStaleLockMinutes.toDouble(),
max: maxStaleLockMinutes.toDouble(),
divisions: maxStaleLockMinutes - minStaleLockMinutes,
label: '${appState.staleLockMinutes} min',
onChanged: (value) =>
context.read<AppState>().setStaleLockMinutes(value.round()),
),
Text('${appState.staleLockMinutes} minute(s)'),
const Divider(height: 32),
Text(
'Danger Zone',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
Text('Purge All Data', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(
'Deletes every vehicle, fuel entry, and receipt photo on this device '
'and starts completely fresh.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _busy ? null : _purgeAllData,
icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error),
label: Text(
'Purge All Data',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: Theme.of(context).colorScheme.error),
),
),
),
const Divider(height: 32),
Text('Purge Data by Date Range', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(
'Deletes fuel entries and receipt photos dated within a range, across '
'all vehicles. Vehicles themselves are kept.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _busy ? null : () => _pickPurgeRangeDate(isStart: true),
child: Text(
_purgeRangeStart == null
? 'Start date'
: _dateFormat.format(_purgeRangeStart!),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: _busy ? null : () => _pickPurgeRangeDate(isStart: false),
child: Text(
_purgeRangeEnd == null ? 'End date' : _dateFormat.format(_purgeRangeEnd!),
),
),
),
],
),
if (_purgeRangeError != null) ...[
const SizedBox(height: 8),
Text(
_purgeRangeError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: (_busy || _purgeRangeStart == null || _purgeRangeEnd == null)
? null
: _purgeDataInRange,
icon: Icon(Icons.delete_forever, color: Theme.of(context).colorScheme.error),
label: Text(
'Purge Range',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: Theme.of(context).colorScheme.error),
),
),
),
],
),
),
],
),
);
}
}
class _WebDavCredentials {
final String serverUrl;
final String username;
final String password;
_WebDavCredentials({required this.serverUrl, required this.username, required this.password});
}
/// Collects the server URL/username/password a [ManualCredentialCloudStorageProvider]
/// needs, since (unlike the OAuth providers) there's no browser flow to
/// gather these instead.
class _WebDavCredentialsDialog extends StatefulWidget {
final String providerName;
const _WebDavCredentialsDialog({required this.providerName});
@override
State<_WebDavCredentialsDialog> createState() => _WebDavCredentialsDialogState();
}
class _WebDavCredentialsDialogState extends State<_WebDavCredentialsDialog> {
final _formKey = GlobalKey<FormState>();
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop(_WebDavCredentials(
serverUrl: _serverController.text.trim(),
username: _usernameController.text.trim(),
password: _passwordController.text,
));
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Connect ${widget.providerName}'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _serverController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
),
keyboardType: TextInputType.url,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(labelText: 'Username'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
onFieldSubmitted: (_) => _submit(),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Connect'),
),
],
);
}
}

View file

@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../services/app_state.dart';
import '../widgets/receipt_thumbnail.dart';
/// Lets the user correct the *data* logged about a receipt (date, gallons,
/// price/gal, total cost) — not the receipt photo itself, which is shown
/// read-only up top (tap it to view full-screen, same as everywhere else
/// it's shown) and can't be replaced from here.
class EditFuelEntryScreen extends StatefulWidget {
final FuelEntry entry;
const EditFuelEntryScreen({super.key, required this.entry});
@override
State<EditFuelEntryScreen> createState() => _EditFuelEntryScreenState();
}
class _EditFuelEntryScreenState extends State<EditFuelEntryScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _gallonsController;
late final TextEditingController _priceController;
late final TextEditingController _totalController;
late DateTime _date;
bool _saving = false;
@override
void initState() {
super.initState();
_date = widget.entry.date;
_gallonsController = TextEditingController(text: widget.entry.gallons.toStringAsFixed(3));
_priceController = TextEditingController(text: widget.entry.pricePerGallon.toStringAsFixed(3));
_totalController = TextEditingController(text: widget.entry.totalCost.toStringAsFixed(2));
}
@override
void dispose() {
_gallonsController.dispose();
_priceController.dispose();
_totalController.dispose();
super.dispose();
}
void _calculateTotal() {
final gallons = double.tryParse(_gallonsController.text);
final price = double.tryParse(_priceController.text);
if (gallons != null && price != null) {
setState(() {
_totalController.text = (gallons * price).toStringAsFixed(2);
});
}
}
Future<void> _pickDate() async {
// initialDate must fall within [firstDate, lastDate] or the picker
// throws — widen the bounds to cover _date in case it's already
// outside the normal 5-years-back-to-today window.
final today = DateTime.now();
final firstDate = _date.isBefore(today.subtract(const Duration(days: 365 * 5)))
? _date
: today.subtract(const Duration(days: 365 * 5));
final lastDate = _date.isAfter(today) ? _date : today;
final pickedDate = await showDatePicker(
context: context,
initialDate: _date,
firstDate: firstDate,
lastDate: lastDate,
);
if (pickedDate == null || !mounted) return;
final pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_date),
);
if (pickedTime == null) return;
setState(() {
_date = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime.hour,
pickedTime.minute,
);
});
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
await context.read<AppState>().updateFuelEntry(
entryId: widget.entry.id,
date: _date,
gallons: double.parse(_gallonsController.text),
pricePerGallon: double.parse(_priceController.text),
totalCost: double.parse(_totalController.text),
);
if (mounted) Navigator.of(context).pop();
} finally {
if (mounted) setState(() => _saving = false);
}
}
String? _requiredDecimal(String? value) {
if (value == null || value.trim().isEmpty) return 'Required';
if (double.tryParse(value) == null) return 'Enter a valid number';
return null;
}
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat.yMMMd().add_jm();
return Scaffold(
appBar: AppBar(title: const Text('Edit Fuel Entry')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Center(
child: ReceiptThumbnail(entry: widget.entry, size: 180),
),
const SizedBox(height: 8),
Text(
'Tap the photo to view it full-screen. The photo itself can\'t '
"be changed here — only the data below can.",
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Date & time'),
subtitle: Text(dateFormat.format(_date)),
trailing: const Icon(Icons.edit_calendar_outlined),
onTap: _pickDate,
),
const SizedBox(height: 8),
TextFormField(
controller: _gallonsController,
decoration: const InputDecoration(labelText: 'Gallons', suffixText: 'gal'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: _requiredDecimal,
),
const SizedBox(height: 12),
TextFormField(
controller: _priceController,
decoration: const InputDecoration(labelText: 'Price per gallon', prefixText: '\$'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: _requiredDecimal,
),
const SizedBox(height: 12),
TextFormField(
controller: _totalController,
decoration: InputDecoration(
labelText: 'Total cost',
prefixText: '\$',
suffixIcon: IconButton(
icon: const Icon(Icons.calculate_outlined),
tooltip: 'Calculate from gallons × price',
onPressed: _calculateTotal,
),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: _requiredDecimal,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Save Changes'),
),
],
),
),
);
}
}

View file

@ -4,10 +4,10 @@ import 'package:provider/provider.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import 'add_edit_vehicle_screen.dart';
import 'report_screen.dart';
import 'settings_screen.dart';
import 'vehicle_detail_screen.dart';
/// The "Vehicles" tab body — Settings and Reports are reached via the
/// bottom nav now (see [MainShell]), not from icons here.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@ -18,20 +18,14 @@ class HomeScreen extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: const Text('My Vehicles'),
title: const Text('Vehicles'),
actionsPadding: const EdgeInsets.only(right: 20),
actions: [
IconButton(
icon: const Icon(Icons.summarize_outlined),
tooltip: 'Fuel Report',
icon: const Icon(Icons.add),
tooltip: 'Add Vehicle',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ReportScreen()),
),
),
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
),
),
],
@ -48,13 +42,6 @@ class HomeScreen extends StatelessWidget {
return _VehicleCard(vehicle: vehicle, totalGallons: gallons);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AddEditVehicleScreen()),
),
icon: const Icon(Icons.add),
label: const Text('Add Vehicle'),
),
);
}
}
@ -72,19 +59,44 @@ class _VehicleCard extends StatelessWidget {
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
title: Text(vehicle.displayLabel),
// Only repeat the VIN here when the title is already showing the
// nickname instead — otherwise the title (falling back to the VIN
// when there's no nickname) would show it twice.
subtitle: Text(hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine),
isThreeLine: hasNickname,
trailing: const Icon(Icons.chevron_right),
child: InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// Row cross-axis defaults to center, so the chevron sits
// vertically centered against the title+subtitle block even when
// the nickname line makes that block taller — ListTile's
// isThreeLine forces leading/trailing to the top instead, per
// Material spec, which is what pinned it to the top before.
child: Row(
children: [
const CircleAvatar(child: Icon(Icons.directions_car_outlined)),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(vehicle.displayLabel, style: Theme.of(context).textTheme.titleMedium),
// Only repeat the VIN here when the title is already
// showing the nickname instead — otherwise the title
// (falling back to the VIN when there's no nickname)
// would show it twice.
Text(
hasNickname ? 'VIN: ${vehicle.vin}\n$gallonsLine' : gallonsLine,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
);
}

View file

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'home_screen.dart';
import 'receipts_screen.dart';
import 'report_screen.dart';
import 'settings_screen.dart';
/// App root once loaded: a persistent bottom nav bar over the four main
/// sections — Receipts (all vehicles), Vehicles (the vehicle list),
/// Reports, and Settings. Each tab keeps its own [Scaffold]/[AppBar]; this
/// shell only owns the [PageView]/[NavigationBar] and which tab is
/// selected.
///
/// A right-to-left swipe on the body advances to the next tab (and
/// left-to-right goes back) with the same sliding animation tapping a
/// [NavigationDestination] uses — both go through [_goToTab], which is the
/// only thing that moves [_pageController]. [AutomaticKeepAliveClientMixin]
/// on the three stateful tab screens (Receipts/Report/Settings) is what
/// keeps each tab's state — scroll position, toggles, in-progress form
/// fields — alive when [PageView] builds/tears down pages outside its
/// cache extent, the same guarantee the previous [IndexedStack]-based
/// version gave "for free".
class MainShell extends StatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _index = 0;
final _pageController = PageController();
static const _tabs = [
ReceiptsScreen(),
HomeScreen(),
ReportScreen(),
SettingsScreen(),
];
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _goToTab(int index) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: PageView(
controller: _pageController,
onPageChanged: (index) => setState(() => _index = index),
children: _tabs,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: _goToTab,
destinations: const [
NavigationDestination(
icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.receipt_long),
label: 'Receipts',
),
NavigationDestination(
icon: Icon(Icons.directions_car_outlined),
selectedIcon: Icon(Icons.directions_car),
label: 'Vehicles',
),
NavigationDestination(
icon: Icon(Icons.summarize_outlined),
selectedIcon: Icon(Icons.summarize),
label: 'Reports',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}

View file

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../services/app_state.dart';
import '../widgets/receipt_thumbnail.dart';
import 'edit_fuel_entry_screen.dart';
/// The single place to view a receipt's photo and its logged data
/// together, with edit/delete for the entry — reached by tapping a receipt
/// row on either the Receipts tab or a vehicle's detail page. Looks up
/// [entryId] fresh from [AppState] on every build (rather than taking a
/// [FuelEntry] directly) so it reflects an edit made via
/// [EditFuelEntryScreen] as soon as that screen is popped back to this one.
class ReceiptDetailScreen extends StatelessWidget {
final String entryId;
const ReceiptDetailScreen({super.key, required this.entryId});
Future<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete fuel entry?'),
content: const Text('This will remove the entry and its receipt photo.'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true && context.mounted) {
await context.read<AppState>().deleteFuelEntry(entry.id);
if (context.mounted) Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final entry = appState.fuelEntryById(entryId);
if (entry == null) {
return const Scaffold(body: Center(child: Text('Receipt not found')));
}
final vehicle = appState.vehicleById(entry.vehicleId);
final dateFormat = DateFormat.yMMMd().add_jm();
final currencyFormat = NumberFormat.simpleCurrency();
return Scaffold(
appBar: AppBar(
title: const Text('Receipt'),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),
tooltip: 'Edit fuel entry',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => EditFuelEntryScreen(entry: entry)),
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete fuel entry',
onPressed: () => _deleteEntry(context, entry),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Center(child: ReceiptThumbnail(entry: entry, size: 240)),
const SizedBox(height: 8),
Text(
'Tap the photo to view it full-screen.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
if (vehicle != null) ...[
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.directions_car_outlined),
title: Text(vehicle.displayLabel),
subtitle: Text('VIN: ${vehicle.vin}'),
),
const Divider(height: 24),
],
_DetailRow(label: 'Date & time', value: dateFormat.format(entry.date)),
_DetailRow(label: 'Gallons', value: '${entry.gallons.toStringAsFixed(3)} gal'),
_DetailRow(label: 'Price per gallon', value: currencyFormat.format(entry.pricePerGallon)),
_DetailRow(label: 'Total cost', value: currencyFormat.format(entry.totalCost)),
],
),
);
}
}
class _DetailRow extends StatelessWidget {
final String label;
final String value;
const _DetailRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
Text(value, style: Theme.of(context).textTheme.titleMedium),
],
),
);
}
}

View file

@ -0,0 +1,332 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import '../services/app_state.dart';
import '../theme/app_theme.dart';
import '../widgets/hero_banner.dart';
import '../widgets/receipt_capture.dart';
import 'add_edit_vehicle_screen.dart';
import 'receipt_detail_screen.dart';
/// The three summary periods selectable below the hero totals — the
/// totals and the "Recent Activity" list below are both filtered to
/// whichever one is currently selected.
enum _DateRange {
currentMonth('Current Month'),
last3Months('Last 3 Months'),
yearToDate('Year to Date');
final String label;
const _DateRange(this.label);
/// Inclusive start of this range, relative to [now]. There's no
/// explicit end — an entry just needs to not be *before* this to count,
/// since receipts are never dated in the future in practice.
DateTime startDate(DateTime now) {
switch (this) {
case _DateRange.currentMonth:
return DateTime(now.year, now.month, 1);
case _DateRange.last3Months:
// Current month plus the two before it, from the 1st of the
// earliest — DateTime normalizes a zero/negative month across a
// year boundary correctly (e.g. month 0 becomes December prior year).
return DateTime(now.year, now.month - 2, 1);
case _DateRange.yearToDate:
return DateTime(now.year, 1, 1);
}
}
}
/// The "Receipts" tab: every fuel entry across every vehicle, newest
/// first, styled after the theme mockups' receipt-focused layout — a
/// hero total, a date-range filter, then a flat, divided list of receipts
/// (date + amount + which vehicle) for that range, each tapping straight
/// through to that receipt's photo rather than to the vehicle it belongs to.
class ReceiptsScreen extends StatefulWidget {
const ReceiptsScreen({super.key});
@override
State<ReceiptsScreen> createState() => _ReceiptsScreenState();
}
class _ReceiptsScreenState extends State<ReceiptsScreen> with AutomaticKeepAliveClientMixin {
_DateRange _range = _DateRange.yearToDate;
// Keeps this tab's state (the selected date range) alive when MainShell's
// PageView builds/tears down tabs outside its cache extent as the user
// swipes between them.
@override
bool get wantKeepAlive => true;
Future<void> _addReceipt(BuildContext context) async {
final vehicles = context.read<AppState>().vehicles;
if (vehicles.isEmpty) {
final newVehicleId = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => const AddEditVehicleScreen(promptToCreateForReceipt: true),
),
);
// Creating the vehicle was only ever a means to an end here — continue
// straight into the receipt capture the user actually came here for,
// rather than dropping them back on a Receipts tab that's still empty.
if (newVehicleId != null && context.mounted) {
await captureReceiptForVehicle(context, newVehicleId);
}
return;
}
if (vehicles.length == 1) {
await captureReceiptForVehicle(context, vehicles.first.id);
return;
}
final chosen = await showModalBottomSheet<Vehicle>(
context: context,
builder: (context) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('Which vehicle is this receipt for?'),
),
for (final vehicle in vehicles)
ListTile(
leading: const Icon(Icons.directions_car_outlined),
title: Text(vehicle.displayLabel),
onTap: () => Navigator.of(context).pop(vehicle),
),
],
),
),
);
if (chosen != null && context.mounted) {
await captureReceiptForVehicle(context, chosen.id);
}
}
void _openReceipt(BuildContext context, FuelEntry entry) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ReceiptDetailScreen(entryId: entry.id)),
);
}
@override
Widget build(BuildContext context) {
super.build(context);
final appState = context.watch<AppState>();
final rangeStart = _range.startDate(DateTime.now());
final entries = appState.fuelEntries.where((e) => !e.date.isBefore(rangeStart)).toList()
..sort((a, b) => b.date.compareTo(a.date));
final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost);
final totalGallons = entries.fold(0.0, (sum, e) => sum + e.gallons);
final dateFormat = DateFormat.yMMMd();
final currencyFormat = NumberFormat.simpleCurrency();
final gallonsFormat = NumberFormat('#,##0.000');
// The hero stat is a big headline number, not a precise figure — round
// to whole gallons there rather than showing the same 3-decimal
// precision the per-entry rows use.
final heroGallonsFormat = NumberFormat('#,##0');
return Scaffold(
appBar: AppBar(
title: const Text('Receipts'),
actionsPadding: const EdgeInsets.only(right: 20),
actions: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Log Fuel Receipt',
onPressed: () => _addReceipt(context),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
children: [
_HeroTotal(
totalCost: totalCost,
totalGallons: totalGallons,
currencyFormat: currencyFormat,
gallonsFormat: heroGallonsFormat,
selectedRange: _range,
onRangeSelected: (range) => setState(() => _range = range),
),
Padding(
padding: const EdgeInsets.fromLTRB(2, 14, 2, 8),
child: Text(
'Recent Activity',
style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
),
if (entries.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('No fuel entries in this range.'),
)
else
for (var i = 0; i < entries.length; i++) ...[
if (i > 0) const Divider(height: 1),
_ReceiptRow(
entry: entries[i],
vehicle: appState.vehicleById(entries[i].vehicleId),
dateFormat: dateFormat,
currencyFormat: currencyFormat,
gallonsFormat: gallonsFormat,
onTap: () => _openReceipt(context, entries[i]),
),
],
],
),
);
}
}
/// Sits inside the hero banner (on the gradient, not the plain scaffold
/// background), so selected/unselected use the hero's own white/translucent
/// text colors rather than the surface-based colorScheme ones.
class _DateRangeSelector extends StatelessWidget {
final _DateRange selected;
final ValueChanged<_DateRange> onSelected;
const _DateRangeSelector({required this.selected, required this.onSelected});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (final range in _DateRange.values)
GestureDetector(
onTap: () => onSelected(range),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
range.label,
style: TextStyle(
color: range == selected
? AppTheme.heroText
: AppTheme.heroTextSecondary(context),
fontWeight: range == selected ? FontWeight.w800 : FontWeight.w600,
fontSize: 12.5,
),
),
),
),
],
);
}
}
class _HeroTotal extends StatelessWidget {
final double totalCost;
final double totalGallons;
final NumberFormat currencyFormat;
final NumberFormat gallonsFormat;
final _DateRange selectedRange;
final ValueChanged<_DateRange> onRangeSelected;
const _HeroTotal({
required this.totalCost,
required this.totalGallons,
required this.currencyFormat,
required this.gallonsFormat,
required this.selectedRange,
required this.onRangeSelected,
});
@override
Widget build(BuildContext context) {
return HeroBanner(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HeroStatsRow(
gallonsValue: gallonsFormat.format(totalGallons),
costValue: currencyFormat.format(totalCost),
),
const SizedBox(height: 7),
_DateRangeSelector(selected: selectedRange, onSelected: onRangeSelected),
],
),
);
}
}
class _ReceiptRow extends StatelessWidget {
final FuelEntry entry;
final Vehicle? vehicle;
final DateFormat dateFormat;
final NumberFormat currencyFormat;
final NumberFormat gallonsFormat;
final VoidCallback onTap;
const _ReceiptRow({
required this.entry,
required this.vehicle,
required this.dateFormat,
required this.currencyFormat,
required this.gallonsFormat,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dateFormat.format(entry.date),
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
),
const SizedBox(height: 3),
Text(
'${gallonsFormat.format(entry.gallons)} gal',
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12.5),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
currencyFormat.format(entry.totalCost),
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
),
child: Text(
vehicle?.displayLabel ?? 'Unknown vehicle',
style: TextStyle(
color: colorScheme.primary,
fontSize: 10.5,
fontWeight: FontWeight.w700,
),
),
),
],
),
],
),
),
);
}
}

View file

@ -1,12 +1,24 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:printing/printing.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/app_state.dart';
import '../services/fuel_report.dart';
import '../services/fuel_report_images.dart';
import '../services/fuel_report_pdf.dart';
/// Missouri's Motor Fuel Refund Claim form isn't something this app can
/// file for the user — it's a state form, submitted to the state — so the
/// Reports tab just links out to where to get it rather than trying to
/// reproduce or embed it.
final _refundFormUrl = Uri.parse(
'https://dor.mo.gov/forms/?formName=Motor+Fuel+Refund+Claim&category=&year=99',
);
class ReportScreen extends StatefulWidget {
const ReportScreen({super.key});
@ -14,13 +26,30 @@ class ReportScreen extends StatefulWidget {
State<ReportScreen> createState() => _ReportScreenState();
}
class _ReportScreenState extends State<ReportScreen> {
class _ReportScreenState extends State<ReportScreen> with AutomaticKeepAliveClientMixin {
static final _dateFormat = DateFormat.yMMMd();
DateTime? _startDate;
DateTime? _endDate;
bool _includeReceiptImages = false;
bool _includeLineItems = false;
bool _busy = false;
/// Which vehicles (by id) to include in the report — checkboxes next to
/// each vehicle below the date range. [_seenVehicleIds] tracks every
/// vehicle a checkbox has ever been shown for, so a vehicle newly
/// appearing in range (first time it's seen) defaults to selected without
/// clobbering a selection the user already made for one seen before (e.g.
/// after picking a different range and back).
final Set<String> _selectedVehicleIds = {};
final Set<String> _seenVehicleIds = {};
// Keeps this tab's state (chosen date range, include-image/line-item
// toggles) alive when MainShell's PageView builds/tears down tabs
// outside its cache extent as the user swipes between them.
@override
bool get wantKeepAlive => true;
Future<void> _pickDate({required bool isStart}) async {
final now = DateTime.now();
final picked = await showDatePicker(
@ -39,10 +68,39 @@ class _ReportScreenState extends State<ReportScreen> {
});
}
Future<Uint8List> _buildPdfBytes(FuelReport report) async {
var receiptImages = const <String, Uint8List>{};
if (_includeReceiptImages) {
final loaded = await loadReceiptImageBytes(
report: report,
activeProvider: context.read<AppState>().activeProvider,
);
receiptImages = loaded.bytesByEntryId;
if (loaded.failedEntries.isNotEmpty && mounted) {
final count = loaded.failedEntries.length;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"$count receipt photo${count == 1 ? '' : 's'} couldn't be included — offline, "
'not signed in to the provider that has it, or the remote file is gone.',
),
duration: const Duration(seconds: 6),
),
);
}
}
return buildFuelReportPdf(
report,
includeLineItems: _includeLineItems,
includeReceiptImages: _includeReceiptImages,
receiptImages: receiptImages,
);
}
Future<void> _share(FuelReport report) async {
setState(() => _busy = true);
try {
final bytes = await buildFuelReportPdf(report);
final bytes = await _buildPdfBytes(report);
await Printing.sharePdf(bytes: bytes, filename: fuelReportFileName(report));
} finally {
if (mounted) setState(() => _busy = false);
@ -52,32 +110,55 @@ class _ReportScreenState extends State<ReportScreen> {
Future<void> _print(FuelReport report) async {
setState(() => _busy = true);
try {
final bytes = await buildFuelReportPdf(report);
final bytes = await _buildPdfBytes(report);
await Printing.layoutPdf(onLayout: (_) async => bytes, name: fuelReportFileName(report));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _openRefundFormLink() async {
final launched = await launchUrl(_refundFormUrl, mode: LaunchMode.externalApplication);
if (!launched && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Couldn't open the link.")),
);
}
}
@override
Widget build(BuildContext context) {
super.build(context);
final appState = context.watch<AppState>();
String? rangeError;
FuelReport? report;
FuelReport? rawReport;
if (_startDate != null && _endDate != null) {
if (_endDate!.isBefore(_startDate!)) {
rangeError = 'End date must be on or after the start date.';
} else {
report = buildFuelReport(
rawReport = buildFuelReport(
vehicles: appState.vehicles,
fuelEntries: appState.fuelEntries,
startDate: _startDate!,
endDate: _endDate!,
);
for (final row in rawReport.rows) {
if (_seenVehicleIds.add(row.vehicle.id)) {
_selectedVehicleIds.add(row.vehicle.id);
}
}
}
}
final report = rawReport == null
? null
: FuelReport(
startDate: rawReport.startDate,
endDate: rawReport.endDate,
rows: rawReport.rows.where((r) => _selectedVehicleIds.contains(r.vehicle.id)).toList(),
);
return Scaffold(
appBar: AppBar(title: const Text('Fuel Report')),
body: ListView(
@ -104,69 +185,202 @@ class _ReportScreenState extends State<ReportScreen> {
const SizedBox(height: 8),
Text(rangeError, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 16),
if (report == null)
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Include Receipt Images'),
subtitle: const Text(
'Every receipt photo in the date range, grouped by vehicle and '
'sorted by date — packed as many to a page as will fit.',
),
value: _includeReceiptImages,
onChanged: (value) => setState(() => _includeReceiptImages = value),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Include Receipt Line Items'),
subtitle: const Text(
'Every receipt as its own line (date, gallons, price/gal, '
'cost), grouped by vehicle and sorted by date.',
),
value: _includeLineItems,
onChanged: (value) => setState(() => _includeLineItems = value),
),
if (_includeReceiptImages && _includeLineItems)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'Each line item and its receipt photo will be tagged with a '
'matching number.',
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 8),
if (rawReport == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('Choose a start and end date to generate a report.'),
)
else if (report.rows.isEmpty)
else if (rawReport.rows.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('No vehicles had a receipted fuel entry in that date range.'),
)
else ...[
for (final row in report.rows)
Card(
child: ListTile(
title: Text(row.vehicle.displayLabel),
subtitle: Text(
'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted '
'entr${row.entryCount == 1 ? 'y' : 'ies'}',
),
isThreeLine: true,
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${row.totalGallons.toStringAsFixed(3)} gal'),
Text(NumberFormat.simpleCurrency().format(row.totalCost)),
],
),
),
for (final row in rawReport.rows)
_VehicleSelectionCard(
row: row,
selected: _selectedVehicleIds.contains(row.vehicle.id),
onTap: () => setState(() {
if (_selectedVehicleIds.contains(row.vehicle.id)) {
_selectedVehicleIds.remove(row.vehicle.id);
} else {
_selectedVehicleIds.add(row.vehicle.id);
}
}),
),
const Divider(height: 32),
ListTile(
title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
trailing: Text(
'${report.totalGallons.toStringAsFixed(3)} gal · '
'${NumberFormat.simpleCurrency().format(report.totalCost)}',
style: const TextStyle(fontWeight: FontWeight.bold),
if (report!.rows.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Text('No vehicles selected — tap at least one above to build a report.'),
)
else ...[
ListTile(
title: const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
trailing: Text(
'${report.totalGallons.toStringAsFixed(3)} gal · '
'${NumberFormat.simpleCurrency().format(report.totalCost)}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _busy ? null : () => _share(report),
icon: const Icon(Icons.share_outlined),
label: const Text('Share'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _print(report),
icon: const Icon(Icons.print_outlined),
label: const Text('Print'),
),
),
],
),
],
],
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Need the refund claim form?', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
"This app tracks your fuel purchases, but the actual refund claim is "
"filed with the state. Get the official form from Missouri's "
'Department of Revenue website.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
InkWell(
onTap: _openRefundFormLink,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.open_in_new, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 6),
Flexible(
child: Text(
'Get the Motor Fuel Refund Claim form',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _busy ? null : () => _share(report!),
icon: const Icon(Icons.share_outlined),
label: const Text('Share'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _busy ? null : () => _print(report!),
icon: const Icon(Icons.print_outlined),
label: const Text('Print'),
),
),
],
),
],
),
],
),
);
}
}
/// A per-vehicle row in the vehicle-selection list — tapping the whole
/// card toggles [selected], indicated purely by color rather than a
/// checkbox: [selected] uses the same primary/on-primary theming as the
/// "Share" [FilledButton] below it, [!selected] looks like a plain
/// unselected [Card] (default surface color, normal text colors).
class _VehicleSelectionCard extends StatelessWidget {
final VehicleReportRow row;
final bool selected;
final VoidCallback onTap;
const _VehicleSelectionCard({required this.row, required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final foreground = selected ? colorScheme.onPrimary : colorScheme.onSurface;
final foregroundVariant = selected ? colorScheme.onPrimary : colorScheme.onSurfaceVariant;
return Card(
color: selected ? colorScheme.primary : null,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
row.vehicle.displayLabel,
style: TextStyle(color: foreground, fontWeight: FontWeight.w600, fontSize: 16),
),
const SizedBox(height: 2),
Text(
'VIN: ${row.vehicle.vin}\n${row.entryCount} receipted '
'entr${row.entryCount == 1 ? 'y' : 'ies'}',
style: TextStyle(color: foregroundVariant),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${row.totalGallons.toStringAsFixed(3)} gal', style: TextStyle(color: foreground)),
Text(
NumberFormat.simpleCurrency().format(row.totalCost),
style: TextStyle(color: foreground),
),
],
),
],
),
),
),
);
}
}

View file

@ -1,85 +1,16 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
import '../services/cloud/cloud_storage_provider.dart';
import 'cloud_folder_browser_screen.dart';
import 'data_settings_screen.dart';
import 'ui_settings_screen.dart';
class SettingsScreen extends StatefulWidget {
/// The "Settings" tab: a landing menu into submenus — [UiSettingsScreen]
/// (appearance) and [DataSettingsScreen] (cloud storage, photo handling,
/// advanced/purge) — plus a read-only summary of what's stored locally.
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _busy = false;
String? _error;
Future<void> _connect(CloudProviderId id) async {
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().connectProvider(id);
} catch (e) {
setState(() => _error = 'Could not sign in: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _connectManual(CloudProviderId id, String providerName) async {
final credentials = await showDialog<_WebDavCredentials>(
context: context,
builder: (_) => _WebDavCredentialsDialog(providerName: providerName),
);
if (credentials == null || !mounted) return;
setState(() {
_busy = true;
_error = null;
});
try {
await context.read<AppState>().connectProviderWithCredentials(
id,
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
);
} catch (e) {
setState(() => _error = 'Could not connect: $e');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _disconnect() async {
setState(() => _busy = true);
try {
await context.read<AppState>().disconnectCloud();
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _chooseFolder() {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()),
);
}
Future<void> _syncNow() async {
setState(() => _busy = true);
try {
await context.read<AppState>().syncNow();
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
@ -90,153 +21,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (!appState.isCloudConnected) ...[
Text(
'Connect a cloud storage account to share vehicles and fuel '
'receipts with other people, and to keep a backup off this device.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
if (_error != null) ...[
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 8),
],
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final provider in appState.availableProviders)
FilledButton.icon(
onPressed: _busy
? null
: () => provider is ManualCredentialCloudStorageProvider
? _connectManual(provider.id, provider.displayName)
: _connect(provider.id),
icon: const Icon(Icons.login),
label: Text('Connect ${provider.displayName}'),
),
],
),
] else ...[
Text('${appState.activeProvider!.displayName}: ${appState.cloudAccountLabel}'),
const SizedBox(height: 4),
Text(
appState.cloudFolderPath == null
? 'No folder selected yet.'
: 'Folder: ${appState.cloudFolderPath}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: _busy ? null : _chooseFolder,
icon: const Icon(Icons.folder_open),
label: Text(appState.cloudFolderPath == null
? 'Choose Folder'
: 'Change Folder'),
),
OutlinedButton.icon(
onPressed: (_busy || appState.cloudFolderPath == null)
? null
: _syncNow,
icon: appState.isSyncing
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.sync),
label: const Text('Sync Now'),
),
TextButton.icon(
onPressed: _busy ? null : _disconnect,
icon: const Icon(Icons.logout),
label: const Text('Disconnect'),
),
],
),
const SizedBox(height: 8),
if (appState.lastSyncedAt != null)
Text(
'Last synced ${DateFormat.yMMMd().add_jm().format(appState.lastSyncedAt!)}',
style: Theme.of(context).textTheme.bodySmall,
),
if (appState.lastSyncError != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Last sync failed: ${appState.lastSyncError}',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
],
child: ListTile(
leading: const Icon(Icons.palette_outlined),
title: const Text('UI'),
subtitle: const Text('Appearance'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UiSettingsScreen()),
),
),
),
const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Keep photos on this phone after syncing'),
subtitle: const Text(
'Otherwise, a receipt photo is removed from this device once '
"it's safely uploaded to the cloud.",
child: ListTile(
leading: const Icon(Icons.storage_outlined),
title: const Text('Data'),
subtitle: const Text('Cloud storage, photo syncing, advanced'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DataSettingsScreen()),
),
value: appState.keepReceiptPhotosLocally,
onChanged: (value) => context.read<AppState>().setKeepReceiptPhotosLocally(value),
),
),
const SizedBox(height: 16),
Card(
child: SwitchListTile(
title: const Text('Keep max quality images'),
subtitle: const Text(
'Otherwise, receipt photos are downscaled to a reasonable size '
'before storing — smaller cloud storage and faster syncs, with '
'no loss of legibility for a printed receipt.',
),
value: appState.keepMaxQualityReceiptPhotos,
onChanged: (value) =>
context.read<AppState>().setKeepMaxQualityReceiptPhotos(value),
),
),
const SizedBox(height: 16),
Card(
child: ExpansionTile(
title: const Text('Advanced'),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
Align(
alignment: Alignment.centerLeft,
child: Text('Stale sync lock timeout', style: Theme.of(context).textTheme.titleSmall),
),
const SizedBox(height: 4),
Text(
"If another device disconnects mid-sync without releasing its lock, "
"this is how long to wait before treating it as abandoned and clearing "
"it so sync can continue.",
style: Theme.of(context).textTheme.bodySmall,
),
Slider(
value: appState.staleLockMinutes.toDouble(),
min: minStaleLockMinutes.toDouble(),
max: maxStaleLockMinutes.toDouble(),
divisions: maxStaleLockMinutes - minStaleLockMinutes,
label: '${appState.staleLockMinutes} min',
onChanged: (value) =>
context.read<AppState>().setStaleLockMinutes(value.round()),
),
Text('${appState.staleLockMinutes} minute(s)'),
],
),
),
const SizedBox(height: 16),
@ -260,93 +64,3 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
}
class _WebDavCredentials {
final String serverUrl;
final String username;
final String password;
_WebDavCredentials({required this.serverUrl, required this.username, required this.password});
}
/// Collects the server URL/username/password a [ManualCredentialCloudStorageProvider]
/// needs, since (unlike the OAuth providers) there's no browser flow to
/// gather these instead.
class _WebDavCredentialsDialog extends StatefulWidget {
final String providerName;
const _WebDavCredentialsDialog({required this.providerName});
@override
State<_WebDavCredentialsDialog> createState() => _WebDavCredentialsDialogState();
}
class _WebDavCredentialsDialogState extends State<_WebDavCredentialsDialog> {
final _formKey = GlobalKey<FormState>();
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop(_WebDavCredentials(
serverUrl: _serverController.text.trim(),
username: _usernameController.text.trim(),
password: _passwordController.text,
));
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Connect ${widget.providerName}'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _serverController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'https://cloud.example.com/remote.php/dav/files/me/',
),
keyboardType: TextInputType.url,
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(labelText: 'Username'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
onFieldSubmitted: (_) => _submit(),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _submit,
child: const Text('Connect'),
),
],
);
}
}

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/app_state.dart';
/// "UI" settings submenu, reached from [SettingsScreen] — currently just
/// Appearance (theme), but its own screen so more display-related settings
/// have somewhere to go without crowding the Settings landing page.
class UiSettingsScreen extends StatelessWidget {
const UiSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return Scaffold(
appBar: AppBar(title: const Text('UI')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Appearance', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(value: ThemeMode.system, label: Text('System')),
ButtonSegment(value: ThemeMode.light, label: Text('Light')),
ButtonSegment(value: ThemeMode.dark, label: Text('Dark')),
],
selected: {appState.themeMode},
onSelectionChanged: (selection) =>
context.read<AppState>().setThemeMode(selection.first),
),
],
),
),
),
],
),
);
}
}

View file

@ -1,174 +1,66 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/fuel_entry.dart';
import '../services/app_state.dart';
import '../services/ocr_service.dart';
import '../services/receipt_parser.dart';
import '../widgets/image_source_sheet.dart';
import '../widgets/hero_banner.dart';
import '../widgets/receipt_capture.dart';
import '../widgets/receipt_thumbnail.dart';
import 'add_edit_vehicle_screen.dart';
import 'confirm_fuel_entry_screen.dart';
import 'receipt_image_screen.dart';
import 'receipt_detail_screen.dart';
/// Long edge and JPEG quality a receipt photo is downscaled to unless
/// "keep max quality" is on — a receipt is a photo of small printed text,
/// not something that benefits from a multi-megapixel original, and this
/// keeps typical files in the low hundreds of KB instead of several MB.
const receiptImageMaxDimension = 1600.0;
const receiptImageQuality = 70;
class VehicleDetailScreen extends StatelessWidget {
/// A swipeable [PageView] over every vehicle (in the same order as the
/// Vehicles tab's list), starting on [vehicleId] — sliding left/right moves
/// to the next/previous vehicle, the same "modern navigation" swipe
/// [MainShell] uses for the bottom-nav tabs. Each page is a full,
/// independent [_VehicleDetailPage] (own AppBar/FAB), so this widget only
/// owns the [PageController].
class VehicleDetailScreen extends StatefulWidget {
final String vehicleId;
const VehicleDetailScreen({super.key, required this.vehicleId});
Future<void> _captureReceipt(BuildContext context) async {
final source = await chooseImageSource(context);
if (source == null || !context.mounted) return;
@override
State<VehicleDetailScreen> createState() => _VehicleDetailScreenState();
}
final keepMaxQuality = context.read<AppState>().keepMaxQualityReceiptPhotos;
final picker = ImagePicker();
XFile? photo;
try {
photo = await picker.pickImage(
source: source,
imageQuality: keepMaxQuality ? null : receiptImageQuality,
maxWidth: keepMaxQuality ? null : receiptImageMaxDimension,
maxHeight: keepMaxQuality ? null : receiptImageMaxDimension,
);
} catch (e) {
if (context.mounted) {
final sourceLabel = source == ImageSource.camera ? 'camera' : 'photo library';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open $sourceLabel: $e')),
);
}
return;
class _VehicleDetailScreenState extends State<VehicleDetailScreen> {
late final PageController _pageController;
@override
void initState() {
super.initState();
final vehicles = context.read<AppState>().vehicles;
final initialIndex = vehicles.indexWhere((v) => v.id == widget.vehicleId);
_pageController = PageController(initialPage: initialIndex < 0 ? 0 : initialIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final vehicles = context.watch<AppState>().vehicles;
if (vehicles.isEmpty) {
return const Scaffold(body: Center(child: Text('Vehicle not found')));
}
if (photo == null) return;
final imageFile = File(photo.path);
if (!context.mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
return PageView.builder(
controller: _pageController,
itemCount: vehicles.length,
itemBuilder: (context, index) => _VehicleDetailPage(vehicleId: vehicles[index].id),
);
final ocrService = OcrService();
String recognizedText = '';
try {
recognizedText = await ocrService.recognizeText(imageFile);
} catch (_) {
recognizedText = '';
} finally {
ocrService.dispose();
}
final parsed = ReceiptParser.parse(recognizedText);
if (!context.mounted) return;
Navigator.of(context).pop(); // close the OCR loading spinner
final state = parsed.state;
if (state != null && state != 'MO') {
final proceed = await _confirmNonMissouriPurchase(context, state);
if (!proceed || !context.mounted) return;
}
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ConfirmFuelEntryScreen(
vehicleId: vehicleId,
imageFile: imageFile,
parsed: parsed,
),
),
);
}
}
}
/// Missouri's fuel tax refund only applies to fuel bought in Missouri —
/// warn if the receipt's address is somewhere else, and let the user
/// decide whether to log it anyway (e.g. it might still be worth
/// tracking for other reasons even if it won't qualify for a refund).
Future<bool> _confirmNonMissouriPurchase(BuildContext context, String stateCode) async {
final stateName = usStateNames[stateCode] ?? stateCode;
final proceed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Non-Missouri Purchase'),
content: Text(
'This receipt looks like it\'s from a gas station in $stateName. '
'The Missouri fuel tax refund only applies to fuel purchased in '
'Missouri.\n\nDo you still want to add this entry?',
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('No')),
FilledButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Yes')),
],
),
);
return proceed ?? false;
}
class _VehicleDetailPage extends StatelessWidget {
final String vehicleId;
Future<void> _deleteEntry(BuildContext context, FuelEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete fuel entry?'),
content: const Text('This will remove the entry and its receipt photo.'),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true && context.mounted) {
await context.read<AppState>().deleteFuelEntry(entry.id);
}
}
Widget _buildReceiptLeading(BuildContext context, FuelEntry entry) {
if (entry.receiptImagePath != null) {
return GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(localImagePath: entry.receiptImagePath),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.file(
File(entry.receiptImagePath!),
width: 48,
height: 48,
fit: BoxFit.cover,
),
),
);
}
if (entry.isReceiptUploadedToDrive) {
return GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptImageScreen(driveFileId: entry.receiptDriveFileId),
),
),
child: const CircleAvatar(child: Icon(Icons.cloud_outlined)),
);
}
return const CircleAvatar(child: Icon(Icons.receipt_long));
}
const _VehicleDetailPage({required this.vehicleId});
@override
Widget build(BuildContext context) {
@ -181,8 +73,12 @@ class VehicleDetailScreen extends StatelessWidget {
final entries = appState.entriesForVehicle(vehicleId);
final totalGallons = appState.totalGallonsForVehicle(vehicleId);
final totalCost = entries.fold(0.0, (sum, e) => sum + e.totalCost);
final dateFormat = DateFormat.yMMMd().add_jm();
final currencyFormat = NumberFormat.simpleCurrency();
// The hero stat is a big headline number, not a precise figure — round
// to whole gallons there, matching the Receipts tab's hero banner.
final heroGallonsFormat = NumberFormat('#,##0');
return Scaffold(
appBar: AppBar(
@ -199,42 +95,74 @@ class VehicleDetailScreen extends StatelessWidget {
),
body: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Total gallons logged', style: Theme.of(context).textTheme.labelLarge),
Text(
'${totalGallons.toStringAsFixed(3)} gal',
style: Theme.of(context).textTheme.headlineMedium,
),
],
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: HeroBanner(
child: HeroStatsRow(
gallonsValue: heroGallonsFormat.format(totalGallons),
costValue: currencyFormat.format(totalCost),
),
),
),
Expanded(
child: entries.isEmpty
? const Center(child: Text('No fuel entries yet. Tap the camera button to log a receipt.'))
: ListView.separated(
// Keyed by vehicle so each vehicle's scroll position is
// remembered independently as the user swipes between
// pages — PageView.builder can dispose an off-screen
// page's element, which would otherwise reset it.
key: PageStorageKey('vehicle-entries-$vehicleId'),
padding: const EdgeInsets.all(12),
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final entry = entries[index];
void openDetail() => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ReceiptDetailScreen(entryId: entry.id),
),
);
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: _buildReceiptLeading(context, entry),
title: Text('${entry.gallons.toStringAsFixed(3)} gal • ${currencyFormat.format(entry.totalCost)}'),
subtitle: Text(
'${currencyFormat.format(entry.pricePerGallon)}/gal\n${dateFormat.format(entry.date)}',
),
isThreeLine: true,
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => _deleteEntry(context, entry),
child: InkWell(
onTap: openDetail,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// Row cross-axis defaults to center, so the
// thumbnail sits vertically centered against the
// title+subtitle block even though it's three
// lines tall — ListTile's isThreeLine would
// instead force leading/trailing to the top, per
// Material spec (the same issue the vehicle list
// cards had).
child: Row(
children: [
ReceiptThumbnail(entry: entry, onTap: openDetail),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${entry.gallons.toStringAsFixed(3)} gal • '
'${currencyFormat.format(entry.totalCost)}',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'${currencyFormat.format(entry.pricePerGallon)}/gal\n'
'${dateFormat.format(entry.date)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
);
@ -244,7 +172,7 @@ class VehicleDetailScreen extends StatelessWidget {
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _captureReceipt(context),
onPressed: () => captureReceiptForVehicle(context, vehicleId),
icon: const Icon(Icons.camera_alt_outlined),
label: const Text('Log Fuel Receipt'),
),