diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index df2009b..c63541b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -64,5 +64,13 @@ + + + + + diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png new file mode 100644 index 0000000..a6ab1bf Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000..cd370fb Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png new file mode 100644 index 0000000..b3b4db8 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000..b261793 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png new file mode 100644 index 0000000..a6ab1bf Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-hdpi/splash.png b/android/app/src/main/res/drawable-night-hdpi/splash.png new file mode 100644 index 0000000..cd370fb Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png new file mode 100644 index 0000000..b3b4db8 Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-mdpi/splash.png b/android/app/src/main/res/drawable-night-mdpi/splash.png new file mode 100644 index 0000000..b261793 Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/android/app/src/main/res/drawable-night-v21/background.png new file mode 100644 index 0000000..649db16 Binary files /dev/null and b/android/app/src/main/res/drawable-night-v21/background.png differ diff --git a/android/app/src/main/res/drawable-night-v21/launch_background.xml b/android/app/src/main/res/drawable-night-v21/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/android/app/src/main/res/drawable-night-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png new file mode 100644 index 0000000..77c3f18 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xhdpi/splash.png b/android/app/src/main/res/drawable-night-xhdpi/splash.png new file mode 100644 index 0000000..a5169e6 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png new file mode 100644 index 0000000..c406a4e Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxhdpi/splash.png new file mode 100644 index 0000000..e9835d6 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png new file mode 100644 index 0000000..7aad901 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png new file mode 100644 index 0000000..63ef10c Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night/background.png b/android/app/src/main/res/drawable-night/background.png new file mode 100644 index 0000000..649db16 Binary files /dev/null and b/android/app/src/main/res/drawable-night/background.png differ diff --git a/android/app/src/main/res/drawable-night/launch_background.xml b/android/app/src/main/res/drawable-night/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/android/app/src/main/res/drawable-night/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 0000000..8e24958 Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index f74085f..3cc4948 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,12 +1,9 @@ - - - - - + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png new file mode 100644 index 0000000..77c3f18 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..a5169e6 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 0000000..c406a4e Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..e9835d6 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 0000000..7aad901 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..63ef10c Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png new file mode 100644 index 0000000..8e24958 Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f..3cc4948 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,9 @@ - - - - - + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..65ca23d 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..25fe56c 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..41a03e6 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..358883b 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..97050a2 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..34cb63f --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be..dbc9ea9 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -5,6 +5,10 @@ @drawable/launch_background + false + false + false + shortEdges + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef88..0d1fa8f 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -5,6 +5,10 @@ @drawable/launch_background + false + false + false + shortEdges - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - com.googleusercontent.apps.TODO-REPLACE-WITH-REVERSED-CLIENT-ID - - - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - mofueltaxback-dropbox - - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - mofueltaxback-onedrive - - - - + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + mofueltaxback-dropbox + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + mofueltaxback-onedrive + + + + UIStatusBarHidden + + diff --git a/lib/main.dart b/lib/main.dart index 819fb45..f1afe70 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'screens/home_screen.dart'; +import 'screens/main_shell.dart'; import 'services/app_state.dart'; import 'theme/app_theme.dart'; @@ -16,11 +16,14 @@ class FuelTaxTrackerApp extends StatelessWidget { Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) => AppState()..init(), - child: MaterialApp( - title: 'Fuel Tax Tracker', - theme: AppTheme.light, - darkTheme: AppTheme.dark, - home: const AppRoot(), + child: Consumer( + builder: (context, appState, _) => MaterialApp( + title: 'Fuel Tax Tracker', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: appState.themeMode, + home: const AppRoot(), + ), ), ); } @@ -64,7 +67,7 @@ class AppRoot extends StatelessWidget { ), ); } - return const HomeScreen(); + return const MainShell(); }, ); } diff --git a/lib/screens/add_edit_vehicle_screen.dart b/lib/screens/add_edit_vehicle_screen.dart index 853e35a..5b08f8d 100644 --- a/lib/screens/add_edit_vehicle_screen.dart +++ b/lib/screens/add_edit_vehicle_screen.dart @@ -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 createState() => _AddEditVehicleScreenState(); @@ -39,6 +47,24 @@ class _AddEditVehicleScreenState extends State { super.initState(); _nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? ''); _vinController = TextEditingController(text: widget.vehicle?.vin ?? ''); + + if (widget.promptToCreateForReceipt) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + showDialog( + 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 { super.dispose(); } + void _showVinLocationHelp() { + showDialog( + 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 _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 { 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( + 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 { _error = null; }); + final vin = _vinController.text.trim(); final nickname = _nicknameController.text.trim(); final appState = context.read(); 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 { diff --git a/lib/screens/data_settings_screen.dart b/lib/screens/data_settings_screen.dart new file mode 100644 index 0000000..2654640 --- /dev/null +++ b/lib/screens/data_settings_screen.dart @@ -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 createState() => _DataSettingsScreenState(); +} + +class _DataSettingsScreenState extends State { + static final _dateFormat = DateFormat.yMMMd(); + + bool _busy = false; + String? _error; + + DateTime? _purgeRangeStart; + DateTime? _purgeRangeEnd; + String? _purgeRangeError; + + Future _connect(CloudProviderId id) async { + setState(() { + _busy = true; + _error = null; + }); + try { + await context.read().connectProvider(id); + } catch (e) { + setState(() => _error = 'Could not sign in: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _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().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 _disconnect() async { + setState(() => _busy = true); + try { + await context.read().disconnectCloud(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + void _chooseFolder() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()), + ); + } + + Future _syncNow() async { + setState(() => _busy = true); + try { + await context.read().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 _confirmPurge({required String title, required String message}) async { + final confirmed = await showDialog( + 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 _purgeAllData() async { + final appState = context.read(); + 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 _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 _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(); + 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(); + + 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().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().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().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(); + 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'), + ), + ], + ); + } +} diff --git a/lib/screens/edit_fuel_entry_screen.dart b/lib/screens/edit_fuel_entry_screen.dart new file mode 100644 index 0000000..c331158 --- /dev/null +++ b/lib/screens/edit_fuel_entry_screen.dart @@ -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 createState() => _EditFuelEntryScreenState(); +} + +class _EditFuelEntryScreenState extends State { + final _formKey = GlobalKey(); + 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 _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 _save() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _saving = true); + try { + await context.read().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'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index d6ae292..39302c3 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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), + ], + ), + ), ), ); } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart new file mode 100644 index 0000000..f747479 --- /dev/null +++ b/lib/screens/main_shell.dart @@ -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 createState() => _MainShellState(); +} + +class _MainShellState extends State { + 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', + ), + ], + ), + ); + } +} diff --git a/lib/screens/receipt_detail_screen.dart b/lib/screens/receipt_detail_screen.dart new file mode 100644 index 0000000..929c91e --- /dev/null +++ b/lib/screens/receipt_detail_screen.dart @@ -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 _deleteEntry(BuildContext context, FuelEntry entry) async { + final confirmed = await showDialog( + 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().deleteFuelEntry(entry.id); + if (context.mounted) Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + 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), + ], + ), + ); + } +} diff --git a/lib/screens/receipts_screen.dart b/lib/screens/receipts_screen.dart new file mode 100644 index 0000000..d2a866f --- /dev/null +++ b/lib/screens/receipts_screen.dart @@ -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 createState() => _ReceiptsScreenState(); +} + +class _ReceiptsScreenState extends State 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 _addReceipt(BuildContext context) async { + final vehicles = context.read().vehicles; + if (vehicles.isEmpty) { + final newVehicleId = await Navigator.of(context).push( + 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( + 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(); + 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, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/report_screen.dart b/lib/screens/report_screen.dart index c3a76b4..743fed7 100644 --- a/lib/screens/report_screen.dart +++ b/lib/screens/report_screen.dart @@ -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 createState() => _ReportScreenState(); } -class _ReportScreenState extends State { +class _ReportScreenState extends State 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 _selectedVehicleIds = {}; + final Set _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 _pickDate({required bool isStart}) async { final now = DateTime.now(); final picked = await showDatePicker( @@ -39,10 +68,39 @@ class _ReportScreenState extends State { }); } + Future _buildPdfBytes(FuelReport report) async { + var receiptImages = const {}; + if (_includeReceiptImages) { + final loaded = await loadReceiptImageBytes( + report: report, + activeProvider: context.read().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 _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 { Future _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 _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(); 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 { 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), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 5a559df..d694928 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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 createState() => _SettingsScreenState(); -} - -class _SettingsScreenState extends State { - bool _busy = false; - String? _error; - - Future _connect(CloudProviderId id) async { - setState(() { - _busy = true; - _error = null; - }); - try { - await context.read().connectProvider(id); - } catch (e) { - setState(() => _error = 'Could not sign in: $e'); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - Future _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().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 _disconnect() async { - setState(() => _busy = true); - try { - await context.read().disconnectCloud(); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - void _chooseFolder() { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()), - ); - } - - Future _syncNow() async { - setState(() => _busy = true); - try { - await context.read().syncNow(); - } finally { - if (mounted) setState(() => _busy = false); - } - } - @override Widget build(BuildContext context) { final appState = context.watch(); @@ -90,153 +21,26 @@ class _SettingsScreenState extends State { 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().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().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().setStaleLockMinutes(value.round()), - ), - Text('${appState.staleLockMinutes} minute(s)'), - ], ), ), const SizedBox(height: 16), @@ -260,93 +64,3 @@ class _SettingsScreenState extends State { ); } } - -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(); - 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'), - ), - ], - ); - } -} diff --git a/lib/screens/ui_settings_screen.dart b/lib/screens/ui_settings_screen.dart new file mode 100644 index 0000000..e183c25 --- /dev/null +++ b/lib/screens/ui_settings_screen.dart @@ -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(); + + 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( + 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().setThemeMode(selection.first), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/vehicle_detail_screen.dart b/lib/screens/vehicle_detail_screen.dart index 1b2f0d1..90ae960 100644 --- a/lib/screens/vehicle_detail_screen.dart +++ b/lib/screens/vehicle_detail_screen.dart @@ -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 _captureReceipt(BuildContext context) async { - final source = await chooseImageSource(context); - if (source == null || !context.mounted) return; + @override + State createState() => _VehicleDetailScreenState(); +} - final keepMaxQuality = context.read().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 { + late final PageController _pageController; + + @override + void initState() { + super.initState(); + final vehicles = context.read().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().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 _confirmNonMissouriPurchase(BuildContext context, String stateCode) async { - final stateName = usStateNames[stateCode] ?? stateCode; - final proceed = await showDialog( - 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 _deleteEntry(BuildContext context, FuelEntry entry) async { - final confirmed = await showDialog( - 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().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'), ), diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart index f0d3bd9..c318276 100644 --- a/lib/services/app_state.dart +++ b/lib/services/app_state.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show ThemeMode; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; @@ -22,6 +23,7 @@ const _prefsKeyCloudFolderPath = 'cloud_folder_path'; const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally'; const _prefsKeyStaleLockMinutes = 'stale_lock_minutes'; const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos'; +const _prefsKeyThemeMode = 'theme_mode'; const defaultStaleLockMinutes = 10; const minStaleLockMinutes = 1; @@ -85,6 +87,8 @@ class AppState extends ChangeNotifier { /// the original camera/gallery image is kept as-is. bool keepMaxQualityReceiptPhotos = false; + ThemeMode themeMode = ThemeMode.system; + /// Set if [init] fails. The UI shows this (with a retry option) instead /// of spinning forever — an unhandled exception here previously left /// `isLoading` stuck at true with no feedback at all. @@ -109,6 +113,10 @@ class AppState extends ChangeNotifier { staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes; keepMaxQualityReceiptPhotos = prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false; + themeMode = ThemeMode.values.firstWhere( + (mode) => mode.name == prefs.getString(_prefsKeyThemeMode), + orElse: () => ThemeMode.system, + ); } catch (e) { initError = e; isLoading = false; @@ -289,6 +297,13 @@ class AppState extends ChangeNotifier { notifyListeners(); } + Future setThemeMode(ThemeMode mode) async { + themeMode = mode; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKeyThemeMode, mode.name); + notifyListeners(); + } + /// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the /// Settings "Advanced" section, which restricts the picker to that range /// anyway; this is a defensive backstop for any other caller. @@ -373,6 +388,34 @@ class AppState extends ChangeNotifier { return entry; } + /// Corrects the logged values (date, gallons, price/gal, total cost) for + /// an existing fuel entry — the receipt photo itself isn't editable here, + /// only the data recorded about it (e.g. fixing a misread OCR value). + /// [entryId]'s receipt photo reference (local path and/or cloud file id) + /// carries over untouched. + Future updateFuelEntry({ + required String entryId, + required DateTime date, + required double gallons, + required double pricePerGallon, + required double totalCost, + }) async { + final existing = fuelEntries.firstWhere((e) => e.id == entryId); + final updated = FuelEntry( + id: existing.id, + vehicleId: existing.vehicleId, + date: date, + gallons: gallons, + pricePerGallon: pricePerGallon, + totalCost: totalCost, + receiptImagePath: existing.receiptImagePath, + receiptDriveFileId: existing.receiptDriveFileId, + updatedAt: DateTime.now().toUtc(), + ); + await database.saveFuelEntry(updated); + await _persist(); + } + Future deleteFuelEntry(String entryId) async { final entry = fuelEntries.firstWhere((e) => e.id == entryId); await database.deleteReceiptImageFile(entry.receiptImagePath); @@ -380,6 +423,30 @@ class AppState extends ChangeNotifier { await _persist(); } + /// Wipes every vehicle, fuel entry, and local receipt photo — "starting + /// completely fresh". Uses the same soft-delete tombstones as any other + /// delete rather than a hard SQL `DELETE`, so if a cloud sync is + /// connected, the deletion pushes on next sync instead of the old data + /// just getting silently re-imported from the remote copy. This is purely + /// local + whatever propagates through sync — it never reaches into the + /// cloud folder directly to delete anything sitting there. + Future purgeAllData() async { + await database.purgeAllData(DateTime.now().toUtc()); + await _persist(); + } + + /// Wipes every fuel entry (and its local receipt photo) dated within + /// [start]–[end] inclusive, across all vehicles. Vehicles themselves are + /// left alone. Same soft-delete/sync-propagation reasoning as + /// [purgeAllData]. + Future purgeFuelEntriesInRange(DateTime start, DateTime end) async { + final localPaths = await database.purgeFuelEntriesInRange(start, end, DateTime.now().toUtc()); + for (final path in localPaths) { + await database.deleteReceiptImageFile(path); + } + await _persist(); + } + List entriesForVehicle(String vehicleId) { final list = fuelEntries.where((e) => e.vehicleId == vehicleId).toList(); list.sort((a, b) => b.date.compareTo(a.date)); @@ -401,6 +468,14 @@ class AppState extends ChangeNotifier { } } + FuelEntry? fuelEntryById(String id) { + try { + return fuelEntries.firstWhere((e) => e.id == id); + } catch (_) { + return null; + } + } + /// Exact match against an active vehicle's VIN — same comparison /// [DatabaseService.vinExists] does, not case-insensitive, since VIN /// isn't normalized to any particular case on manual entry (only OCR diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart index cbf1716..dfcac2c 100644 --- a/lib/services/database_service.dart +++ b/lib/services/database_service.dart @@ -267,6 +267,67 @@ class DatabaseService { return copied.path; } + /// Soft-deletes every active vehicle and fuel entry — as if the user had + /// deleted each one individually (see [softDeleteVehicle]/ + /// [softDeleteFuelEntry] for why these aren't real `DELETE`s: the + /// tombstones are what let the deletion propagate to other synced devices + /// on the next sync, rather than the rows just being silently re-imported + /// from the cloud copy). Also empties the local receipts folder outright + /// rather than deleting file-by-file, to catch any orphaned file a + /// tracked path might have missed. + Future purgeAllData(DateTime deletedAt) async { + final ts = deletedAt.millisecondsSinceEpoch; + await _db.update( + 'fuel_entries', + {'deleted_at': ts, 'updated_at': ts, 'dirty': 1}, + where: 'deleted_at IS NULL', + ); + await _db.update( + 'vehicles', + {'deleted_at': ts, 'updated_at': ts, 'dirty': 1}, + where: 'deleted_at IS NULL', + ); + + if (await receiptsDirectory.exists()) { + await receiptsDirectory.delete(recursive: true); + } + await receiptsDirectory.create(recursive: true); + } + + /// Soft-deletes every active fuel entry (across all vehicles) whose + /// [FuelEntry.date] falls within [start]–[end] inclusive, and returns + /// their local receipt image paths so the caller can clean those files up + /// too. Vehicles are left untouched — a date range only makes sense + /// against the fuel entries logged against them, not the vehicles + /// themselves. + Future> purgeFuelEntriesInRange( + DateTime start, + DateTime end, + DateTime deletedAt, + ) async { + final rangeStart = DateTime(start.year, start.month, start.day).millisecondsSinceEpoch; + final rangeEndExclusive = + DateTime(end.year, end.month, end.day).add(const Duration(days: 1)).millisecondsSinceEpoch; + final ts = deletedAt.millisecondsSinceEpoch; + + final rows = await _db.query( + 'fuel_entries', + where: 'deleted_at IS NULL AND date >= ? AND date < ?', + whereArgs: [rangeStart, rangeEndExclusive], + ); + final localPaths = + rows.map((r) => r['receipt_image_path'] as String?).whereType().toList(); + + await _db.update( + 'fuel_entries', + {'deleted_at': ts, 'updated_at': ts, 'dirty': 1}, + where: 'deleted_at IS NULL AND date >= ? AND date < ?', + whereArgs: [rangeStart, rangeEndExclusive], + ); + + return localPaths; + } + Future deleteReceiptImageFile(String? path) async { if (path == null) return; final file = File(path); diff --git a/lib/services/fuel_report.dart b/lib/services/fuel_report.dart index 4691b1d..ea4342d 100644 --- a/lib/services/fuel_report.dart +++ b/lib/services/fuel_report.dart @@ -1,19 +1,55 @@ import '../models/fuel_entry.dart'; import '../models/vehicle.dart'; -/// One vehicle's totals within a [FuelReport]'s date range. +/// One vehicle's totals — and, for the line-items/receipt-images report +/// sections, its individual entries — within a [FuelReport]'s date range. +/// [entries] is sorted oldest-first, the natural order for a ledger of +/// line items building up to a total. class VehicleReportRow { final Vehicle vehicle; - final int entryCount; + final List entries; final double totalGallons; final double totalCost; VehicleReportRow({ required this.vehicle, - required this.entryCount, + required this.entries, required this.totalGallons, required this.totalCost, }); + + int get entryCount => entries.length; +} + +/// One entry paired with the vehicle it belongs to — [FuelEntry] only knows +/// [FuelEntry.vehicleId], not the [Vehicle] itself, so anything that needs +/// to show both together (like [FuelReport.allEntries], for a combined +/// cross-vehicle list) needs this pairing. +class ReportEntry { + final FuelEntry entry; + final Vehicle vehicle; + + ReportEntry({required this.entry, required this.vehicle}); +} + +/// Every qualifying entry in a single calendar month, across every vehicle +/// in the report — the "Gallons by month" breakdown is this, one per +/// month present in [FuelReport.monthlyTotals]. +class MonthlyTotal { + /// The first of the month, e.g. `DateTime(2026, 8)` for August 2026. + final DateTime month; + final int fillCount; + final double totalGallons; + final double totalCost; + + MonthlyTotal({ + required this.month, + required this.fillCount, + required this.totalGallons, + required this.totalCost, + }); + + double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons; } /// Per-vehicle fuel totals for entries with a receipt, dated within @@ -27,6 +63,47 @@ class FuelReport { double get totalGallons => rows.fold(0.0, (sum, r) => sum + r.totalGallons); double get totalCost => rows.fold(0.0, (sum, r) => sum + r.totalCost); + int get fillCount => rows.fold(0, (sum, r) => sum + r.entryCount); + double get avgPricePerGallon => totalGallons == 0 ? 0 : totalCost / totalGallons; + + /// Every entry across every vehicle, oldest first — the natural order for + /// a combined "purchase detail" ledger that isn't grouped by vehicle. + List get allEntries { + final list = [ + for (final row in rows) + for (final entry in row.entries) ReportEntry(entry: entry, vehicle: row.vehicle), + ]; + list.sort((a, b) => a.entry.date.compareTo(b.entry.date)); + return list; + } + + /// [allEntries] grouped by calendar month, in chronological order — the + /// "Purchase detail" section's grouping, and the source data for + /// [monthlyTotals]. + Map> get entriesByMonth { + final map = >{}; + for (final reportEntry in allEntries) { + final month = DateTime(reportEntry.entry.date.year, reportEntry.entry.date.month); + (map[month] ??= []).add(reportEntry); + } + return map; + } + + /// One [MonthlyTotal] per calendar month with qualifying entries, oldest + /// first — the "Gallons by month" chart/table's data. + List get monthlyTotals { + final byMonth = entriesByMonth; + final months = byMonth.keys.toList()..sort(); + return [ + for (final month in months) + MonthlyTotal( + month: month, + fillCount: byMonth[month]!.length, + totalGallons: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.gallons), + totalCost: byMonth[month]!.fold(0.0, (sum, re) => sum + re.entry.totalCost), + ), + ]; + } } /// Builds a per-vehicle fuel summary for [startDate]–[endDate] (inclusive, @@ -68,11 +145,12 @@ FuelReport buildFuelReport({ // No matching (active) vehicle — e.g. deleted since — nothing sane to // attribute these entries to. if (vehicle == null) continue; + final sortedEntries = entry.value.toList()..sort((a, b) => a.date.compareTo(b.date)); rows.add(VehicleReportRow( vehicle: vehicle, - entryCount: entry.value.length, - totalGallons: entry.value.fold(0.0, (sum, e) => sum + e.gallons), - totalCost: entry.value.fold(0.0, (sum, e) => sum + e.totalCost), + entries: sortedEntries, + totalGallons: sortedEntries.fold(0.0, (sum, e) => sum + e.gallons), + totalCost: sortedEntries.fold(0.0, (sum, e) => sum + e.totalCost), )); } rows.sort((a, b) => a.vehicle.displayLabel.compareTo(b.vehicle.displayLabel)); diff --git a/lib/services/fuel_report_images.dart b/lib/services/fuel_report_images.dart new file mode 100644 index 0000000..64186d3 --- /dev/null +++ b/lib/services/fuel_report_images.dart @@ -0,0 +1,70 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import '../models/fuel_entry.dart'; +import 'cloud/cloud_storage_provider.dart'; +import 'fuel_report.dart'; + +/// Result of [loadReceiptImageBytes]: the photo bytes that were resolved, +/// plus which entries couldn't be — e.g. because the receipt was uploaded +/// through a *different* cloud provider than the one currently connected +/// (its stored reference is meaningless to the active provider's API), or +/// the entry is offline/the remote file's since been deleted. Surfacing +/// [failedEntries] instead of only ever silently dropping them is what +/// makes that distinguishable from "there was never a receipt at all". +class ReceiptImageLoadResult { + final Map bytesByEntryId; + final List failedEntries; + + ReceiptImageLoadResult({required this.bytesByEntryId, required this.failedEntries}); +} + +/// Reads (or downloads) the receipt photo for every entry in [report]. +/// +/// Prefers the local copy when one's still on-device; falls back to +/// downloading via [activeProvider] for entries that have already been +/// uploaded and cleared locally. An entry that fails to download (offline, +/// revoked cloud access, since-deleted remote file, a reference belonging +/// to a different provider than the one currently connected, ...) is left +/// out of [ReceiptImageLoadResult.bytesByEntryId] and reported in +/// [ReceiptImageLoadResult.failedEntries] instead — one missing receipt +/// photo shouldn't block the rest of the document, but it also shouldn't +/// vanish without a trace. +Future loadReceiptImageBytes({ + required FuelReport report, + required CloudStorageProvider? activeProvider, +}) async { + final bytesByEntryId = {}; + final failedEntries = []; + CloudStorageSession? session; + try { + for (final row in report.rows) { + for (final entry in row.entries) { + final localPath = entry.receiptImagePath; + if (localPath != null) { + final file = File(localPath); + if (await file.exists()) { + bytesByEntryId[entry.id] = Uint8List.fromList(await file.readAsBytes()); + continue; + } + } + + final driveFileId = entry.receiptDriveFileId; + if (driveFileId == null || activeProvider == null || !activeProvider.isSignedIn) { + failedEntries.add(entry); + continue; + } + try { + session ??= activeProvider.beginSession(); + final bytes = await session.downloadFileBytes(driveFileId); + bytesByEntryId[entry.id] = Uint8List.fromList(bytes); + } catch (_) { + failedEntries.add(entry); + } + } + } + } finally { + session?.close(); + } + return ReceiptImageLoadResult(bytesByEntryId: bytesByEntryId, failedEntries: failedEntries); +} diff --git a/lib/services/fuel_report_pdf.dart b/lib/services/fuel_report_pdf.dart index 2490c00..bbdc65e 100644 --- a/lib/services/fuel_report_pdf.dart +++ b/lib/services/fuel_report_pdf.dart @@ -7,77 +7,596 @@ import 'package:pdf/widgets.dart' as pw; import 'fuel_report.dart'; final _dateFormat = DateFormat.yMMMd(); +final _dateTimeFormat = DateFormat.yMMMd().add_jm(); +final _monthFormat = DateFormat('MMM'); +final _monthHeadingFormat = DateFormat.yMMMM(); +final _dayMonthFormat = DateFormat('d MMM'); +final _generatedFormat = DateFormat('d MMM yyyy'); final _currencyFormat = NumberFormat.simpleCurrency(); -/// Renders [report] as a single-page-friendly PDF: a per-vehicle table -/// (VIN, nickname, entry count, gallons, total cost) plus a grand-total -/// row, suitable for attaching to a fuel tax refund submission or handing -/// to a printer. -Future buildFuelReportPdf(FuelReport report) async { +// Palette lifted from the report design mockup — kept in one place so +// every section (banner, cards, tables, watermark) draws from the same +// set of colors rather than each picking its own. +final _bannerBackground = PdfColor.fromHex('#EAF3DE'); +final _bannerText = PdfColor.fromHex('#27500A'); +final _textPrimary = PdfColor.fromHex('#1F1E1C'); +final _textMuted = PdfColor.fromHex('#6B6A64'); +final _textFaint = PdfColor.fromHex('#94938C'); +final _borderLight = PdfColor.fromHex('#E5E3DB'); +final _borderStrong = PdfColor.fromHex('#CFCDC4'); +final _accentGreen = PdfColor.fromHex('#639922'); +final _accentGreenDark = PdfColor.fromHex('#3B6D11'); +final _cardBackground = PdfColor.fromHex('#F7F6F1'); + +final _tableBorder = pw.TableBorder.all(color: _borderLight, width: 0.5); +final _tableHeaderDecoration = pw.BoxDecoration(color: PdfColor.fromHex('#EFEEE7')); +final _tableEvenRowDecoration = const pw.BoxDecoration(color: PdfColors.white); +final _tableOddRowDecoration = pw.BoxDecoration(color: _cardBackground); +final _sectionHeadingStyle = pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary); + +/// Renders [report] as a PDF, styled after the app's report design mockup: +/// a branded letterhead banner, a headline total, three at-a-glance stat +/// cards, a "gallons by month" bar chart + table, and a by-vehicle +/// breakdown. When requested, a "Line Items" section (a table per vehicle) +/// and/or a "Receipt Images" section (photos per vehicle, sized to +/// actually be legible — not a small inline thumbnail) follow, each +/// grouped by vehicle and sorted oldest-first within it. When both are on, +/// each line item and its receipt photo are tagged with the same number +/// (assigned once, continuing across vehicles) so the two can be matched +/// up by eye despite living in separate sections. +/// +/// [receiptImages] supplies the actual photo bytes, keyed by +/// [FuelEntry.id] — see `loadReceiptImageBytes`; an entry with no entry in +/// that map (couldn't be read locally or downloaded) is left out of the +/// images section rather than shown as a broken image. +Future buildFuelReportPdf( + FuelReport report, { + bool includeLineItems = false, + bool includeReceiptImages = false, + Map receiptImages = const {}, +}) async { final doc = pw.Document(); + final generatedAt = DateTime.now(); doc.addPage( pw.MultiPage( pageFormat: PdfPageFormat.letter, - header: (context) => pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)), - pw.Text( - '${_dateFormat.format(report.startDate)} – ${_dateFormat.format(report.endDate)}', - style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700), - ), - pw.SizedBox(height: 16), - ], + margin: pw.EdgeInsets.zero, + header: (context) => _buildBanner(generatedAt), + footer: (context) => _buildFooter(report, context), + build: (context) => _buildBody( + report, + includeLineItems: includeLineItems, + includeReceiptImages: includeReceiptImages, + receiptImages: receiptImages, ), - build: (context) => [ - if (report.rows.isEmpty) - pw.Text('No vehicles had a receipted fuel entry in this date range.') - else - pw.TableHelper.fromTextArray( - headers: ['Vehicle', 'VIN', 'Entries', 'Gallons', 'Total Cost'], - data: [ - for (final row in report.rows) - [ - row.vehicle.nickname?.trim().isNotEmpty == true ? row.vehicle.nickname! : '—', - row.vehicle.vin, - row.entryCount.toString(), - row.totalGallons.toStringAsFixed(3), - _currencyFormat.format(row.totalCost), - ], - ], - cellAlignments: const { - 2: pw.Alignment.centerRight, - 3: pw.Alignment.centerRight, - 4: pw.Alignment.centerRight, - }, - headerAlignments: const { - 2: pw.Alignment.centerRight, - 3: pw.Alignment.centerRight, - 4: pw.Alignment.centerRight, - }, - headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold), - cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6), - ), - pw.SizedBox(height: 12), - pw.Divider(), - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)), - pw.Text( - '${report.totalGallons.toStringAsFixed(3)} gal · ${_currencyFormat.format(report.totalCost)}', - style: pw.TextStyle(fontWeight: pw.FontWeight.bold), - ), - ], - ), - ], ), ); return doc.save(); } +/// A *flat* list of top-level widgets — deliberately not one big [pw.Column] +/// wrapping everything. [pw.MultiPage] paginates by walking its `build:` +/// list one top-level item at a time, resetting its "stuck" bookkeeping +/// only once each one fully finishes; a [pw.Table]/[pw.Wrap] (both of which +/// span pages on their own, e.g. a long line-items table or a big receipt +/// photo grid) nested *inside* one enclosing spanning [pw.Column] instead +/// of being its own top-level entry defeats that bookkeeping and throws +/// `TooManyPagesException` on anything long enough to need several page +/// breaks — even though the content itself is perfectly valid. +List _buildBody( + FuelReport report, { + required bool includeLineItems, + required bool includeReceiptImages, + required Map receiptImages, +}) { + pw.Widget pad(pw.Widget child) => + pw.Padding(padding: const pw.EdgeInsets.symmetric(horizontal: 36), child: child); + + if (report.rows.isEmpty) { + return [ + pw.Padding( + padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0), + child: _buildTitleBlock(report), + ), + pad(pw.Text('No vehicles had a receipted fuel entry in this date range.')), + ]; + } + + final widgets = [ + pw.Padding( + padding: const pw.EdgeInsets.fromLTRB(36, 20, 36, 0), + child: _buildTitleBlock(report), + ), + pad(_buildHeroStat(report)), + pw.SizedBox(height: 22), + pad(_buildStatCardsRow(report)), + pw.SizedBox(height: 26), + pad(_buildMonthlySection(report)), + pw.SizedBox(height: 26), + pad(_buildVehicleSection(report)), + ]; + + if (includeLineItems || includeReceiptImages) { + widgets.add(pw.SizedBox(height: 26)); + widgets.addAll( + _buildLineItemsAndImagesSections( + report, + includeLineItems: includeLineItems, + includeReceiptImages: includeReceiptImages, + receiptImages: receiptImages, + ).map(pad), + ); + } + + widgets.add(pw.SizedBox(height: 8)); + return widgets; +} + +pw.Widget _buildBanner(DateTime generatedAt) { + // The watermark lives here, inside the banner's own bounded height, + // rather than overlaid on the flowing body content below — a Stack + // wrapping unbounded/multi-page content doesn't paginate (MultiPage + // needs a SpanningWidget or a fixed-size child for that), so scoping it + // to this fixed-height strip is what keeps it safe across page breaks. + return pw.Container( + color: _bannerBackground, + child: pw.Stack( + children: [ + pw.Positioned( + right: 12, + top: -14, + child: pw.SizedBox(width: 60, height: 60, child: _buildWatermark(alpha: 0.14)), + ), + pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 36, vertical: 14), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'Show Me The Fuel Refund', + style: pw.TextStyle(fontSize: 13, fontWeight: pw.FontWeight.bold, color: _bannerText), + ), + pw.Container( + padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: pw.BoxDecoration( + color: PdfColors.white, + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)), + ), + child: pw.Text( + 'Generated ${_generatedFormat.format(generatedAt)}', + style: pw.TextStyle(fontSize: 10, color: _bannerText), + ), + ), + ], + ), + ), + ], + ), + ); +} + +pw.Widget _buildFooter(FuelReport report, pw.Context context) { + return pw.Container( + padding: const pw.EdgeInsets.fromLTRB(36, 8, 36, 20), + decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderStrong, width: 0.5))), + margin: const pw.EdgeInsets.only(top: 6), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + '${report.fillCount} purchase${report.fillCount == 1 ? '' : 's'} · ' + '${report.rows.length} vehicle${report.rows.length == 1 ? '' : 's'} · ' + '${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}', + style: pw.TextStyle(fontSize: 9, color: _textFaint), + ), + pw.Text( + 'Page ${context.pageNumber} of ${context.pagesCount}', + style: pw.TextStyle(fontSize: 9, color: _textFaint), + ), + ], + ), + ); +} + +/// A faint brand-colored fuel-can silhouette — purely decorative, +/// matching the report mockup's watermark shape. +pw.Widget _buildWatermark({required double alpha}) { + const svg = ''' + + + +'''; + return pw.SvgImage(svg: svg, colorFilter: _accentGreen.withAlpha(alpha)); +} + +pw.Widget _buildTitleBlock(FuelReport report) { + final months = report.monthlyTotals.length; + final vehicles = report.rows.length; + final fills = report.fillCount; + final subtitle = StringBuffer('${_dateFormat.format(report.startDate)} - ${_dateFormat.format(report.endDate)}'); + if (report.rows.isNotEmpty) { + subtitle.write( + ' · $months month${months == 1 ? '' : 's'} · $vehicles vehicle${vehicles == 1 ? '' : 's'} · ' + '$fills fill-up${fills == 1 ? '' : 's'}', + ); + } + + return pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 20), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Fuel Report', style: pw.TextStyle(fontSize: 22, fontWeight: pw.FontWeight.bold, color: _textPrimary)), + pw.SizedBox(height: 3), + pw.Text(subtitle.toString(), style: pw.TextStyle(fontSize: 12, color: _textMuted)), + ], + ), + ); +} + +pw.Widget _buildHeroStat(FuelReport report) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Total fuel purchased', style: pw.TextStyle(fontSize: 11, color: _textMuted)), + pw.SizedBox(height: 4), + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text( + report.totalGallons.toStringAsFixed(3), + style: pw.TextStyle(fontSize: 34, fontWeight: pw.FontWeight.bold, color: _accentGreenDark), + ), + pw.SizedBox(width: 6), + pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 5), + child: pw.Text('gal', style: pw.TextStyle(fontSize: 13, color: _accentGreenDark)), + ), + ], + ), + ], + ); +} + +pw.Widget _buildStatCardsRow(FuelReport report) { + pw.Widget statCard(String label, String value) => pw.Expanded( + child: pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + color: _cardBackground, + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text(label, style: pw.TextStyle(fontSize: 10, color: _textMuted)), + pw.SizedBox(height: 3), + pw.Text(value, style: pw.TextStyle(fontSize: 15, fontWeight: pw.FontWeight.bold, color: _textPrimary)), + ], + ), + ), + ); + + return pw.Row( + children: [ + statCard('Spent at the pump', _currencyFormat.format(report.totalCost)), + pw.SizedBox(width: 10), + statCard('Avg price/gal', _currencyFormat.format(report.avgPricePerGallon)), + pw.SizedBox(width: 10), + statCard('Fill-ups logged', report.fillCount.toString()), + ], + ); +} + +pw.Widget _buildMonthlySection(FuelReport report) { + final months = report.monthlyTotals; + if (months.isEmpty) return pw.SizedBox(); + + const chartHeight = 64.0; + final maxGallons = months.fold(0.0, (max, m) => m.totalGallons > max ? m.totalGallons : max); + + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Gallons by month', style: pw.TextStyle(fontSize: 11, color: _textMuted)), + pw.SizedBox(height: 8), + if (months.length > 1) ...[ + pw.SizedBox( + height: chartHeight, + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + for (final month in months) + pw.Expanded( + child: pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 3), + child: pw.Container( + height: maxGallons == 0 + ? 2 + : (chartHeight * (month.totalGallons / maxGallons)).clamp(2, chartHeight), + decoration: pw.BoxDecoration( + color: _accentGreen, + borderRadius: const pw.BorderRadius.only( + topLeft: pw.Radius.circular(2), + topRight: pw.Radius.circular(2), + ), + ), + ), + ), + ), + ], + ), + ), + pw.SizedBox(height: 4), + pw.Row( + children: [ + for (final month in months) + pw.Expanded( + child: pw.Center( + child: pw.Text(_monthFormat.format(month.month), style: pw.TextStyle(fontSize: 9, color: _textFaint)), + ), + ), + ], + ), + pw.SizedBox(height: 10), + ], + pw.TableHelper.fromTextArray( + headers: ['Month', 'Fills', 'Gallons', 'Spent', 'Avg/gal'], + data: [ + for (final month in months) + [ + _monthHeadingFormat.format(month.month), + month.fillCount.toString(), + month.totalGallons.toStringAsFixed(3), + _currencyFormat.format(month.totalCost), + _currencyFormat.format(month.avgPricePerGallon), + ], + ], + cellAlignments: const { + 1: pw.Alignment.centerRight, + 2: pw.Alignment.centerRight, + 3: pw.Alignment.centerRight, + 4: pw.Alignment.centerRight, + }, + headerAlignments: const { + 1: pw.Alignment.centerRight, + 2: pw.Alignment.centerRight, + 3: pw.Alignment.centerRight, + 4: pw.Alignment.centerRight, + }, + headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted), + cellStyle: const pw.TextStyle(fontSize: 10), + cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6), + border: _tableBorder, + headerDecoration: _tableHeaderDecoration, + rowDecoration: _tableEvenRowDecoration, + oddRowDecoration: _tableOddRowDecoration, + ), + if (months.length > 1) ...[ + pw.SizedBox(height: 6), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10)), + pw.Text( + '${report.fillCount} · ${report.totalGallons.toStringAsFixed(3)} gal · ' + '${_currencyFormat.format(report.totalCost)} · ${_currencyFormat.format(report.avgPricePerGallon)}', + style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10), + ), + ], + ), + ], + ], + ); +} + +pw.Widget _buildVehicleSection(FuelReport report) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('By vehicle', style: pw.TextStyle(fontSize: 11, color: _textMuted)), + for (final row in report.rows) + pw.Container( + padding: const pw.EdgeInsets.symmetric(vertical: 10), + decoration: pw.BoxDecoration(border: pw.Border(top: pw.BorderSide(color: _borderLight, width: 0.5))), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Expanded( + flex: 2, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), + pw.Text(row.vehicle.vin, style: pw.TextStyle(fontSize: 9, color: _textFaint)), + ], + ), + ), + pw.Expanded( + child: pw.Text( + '${row.entryCount} fill${row.entryCount == 1 ? '' : 's'}', + textAlign: pw.TextAlign.right, + style: pw.TextStyle(fontSize: 11, color: _textMuted), + ), + ), + pw.Expanded( + child: pw.Text( + _currencyFormat.format(row.totalCost), + textAlign: pw.TextAlign.right, + style: const pw.TextStyle(fontSize: 11), + ), + ), + pw.Expanded( + child: pw.Text( + '${row.totalGallons.toStringAsFixed(3)} gal', + textAlign: pw.TextAlign.right, + style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold, color: _accentGreenDark), + ), + ), + ], + ), + ), + ], + ); +} + +/// Item numbers (when both [includeLineItems] and [includeReceiptImages] +/// are on) are assigned once, in report order — vehicle by vehicle, +/// oldest-entry-first within each — and reused by both the "Line Items" +/// table and the "Receipt Images" section below it, so a photo and its +/// line item carry the same number despite living in separate sections. +List _buildLineItemsAndImagesSections( + FuelReport report, { + required bool includeLineItems, + required bool includeReceiptImages, + required Map receiptImages, +}) { + final showItemNumbers = includeLineItems && includeReceiptImages; + final itemNumbers = {}; + var nextNumber = 1; + for (final row in report.rows) { + for (final entry in row.entries) { + itemNumbers[entry.id] = nextNumber++; + } + } + + final widgets = []; + + if (includeLineItems) { + widgets.add(pw.Text('Line Items', style: _sectionHeadingStyle)); + for (final row in report.rows) { + widgets.add(pw.SizedBox(height: 14)); + widgets.add(_buildVehicleSubheading(row)); + widgets.add(pw.SizedBox(height: 6)); + widgets.add(_buildLineItemsTable(row, showItemNumbers: showItemNumbers, itemNumbers: itemNumbers)); + } + } + + if (includeReceiptImages) { + final rowsWithImages = report.rows.where((row) => row.entries.any((e) => receiptImages.containsKey(e.id))); + if (rowsWithImages.isNotEmpty) { + if (includeLineItems) widgets.add(pw.SizedBox(height: 26)); + widgets.add(pw.Text('Receipt Images', style: _sectionHeadingStyle)); + for (final row in rowsWithImages) { + widgets.add(pw.SizedBox(height: 14)); + widgets.add(_buildVehicleSubheading(row)); + widgets.add(pw.SizedBox(height: 8)); + widgets.add(_buildReceiptImagesWrap( + row, + receiptImages, + showItemNumbers: showItemNumbers, + itemNumbers: itemNumbers, + )); + } + } + } + + return widgets; +} + +pw.Widget _buildVehicleSubheading(VehicleReportRow row) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text(row.vehicle.displayLabel, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), + pw.Text('VIN: ${row.vehicle.vin}', style: pw.TextStyle(fontSize: 9, color: _textFaint)), + ], + ); +} + +pw.Widget _buildLineItemsTable( + VehicleReportRow row, { + required bool showItemNumbers, + required Map itemNumbers, +}) { + return pw.TableHelper.fromTextArray( + headers: [ + if (showItemNumbers) '#', + 'Date', + 'Gallons', + 'Price/Gal', + 'Total Cost', + ], + data: [ + for (final entry in row.entries) + [ + if (showItemNumbers) itemNumbers[entry.id].toString(), + _dateTimeFormat.format(entry.date), + entry.gallons.toStringAsFixed(3), + _currencyFormat.format(entry.pricePerGallon), + _currencyFormat.format(entry.totalCost), + ], + ], + cellAlignments: showItemNumbers + ? const { + 0: pw.Alignment.centerLeft, + 2: pw.Alignment.centerRight, + 3: pw.Alignment.centerRight, + 4: pw.Alignment.centerRight, + } + : const { + 1: pw.Alignment.centerRight, + 2: pw.Alignment.centerRight, + 3: pw.Alignment.centerRight, + }, + headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10, color: _textMuted), + cellStyle: const pw.TextStyle(fontSize: 10), + cellPadding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6), + border: _tableBorder, + headerDecoration: _tableHeaderDecoration, + rowDecoration: _tableEvenRowDecoration, + oddRowDecoration: _tableOddRowDecoration, + ); +} + +/// 140×140 — comfortably legible (the previous design's inline thumbnails, +/// at ~34×44, were too small to make out) while still fitting several per +/// row on a letter-width page. +const _receiptImageSize = 140.0; + +pw.Widget _buildReceiptImagesWrap( + VehicleReportRow row, + Map receiptImages, { + required bool showItemNumbers, + required Map itemNumbers, +}) { + return pw.Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (final entry in row.entries) + if (receiptImages[entry.id] case final bytes?) + pw.Column( + children: [ + pw.Container( + width: _receiptImageSize, + height: _receiptImageSize, + decoration: pw.BoxDecoration( + color: _cardBackground, + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)), + border: pw.Border.all(color: _borderLight, width: 0.5), + ), + child: pw.ClipRRect( + horizontalRadius: 6, + verticalRadius: 6, + child: pw.Image(pw.MemoryImage(bytes), fit: pw.BoxFit.cover), + ), + ), + pw.SizedBox(height: 4), + pw.Text( + showItemNumbers + ? '#${itemNumbers[entry.id]} · ${_dayMonthFormat.format(entry.date)}' + : _dayMonthFormat.format(entry.date), + style: pw.TextStyle(fontSize: 9, color: _textMuted), + ), + ], + ), + ], + ); +} + /// `fuel-report--.pdf`, e.g. `fuel-report-20260101-20260331.pdf`. String fuelReportFileName(FuelReport report) { final fmt = DateFormat('yyyyMMdd'); diff --git a/lib/services/vin_parser.dart b/lib/services/vin_parser.dart index 6ca6949..ae40457 100644 --- a/lib/services/vin_parser.dart +++ b/lib/services/vin_parser.dart @@ -2,18 +2,24 @@ /// sticker, dashboard plate, title, etc). /// /// Real VINs never contain the letters I, O, or Q (they're excluded from -/// the standard specifically so they can't be confused with 1 and 0), so -/// requiring that charset both matches genuine VINs and rules out a lot of -/// incidental 17-character noise elsewhere on the sticker (barcodes text, -/// weight ratings, date codes, etc). +/// the standard specifically so they can't be confused with 1, 0, and 9 — +/// which is exactly the kind of confusion OCR itself is prone to: a +/// genuine VIN's "0" is sometimes read back as "O"). So rather than reject +/// a candidate for containing an I or O, [_normalize] treats it as a +/// misread digit and corrects it — Q is left alone, since it doesn't +/// closely resemble any digit and so is more likely to mean the candidate +/// isn't a VIN at all (e.g. noise from elsewhere on the sticker). class VinParser { + // V\.?\s*I\.?\s*N tolerates the "V.I.N" (period between each letter) + // styling common on manufacturer compliance/data plates, not just the + // plain "VIN" a door-jamb sticker or title usually uses. static final _labeledVinPattern = - RegExp(r'VIN[:\s]*([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false); + RegExp(r'V\.?\s*I\.?\s*N[:.\s]*([A-PR-Z0-9]{17})\b', caseSensitive: false); // \b on both sides matters: since digits and letters are both "word" // characters, this only matches a maximal run of exactly 17 eligible // characters — not a 17-character slice out of an 18+ character run. - static final _bareVinPattern = RegExp(r'\b([A-HJ-NPR-Z0-9]{17})\b', caseSensitive: false); + static final _bareVinPattern = RegExp(r'\b([A-PR-Z0-9]{17})\b', caseSensitive: false); /// Returns the VIN in uppercase, or null if nothing matching the VIN /// charset/length was found. A labeled "VIN: ..." match is preferred @@ -21,11 +27,24 @@ class VinParser { /// one candidate (e.g. also a 17-digit tire/parts barcode number). static String? parse(String text) { final labeled = _labeledVinPattern.firstMatch(text); - if (labeled != null) return labeled.group(1)!.toUpperCase(); + if (labeled != null) return _normalize(labeled.group(1)!); final bare = _bareVinPattern.firstMatch(text); - if (bare != null) return bare.group(1)!.toUpperCase(); + if (bare != null) return _normalize(bare.group(1)!); + + // Last resort: OCR sometimes splits a VIN across a stray space (e.g. + // an etched/curved surface gets read as two separate text regions) — + // retry once against the text with all whitespace removed. Tried last, + // after both whitespace-preserving attempts, since collapsing + // whitespace elsewhere in a busy label risks gluing unrelated text + // together into a spurious 17-character run. + final stripped = text.replaceAll(RegExp(r'\s+'), ''); + final strippedMatch = _bareVinPattern.firstMatch(stripped); + if (strippedMatch != null) return _normalize(strippedMatch.group(1)!); return null; } + + static String _normalize(String candidate) => + candidate.toUpperCase().replaceAll('O', '0').replaceAll('I', '1'); } diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index eb38561..bd1a5b0 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -31,6 +31,20 @@ class AppTheme { static const _darkOutline = Color(0xFF7E877F); // --text-3 static const _darkOutlineVariant = Color(0xFF262926); // --line + // --- Hero card (used by ReceiptsScreen) --- + static const heroGradientLight = [Color(0xFF316F44), Color(0xFF1B4527)]; // --hero-from/to + static const heroGradientDark = [Color(0xFF2E6B41), Color(0xFF17331F)]; + static const heroText = Colors.white; // --hero-text (same in both themes) + static const heroTextSecondaryLight = Color(0xC7FFFFFF); // --hero-text-2, 78% white + static const heroTextSecondaryDark = Color(0xB8FFFFFF); // --hero-text-2, 72% white + + /// The hero card's gradient colors for whichever brightness is active. + static List heroGradient(BuildContext context) => + Theme.of(context).brightness == Brightness.dark ? heroGradientDark : heroGradientLight; + + static Color heroTextSecondary(BuildContext context) => + Theme.of(context).brightness == Brightness.dark ? heroTextSecondaryDark : heroTextSecondaryLight; + static ThemeData get light { final colorScheme = ColorScheme.fromSeed( seedColor: _lightPrimary, @@ -91,6 +105,7 @@ class AppTheme { backgroundColor: colorScheme.surface, foregroundColor: colorScheme.onSurface, surfaceTintColor: Colors.transparent, + centerTitle: true, titleTextStyle: TextStyle( color: colorScheme.onSurface, fontSize: 18, diff --git a/lib/widgets/hero_banner.dart b/lib/widgets/hero_banner.dart new file mode 100644 index 0000000..0a5d0c5 --- /dev/null +++ b/lib/widgets/hero_banner.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// Green gradient hero banner background shared by every screen that shows +/// a totals summary (Receipts, Vehicle Detail) — callers supply their own +/// stat row / controls as [child], keeping the container/gradient styling +/// itself in one place. +class HeroBanner extends StatelessWidget { + final Widget child; + + const HeroBanner({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: AppTheme.heroGradient(context), + ), + ), + child: child, + ); + } +} + +/// A single label/value pair inside a [HeroBanner], e.g. "Total Gallons". +class HeroStat extends StatelessWidget { + final String label; + final String value; + final bool alignEnd; + + const HeroStat({super.key, required this.label, required this.value, required this.alignEnd}); + + @override + Widget build(BuildContext context) { + final textAlign = alignEnd ? TextAlign.end : TextAlign.start; + return Column( + crossAxisAlignment: alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Text( + label, + textAlign: textAlign, + style: TextStyle( + color: AppTheme.heroTextSecondary(context), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + value, + textAlign: textAlign, + style: const TextStyle( + color: AppTheme.heroText, + fontSize: 28, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), + ), + ], + ); + } +} + +/// The icon + two-stat row shared by every hero banner: gallons on the +/// left, the hero icon in the middle, cost on the right. +class HeroStatsRow extends StatelessWidget { + final String gallonsValue; + final String costValue; + final String gallonsLabel; + final String costLabel; + + const HeroStatsRow({ + super.key, + required this.gallonsValue, + required this.costValue, + this.gallonsLabel = 'Total Gallons', + this.costLabel = 'Total Fuel Cost', + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: HeroStat(label: gallonsLabel, value: gallonsValue, alignEnd: false)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + // Tall enough to extend past the bottom of the label+value text + // next to it, not just match the label's height — width scales + // automatically from the source's own aspect ratio since only + // height is given. + child: Image.asset( + 'assets/icon/hero_icon.png', + height: 58, + fit: BoxFit.contain, + ), + ), + Expanded(child: HeroStat(label: costLabel, value: costValue, alignEnd: true)), + ], + ); + } +} diff --git a/lib/widgets/image_source_sheet.dart b/lib/widgets/image_source_sheet.dart index df5beba..e14d644 100644 --- a/lib/widgets/image_source_sheet.dart +++ b/lib/widgets/image_source_sheet.dart @@ -4,13 +4,46 @@ import 'package:image_picker/image_picker.dart'; /// Bottom sheet letting the user pick a photo from the camera or their /// existing library. Shared by any screen that needs to snap or choose a /// photo (receipt capture, VIN scanning) so the choice looks and behaves -/// the same everywhere. -Future chooseImageSource(BuildContext context) { +/// the same everywhere. Pass [heading] to label what the photo is for +/// (e.g. receipt capture passes "Receipt From") — omitted by callers where +/// that's already obvious from context. [onInfoTap], if given, adds an +/// info button next to [heading] (e.g. VIN scanning uses it to explain +/// where to find a VIN); ignored if [heading] is null, since there'd be +/// nowhere to put it. +Future chooseImageSource( + BuildContext context, { + String? heading, + VoidCallback? onInfoTap, +}) { return showModalBottomSheet( context: context, builder: (context) => SafeArea( child: Wrap( children: [ + if (heading != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 4), + child: Row( + children: [ + Expanded( + child: Text( + heading, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + if (onInfoTap != null) + IconButton( + icon: const Icon(Icons.info_outline), + iconSize: 20, + tooltip: 'Where do I find this?', + onPressed: onInfoTap, + ), + ], + ), + ), ListTile( leading: const Icon(Icons.camera_alt_outlined), title: const Text('Take Photo'), diff --git a/lib/widgets/receipt_capture.dart b/lib/widgets/receipt_capture.dart new file mode 100644 index 0000000..3eaef49 --- /dev/null +++ b/lib/widgets/receipt_capture.dart @@ -0,0 +1,115 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.dart'; + +import '../screens/confirm_fuel_entry_screen.dart'; +import '../services/app_state.dart'; +import '../services/ocr_service.dart'; +import '../services/receipt_parser.dart'; +import 'image_source_sheet.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; + +/// Shared by [VehicleDetailScreen]'s "Log Fuel Receipt" button and the +/// Receipts tab's "+" action: pick a photo, OCR it, and — unless the +/// receipt looks like it's from outside Missouri, in which case the user +/// gets a chance to back out first — hand off to [ConfirmFuelEntryScreen] +/// for [vehicleId]. +Future captureReceiptForVehicle(BuildContext context, String vehicleId) async { + final source = await chooseImageSource(context, heading: 'Receipt From'); + if (source == null || !context.mounted) return; + + final keepMaxQuality = context.read().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; + } + if (photo == null) return; + + final imageFile = File(photo.path); + + if (!context.mounted) return; + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + + 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 _confirmNonMissouriPurchase(BuildContext context, String stateCode) async { + final stateName = usStateNames[stateCode] ?? stateCode; + final proceed = await showDialog( + 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; +} diff --git a/lib/widgets/receipt_thumbnail.dart b/lib/widgets/receipt_thumbnail.dart new file mode 100644 index 0000000..21e6112 --- /dev/null +++ b/lib/widgets/receipt_thumbnail.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../models/fuel_entry.dart'; +import '../screens/receipt_image_screen.dart'; + +/// A fuel entry's receipt as a tappable thumbnail: the local photo if one's +/// still on-device, a cloud icon (tap to download-and-view) if it's been +/// uploaded and the local copy cleared, or a plain placeholder if there's +/// no receipt at all. Shared by [VehicleDetailScreen]'s entry list, the +/// cross-vehicle Receipts tab, [ReceiptDetailScreen] (at a larger [size], +/// as its primary preview), and [EditFuelEntryScreen] so all four present a +/// receipt the same way. +/// +/// Tapping it opens the full-screen photo viewer by default; pass [onTap] +/// to send it somewhere else instead (e.g. the list rows send it to +/// [ReceiptDetailScreen] instead of straight to the photo). +class ReceiptThumbnail extends StatelessWidget { + final FuelEntry entry; + final double size; + final VoidCallback? onTap; + + const ReceiptThumbnail({super.key, required this.entry, this.size = 48, this.onTap}); + + @override + Widget build(BuildContext context) { + if (entry.receiptImagePath != null) { + return GestureDetector( + onTap: onTap ?? + () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReceiptImageScreen(localImagePath: entry.receiptImagePath), + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.file( + File(entry.receiptImagePath!), + width: size, + height: size, + fit: BoxFit.cover, + ), + ), + ); + } + if (entry.isReceiptUploadedToDrive) { + return GestureDetector( + onTap: onTap ?? + () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReceiptImageScreen(driveFileId: entry.receiptDriveFileId), + ), + ), + child: CircleAvatar(radius: size / 2, child: Icon(Icons.cloud_outlined, size: size * 0.5)), + ); + } + final placeholder = CircleAvatar(radius: size / 2, child: Icon(Icons.receipt_long, size: size * 0.5)); + return onTap == null ? placeholder : GestureDetector(onTap: onTap, child: placeholder); + } +} diff --git a/pubspec.lock b/pubspec.lock index 7fb6d87..ff92767 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.7" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" archive: dependency: transitive description: @@ -65,6 +73,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -121,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -222,6 +254,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" flutter_lints: dependency: "direct dev" description: @@ -230,6 +270,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff" + url: "https://pub.dev" + source: hosted + version: "2.4.8" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -400,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: "direct main" description: @@ -520,6 +576,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" leak_tracker: dependency: transitive description: @@ -965,8 +1029,16 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - url_launcher: + universal_io: dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + url_launcher: + dependency: "direct main" description: name: url_launcher sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 diff --git a/pubspec.yaml b/pubspec.yaml index 0540960..8941af5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,6 +91,7 @@ dependencies: flutter_secure_storage: ^10.0.0 pdf: ^3.12.0 printing: ^5.14.3 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: @@ -116,6 +117,52 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 + # Generates the Android/iOS launcher icon files from assets/icon/app_icon.png + # — run `dart run flutter_launcher_icons` after changing that source image. + flutter_launcher_icons: ^0.14.4 + + # Generates the native (pre-Dart-engine) splash screen shown at cold + # start — run `dart run flutter_native_splash:create` after changing + # assets/splash/*. Separate from flutter_launcher_icons: without this, + # Android falls back to its own default Splash Screen behavior, which + # wraps the launcher icon in a small icon-in-a-white-circle look we don't + # control at all. + flutter_native_splash: ^2.4.8 + +flutter_launcher_icons: + android: true + ios: true + image_path: "assets/icon/app_icon.png" + # The source image is a full-bleed square with its own rounded corners + # already baked in via real alpha transparency (not a white margin), so + # each platform's own icon masking just clips already-transparent pixels + # — no double-rounding artifact. iOS icons must be fully opaque (the App + # Store rejects alpha, and the OS doesn't composite icon transparency the + # way Android does), so flatten alpha to a solid background there only. + remove_alpha_ios: true + +flutter_native_splash: + # Pre-Android-12 (and iOS): no OS-imposed circular mask, so this can be + # sized generously — the brand mark centered on a solid background that + # matches the app icon's own green, so there's no white border/seam. + color: "#24603A" + color_dark: "#101110" + image: assets/splash/splash_logo.png + image_dark: assets/splash/splash_logo.png + + android_12: + # Android 12+ handles splash screens itself: a background color, an + # icon, and (optionally) a separate icon background color — it enforces + # its own circular mask no matter what we provide (clips the outer + # third), so splash_logo_android12.png is pre-fit to stay inside that + # safe circle rather than relying on this package to size it for us. + color: "#24603A" + color_dark: "#101110" + icon_background_color: "#24603A" + icon_background_color_dark: "#101110" + image: assets/splash/splash_logo_android12.png + image_dark: assets/splash/splash_logo_android12.png + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -127,10 +174,14 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + # The brand mark shown between the two stats in the Receipts tab's hero + # banner (see ReceiptsScreen) — distinct from assets/icon/app_icon.png, + # which flutter_launcher_icons reads at build time and never ships inside + # the app bundle itself. + assets: + - assets/icon/hero_icon.png + - assets/vin_help/door_jamb.jpg + - assets/vin_help/windshield.jpg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/add_edit_vehicle_screen_test.dart b/test/add_edit_vehicle_screen_test.dart index 4d594f9..4d43509 100644 --- a/test/add_edit_vehicle_screen_test.dart +++ b/test/add_edit_vehicle_screen_test.dart @@ -101,4 +101,28 @@ void main() { expect(find.text('Vehicle already exists'), findsNothing); }); + + testWidgets( + 'tapping the VIN scan icon shows "VIN From" with an info button explaining where to find one', + (tester) async { + await pumpAddVehicleScreen(tester); + + await tester.tap(find.byTooltip('Scan VIN from a photo')); + await tester.pumpAndSettle(); + + expect(find.text('VIN From'), findsOneWidget); + expect(find.text('Take Photo'), findsOneWidget); + expect(find.text('Choose from Gallery'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + + expect(find.text('Where to find the VIN'), findsOneWidget); + expect(find.byType(Image), findsNWidgets(2), reason: 'door jamb + windshield examples'); + + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + + expect(find.text('Where to find the VIN'), findsNothing); + }); } diff --git a/test/edit_fuel_entry_test.dart b/test/edit_fuel_entry_test.dart new file mode 100644 index 0000000..322f08f --- /dev/null +++ b/test/edit_fuel_entry_test.dart @@ -0,0 +1,157 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/screens/edit_fuel_entry_screen.dart'; +import 'package:fuel_tax_tracker/screens/receipt_detail_screen.dart'; +import 'package:fuel_tax_tracker/screens/vehicle_detail_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; +import 'package:fuel_tax_tracker/services/database_service.dart'; + +/// Covers editing a fuel entry's logged data (date/gallons/price/total) — +/// not its receipt photo, which [EditFuelEntryScreen] deliberately shows +/// read-only. The database-level overwrite semantics ([DatabaseService] +/// group) are what [AppState.updateFuelEntry] relies on: it just calls +/// [DatabaseService.saveFuelEntry] again with the same id, same as any +/// other upsert in this app (see [DatabaseService.saveVehicle]). +void main() { + final vehicle = Vehicle(id: 'v1', vin: '1FMPU18L1TLB51349', updatedAt: DateTime.utc(2026, 1, 1)); + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 3, 5, 14, 30), + gallons: 10.5, + pricePerGallon: 3.499, + totalCost: 36.74, + updatedAt: DateTime.utc(2026, 3, 5), + ); + + group('DatabaseService.saveFuelEntry overwrite semantics', () { + late Directory tempDir; + late DatabaseService database; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('edit_fuel_entry_test_'); + PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); + database = DatabaseService(); + await database.init(); + }); + + tearDown(() async { + await database.rawDb.close(); + await tempDir.delete(recursive: true); + }); + + test('saving again with the same id overwrites gallons/price/total/date, marking it dirty', + () async { + await database.saveVehicle(vehicle); + await database.saveFuelEntry(entry); + // Clear the dirty flag the way a completed sync would, so the + // overwrite below can prove it gets re-marked dirty. + await database.rawDb.update('fuel_entries', {'dirty': 0}, where: 'id = ?', whereArgs: ['e1']); + + await database.saveFuelEntry(FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 3, 6, 9, 0), + gallons: 11.2, + pricePerGallon: 3.599, + totalCost: 40.31, + updatedAt: DateTime.utc(2026, 3, 6), + )); + + final rows = await database.getFuelEntries(); + expect(rows, hasLength(1), reason: 'overwritten, not duplicated'); + final saved = rows.single; + expect(saved.gallons, 11.2); + expect(saved.pricePerGallon, 3.599); + expect(saved.totalCost, 40.31); + expect(saved.date, DateTime(2026, 3, 6, 9, 0)); + + final rawRow = (await database.rawDb.query('fuel_entries', where: 'id = ?', whereArgs: ['e1'])).single; + expect(rawRow['dirty'], 1); + }); + }); + + group('EditFuelEntryScreen', () { + Future pumpVehicleDetail(WidgetTester tester, AppState appState) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp(home: VehicleDetailScreen(vehicleId: vehicle.id)), + ), + ); + } + + testWidgets( + 'tapping an entry row opens its receipt detail page, and its edit icon opens ' + 'EditFuelEntryScreen pre-filled', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await pumpVehicleDetail(tester, appState); + + await tester.tap(find.text('10.500 gal • \$36.74')); + await tester.pumpAndSettle(); + + expect(find.byType(ReceiptDetailScreen), findsOneWidget); + + await tester.tap(find.byTooltip('Edit fuel entry')); + await tester.pumpAndSettle(); + + expect(find.byType(EditFuelEntryScreen), findsOneWidget); + expect(find.widgetWithText(TextFormField, '10.500'), findsOneWidget); + expect(find.widgetWithText(TextFormField, '3.499'), findsOneWidget); + expect(find.widgetWithText(TextFormField, '36.74'), findsOneWidget); + }); + + testWidgets('clearing a required field shows a validation error instead of saving', + (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp(home: EditFuelEntryScreen(entry: entry)), + ), + ); + + await tester.enterText(find.widgetWithText(TextFormField, '10.500'), ''); + await tester.drag(find.byType(ListView), const Offset(0, -300)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save Changes')); + await tester.pumpAndSettle(); + + expect(find.text('Required'), findsOneWidget); + expect(find.byType(EditFuelEntryScreen), findsOneWidget, reason: 'did not navigate away'); + }); + }); +} + +class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { + final String tempPath; + _FakePathProviderPlatform(this.tempPath); + + @override + Future getApplicationDocumentsPath() async => tempPath; + + @override + Future getTemporaryPath() async => tempPath; +} diff --git a/test/fuel_report_images_test.dart b/test/fuel_report_images_test.dart new file mode 100644 index 0000000..3328a4c --- /dev/null +++ b/test/fuel_report_images_test.dart @@ -0,0 +1,201 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart'; +import 'package:fuel_tax_tracker/services/fuel_report.dart'; +import 'package:fuel_tax_tracker/services/fuel_report_images.dart'; + +void main() { + final vehicle = Vehicle( + id: 'v1', + vin: '1FMPU18L1TLB51349', + updatedAt: DateTime.utc(2026, 1, 1), + ); + + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('fuel_report_images_test_'); + }); + + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + FuelReport reportWith(List entries) => FuelReport( + startDate: DateTime(2026, 1, 1), + endDate: DateTime(2026, 12, 31), + rows: [ + VehicleReportRow( + vehicle: vehicle, + entries: entries, + totalGallons: entries.fold(0.0, (sum, e) => sum + e.gallons), + totalCost: entries.fold(0.0, (sum, e) => sum + e.totalCost), + ), + ], + ); + + FuelEntry entry({ + required String id, + String? receiptImagePath, + String? receiptDriveFileId, + }) => + FuelEntry( + id: id, + vehicleId: 'v1', + date: DateTime(2026, 3, 1), + gallons: 10, + pricePerGallon: 3.5, + totalCost: 35, + updatedAt: DateTime.utc(2026, 3, 1), + receiptImagePath: receiptImagePath, + receiptDriveFileId: receiptDriveFileId, + ); + + test('reads bytes from a local file when one exists', () async { + final file = File('${tempDir.path}/a.jpg')..writeAsBytesSync([1, 2, 3]); + final report = reportWith([entry(id: 'e1', receiptImagePath: file.path)]); + + final result = await loadReceiptImageBytes(report: report, activeProvider: null); + + expect(result.bytesByEntryId['e1'], [1, 2, 3]); + expect(result.failedEntries, isEmpty); + }); + + test('falls back to downloading via the cloud provider when there is no local file', () async { + final report = reportWith([entry(id: 'e1', receiptDriveFileId: 'remote-1')]); + final provider = _FakeProvider(_FakeSession(bytesByFileId: {'remote-1': [4, 5, 6]})); + + final result = await loadReceiptImageBytes(report: report, activeProvider: provider); + + expect(result.bytesByEntryId['e1'], [4, 5, 6]); + expect(result.failedEntries, isEmpty); + }); + + test('reports (rather than silently drops) an entry with neither a local file nor a cloud reference', + () async { + final report = reportWith([entry(id: 'e1')]); + + final result = await loadReceiptImageBytes(report: report, activeProvider: null); + + expect(result.bytesByEntryId.containsKey('e1'), isFalse); + expect(result.failedEntries.map((e) => e.id), ['e1']); + }); + + test('reports (rather than throws) when the download fails', () async { + final report = reportWith([entry(id: 'e1', receiptDriveFileId: 'missing')]); + final provider = _FakeProvider(_FakeSession(bytesByFileId: {})); + + final result = await loadReceiptImageBytes(report: report, activeProvider: provider); + + expect(result.bytesByEntryId.containsKey('e1'), isFalse); + expect(result.failedEntries.map((e) => e.id), ['e1']); + }); + + test('reports a cloud-only entry when there is no signed-in provider', () async { + final report = reportWith([entry(id: 'e1', receiptDriveFileId: 'remote-1')]); + + final result = await loadReceiptImageBytes(report: report, activeProvider: null); + + expect(result.bytesByEntryId.containsKey('e1'), isFalse); + expect(result.failedEntries.map((e) => e.id), ['e1']); + }); + + test('a receipt uploaded via a different provider than the one currently connected fails cleanly', + () async { + // Mirrors the real scenario this was built for: a WebDAV-style path + // stored as receiptDriveFileId, but the currently active provider is + // something else entirely (here, the fake) — the ID is meaningless to + // it and the download call throws, same as any other failed download. + final report = reportWith([ + entry( + id: 'e1', + receiptDriveFileId: + '/remote.php/dav/files/user/Show Me The Fuel Refund/receipts/VIN/e1.jpg', + ), + ]); + final provider = _FakeProvider(_FakeSession(bytesByFileId: {})); + + final result = await loadReceiptImageBytes(report: report, activeProvider: provider); + + expect(result.bytesByEntryId.containsKey('e1'), isFalse); + expect(result.failedEntries.map((e) => e.id), ['e1']); + }); + + test('mixed success and failure across multiple entries', () async { + final file = File('${tempDir.path}/a.jpg')..writeAsBytesSync([9]); + final report = reportWith([ + entry(id: 'local', receiptImagePath: file.path), + entry(id: 'cloud-ok', receiptDriveFileId: 'remote-1'), + entry(id: 'cloud-missing', receiptDriveFileId: 'nope'), + ]); + final provider = _FakeProvider(_FakeSession(bytesByFileId: {'remote-1': [1]})); + + final result = await loadReceiptImageBytes(report: report, activeProvider: provider); + + expect(result.bytesByEntryId.keys, {'local', 'cloud-ok'}); + expect(result.failedEntries.map((e) => e.id), ['cloud-missing']); + }); +} + +class _FakeSession implements CloudStorageSession { + final Map> bytesByFileId; + _FakeSession({required this.bytesByFileId}); + + @override + Future> downloadFileBytes(String fileId) async { + final bytes = bytesByFileId[fileId]; + if (bytes == null) throw StateError('not found: $fileId'); + return bytes; + } + + @override + bool get supportsSharedWithMe => false; + @override + Future> listFolders({String? parentId, bool sharedWithMe = false}) async => []; + @override + Future findOrCreateFolder({required String parentId, required String name}) async => ''; + @override + Future findFile({required String folderId, required String name}) async => null; + @override + Future uploadFile({ + required String folderId, + required String name, + String? existingFileId, + required File localFile, + required String contentType, + }) async => + CloudFileInfo(id: 'x', versionTag: null); + @override + Future deleteFile(String fileId) async {} + @override + Future createLockFile({required String folderId, required String name}) async => 'lock'; + @override + Future> listLockFiles(String folderId) async => []; + @override + void close() {} +} + +class _FakeProvider implements CloudStorageProvider { + final _FakeSession session; + _FakeProvider(this.session); + + @override + CloudProviderId get id => CloudProviderId.googleDrive; + @override + String get displayName => 'Fake Provider'; + @override + bool get isSignedIn => true; + @override + String? get accountLabel => 'tester@example.com'; + @override + Future attemptSilentSignIn() async => true; + @override + Future signIn() async => accountLabel!; + @override + Future signOut() async {} + @override + CloudStorageSession beginSession() => session; +} diff --git a/test/fuel_report_pdf_test.dart b/test/fuel_report_pdf_test.dart new file mode 100644 index 0000000..68a4c49 --- /dev/null +++ b/test/fuel_report_pdf_test.dart @@ -0,0 +1,158 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/services/fuel_report.dart'; +import 'package:fuel_tax_tracker/services/fuel_report_pdf.dart'; + +/// PDF content isn't practically assertable byte-for-byte, so this is a +/// smoke test: every include-images/include-line-items combination should +/// render without throwing and produce a well-formed (non-trivial, valid +/// header) document — the real coverage of what each section *contains* +/// lives in fuel_report_test.dart (data) and is exercised by hand. +void main() { + final vehicle = Vehicle( + id: 'v1', + vin: '1FMPU18L1TLB51349', + nickname: 'Truck', + updatedAt: DateTime.utc(2026, 1, 1), + ); + + final entryA = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 3, 5), + gallons: 10, + pricePerGallon: 3.5, + totalCost: 35, + updatedAt: DateTime.utc(2026, 3, 5), + receiptImagePath: '/local/a.jpg', + ); + final entryB = FuelEntry( + id: 'e2', + vehicleId: 'v1', + date: DateTime(2026, 3, 12), + gallons: 12, + pricePerGallon: 3.6, + totalCost: 43.2, + updatedAt: DateTime.utc(2026, 3, 12), + receiptDriveFileId: 'remote-1', + ); + + final report = FuelReport( + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + rows: [ + VehicleReportRow( + vehicle: vehicle, + entries: [entryA, entryB], + totalGallons: 22, + totalCost: 78.2, + ), + ], + ); + + // A 1x1 red pixel PNG — enough for the pdf package to embed as an image. + final onePixelPng = Uint8List.fromList([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, + 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, + 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xDD, 0x8D, 0xB0, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + ]); + + Future expectValidPdf(Uint8List bytes) async { + expect(bytes.length, greaterThan(100)); + expect(String.fromCharCodes(bytes.take(5)), '%PDF-'); + } + + test('summary only (both toggles off)', () async { + final bytes = await buildFuelReportPdf(report); + await expectValidPdf(bytes); + }); + + test('with line items, no images', () async { + final bytes = await buildFuelReportPdf(report, includeLineItems: true); + await expectValidPdf(bytes); + }); + + test('with images, no line items', () async { + final bytes = await buildFuelReportPdf( + report, + includeReceiptImages: true, + receiptImages: {'e1': onePixelPng, 'e2': onePixelPng}, + ); + await expectValidPdf(bytes); + }); + + test('with both — line items and images each get their own section, cross-referenced by number', + () async { + final bytes = await buildFuelReportPdf( + report, + includeLineItems: true, + includeReceiptImages: true, + receiptImages: {'e1': onePixelPng, 'e2': onePixelPng}, + ); + await expectValidPdf(bytes); + }); + + test('a report with enough entries to span multiple pages does not throw', () async { + // Regression test: the page banner/watermark used to be drawn by + // wrapping the *entire*, unbounded-height body in a Stack, which + // doesn't paginate — MultiPage threw "Widget won't fit into the page" + // for anything long enough to need a page break. Enough months/entries + // here to force at least one break. + final vehicle2 = Vehicle(id: 'v2', vin: '1GNWK5EG4DR302326', updatedAt: DateTime.utc(2026, 1, 1)); + final manyEntries = []; + for (var month = 1; month <= 12; month++) { + for (var day = 2; day <= 26; day += 6) { + final id = 'm$month-d$day'; + manyEntries.add(FuelEntry( + id: id, + vehicleId: month.isEven ? 'v1' : 'v2', + date: DateTime(2026, month, day), + gallons: 20, + pricePerGallon: 3.5, + totalCost: 70, + updatedAt: DateTime.utc(2026, month, day), + receiptDriveFileId: 'remote-$id', + )); + } + } + final bigReport = buildFuelReport( + vehicles: [vehicle, vehicle2], + fuelEntries: manyEntries, + startDate: DateTime(2026, 1, 1), + endDate: DateTime(2026, 12, 31), + ); + + final bytes = await buildFuelReportPdf( + bigReport, + includeLineItems: true, + includeReceiptImages: true, + receiptImages: {for (final e in manyEntries) e.id: onePixelPng}, + ); + await expectValidPdf(bytes); + }); + + test('receiptImages missing an entry does not crash — that photo is just skipped', () async { + final bytes = await buildFuelReportPdf( + report, + includeReceiptImages: true, + receiptImages: {'e1': onePixelPng}, // e2 deliberately missing + ); + await expectValidPdf(bytes); + }); + + test('empty report (no rows) with all sections on', () async { + final empty = FuelReport(startDate: DateTime(2026, 1, 1), endDate: DateTime(2026, 1, 31), rows: []); + final bytes = await buildFuelReportPdf(empty, includeLineItems: true, includeReceiptImages: true); + await expectValidPdf(bytes); + }); + + test('fuelReportFileName formats the date range', () { + expect(fuelReportFileName(report), 'fuel-report-20260301-20260331.pdf'); + }); +} diff --git a/test/fuel_report_test.dart b/test/fuel_report_test.dart index 6ffca48..d73df3d 100644 --- a/test/fuel_report_test.dart +++ b/test/fuel_report_test.dart @@ -181,4 +181,101 @@ void main() { expect(report.totalGallons, 18); expect(report.totalCost, 63); }); + + test('a row\'s entries are sorted oldest-first, regardless of insertion order', () { + final report = buildFuelReport( + vehicles: [truck], + fuelEntries: [ + entry(id: 'newest', vehicleId: 'v1', date: DateTime(2026, 3, 20), receiptImagePath: '/c.jpg'), + entry(id: 'oldest', vehicleId: 'v1', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'), + entry(id: 'middle', vehicleId: 'v1', date: DateTime(2026, 3, 12), receiptImagePath: '/b.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.rows.single.entries.map((e) => e.id), ['oldest', 'middle', 'newest']); + }); + + test('fillCount and avgPricePerGallon are derived from the totals across all rows', () { + final report = buildFuelReport( + vehicles: [truck, car], + fuelEntries: [ + entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 5), gallons: 10, totalCost: 40, receiptImagePath: '/a.jpg'), + entry(id: 'e2', vehicleId: 'v2', date: DateTime(2026, 3, 6), gallons: 10, totalCost: 30, receiptImagePath: '/b.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.fillCount, 2); + expect(report.avgPricePerGallon, 3.5); // $70 / 20 gal + }); + + test('avgPricePerGallon is 0, not NaN/infinity, for an empty report', () { + final report = buildFuelReport( + vehicles: [truck], + fuelEntries: const [], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + expect(report.avgPricePerGallon, 0); + }); + + test('allEntries pairs each entry with its vehicle and sorts oldest-first across vehicles', () { + final report = buildFuelReport( + vehicles: [truck, car], + fuelEntries: [ + entry(id: 'truck-later', vehicleId: 'v1', date: DateTime(2026, 3, 20), receiptImagePath: '/a.jpg'), + entry(id: 'car-earlier', vehicleId: 'v2', date: DateTime(2026, 3, 5), receiptImagePath: '/b.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 3, 31), + ); + + final all = report.allEntries; + expect(all.map((re) => re.entry.id), ['car-earlier', 'truck-later']); + expect(all[0].vehicle.id, 'v2'); + expect(all[1].vehicle.id, 'v1'); + }); + + test('monthlyTotals buckets entries by calendar month across vehicles, oldest month first', () { + final report = buildFuelReport( + vehicles: [truck, car], + fuelEntries: [ + entry(id: 'mar-1', vehicleId: 'v1', date: DateTime(2026, 3, 5), gallons: 10, totalCost: 35, receiptImagePath: '/a.jpg'), + entry(id: 'mar-2', vehicleId: 'v2', date: DateTime(2026, 3, 20), gallons: 10, totalCost: 35, receiptImagePath: '/b.jpg'), + entry(id: 'apr-1', vehicleId: 'v1', date: DateTime(2026, 4, 2), gallons: 10, totalCost: 40, receiptImagePath: '/c.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 4, 30), + ); + + final months = report.monthlyTotals; + expect(months, hasLength(2)); + expect(months[0].month, DateTime(2026, 3)); + expect(months[0].fillCount, 2); + expect(months[0].totalGallons, 20); + expect(months[0].totalCost, 70); + expect(months[0].avgPricePerGallon, 3.5); + expect(months[1].month, DateTime(2026, 4)); + expect(months[1].fillCount, 1); + }); + + test('entriesByMonth is the grouping monthlyTotals is built from', () { + final report = buildFuelReport( + vehicles: [truck], + fuelEntries: [ + entry(id: 'mar-1', vehicleId: 'v1', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'), + entry(id: 'apr-1', vehicleId: 'v1', date: DateTime(2026, 4, 2), receiptImagePath: '/b.jpg'), + ], + startDate: DateTime(2026, 3, 1), + endDate: DateTime(2026, 4, 30), + ); + + final byMonth = report.entriesByMonth; + expect(byMonth[DateTime(2026, 3)]!.map((re) => re.entry.id), ['mar-1']); + expect(byMonth[DateTime(2026, 4)]!.map((re) => re.entry.id), ['apr-1']); + }); } diff --git a/test/image_source_sheet_test.dart b/test/image_source_sheet_test.dart new file mode 100644 index 0000000..bf17367 --- /dev/null +++ b/test/image_source_sheet_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fuel_tax_tracker/widgets/image_source_sheet.dart'; + +/// Covers the optional [heading] and [onInfoTap] on [chooseImageSource]'s +/// bottom sheet — receipt capture passes "Receipt From" with no info +/// button (see receipt_capture.dart); VIN scanning passes "VIN From" plus +/// an info button that explains where to find one (see +/// add_edit_vehicle_screen.dart). +void main() { + Future pumpSheet( + WidgetTester tester, { + String? heading, + VoidCallback? onInfoTap, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => chooseImageSource(context, heading: heading, onInfoTap: onInfoTap), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + testWidgets('shows the heading above Take Photo / Choose from Gallery when provided', + (tester) async { + await pumpSheet(tester, heading: 'Receipt From'); + + expect(find.text('Receipt From'), findsOneWidget); + expect(find.text('Take Photo'), findsOneWidget); + expect(find.text('Choose from Gallery'), findsOneWidget); + }); + + testWidgets('shows no heading when none is provided', (tester) async { + await pumpSheet(tester); + + expect(find.text('Receipt From'), findsNothing); + expect(find.text('Take Photo'), findsOneWidget); + expect(find.text('Choose from Gallery'), findsOneWidget); + }); + + testWidgets('shows no info button when onInfoTap is omitted, even with a heading', + (tester) async { + await pumpSheet(tester, heading: 'Receipt From'); + + expect(find.byIcon(Icons.info_outline), findsNothing); + }); + + testWidgets('shows an info button next to the heading when onInfoTap is provided, and invokes it', + (tester) async { + var tapped = false; + await pumpSheet(tester, heading: 'VIN From', onInfoTap: () => tapped = true); + + expect(find.text('VIN From'), findsOneWidget); + expect(find.byIcon(Icons.info_outline), findsOneWidget); + + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + + expect(tapped, isTrue); + }); +} diff --git a/test/main_shell_test.dart b/test/main_shell_test.dart new file mode 100644 index 0000000..8edceae --- /dev/null +++ b/test/main_shell_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/screens/main_shell.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Confirms the bottom nav actually switches between the four tabs and +/// that each one builds without crashing against a freshly-constructed, +/// not-yet-synced [AppState] — the same "no cloud connected, no vehicles +/// yet" state a real fresh install starts in. +void main() { + testWidgets('bottom nav switches between all four tabs', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: MainShell()), + ), + ); + + // Receipts is the default tab. + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Vehicles')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Reports')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, 'Fuel Report'), findsOneWidget); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Settings')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, 'Settings'), findsOneWidget); + + await tester.tap(find.widgetWithText(NavigationDestination, 'Receipts')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + }); + + testWidgets('Receipts tab shows an empty state with no fuel entries yet', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: MainShell()), + ), + ); + + expect(find.textContaining('No fuel entries'), findsOneWidget); + }); + + testWidgets('a right-to-left swipe on the body advances to the next tab', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: MainShell()), + ), + ); + + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + + await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget); + + // And a left-to-right swipe goes back. + await tester.fling(find.byType(PageView), const Offset(400, 0), 1000); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget); + }); + + testWidgets( + "swiping away from Receipts and back preserves its selected date range " + '(AutomaticKeepAliveClientMixin keeping the tab alive across the PageView, ' + 'not just the old IndexedStack)', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: MainShell()), + ), + ); + + await tester.tap(find.text('Last 3 Months')); + await tester.pumpAndSettle(); + + // Swipe all the way to Settings and back. + await tester.tap(find.widgetWithText(NavigationDestination, 'Settings')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(NavigationDestination, 'Receipts')); + await tester.pumpAndSettle(); + + final selectedLabel = tester.widget(find.text('Last 3 Months')); + expect((selectedLabel.style?.fontWeight), FontWeight.w800, reason: 'still the active range'); + }); +} diff --git a/test/purge_data_test.dart b/test/purge_data_test.dart new file mode 100644 index 0000000..0a99422 --- /dev/null +++ b/test/purge_data_test.dart @@ -0,0 +1,174 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/services/database_service.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Both purge paths reuse the same soft-delete tombstones as a normal +/// per-row delete (see [DatabaseService.softDeleteVehicle]) rather than a +/// real SQL `DELETE`, specifically so the deletion is something a cloud +/// sync can push to other devices instead of the rows just getting +/// silently re-imported from the remote copy on the next sync. +void main() { + late Directory tempDir; + late DatabaseService database; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('purge_data_test_'); + PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); + database = DatabaseService(); + await database.init(); + }); + + tearDown(() async { + await database.rawDb.close(); + await tempDir.delete(recursive: true); + }); + + Vehicle vehicle({required String id, required String vin}) => Vehicle( + id: id, + vin: vin, + updatedAt: DateTime.utc(2026, 1, 1), + ); + + FuelEntry entry({ + required String id, + required String vehicleId, + required DateTime date, + String? receiptImagePath, + }) => + FuelEntry( + id: id, + vehicleId: vehicleId, + date: date, + gallons: 10, + pricePerGallon: 3.5, + totalCost: 35, + updatedAt: DateTime.utc(2026, 1, 1), + receiptImagePath: receiptImagePath, + ); + + group('purgeAllData', () { + test('soft-deletes every active vehicle and fuel entry, marking them dirty', () async { + await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1')); + await database.saveVehicle(vehicle(id: 'v2', vin: 'VIN2')); + await database.saveFuelEntry(entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 1))); + await database.saveFuelEntry(entry(id: 'e2', vehicleId: 'v2', date: DateTime(2026, 4, 1))); + + await database.purgeAllData(DateTime.utc(2026, 8, 13)); + + expect(await database.getVehicles(), isEmpty); + expect(await database.getFuelEntries(), isEmpty); + + final vehicleRows = await database.rawDb.query('vehicles'); + final entryRows = await database.rawDb.query('fuel_entries'); + expect(vehicleRows, hasLength(2), reason: 'tombstoned, not hard-deleted'); + expect(entryRows, hasLength(2)); + for (final row in [...vehicleRows, ...entryRows]) { + expect(row['deleted_at'], isNotNull); + expect(row['dirty'], 1); + } + }); + + test('empties the local receipts folder, including untracked files', () async { + final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]); + final storedPath = + await database.storeReceiptImage(source, 'e1', 'VIN1', DateTime(2026, 3, 1)); + // Simulate an orphaned file the DB never referenced. + final strayFile = File(p.join(database.receiptsDirectory.path, 'stray.jpg')) + ..writeAsBytesSync([9]); + + await database.purgeAllData(DateTime.utc(2026, 8, 13)); + + expect(await File(storedPath).exists(), isFalse); + expect(await strayFile.exists(), isFalse); + expect(await database.receiptsDirectory.exists(), isTrue, + reason: 'recreated empty, ready for future use'); + }); + + test('leaves already-deleted rows alone', () async { + await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1')); + await database.softDeleteVehicle('v1', DateTime.utc(2026, 1, 2)); + + await database.purgeAllData(DateTime.utc(2026, 8, 13)); + + final rows = await database.rawDb.query('vehicles', where: 'id = ?', whereArgs: ['v1']); + expect(rows.single['deleted_at'], DateTime.utc(2026, 1, 2).millisecondsSinceEpoch); + }); + }); + + group('purgeFuelEntriesInRange', () { + test('soft-deletes only entries dated within the inclusive range', () async { + await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1')); + await database.saveFuelEntry( + entry(id: 'before', vehicleId: 'v1', date: DateTime(2026, 2, 28)), + ); + await database.saveFuelEntry( + entry(id: 'start-boundary', vehicleId: 'v1', date: DateTime(2026, 3, 1)), + ); + await database.saveFuelEntry( + entry(id: 'inside', vehicleId: 'v1', date: DateTime(2026, 3, 15)), + ); + await database.saveFuelEntry( + entry(id: 'end-boundary', vehicleId: 'v1', date: DateTime(2026, 3, 31)), + ); + await database.saveFuelEntry( + entry(id: 'after', vehicleId: 'v1', date: DateTime(2026, 4, 1)), + ); + + await database.purgeFuelEntriesInRange( + DateTime(2026, 3, 1), + DateTime(2026, 3, 31), + DateTime.utc(2026, 8, 13), + ); + + final remaining = await database.getFuelEntries(); + expect(remaining.map((e) => e.id).toSet(), {'before', 'after'}); + expect(await database.getVehicles(), hasLength(1), reason: 'vehicles are untouched'); + }); + + test('returns local receipt paths for the caller to delete, without touching untracked files', + () async { + await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1')); + final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]); + final storedPath = + await database.storeReceiptImage(source, 'e1', 'VIN1', DateTime(2026, 3, 15)); + await database.saveFuelEntry( + entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 15), receiptImagePath: storedPath), + ); + + final paths = await database.purgeFuelEntriesInRange( + DateTime(2026, 3, 1), + DateTime(2026, 3, 31), + DateTime.utc(2026, 8, 13), + ); + + expect(paths, [storedPath]); + // purgeFuelEntriesInRange only reports paths — deleting the files + // themselves is the caller's job (AppState), mirroring + // softDeleteFuelEntriesForVehicle. + expect(await File(storedPath).exists(), isTrue); + }); + }); +} + +class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { + final String tempPath; + _FakePathProviderPlatform(this.tempPath); + + @override + Future getApplicationDocumentsPath() async => tempPath; + + @override + Future getTemporaryPath() async => tempPath; +} diff --git a/test/receipt_detail_screen_test.dart b/test/receipt_detail_screen_test.dart new file mode 100644 index 0000000..5e368fd --- /dev/null +++ b/test/receipt_detail_screen_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/screens/edit_fuel_entry_screen.dart'; +import 'package:fuel_tax_tracker/screens/receipt_detail_screen.dart'; +import 'package:fuel_tax_tracker/screens/receipt_image_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers [ReceiptDetailScreen] itself — the single place to view a +/// receipt's photo and logged data together, with edit/delete for the +/// entry. Deliberately never taps through to an actual delete: that would +/// call [AppState.deleteFuelEntry], which needs an initialized database not +/// set up here (see settings_purge_test.dart for the same convention). +void main() { + final vehicle = Vehicle(id: 'v1', vin: '1FMPU18L1TLB51349', nickname: 'Red Truck', updatedAt: DateTime.utc(2026, 1, 1)); + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 5, 14, 9, 30), + gallons: 10.523, + pricePerGallon: 3.499, + totalCost: 36.81, + updatedAt: DateTime.utc(2026, 5, 14), + receiptDriveFileId: 'remote-1', + ); + + Future pumpDetail(WidgetTester tester, AppState appState) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp(home: ReceiptDetailScreen(entryId: 'e1')), + ), + ); + } + + testWidgets('shows the vehicle, date, gallons, price, and total', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await pumpDetail(tester, appState); + + expect(find.text('Red Truck'), findsOneWidget); + expect(find.text('VIN: 1FMPU18L1TLB51349'), findsOneWidget); + expect(find.text('10.523 gal'), findsOneWidget); + expect(find.text('\$3.50'), findsOneWidget, reason: 'price per gallon'); + expect(find.text('\$36.81'), findsOneWidget, reason: 'total cost'); + }); + + testWidgets('shows a fallback instead of crashing when the entry no longer exists', + (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = []; + + await pumpDetail(tester, appState); + + expect(find.text('Receipt not found'), findsOneWidget); + }); + + testWidgets('the edit icon opens EditFuelEntryScreen pre-filled', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await pumpDetail(tester, appState); + + await tester.tap(find.byTooltip('Edit fuel entry')); + await tester.pumpAndSettle(); + + expect(find.byType(EditFuelEntryScreen), findsOneWidget); + expect(find.widgetWithText(TextFormField, '10.523'), findsOneWidget); + }); + + testWidgets('the delete icon shows a warning dialog; Cancel dismisses it without deleting', + (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await pumpDetail(tester, appState); + + await tester.tap(find.byTooltip('Delete fuel entry')); + await tester.pumpAndSettle(); + + expect(find.text('Delete fuel entry?'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Delete fuel entry?'), findsNothing); + expect(appState.fuelEntries, hasLength(1), reason: 'not deleted'); + }); + + testWidgets('tapping the photo opens the full-screen viewer', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await pumpDetail(tester, appState); + + await tester.tap(find.byType(GestureDetector).first); + await tester.pumpAndSettle(); + + expect(find.byType(ReceiptImageScreen), findsOneWidget); + }); +} diff --git a/test/receipts_screen_test.dart b/test/receipts_screen_test.dart new file mode 100644 index 0000000..cc650e4 --- /dev/null +++ b/test/receipts_screen_test.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/screens/receipt_detail_screen.dart'; +import 'package:fuel_tax_tracker/screens/receipts_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +void main() { + final vehicle = Vehicle( + id: 'v1', + vin: '1FMPU18L1TLB51349', + nickname: 'Red Truck', + updatedAt: DateTime.utc(2026, 1, 1), + ); + + testWidgets('shows an empty state with no fuel entries', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + expect(find.textContaining('No fuel entries'), findsOneWidget); + }); + + testWidgets('shows the hero total and a receipt row with date/amount/vehicle chip', + (tester) async { + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 5, 14), + gallons: 10.523, + pricePerGallon: 3.5, + totalCost: 32.48, + updatedAt: DateTime.utc(2026, 5, 14), + receiptDriveFileId: 'remote-1', + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + // Hero total sums totalCost across all entries — just the one here. + expect(find.text('\$32.48'), findsNWidgets(2)); // hero total + row amount + expect(find.text('Total Fuel Cost'), findsOneWidget, reason: 'no year prefix'); + expect(find.text('Total Gallons'), findsOneWidget); + expect(find.text('11'), findsOneWidget, reason: 'hero total gallons, rounded to whole gallons'); + expect(find.text('May 14, 2026'), findsOneWidget); + expect(find.text('10.523 gal'), findsOneWidget, reason: 'row subtitle'); + expect(find.text('Red Truck'), findsOneWidget); + }); + + testWidgets('tapping a receipted row opens the receipt detail page', (tester) async { + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 5, 14), + gallons: 10, + pricePerGallon: 3.5, + totalCost: 35, + updatedAt: DateTime.utc(2026, 5, 14), + receiptDriveFileId: 'remote-1', + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + await tester.tap(find.text('May 14, 2026')); + await tester.pumpAndSettle(); + + expect(find.byType(ReceiptDetailScreen), findsOneWidget); + }); + + testWidgets('tapping a row with no receipt photo still opens the detail page', (tester) async { + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 5, 14), + gallons: 10, + pricePerGallon: 3.5, + totalCost: 35, + updatedAt: DateTime.utc(2026, 5, 14), + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + await tester.tap(find.text('May 14, 2026')); + await tester.pumpAndSettle(); + + expect(find.byType(ReceiptDetailScreen), findsOneWidget, + reason: 'the data is still viewable/editable even without a photo'); + }); + + testWidgets('formats large gallon totals with thousands separators', (tester) async { + final entry = FuelEntry( + id: 'e1', + vehicleId: 'v1', + date: DateTime(2026, 8, 5), + gallons: 1234.5678, + pricePerGallon: 3.5, + totalCost: 4320.99, + updatedAt: DateTime.utc(2026, 8, 5), + receiptDriveFileId: 'remote-1', + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [entry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + expect(find.text('1,235'), findsOneWidget, reason: 'hero total gallons, rounded to whole gallons'); + expect(find.text('1,234.568 gal'), findsOneWidget, reason: 'row subtitle keeps full precision'); + }); + + testWidgets('date range selector filters totals and the list, and recolors the active label', + (tester) async { + // Aug 5 2026: within Current Month, Last 3 Months, and Year to Date. + final recentEntry = FuelEntry( + id: 'recent', + vehicleId: 'v1', + date: DateTime(2026, 8, 5), + gallons: 10, + pricePerGallon: 3, + totalCost: 30, + updatedAt: DateTime.utc(2026, 8, 5), + receiptDriveFileId: 'r1', + ); + // Jul 1 2026: within Last 3 Months and Year to Date, not Current Month. + final midEntry = FuelEntry( + id: 'mid', + vehicleId: 'v1', + date: DateTime(2026, 7, 1), + gallons: 20, + pricePerGallon: 3, + totalCost: 60, + updatedAt: DateTime.utc(2026, 7, 1), + receiptDriveFileId: 'r2', + ); + // Mar 1 2026: within Year to Date only. + final oldEntry = FuelEntry( + id: 'old', + vehicleId: 'v1', + date: DateTime(2026, 3, 1), + gallons: 40, + pricePerGallon: 3, + totalCost: 120, + updatedAt: DateTime.utc(2026, 3, 1), + receiptDriveFileId: 'r3', + ); + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicle] + ..fuelEntries = [recentEntry, midEntry, oldEntry]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + bool isBold(String label) => tester + .widgetList(find.text(label)) + .any((t) => t.style?.fontWeight == FontWeight.w800); + + // Defaults to Year to Date: all three visible. + expect(find.text('Aug 5, 2026'), findsOneWidget); + expect(find.text('Jul 1, 2026'), findsOneWidget); + expect(find.text('Mar 1, 2026'), findsOneWidget); + expect(isBold('Year to Date'), isTrue, reason: 'selected by default'); + + await tester.tap(find.text('Last 3 Months')); + await tester.pumpAndSettle(); + + expect(find.text('Aug 5, 2026'), findsOneWidget); + expect(find.text('Jul 1, 2026'), findsOneWidget); + expect(find.text('Mar 1, 2026'), findsNothing, reason: 'older than 3 months back'); + expect(isBold('Last 3 Months'), isTrue); + expect(isBold('Year to Date'), isFalse, reason: 'no longer selected'); + + await tester.tap(find.text('Current Month')); + await tester.pumpAndSettle(); + + expect(find.text('Aug 5, 2026'), findsOneWidget); + expect(find.text('Jul 1, 2026'), findsNothing, reason: 'not in the current month'); + expect(find.text('Mar 1, 2026'), findsNothing); + }); + + testWidgets( + 'tapping + with no vehicles opens vehicle creation and prompts to create one for the receipt', + (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReceiptsScreen()), + ), + ); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Add Vehicle'), findsOneWidget); + expect(find.text('Create a vehicle to attach receipt'), findsOneWidget); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(find.text('Create a vehicle to attach receipt'), findsNothing); + expect(find.widgetWithText(AppBar, 'Add Vehicle'), findsOneWidget, reason: 'still on the form'); + }); + + // Not covered here: that saving the new vehicle continues into receipt + // capture (rather than back to an empty Receipts tab). Exercising that + // needs AddEditVehicleScreen's real _save() to run, which touches a real + // database — and combining a real sqflite_common_ffi database with + // testWidgets (as opposed to a plain test()) reliably deadlocks in this + // project's test environment (confirmed directly: the identical + // `DatabaseService().init()` call returns immediately under test(), but + // times out under testWidgets()). Verified manually on-device instead. +} diff --git a/test/report_screen_test.dart b/test/report_screen_test.dart new file mode 100644 index 0000000..dadaf81 --- /dev/null +++ b/test/report_screen_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/screens/report_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +void main() { + testWidgets('shows both toggles, off by default, with the cross-reference hint only when both are on', + (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReportScreen()), + ), + ); + + expect(find.text('Include Receipt Images'), findsOneWidget); + expect(find.text('Include Receipt Line Items'), findsOneWidget); + expect( + tester.widgetList(find.byType(SwitchListTile)).map((s) => s.value), + [false, false], + ); + expect(find.textContaining('matching number'), findsNothing); + + await tester.tap(find.text('Include Receipt Images')); + await tester.pumpAndSettle(); + expect(find.textContaining('matching number'), findsNothing, reason: 'only one toggle is on'); + + await tester.tap(find.text('Include Receipt Line Items')); + await tester.pumpAndSettle(); + expect(find.textContaining('matching number'), findsOneWidget, reason: 'both toggles now on'); + }); + + testWidgets( + 'shows a link to the state refund claim form, regardless of whether a range is picked', + (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReportScreen()), + ), + ); + + expect(find.text('Need the refund claim form?'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('Get the Motor Fuel Refund Claim form'), + 200, + scrollable: find.byType(Scrollable), + ); + expect(find.text('Get the Motor Fuel Refund Claim form'), findsOneWidget); + }); +} diff --git a/test/report_vehicle_selection_test.dart b/test/report_vehicle_selection_test.dart new file mode 100644 index 0000000..372921e --- /dev/null +++ b/test/report_vehicle_selection_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/models/fuel_entry.dart'; +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/screens/report_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers the per-vehicle selection cards on the Reports tab: once a date +/// range is picked, every vehicle with a receipted entry in range starts +/// selected (colored like the "Share" button), tapping one to deselect it +/// drops it from the total (and, by extension, the generated report) and +/// reverts its coloring to a plain unselected card, and deselecting every +/// vehicle shows a distinct "nothing selected" message rather than "no +/// entries in range". There's deliberately no checkbox — selection state is +/// conveyed by color alone. +void main() { + final vehicleA = Vehicle(id: 'a', vin: 'VINAAAAAAAAAAAAAA', nickname: 'Truck', updatedAt: DateTime.utc(2026, 1, 1)); + final vehicleB = Vehicle(id: 'b', vin: 'VINBBBBBBBBBBBBBB', nickname: 'Van', updatedAt: DateTime.utc(2026, 1, 1)); + + // showDatePicker's initial date (when none is set yet) defaults to + // "now" — dating these entries to "now" too, rather than a fixed date, + // means just accepting that default on both pickers (below) already + // covers them, without needing to drive the calendar UI to a specific day. + final now = DateTime.now(); + + FuelEntry entryFor(String vehicleId, String id, double gallons, double cost) => FuelEntry( + id: id, + vehicleId: vehicleId, + date: now, + gallons: gallons, + pricePerGallon: cost / gallons, + totalCost: cost, + updatedAt: now.toUtc(), + receiptDriveFileId: 'remote-$id', + ); + + Future pickRange(WidgetTester tester) async { + await tester.tap(find.text('Start date')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('End date')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + } + + Future pumpReport(WidgetTester tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA, vehicleB] + ..fuelEntries = [ + entryFor('a', 'e1', 10, 35), + entryFor('b', 'e2', 20, 70), + ]; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: ReportScreen()), + ), + ); + return appState; + } + + Card cardFor(WidgetTester tester, String vehicleLabel) => tester.widget( + find.ancestor(of: find.text(vehicleLabel), matching: find.byType(Card)), + ); + + testWidgets('both vehicles start colored as selected once a range with entries for both is picked', + (tester) async { + await pumpReport(tester); + await pickRange(tester); + + final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme; + + expect(cardFor(tester, 'Truck').color, colorScheme.primary); + expect(cardFor(tester, 'Van').color, colorScheme.primary); + expect(find.text('30.000 gal · \$105.00'), findsOneWidget, reason: 'combined total'); + }); + + testWidgets('tapping a selected vehicle deselects it (reverts its coloring) and drops it from the total', + (tester) async { + await pumpReport(tester); + await pickRange(tester); + + final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme; + + await tester.tap(find.text('Truck')); + await tester.pumpAndSettle(); + + expect(find.text('20.000 gal · \$70.00'), findsOneWidget, reason: "only Van's total remains"); + expect(cardFor(tester, 'Truck').color, isNot(colorScheme.primary), reason: 'deselected'); + expect(cardFor(tester, 'Van').color, colorScheme.primary, reason: 'still selected'); + }); + + testWidgets('tapping a deselected vehicle again reselects it (restores its coloring)', + (tester) async { + await pumpReport(tester); + await pickRange(tester); + + final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme; + + await tester.tap(find.text('Truck')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Truck')); + await tester.pumpAndSettle(); + + expect(cardFor(tester, 'Truck').color, colorScheme.primary); + expect(find.text('30.000 gal · \$105.00'), findsOneWidget, reason: 'both counted again'); + }); + + testWidgets('deselecting every vehicle shows a distinct "nothing selected" message', + (tester) async { + await pumpReport(tester); + await pickRange(tester); + + await tester.tap(find.text('Truck')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Van')); + await tester.pumpAndSettle(); + + expect( + find.text('No vehicles selected — tap at least one above to build a report.'), + findsOneWidget, + ); + expect(find.text('Share'), findsNothing); + expect(find.text('Print'), findsNothing); + // The cards themselves stay visible so the user can tap one again. + expect(find.text('Truck'), findsOneWidget); + expect(find.text('Van'), findsOneWidget); + }); +} diff --git a/test/settings_purge_test.dart b/test/settings_purge_test.dart new file mode 100644 index 0000000..45f0a5f --- /dev/null +++ b/test/settings_purge_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:fuel_tax_tracker/screens/data_settings_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers the "Danger Zone" purge UI's warning-dialog gating, now folded +/// into the Settings > Data > Advanced section — the actual delete behavior +/// (soft-delete tombstones, dirty flags, receipt file cleanup) is covered +/// against a real database in purge_data_test.dart. This deliberately never +/// taps a dialog's destructive "Delete" button: doing so would call through +/// to [AppState.purgeAllData]/[AppState.purgeFuelEntriesInRange], which need +/// an initialized database — not set up here, matching every other Settings +/// widget test (see theme_toggle_test.dart) that stops short of a full +/// [AppState.init]. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future pumpSettings(WidgetTester tester, AppState appState) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: DataSettingsScreen()), + ), + ); + } + + // The purge controls sit inside the collapsed "Advanced" ExpansionTile, + // below the fold — every test needs to expand it and scroll it into view + // before tapping anything inside it. + Future scrollToDangerZone(WidgetTester tester) async { + await tester.scrollUntilVisible(find.text('Advanced'), 200, scrollable: find.byType(Scrollable)); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Danger Zone'), 200, scrollable: find.byType(Scrollable)); + await tester.pumpAndSettle(); + } + + testWidgets('Purge All Data shows a warning dialog; Cancel dismisses it without purging', + (tester) async { + final appState = AppState()..isLoading = false; + await pumpSettings(tester, appState); + await scrollToDangerZone(tester); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Purge All Data')); + await tester.pumpAndSettle(); + + expect(find.text('Purge all data?'), findsOneWidget); + expect(find.textContaining("can't be undone"), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Purge all data?'), findsNothing); + expect(appState.vehicles, isEmpty); + }); + + testWidgets('mentions the connected cloud provider in the warning when one is connected', + (tester) async { + final appState = AppState()..isLoading = false; + await pumpSettings(tester, appState); + await scrollToDangerZone(tester); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Purge All Data')); + await tester.pumpAndSettle(); + + expect(find.textContaining('connected to'), findsNothing, + reason: 'no cloud provider connected in this test'); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); + + testWidgets('Purge Range is disabled until both a start and end date are picked', + (tester) async { + final appState = AppState()..isLoading = false; + await pumpSettings(tester, appState); + await scrollToDangerZone(tester); + + final purgeRangeButton = + tester.widget(find.widgetWithText(OutlinedButton, 'Purge Range')); + expect(purgeRangeButton.onPressed, isNull); + }); + + testWidgets('picking a start and end date enables Purge Range, which shows a warning dialog', + (tester) async { + final appState = AppState()..isLoading = false; + await pumpSettings(tester, appState); + await scrollToDangerZone(tester); + + await tester.tap(find.text('Start date')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('End date')); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + final purgeRangeButton = + tester.widget(find.widgetWithText(OutlinedButton, 'Purge Range')); + expect(purgeRangeButton.onPressed, isNotNull); + + await tester.tap(find.widgetWithText(OutlinedButton, 'Purge Range')); + await tester.pumpAndSettle(); + + expect(find.text('Purge data in range?'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); +} diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart new file mode 100644 index 0000000..7d63c96 --- /dev/null +++ b/test/settings_screen_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/screens/data_settings_screen.dart'; +import 'package:fuel_tax_tracker/screens/settings_screen.dart'; +import 'package:fuel_tax_tracker/screens/ui_settings_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers the Settings landing page's menu wiring: it groups settings into +/// two submenus — [UiSettingsScreen] (appearance) and [DataSettingsScreen] +/// (cloud storage, photo syncing/quality, advanced/purge) — plus keeps a +/// read-only summary on the landing page itself. The submenu screens' +/// actual contents are covered by theme_toggle_test.dart and +/// settings_purge_test.dart respectively. +void main() { + Future pumpSettings(WidgetTester tester) async { + final appState = AppState()..isLoading = false; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: SettingsScreen()), + ), + ); + } + + testWidgets('shows UI and Data menu entries, plus the summary', (tester) async { + await pumpSettings(tester); + + expect(find.text('UI'), findsOneWidget); + expect(find.text('Data'), findsOneWidget); + expect(find.text('Summary'), findsOneWidget); + // None of Data's contents leaked onto the landing page. + expect(find.text('Cloud Storage'), findsNothing); + expect(find.text('Advanced'), findsNothing); + }); + + testWidgets('tapping UI opens UiSettingsScreen', (tester) async { + await pumpSettings(tester); + + await tester.tap(find.text('UI')); + await tester.pumpAndSettle(); + + expect(find.byType(UiSettingsScreen), findsOneWidget); + expect(find.text('Appearance'), findsOneWidget); + }); + + testWidgets('tapping Data opens DataSettingsScreen', (tester) async { + await pumpSettings(tester); + + await tester.tap(find.text('Data')); + await tester.pumpAndSettle(); + + expect(find.byType(DataSettingsScreen), findsOneWidget); + expect(find.text('Cloud Storage'), findsOneWidget); + await tester.scrollUntilVisible(find.text('Advanced'), 200, scrollable: find.byType(Scrollable)); + expect(find.text('Advanced'), findsOneWidget); + }); +} diff --git a/test/theme_toggle_test.dart b/test/theme_toggle_test.dart new file mode 100644 index 0000000..331551c --- /dev/null +++ b/test/theme_toggle_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:fuel_tax_tracker/screens/ui_settings_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers the light/dark/system theme toggle on the Settings > UI screen — +/// defaults to System, selecting a segment updates [AppState.themeMode] +/// and persists it to SharedPreferences (so it survives a cold start). +/// +/// Deliberately doesn't round-trip through a second [AppState.init] to +/// verify persistence — that pulls in unrelated subsystems (connectivity +/// listening, cloud-restore) with no test-environment platform mocks, +/// which hangs rather than failing fast. Reading the pref back directly +/// verifies the same thing without that baggage. +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('defaults to System and switches to Dark/Light on tap', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: UiSettingsScreen()), + ), + ); + + expect(appState.themeMode, ThemeMode.system); + + await tester.tap(find.text('Dark')); + await tester.pumpAndSettle(); + expect(appState.themeMode, ThemeMode.dark); + + await tester.tap(find.text('Light')); + await tester.pumpAndSettle(); + expect(appState.themeMode, ThemeMode.light); + }); + + testWidgets('persists the choice to SharedPreferences', (tester) async { + final appState = AppState()..isLoading = false; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: const MaterialApp(home: UiSettingsScreen()), + ), + ); + + await tester.tap(find.text('Dark')); + await tester.pumpAndSettle(); + + final prefs = await SharedPreferences.getInstance(); + // Mirrors the private `_prefsKeyThemeMode` key in app_state.dart — + // a black-box persistence check necessarily has to know the storage + // key, and this doubles as a guardrail against silently breaking + // already-persisted user settings by renaming it there. + expect(prefs.getString('theme_mode'), 'dark'); + }); +} diff --git a/test/vehicle_detail_screen_test.dart b/test/vehicle_detail_screen_test.dart new file mode 100644 index 0000000..779647f --- /dev/null +++ b/test/vehicle_detail_screen_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fuel_tax_tracker/models/vehicle.dart'; +import 'package:fuel_tax_tracker/screens/vehicle_detail_screen.dart'; +import 'package:fuel_tax_tracker/services/app_state.dart'; + +/// Covers the "modern navigation" swipe added to the single-vehicle +/// receipt page: it's a PageView over every vehicle, starting on whichever +/// one was tapped — sliding left/right moves to the next/previous vehicle, +/// same as swiping between MainShell's bottom-nav tabs. +void main() { + final vehicleA = Vehicle(id: 'a', vin: 'VINAAAAAAAAAAAAAA', nickname: 'Truck', updatedAt: DateTime.utc(2026, 1, 1)); + final vehicleB = Vehicle(id: 'b', vin: 'VINBBBBBBBBBBBBBB', nickname: 'Van', updatedAt: DateTime.utc(2026, 1, 1)); + final vehicleC = Vehicle(id: 'c', vin: 'VINCCCCCCCCCCCCCC', nickname: 'Sedan', updatedAt: DateTime.utc(2026, 1, 1)); + + Future pumpDetail(WidgetTester tester, AppState appState, String vehicleId) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp(home: VehicleDetailScreen(vehicleId: vehicleId)), + ), + ); + } + + testWidgets('starts on the requested vehicle, not necessarily the first in the list', + (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA, vehicleB, vehicleC]; + + await pumpDetail(tester, appState, 'b'); + + expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget); + }); + + testWidgets('a right-to-left swipe advances to the next vehicle', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA, vehicleB, vehicleC]; + + await pumpDetail(tester, appState, 'a'); + expect(find.widgetWithText(AppBar, 'Truck'), findsOneWidget); + + await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget); + }); + + testWidgets('a left-to-right swipe goes back to the previous vehicle', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA, vehicleB, vehicleC]; + + await pumpDetail(tester, appState, 'c'); + expect(find.widgetWithText(AppBar, 'Sedan'), findsOneWidget); + + await tester.fling(find.byType(PageView), const Offset(400, 0), 1000); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget); + }); + + testWidgets('swiping does not go past the last vehicle', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA, vehicleB]; + + await pumpDetail(tester, appState, 'b'); + + await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget, reason: 'stayed on the last page'); + }); + + testWidgets('a single vehicle has nothing to swipe to and just shows itself', (tester) async { + final appState = AppState() + ..isLoading = false + ..vehicles = [vehicleA]; + + await pumpDetail(tester, appState, 'a'); + + expect(find.widgetWithText(AppBar, 'Truck'), findsOneWidget); + }); +} diff --git a/test/vin_parser_test.dart b/test/vin_parser_test.dart index a91a426..ee327ad 100644 --- a/test/vin_parser_test.dart +++ b/test/vin_parser_test.dart @@ -26,10 +26,40 @@ void main() { expect(VinParser.parse('vin: 1hgcm82633a004352'), '1HGCM82633A004352'); }); - test('does not match a run containing I, O, or Q (never valid in a real VIN)', () { - // Same length (17) but contains an "O" partway through, so no - // 17-character eligible-charset run exists anywhere in it. - expect(VinParser.parse('1HGCM82633AOO4352'), isNull); + test('normalizes an O to 0 — real VINs never contain one, so it reads as a misread zero', () { + // Real OCR output from a Kia/Hyundai data plate photo: "0" (zero) + // came back as "O" (letter) — this is exactly why I/O/Q are excluded + // from real VINs in the first place (too easily confused with + // 1/0/9), which cuts both ways for OCR reading one back off a plate. + expect(VinParser.parse('V.I.N 5XYZKDAG2BGO50982 TYPE:MPY'), '5XYZKDAG2BG050982'); + }); + + test('normalizes an I to 1 the same way', () { + expect(VinParser.parse('VIN: 1HGCM82633AI04352'), '1HGCM82633A104352'); + }); + + test('does not match a run containing Q — unlike I/O, not a plausible misread digit', () { + expect(VinParser.parse('1HGCM82633AQ04352'), isNull); + }); + + test('retries with whitespace stripped when OCR splits the VIN across a stray space', () { + // Real OCR output from an etched-glass VIN photo: a space landed + // mid-VIN (likely two separate detected text regions on the curved + // surface), so no single unbroken 17-character run exists until the + // whitespace is collapsed. + expect(VinParser.parse('5XYZKD AG2BG050982'), '5XYZKDAG2BG050982'); + }); + + test('a whitespace-stripped retry is only a last resort, after a normal match is preferred', + () { + const text = ''' + PART NO 4F2CZ 58899BA12345 + VIN: 1HGCM82633A004352 + '''; + // The "VIN:"-labeled match (unaffected by the space in the unrelated + // part number above it) should win over anything the fallback might + // have found in the stripped text. + expect(VinParser.parse(text), '1HGCM82633A004352'); }); test('does not match a 16-character (too short) run', () { @@ -43,5 +73,34 @@ void main() { test('returns null when nothing matches', () { expect(VinParser.parse('THANK YOU FOR YOUR PURCHASE'), isNull); }); + + test('matches a "V.I.N" label (period between each letter) — common on compliance/data plates', + () { + // Real text from a Kia/Hyundai-built data plate: "V.I.N" rather than + // "VIN:" — a manufacturer plate styling the labeled pattern didn't + // originally account for, so this fell through to relying on OCR + // reading the bare 17-char run cleanly. + const text = ''' + THIS VEHICLE CONFORMS TO ALL APPLICABLE U.S.A. FEDERAL + MOTOR VEHICLE SAFETY AND THEFT PREVENTION STANDARDS + IN EFFECT ON THE DATE OF MANUFACTURE SHOWN ABOVE + V.I.N 5XYZKDAG2BG050982 TYPE:MPV + '''; + + expect(VinParser.parse(text), '5XYZKDAG2BG050982'); + }); + + test('matches "V.I.N." with a trailing period too', () { + expect(VinParser.parse('V.I.N. 5XYZKDAG2BG050982'), '5XYZKDAG2BG050982'); + }); + + test('a "V.I.N" labeled match is still preferred over another candidate elsewhere', () { + const text = ''' + PART NO 4F2CZ58899BA12345 + V.I.N 5XYZKDAG2BG050982 + '''; + + expect(VinParser.parse(text), '5XYZKDAG2BG050982'); + }); }); } diff --git a/test/widget_test.dart b/test/widget_test.dart index 25f65ba..1c2235d 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -17,8 +17,8 @@ void main() { ), ); - expect(find.text('My Vehicles'), findsOneWidget); - expect(find.text('Add Vehicle'), findsOneWidget); + expect(find.text('Vehicles'), findsOneWidget); + expect(find.byTooltip('Add Vehicle'), findsOneWidget); expect(find.textContaining('No vehicles yet'), findsOneWidget); }); }