A whole bunch of changes
This commit is contained in:
parent
901ec678a3
commit
956231048e
122 changed files with 5442 additions and 914 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
550
lib/screens/data_settings_screen.dart
Normal file
550
lib/screens/data_settings_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
186
lib/screens/edit_fuel_entry_screen.dart
Normal file
186
lib/screens/edit_fuel_entry_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
91
lib/screens/main_shell.dart
Normal file
91
lib/screens/main_shell.dart
Normal 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
128
lib/screens/receipt_detail_screen.dart
Normal file
128
lib/screens/receipt_detail_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
332
lib/screens/receipts_screen.dart
Normal file
332
lib/screens/receipts_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
lib/screens/ui_settings_screen.dart
Normal file
47
lib/screens/ui_settings_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue