From 73a7c93e646377a1e3d77ac32f8e13119f20a5c6 Mon Sep 17 00:00:00 2001 From: Courtney Arnold Date: Sat, 8 Aug 2026 14:59:18 -0500 Subject: [PATCH 1/3] Added Google Drive storage location --- README.md | 151 ++++++++---- android/app/src/main/AndroidManifest.xml | 5 + ios/Runner/Info.plist | 17 ++ lib/models/fuel_entry.dart | 39 ++++ lib/models/vehicle.dart | 8 + lib/screens/confirm_fuel_entry_screen.dart | 2 +- lib/screens/drive_folder_browser_screen.dart | 219 ++++++++++++++++++ lib/screens/receipt_image_screen.dart | 88 ++++++- lib/screens/settings_screen.dart | 144 ++++++++---- lib/screens/vehicle_detail_screen.dart | 51 +++-- lib/services/app_state.dart | 132 ++++++++++- lib/services/drive_auth_service.dart | 108 +++++++++ lib/services/drive_oauth_config.dart | 20 ++ lib/services/drive_service.dart | 229 +++++++++++++++++++ lib/services/drive_sync_service.dart | 204 +++++++++++++++++ lib/services/lock_coordinator.dart | 48 ++++ lib/services/merge_utils.dart | 23 ++ lib/services/storage_service.dart | 87 +++---- pubspec.lock | 106 +++++++-- pubspec.yaml | 27 +-- test/lock_coordinator_test.dart | 133 +++++++++++ test/merge_utils_test.dart | 54 +++++ 22 files changed, 1690 insertions(+), 205 deletions(-) create mode 100644 lib/screens/drive_folder_browser_screen.dart create mode 100644 lib/services/drive_auth_service.dart create mode 100644 lib/services/drive_oauth_config.dart create mode 100644 lib/services/drive_service.dart create mode 100644 lib/services/drive_sync_service.dart create mode 100644 lib/services/lock_coordinator.dart create mode 100644 lib/services/merge_utils.dart create mode 100644 test/lock_coordinator_test.dart create mode 100644 test/merge_utils_test.dart diff --git a/README.md b/README.md index f160e3b..f5ae7f6 100644 --- a/README.md +++ b/README.md @@ -3,37 +3,92 @@ A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device, you confirm or correct the numbers, and it's saved alongside the receipt -photo to a data file in a folder you choose. +photo. Vehicles and fuel entries live in a shared Google Drive folder, so +multiple people can log fuel against the same pool of vehicles from their +own phones; each device also keeps a local offline copy and syncs when it +can. ## Features - Manage a list of vehicles (make, model, color, license plate). - Capture a fuel receipt photo per vehicle and OCR it on-device with Google - ML Kit — no internet connection or API key required. + ML Kit — no internet connection or API key required for the OCR itself. - Review/edit the parsed gallons, price per gallon, and total cost before saving (OCR on printed receipts is usually good but not perfect). - Per-vehicle fuel log with running gallons total, tap-to-zoom receipt photos, and delete. -- Settings screen to choose where receipt photos and the data file are - stored (defaults to the app's own documents folder; existing data is - copied over when you change it). +- Connect Google Drive and pick any folder you have access to — including + one someone else created and shared with you — as the shared storage + location. The app looks for (or creates) a `MO-Fuel-Tax-Back` subfolder + there, so anyone pointed at the same shared parent converges on the same + data automatically. +- Works fully offline: writes always land locally first; a background sync + pushes changes to Drive and pulls others' changes down once online. +- Once a receipt photo is uploaded to Drive, the local copy is deleted; + viewing it later downloads it fresh from Drive on demand. ## Project layout ``` lib/ - models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization + models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization. + Both carry `updatedAt` (for merge conflict resolution); FuelEntry + carries either a local receiptImagePath (not yet uploaded) or a + receiptDriveFileId (uploaded, local copy removed). services/ - app_state.dart In-memory state + CRUD, backed by StorageService, exposed via Provider - storage_service.dart Owns the data.json file + receipts/ folder and the configurable save path - ocr_service.dart Thin wrapper around google_mlkit_text_recognition - receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text + app_state.dart In-memory state + CRUD, exposed via Provider. Stamps + updatedAt on mutations and triggers background sync. + storage_service.dart Local offline staging area: data.json + receipts/, + always at ApplicationDocumentsDirectory/FuelTaxTracker. + drive_auth_service.dart Google sign-in + builds an authenticated http.Client + for the Drive API. + drive_service.dart Raw Drive API calls: browse folders, find-or-create + the MO-Fuel-Tax-Back folder, upload/download files, + lock file operations. + drive_sync_service.dart Orchestrates one sync round: acquire the cross-device + lock, download + merge the remote data file, upload + pending receipt photos, write the merge back, release + the lock. + merge_utils.dart Pure by-ID union merge logic (independently unit-tested). + drive_oauth_config.dart Fill in your OAuth client IDs here — see setup below. + ocr_service.dart Thin wrapper around google_mlkit_text_recognition. + receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text. screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail, - confirm fuel entry, receipt viewer, settings) + confirm fuel entry, receipt viewer, settings, Drive folder browser). ``` -Data is stored as a single `fuel_tax_data.json` file plus a `receipts/` -subfolder of photos, both inside whatever directory Settings points at. +## Manual setup required (Google Cloud Console) + +This app needs OAuth credentials you create yourself — I can't provision +cloud resources on your behalf. In [Google Cloud Console](https://console.cloud.google.com/): + +1. Create/select a project, enable the **Google Drive API** (APIs & Services + → Library). +2. **OAuth consent screen**: set it to **External**, keep it in **Testing** + status, and add your Google account plus everyone else's you're sharing + with as **test users**. This avoids Google's formal verification review, + which the broad `drive` scope would otherwise require — fine for a known, + small group, not a public release. +3. Add scope `https://www.googleapis.com/auth/drive` to the consent screen + (shows as "restricted/sensitive" — expected, fine in Testing mode). +4. **Credentials → Create Credentials → OAuth client ID**, three times: + - **Android**: package name `com.courtneyarnold.fuel_tax_tracker` + the + SHA-1 of your debug keystore (`keytool -list -v -keystore + ~/.android/debug.keystore`, password `android`), and later your release + keystore's SHA-1 too. This client ID itself is never referenced in + code — it exists purely so Android's Credential Manager trusts this + specific signed app. + - **Web application**: no redirect URIs needed. Copy its **Client ID** — + this is what Android sign-in actually authenticates against (a + Credential Manager quirk: it needs a *web* client ID, passed as + `serverClientId`, even for a mobile app). + - **iOS**: bundle ID matching the Xcode project. Copy its **Client ID** + and note the reversed form (`com.googleusercontent.apps.<...>`). +5. Fill in `lib/services/drive_oauth_config.dart`: + - `androidServerClientId` ← the **Web application** client's ID. + - `iosClientId` ← the **iOS** client's ID. +6. Replace the placeholder in `ios/Runner/Info.plist`'s `CFBundleURLTypes` → + `CFBundleURLSchemes` with your iOS client's reversed ID. ## Running it @@ -51,43 +106,61 @@ flutter build ios --release # iOS (requires a full Xcode install + signing This was scaffolded and verified with Flutter 3.44.9. `flutter analyze` and `flutter test` are clean, and `flutter build apk --debug` has been confirmed -to produce a working APK. +to produce a working APK. The Drive sign-in/sync path needs the manual +OAuth setup above before it can be exercised end-to-end. ## Permissions - **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and `android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already set up for receipt capture. -- **Storage**: no explicit storage permission is declared. The default save - location is inside the app's own sandbox (no permission needed). If you - point Settings at a location outside the sandbox, the OS-native folder - picker (via `file_picker`) is what grants access — see the caveat below. +- **Network/Drive**: no Android manifest changes are needed for + `google_sign_in` when not using `google-services.json` — see the manual + setup section above instead. + +## How sync works + +Every local change (add/edit a vehicle, log a fuel entry) writes to the +local `fuel_tax_data.json` immediately, then triggers a best-effort +background sync — also triggered whenever connectivity comes back. Sync: + +1. Creates a lock file `{email}-{utcEpochMillis}.lock` in the shared Drive + folder, then waits until no *other* lock file older than its own remains + (polling every second, deleting any it finds older than 10 minutes as + orphaned/stale) — a simple ticket-based mutex using the Drive folder + itself as the coordination point, so two devices never overwrite each + other's edits to the shared data file mid-write. +2. Downloads the current remote data file and merges it with local changes: + vehicles and fuel entries are unioned by ID, with the newer `updatedAt` + winning when a record exists on both sides. +3. Uploads any locally-pending receipt photos, then deletes the local copy. +4. Writes the merged data back to Drive, then deletes its own lock file. ## Known caveats / things to revisit -- **`file_picker` is pinned to `10.3.10`**, not the latest release. Versions - 11.0.0–11.0.3 skip applying the Kotlin Gradle plugin when they detect AGP - 9+ (assuming AGP's built-in Kotlin support handles it), but that isn't - actually wired up for library modules in this Flutter/AGP combination yet, - so the plugin's own Kotlin sources never get compiled and the build fails - with `cannot find symbol: FilePickerPlugin`. 10.3.11 fixes that but is - retracted on pub.dev, hence 10.3.10. Worth revisiting this pin next time - you bump dependencies — check the package's CHANGELOG for when this is - properly resolved upstream. -- **iOS folder picking and app restarts**: `file_picker`'s directory picker - on iOS uses `UIDocumentPickerViewController`, which hands back a - security-scoped URL. This app does not currently persist a security-scoped - bookmark for that URL, so if you pick a folder outside the app's own - sandbox (e.g. an iCloud Drive folder) on iOS, continued write access after - an app restart is not guaranteed. Picking the default in-sandbox location, - or a folder on Android, does not have this limitation. If cross-restart - external storage on iOS matters for your use case, this needs a proper - bookmark implementation (`NSURL` bookmarkData + `startAccessingSecurityScopedResource`). +- **Deletions don't propagate through the merge.** If one device deletes a + vehicle/entry before another device has seen that deletion, the deleting + change can be resurrected by the other device's next sync. Fixing this + properly needs tombstones (tracking deleted-record IDs for a retention + window) — deferred for now; the by-ID union merge is intentionally simple + and expected to evolve. +- **Field-level conflicts aren't merged.** Two people editing the *same* + record at the same time: whole record, newer `updatedAt` wins — not a + field-by-field merge. +- **Lock acquisition has no hard timeout** beyond the 10-minute staleness + reap. Fine at the scale this is built for (a handful of people); could in + theory spin under many simultaneous contenders. +- **`google_sign_in` v7's Android path requires a *Web* OAuth client ID** + (`serverClientId`), not just the Android client's SHA-1 registration — + see the manual setup section. This is a quirk of the Credential + Manager-based implementation and easy to miss if you're used to older + `google_sign_in` versions. - **Receipt parsing is best-effort regex matching** on the OCR'd text (`lib/services/receipt_parser.dart`), tuned against common receipt phrasing ("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may parse partially or not at all — the confirm screen always lets you fill in or correct whatever wasn't found. -- No automated tests exercise the OCR or camera capture path itself (that - requires a real device/emulator with a camera); `receipt_parser_test.dart` - covers the parsing logic against fixed OCR text. +- No automated tests exercise the OCR, camera, or real Drive API calls + (those need a real device/emulator and live credentials); `receipt_parser_test.dart`, + `merge_utils_test.dart`, and `drive_lock_test.dart` cover the pure logic + pieces against fixed inputs. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ab01282..830a402 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,11 @@ + + + UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.TODO-REPLACE-WITH-REVERSED-CLIENT-ID + + + diff --git a/lib/models/fuel_entry.dart b/lib/models/fuel_entry.dart index 1dcf87f..580b80c 100644 --- a/lib/models/fuel_entry.dart +++ b/lib/models/fuel_entry.dart @@ -6,6 +6,8 @@ class FuelEntry { final double pricePerGallon; final double totalCost; final String? receiptImagePath; + final String? receiptDriveFileId; + final DateTime updatedAt; FuelEntry({ required this.id, @@ -14,9 +16,40 @@ class FuelEntry { required this.gallons, required this.pricePerGallon, required this.totalCost, + required this.updatedAt, this.receiptImagePath, + this.receiptDriveFileId, }); + /// True once the receipt photo has been uploaded to Drive and the local + /// copy removed. Viewing it then requires downloading it on demand. + bool get isReceiptUploadedToDrive => + receiptImagePath == null && receiptDriveFileId != null; + + /// True while a receipt photo exists only locally and still needs to be + /// uploaded next sync. + bool get hasPendingLocalReceipt => receiptImagePath != null; + + FuelEntry copyWith({ + String? receiptImagePath, + String? receiptDriveFileId, + DateTime? updatedAt, + bool clearReceiptImagePath = false, + }) { + return FuelEntry( + id: id, + vehicleId: vehicleId, + date: date, + gallons: gallons, + pricePerGallon: pricePerGallon, + totalCost: totalCost, + updatedAt: updatedAt ?? this.updatedAt, + receiptImagePath: + clearReceiptImagePath ? null : (receiptImagePath ?? this.receiptImagePath), + receiptDriveFileId: receiptDriveFileId ?? this.receiptDriveFileId, + ); + } + Map toJson() => { 'id': id, 'vehicleId': vehicleId, @@ -25,6 +58,8 @@ class FuelEntry { 'pricePerGallon': pricePerGallon, 'totalCost': totalCost, 'receiptImagePath': receiptImagePath, + 'receiptDriveFileId': receiptDriveFileId, + 'updatedAt': updatedAt.toIso8601String(), }; factory FuelEntry.fromJson(Map json) => FuelEntry( @@ -35,5 +70,9 @@ class FuelEntry { pricePerGallon: (json['pricePerGallon'] as num).toDouble(), totalCost: (json['totalCost'] as num).toDouble(), receiptImagePath: json['receiptImagePath'] as String?, + receiptDriveFileId: json['receiptDriveFileId'] as String?, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : DateTime.fromMillisecondsSinceEpoch(0), ); } diff --git a/lib/models/vehicle.dart b/lib/models/vehicle.dart index 42ea698..9c483e0 100644 --- a/lib/models/vehicle.dart +++ b/lib/models/vehicle.dart @@ -4,6 +4,7 @@ class Vehicle { final String model; final String color; final String licensePlate; + final DateTime updatedAt; Vehicle({ required this.id, @@ -11,6 +12,7 @@ class Vehicle { required this.model, required this.color, required this.licensePlate, + required this.updatedAt, }); String get displayName => '$color $make $model ($licensePlate)'; @@ -20,6 +22,7 @@ class Vehicle { String? model, String? color, String? licensePlate, + DateTime? updatedAt, }) { return Vehicle( id: id, @@ -27,6 +30,7 @@ class Vehicle { model: model ?? this.model, color: color ?? this.color, licensePlate: licensePlate ?? this.licensePlate, + updatedAt: updatedAt ?? this.updatedAt, ); } @@ -36,6 +40,7 @@ class Vehicle { 'model': model, 'color': color, 'licensePlate': licensePlate, + 'updatedAt': updatedAt.toIso8601String(), }; factory Vehicle.fromJson(Map json) => Vehicle( @@ -44,5 +49,8 @@ class Vehicle { model: json['model'] as String, color: json['color'] as String, licensePlate: json['licensePlate'] as String, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : DateTime.fromMillisecondsSinceEpoch(0), ); } diff --git a/lib/screens/confirm_fuel_entry_screen.dart b/lib/screens/confirm_fuel_entry_screen.dart index 554dc56..d5ca094 100644 --- a/lib/screens/confirm_fuel_entry_screen.dart +++ b/lib/screens/confirm_fuel_entry_screen.dart @@ -136,7 +136,7 @@ class _ConfirmFuelEntryScreenState extends State { GestureDetector( onTap: () => Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ReceiptImageScreen(imagePath: widget.imageFile.path), + builder: (_) => ReceiptImageScreen(localImagePath: widget.imageFile.path), ), ), child: ClipRRect( diff --git a/lib/screens/drive_folder_browser_screen.dart b/lib/screens/drive_folder_browser_screen.dart new file mode 100644 index 0000000..e0ec3e9 --- /dev/null +++ b/lib/screens/drive_folder_browser_screen.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; + +import '../services/app_state.dart'; +import '../services/drive_service.dart'; + +enum _BrowseRoot { myDrive, sharedWithMe } + +/// Lets the user navigate their Google Drive — either "My Drive" or +/// folders others have shared with them — and pick a parent location. +/// Confirming looks for (or creates) the `MO-Fuel-Tax-Back` folder under +/// that location, so two people pointing at the same shared parent +/// converge on the same app folder. +class DriveFolderBrowserScreen extends StatefulWidget { + const DriveFolderBrowserScreen({super.key}); + + @override + State createState() => _DriveFolderBrowserScreenState(); +} + +class _DriveFolderBrowserScreenState extends State { + late final http.Client _client; + late final DriveService _drive; + + _BrowseRoot _root = _BrowseRoot.myDrive; + final List _pathStack = []; + + List? _folders; + bool _loading = true; + String? _error; + bool _confirming = false; + + @override + void initState() { + super.initState(); + _client = context.read().driveAuth.authenticatedHttpClient(); + _drive = DriveService(_client); + _load(); + } + + @override + void dispose() { + _client.close(); + super.dispose(); + } + + String get _rootLabel => _root == _BrowseRoot.myDrive ? 'My Drive' : 'Shared with me'; + + /// The folder ID that "Use This Folder" would act on, or null if the + /// current view is a virtual listing (top-level "Shared with me") rather + /// than an actual folder. + String? get _currentFolderId { + if (_pathStack.isNotEmpty) return _pathStack.last.id; + if (_root == _BrowseRoot.myDrive) return 'root'; + return null; + } + + String get _breadcrumbPath => + ([_rootLabel] + _pathStack.map((f) => f.name).toList()).join(' / '); + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + + try { + List folders; + if (_pathStack.isNotEmpty) { + folders = await _drive.listFolders(parentId: _pathStack.last.id); + } else if (_root == _BrowseRoot.myDrive) { + folders = await _drive.listFolders(parentId: 'root'); + } else { + folders = await _drive.listFolders(sharedWithMe: true); + } + if (!mounted) return; + setState(() { + _folders = folders; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = 'Could not load folders: $e'; + _loading = false; + }); + } + } + + void _switchRoot(_BrowseRoot root) { + if (root == _root) return; + setState(() { + _root = root; + _pathStack.clear(); + }); + _load(); + } + + void _openFolder(DriveFolder folder) { + setState(() => _pathStack.add(folder)); + _load(); + } + + void _goToBreadcrumb(int index) { + // index == -1 means the root label itself. + setState(() { + if (index < 0) { + _pathStack.clear(); + } else { + _pathStack.removeRange(index + 1, _pathStack.length); + } + }); + _load(); + } + + Future _useThisFolder() async { + final parentId = _currentFolderId; + if (parentId == null) return; + + setState(() => _confirming = true); + try { + await context.read().chooseDriveFolder( + parentId: parentId, + breadcrumbPath: _breadcrumbPath, + ); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not use this folder: $e')), + ); + } + } finally { + if (mounted) setState(() => _confirming = false); + } + } + + @override + Widget build(BuildContext context) { + final canUseCurrentFolder = _currentFolderId != null; + + return Scaffold( + appBar: AppBar(title: const Text('Choose Drive Folder')), + body: Column( + children: [ + SegmentedButton<_BrowseRoot>( + segments: const [ + ButtonSegment(value: _BrowseRoot.myDrive, label: Text('My Drive')), + ButtonSegment(value: _BrowseRoot.sharedWithMe, label: Text('Shared with me')), + ], + selected: {_root}, + onSelectionChanged: (selection) => _switchRoot(selection.first), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + TextButton( + onPressed: () => _goToBreadcrumb(-1), + child: Text(_rootLabel), + ), + for (var i = 0; i < _pathStack.length; i++) ...[ + const Icon(Icons.chevron_right, size: 18), + TextButton( + onPressed: () => _goToBreadcrumb(i), + child: Text(_pathStack[i].name), + ), + ], + ], + ), + ), + ), + const Divider(height: 1), + Expanded(child: _buildBody()), + ], + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: (!canUseCurrentFolder || _confirming) ? null : _useThisFolder, + icon: _confirming + ? const SizedBox(height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.check), + label: Text('Use "$_breadcrumbPath"'), + ), + ), + ), + ); + } + + Widget _buildBody() { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(_error!))); + } + final folders = _folders ?? []; + if (folders.isEmpty) { + return const Center(child: Text('No folders here.')); + } + return ListView.builder( + itemCount: folders.length, + itemBuilder: (context, index) { + final folder = folders[index]; + return ListTile( + leading: const Icon(Icons.folder_outlined), + title: Text(folder.name), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openFolder(folder), + ); + }, + ); + } +} diff --git a/lib/screens/receipt_image_screen.dart b/lib/screens/receipt_image_screen.dart index 8a1408a..64cad93 100644 --- a/lib/screens/receipt_image_screen.dart +++ b/lib/screens/receipt_image_screen.dart @@ -1,12 +1,67 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; -/// Full-screen, pinch-zoomable view of a saved receipt photo. -class ReceiptImageScreen extends StatelessWidget { - final String imagePath; +import '../services/app_state.dart'; +import '../services/drive_service.dart'; - const ReceiptImageScreen({super.key, required this.imagePath}); +/// Full-screen, pinch-zoomable view of a receipt photo. Pass +/// [localImagePath] for a receipt still held locally, or [driveFileId] for +/// one already uploaded to Drive and removed locally — in which case it's +/// downloaded on demand (no local caching afterward). +class ReceiptImageScreen extends StatefulWidget { + final String? localImagePath; + final String? driveFileId; + + const ReceiptImageScreen({super.key, this.localImagePath, this.driveFileId}) + : assert(localImagePath != null || driveFileId != null, + 'Must provide either a local path or a Drive file ID'); + + @override + State createState() => _ReceiptImageScreenState(); +} + +class _ReceiptImageScreenState extends State { + Uint8List? _downloadedBytes; + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + if (widget.driveFileId != null) { + _download(); + } + } + + Future _download() async { + setState(() { + _loading = true; + _error = null; + }); + + final authService = context.read().driveAuth; + final client = authService.authenticatedHttpClient(); + try { + final drive = DriveService(client); + final bytes = await drive.downloadFileBytes(widget.driveFileId!); + if (!mounted) return; + setState(() { + _downloadedBytes = Uint8List.fromList(bytes); + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = 'Could not download receipt: $e'; + _loading = false; + }); + } finally { + client.close(); + } + } @override Widget build(BuildContext context) { @@ -17,11 +72,26 @@ class ReceiptImageScreen extends StatelessWidget { foregroundColor: Colors.white, title: const Text('Receipt'), ), - body: Center( - child: InteractiveViewer( - child: Image.file(File(imagePath)), - ), - ), + body: Center(child: _buildBody()), ); } + + Widget _buildBody() { + if (widget.localImagePath != null) { + return InteractiveViewer(child: Image.file(File(widget.localImagePath!))); + } + if (_loading) { + return const CircularProgressIndicator(color: Colors.white); + } + if (_error != null) { + return Padding( + padding: const EdgeInsets.all(24), + child: Text(_error!, style: const TextStyle(color: Colors.white)), + ); + } + if (_downloadedBytes != null) { + return InteractiveViewer(child: Image.memory(_downloadedBytes!)); + } + return const SizedBox.shrink(); + } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 43005b6..7a89e64 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,8 +1,9 @@ -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../services/app_state.dart'; +import 'drive_folder_browser_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -12,31 +13,50 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { - bool _changing = false; + bool _busy = false; String? _error; - Future _chooseFolder() async { - setState(() => _error = null); - - final selectedPath = await FilePicker.platform.getDirectoryPath( - dialogTitle: 'Choose a folder for receipts and data', - ); - if (selectedPath == null || !mounted) return; - - setState(() => _changing = true); + Future _connect() async { + setState(() { + _busy = true; + _error = null; + }); try { - await context.read().changeSaveDirectory(selectedPath); + await context.read().connectDrive(); } catch (e) { - setState(() => _error = 'Could not switch to that folder: $e'); + setState(() => _error = 'Could not sign in: $e'); } finally { - if (mounted) setState(() => _changing = false); + if (mounted) setState(() => _busy = false); + } + } + + Future _disconnect() async { + setState(() => _busy = true); + try { + await context.read().disconnectDrive(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + void _chooseFolder() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const DriveFolderBrowserScreen()), + ); + } + + 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(); - final saveDir = appState.storage.saveDirectory.path; return Scaffold( appBar: AppBar(title: const Text('Settings')), @@ -49,35 +69,79 @@ class _SettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Save Location', style: Theme.of(context).textTheme.titleMedium), + Text('Google Drive', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), - Text( - 'Receipt photos and the data file are stored here:', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 4), - SelectableText( - saveDir, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', + if (!appState.isDriveConnected) ...[ + Text( + 'Connect Google Drive 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), + ], + FilledButton.icon( + onPressed: _busy ? null : _connect, + icon: const Icon(Icons.login), + label: const Text('Connect Google Drive'), + ), + ] else ...[ + Text('Signed in as ${appState.driveAccountEmail}'), + const SizedBox(height: 4), + Text( + appState.driveFolderPath == null + ? 'No folder selected yet.' + : 'Folder: ${appState.driveFolderPath}', + 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.driveFolderPath == null + ? 'Choose Folder' + : 'Change Folder'), ), - ), - const SizedBox(height: 16), - if (_error != null) ...[ - Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + OutlinedButton.icon( + onPressed: (_busy || appState.driveFolderPath == 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), + ), + ), ], - FilledButton.icon( - onPressed: _changing ? null : _chooseFolder, - icon: _changing - ? const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.folder_open), - label: const Text('Choose Folder'), - ), ], ), ), diff --git a/lib/screens/vehicle_detail_screen.dart b/lib/screens/vehicle_detail_screen.dart index 84fe3c0..486df82 100644 --- a/lib/screens/vehicle_detail_screen.dart +++ b/lib/screens/vehicle_detail_screen.dart @@ -88,6 +88,38 @@ class VehicleDetailScreen extends StatelessWidget { } } + 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)); + } + @override Widget build(BuildContext context) { final appState = context.watch(); @@ -144,24 +176,7 @@ class VehicleDetailScreen extends StatelessWidget { return Card( clipBehavior: Clip.antiAlias, child: ListTile( - leading: entry.receiptImagePath != null - ? GestureDetector( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ReceiptImageScreen(imagePath: entry.receiptImagePath!), - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Image.file( - File(entry.receiptImagePath!), - width: 48, - height: 48, - fit: BoxFit.cover, - ), - ), - ) - : const CircleAvatar(child: Icon(Icons.receipt_long)), + 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)}', diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart index c353525..a45cf98 100644 --- a/lib/services/app_state.dart +++ b/lib/services/app_state.dart @@ -1,24 +1,46 @@ +import 'dart:async'; import 'dart:io'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; import '../models/fuel_entry.dart'; import '../models/vehicle.dart'; +import 'drive_auth_service.dart'; +import 'drive_sync_service.dart'; import 'storage_service.dart'; +const _prefsKeyDriveFolderId = 'drive_app_folder_id'; +const _prefsKeyDriveFolderPath = 'drive_app_folder_path'; + /// Single source of truth for the app's in-memory data (vehicles + fuel -/// entries), backed by [StorageService] for persistence. Screens read from -/// this via Provider and call its mutating methods, which take care of -/// writing through to disk and notifying listeners. +/// entries), backed by [StorageService] for local persistence and +/// [DriveSyncService] for pushing/pulling the shared Drive copy. Screens +/// read from this via Provider and call its mutating methods, which write +/// through to local disk immediately and kick off a best-effort background +/// sync to Drive. class AppState extends ChangeNotifier { final StorageService storage = StorageService(); + final DriveAuthService driveAuth = DriveAuthService(); + late final DriveSyncService driveSync = + DriveSyncService(authService: driveAuth, storageService: storage); final _uuid = const Uuid(); List vehicles = []; List fuelEntries = []; bool isLoading = true; + bool isDriveConnected = false; + String? driveAccountEmail; + String? driveFolderPath; + bool isSyncing = false; + DateTime? lastSyncedAt; + Object? lastSyncError; + + StreamSubscription>? _connectivitySubscription; + Future init() async { await storage.init(); final data = await storage.loadData(); @@ -26,6 +48,100 @@ class AppState extends ChangeNotifier { fuelEntries = data.entries; isLoading = false; notifyListeners(); + + _connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) { + if (results.any((r) => r != ConnectivityResult.none)) { + unawaited(syncNow()); + } + }); + + unawaited(_restoreDriveConnection()); + } + + @override + void dispose() { + _connectivitySubscription?.cancel(); + super.dispose(); + } + + Future _restoreDriveConnection() async { + final signedIn = await driveAuth.attemptSilentSignIn(); + if (!signedIn) return; + + isDriveConnected = true; + driveAccountEmail = driveAuth.currentAccountEmail; + + final prefs = await SharedPreferences.getInstance(); + final folderId = prefs.getString(_prefsKeyDriveFolderId); + driveFolderPath = prefs.getString(_prefsKeyDriveFolderPath); + if (folderId != null) { + driveSync.configure(folderId); + } + notifyListeners(); + + if (folderId != null) { + unawaited(syncNow()); + } + } + + Future connectDrive() async { + final email = await driveAuth.signIn(); + isDriveConnected = true; + driveAccountEmail = email; + notifyListeners(); + } + + Future disconnectDrive() async { + await driveAuth.signOut(); + driveSync.clearConfiguration(); + isDriveConnected = false; + driveAccountEmail = null; + driveFolderPath = null; + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefsKeyDriveFolderId); + await prefs.remove(_prefsKeyDriveFolderPath); + notifyListeners(); + } + + Future chooseDriveFolder({ + required String parentId, + required String breadcrumbPath, + }) async { + final folderId = await driveSync.selectAppFolder(parentId); + driveFolderPath = breadcrumbPath; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKeyDriveFolderId, folderId); + await prefs.setString(_prefsKeyDriveFolderPath, breadcrumbPath); + notifyListeners(); + + unawaited(syncNow()); + } + + Future syncNow() async { + if (isSyncing || !driveSync.isConfigured) return; + + isSyncing = true; + notifyListeners(); + + final result = await driveSync.syncNow( + localVehicles: vehicles, + localFuelEntries: fuelEntries, + ); + + if (result.ranSync) { + vehicles = result.vehicles!; + fuelEntries = result.fuelEntries!; + await storage.saveData(vehicles: vehicles, entries: fuelEntries); + lastSyncedAt = DateTime.now(); + lastSyncError = null; + } else if (result.error != null) { + lastSyncError = result.error; + } + + isSyncing = false; + notifyListeners(); } Future addVehicle({ @@ -40,6 +156,7 @@ class AppState extends ChangeNotifier { model: model, color: color, licensePlate: licensePlate, + updatedAt: DateTime.now().toUtc(), )); await _persist(); } @@ -47,7 +164,7 @@ class AppState extends ChangeNotifier { Future updateVehicle(Vehicle updated) async { final index = vehicles.indexWhere((v) => v.id == updated.id); if (index != -1) { - vehicles[index] = updated; + vehicles[index] = updated.copyWith(updatedAt: DateTime.now().toUtc()); await _persist(); } } @@ -83,6 +200,7 @@ class AppState extends ChangeNotifier { pricePerGallon: pricePerGallon, totalCost: totalCost, receiptImagePath: storedImagePath, + updatedAt: DateTime.now().toUtc(), ); fuelEntries.add(entry); await _persist(); @@ -117,13 +235,9 @@ class AppState extends ChangeNotifier { } } - Future changeSaveDirectory(String newPath) async { - await storage.setSaveDirectory(newPath); - notifyListeners(); - } - Future _persist() async { await storage.saveData(vehicles: vehicles, entries: fuelEntries); notifyListeners(); + unawaited(syncNow()); } } diff --git a/lib/services/drive_auth_service.dart b/lib/services/drive_auth_service.dart new file mode 100644 index 0000000..7bbf814 --- /dev/null +++ b/lib/services/drive_auth_service.dart @@ -0,0 +1,108 @@ +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:http/http.dart' as http; + +import 'drive_oauth_config.dart'; + +/// Full Drive access is required (not the narrower `drive.file` scope) +/// because users need to browse to and reuse folders that someone else +/// created and shared with them, not just folders/files this app itself +/// created. See the plan doc for the tradeoffs (this requires Google +/// Cloud Console "Testing" mode with explicit test users, to avoid needing +/// a full OAuth verification review). +const driveScopes = ['https://www.googleapis.com/auth/drive']; + +/// Thrown when a Drive API call needs authorization that isn't currently +/// available without prompting the user, e.g. during a background sync. +class DriveNotAuthorizedException implements Exception { + @override + String toString() => 'Drive access is not currently authorized.'; +} + +/// Wraps `google_sign_in` for authenticating with Google and producing an +/// authenticated [http.Client] for the Drive API. +class DriveAuthService { + bool _initialized = false; + GoogleSignInAccount? _account; + + bool get isSignedIn => _account != null; + + String? get currentAccountEmail => _account?.email; + + Future _ensureInitialized() async { + if (_initialized) return; + await GoogleSignIn.instance.initialize( + clientId: Platform.isIOS ? DriveOAuthConfig.iosClientId : null, + serverClientId: Platform.isAndroid ? DriveOAuthConfig.androidServerClientId : null, + ); + _initialized = true; + } + + /// Attempts to restore a previous sign-in without any UI. Returns true if + /// the user is signed in and Drive access is already authorized. + Future attemptSilentSignIn() async { + await _ensureInitialized(); + final account = await GoogleSignIn.instance.attemptLightweightAuthentication(); + _account = account; + if (account == null) return false; + + final authorization = + await account.authorizationClient.authorizationForScopes(driveScopes); + return authorization != null; + } + + /// Interactive sign-in + Drive scope authorization. Must be called from a + /// user-initiated action (e.g. a button press). + Future signIn() async { + await _ensureInitialized(); + final account = await GoogleSignIn.instance.authenticate(scopeHint: driveScopes); + _account = account; + await account.authorizationClient.authorizeScopes(driveScopes); + return account.email; + } + + Future signOut() async { + await GoogleSignIn.instance.signOut(); + _account = null; + } + + /// Builds an [http.Client] that attaches a fresh Drive authorization + /// header to every request. Fetches headers per-request (rather than + /// once) so a client that lives across a long sync doesn't use a stale, + /// expired token. Never prompts for UI — suitable for background sync — + /// so throws [DriveNotAuthorizedException] if authorization isn't already + /// in place (the caller should treat that as "Drive is disconnected"). + http.Client authenticatedHttpClient() { + final account = _account; + if (account == null) { + throw DriveNotAuthorizedException(); + } + return _DriveHttpClient(account.authorizationClient); + } +} + +class _DriveHttpClient extends http.BaseClient { + final GoogleSignInAuthorizationClient _authClient; + final http.Client _inner = http.Client(); + + _DriveHttpClient(this._authClient); + + @override + Future send(http.BaseRequest request) async { + final headers = + await _authClient.authorizationHeaders(driveScopes, promptIfNecessary: false); + if (headers == null) { + throw DriveNotAuthorizedException(); + } + request.headers.addAll(headers); + return _inner.send(request); + } + + @override + void close() { + _inner.close(); + super.close(); + } +} diff --git a/lib/services/drive_oauth_config.dart b/lib/services/drive_oauth_config.dart new file mode 100644 index 0000000..deff9c8 --- /dev/null +++ b/lib/services/drive_oauth_config.dart @@ -0,0 +1,20 @@ +/// Fill these in once the corresponding OAuth clients exist in Google Cloud +/// Console (see README.md "Manual setup required"). Both are needed even +/// though only one app runs on each platform: +/// +/// - Android sign-in (Credential Manager-based, as of google_sign_in v7) +/// authenticates using a *Web application* type OAuth client's ID, not the +/// Android client's own ID. The separate Android OAuth client (registered +/// with the package name + debug/release SHA-1) is still required, but +/// only to let Credential Manager verify this specific signed app — its +/// client ID itself is never referenced here. +/// - iOS uses its own iOS-type OAuth client ID directly. +class DriveOAuthConfig { + /// The Web application OAuth client ID. Required for sign-in to work on + /// Android. + static const String? androidServerClientId = null; // TODO: fill in + + /// The iOS OAuth client ID. Leave null if GIDClientID is instead set + /// directly in ios/Runner/Info.plist. + static const String? iosClientId = null; // TODO: fill in +} diff --git a/lib/services/drive_service.dart b/lib/services/drive_service.dart new file mode 100644 index 0000000..a81c222 --- /dev/null +++ b/lib/services/drive_service.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:googleapis/drive/v3.dart' as drive; +import 'package:http/http.dart' as http; + +const appFolderName = 'MO-Fuel-Tax-Back'; +const receiptsFolderName = 'receipts'; +const dataFileName = 'fuel_tax_data.json'; + +const _folderMimeType = 'application/vnd.google-apps.folder'; + +class DriveFolder { + final String id; + final String name; + + DriveFolder({required this.id, required this.name}); +} + +class DriveLockFile { + final String id; + final String username; + final DateTime createdAtUtc; + + DriveLockFile({required this.id, required this.username, required this.createdAtUtc}); +} + +/// Raw Google Drive API operations, built on top of an already-authenticated +/// [http.Client] (see [DriveAuthService.authenticatedHttpClient]). Callers +/// own the client's lifecycle (create it, use a [DriveService] instance for +/// the duration of one sync, then close it). +class DriveService { + final drive.DriveApi _api; + + DriveService(http.Client authenticatedClient) : _api = drive.DriveApi(authenticatedClient); + + /// Lists folders under [parentId], or (if [sharedWithMe] is true) the + /// top-level folders that other users have shared with the signed-in + /// account, regardless of parent. + Future> listFolders({String? parentId, bool sharedWithMe = false}) async { + final query = sharedWithMe + ? "sharedWithMe=true and mimeType='$_folderMimeType' and trashed=false" + : "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false"; + + final result = await _api.files.list( + q: query, + orderBy: 'name', + $fields: 'files(id,name)', + spaces: 'drive', + ); + + return (result.files ?? []) + .where((f) => f.id != null && f.name != null) + .map((f) => DriveFolder(id: f.id!, name: f.name!)) + .toList(); + } + + /// Finds a folder named [appFolderName] under [parentId], or creates one + /// if none exists. Multiple devices pointed at the same shared parent + /// converge on the same folder this way. If duplicates exist (Drive + /// allows same-named folders), the earliest-created one wins. + Future findOrCreateAppFolder(String parentId) { + return _findOrCreateFolder(name: appFolderName, parentId: parentId); + } + + Future findOrCreateReceiptsFolder(String appFolderId) { + return _findOrCreateFolder(name: receiptsFolderName, parentId: appFolderId); + } + + Future _findOrCreateFolder({required String name, required String parentId}) async { + final existing = await _api.files.list( + q: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false and name='$name'", + orderBy: 'createdTime', + $fields: 'files(id,name)', + spaces: 'drive', + ); + + final firstMatch = (existing.files ?? []).firstOrNullWithId(); + if (firstMatch != null) return firstMatch; + + final created = await _api.files.create( + drive.File() + ..name = name + ..mimeType = _folderMimeType + ..parents = [parentId], + ); + return created.id!; + } + + Future findDataFileId(String appFolderId) async { + final result = await _api.files.list( + q: "'$appFolderId' in parents and trashed=false and name='$dataFileName'", + orderBy: 'modifiedTime desc', + $fields: 'files(id,name)', + spaces: 'drive', + ); + return (result.files ?? []).firstOrNullWithId(); + } + + Future downloadTextFile(String fileId) async { + final media = await _api.files.get( + fileId, + downloadOptions: drive.DownloadOptions.fullMedia, + ) as drive.Media; + final bytes = await _collectBytes(media.stream); + return utf8.decode(bytes); + } + + Future> downloadFileBytes(String fileId) async { + final media = await _api.files.get( + fileId, + downloadOptions: drive.DownloadOptions.fullMedia, + ) as drive.Media; + return _collectBytes(media.stream); + } + + /// Creates the data file if [existingFileId] is null, otherwise + /// overwrites its content. Returns the (possibly new) file ID. + Future uploadDataFile({ + required String appFolderId, + required String? existingFileId, + required String jsonContent, + }) async { + final bytes = utf8.encode(jsonContent); + final media = drive.Media( + Stream.value(bytes), + bytes.length, + contentType: 'application/json', + ); + + if (existingFileId != null) { + final updated = await _api.files.update( + drive.File(), + existingFileId, + uploadMedia: media, + ); + return updated.id ?? existingFileId; + } + + final created = await _api.files.create( + drive.File() + ..name = dataFileName + ..parents = [appFolderId], + uploadMedia: media, + ); + return created.id!; + } + + Future uploadReceiptImage({ + required String receiptsFolderId, + required String fileName, + required File imageFile, + }) async { + final length = await imageFile.length(); + final media = drive.Media( + imageFile.openRead(), + length, + contentType: 'image/jpeg', + ); + final created = await _api.files.create( + drive.File() + ..name = fileName + ..parents = [receiptsFolderId], + uploadMedia: media, + ); + return created.id!; + } + + Future deleteFile(String fileId) async { + try { + await _api.files.delete(fileId); + } on drive.DetailedApiRequestError catch (e) { + // Already gone (e.g. deleted by another device) — not an error for + // our purposes. + if (e.status != 404) rethrow; + } + } + + Future createLockFile({required String appFolderId, required String name}) async { + final created = await _api.files.create( + drive.File() + ..name = name + ..parents = [appFolderId], + uploadMedia: drive.Media(Stream.value(const []), 0, contentType: 'text/plain'), + ); + return created.id!; + } + + Future> listLockFiles(String appFolderId) async { + final result = await _api.files.list( + q: "'$appFolderId' in parents and trashed=false and name contains '.lock'", + $fields: 'files(id,name)', + spaces: 'drive', + ); + + final locks = []; + for (final f in result.files ?? []) { + final parsed = _parseLockFileName(f.name); + if (f.id != null && parsed != null) { + locks.add(DriveLockFile(id: f.id!, username: parsed.$1, createdAtUtc: parsed.$2)); + } + } + return locks; + } + + static (String, DateTime)? _parseLockFileName(String? name) { + if (name == null || !name.endsWith('.lock')) return null; + final withoutExt = name.substring(0, name.length - '.lock'.length); + final lastDash = withoutExt.lastIndexOf('-'); + if (lastDash == -1) return null; + final username = withoutExt.substring(0, lastDash); + final epochStr = withoutExt.substring(lastDash + 1); + final epoch = int.tryParse(epochStr); + if (epoch == null) return null; + return (username, DateTime.fromMillisecondsSinceEpoch(epoch, isUtc: true)); + } + + Future> _collectBytes(Stream> stream) async { + final bytes = []; + await for (final chunk in stream) { + bytes.addAll(chunk); + } + return bytes; + } +} + +extension _FirstMatchExtension on List { + String? firstOrNullWithId() => isEmpty ? null : first.id; +} diff --git a/lib/services/drive_sync_service.dart b/lib/services/drive_sync_service.dart new file mode 100644 index 0000000..e2958e6 --- /dev/null +++ b/lib/services/drive_sync_service.dart @@ -0,0 +1,204 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; + +import '../models/fuel_entry.dart'; +import '../models/vehicle.dart'; +import 'drive_auth_service.dart'; +import 'drive_service.dart'; +import 'lock_coordinator.dart' as lock; +import 'merge_utils.dart'; +import 'storage_service.dart'; + +class SyncResult { + final bool ranSync; + final List? vehicles; + final List? fuelEntries; + final Object? error; + + SyncResult.skipped() + : ranSync = false, + vehicles = null, + fuelEntries = null, + error = null; + + SyncResult.success({required this.vehicles, required this.fuelEntries}) + : ranSync = true, + error = null; + + SyncResult.failure(this.error) + : ranSync = false, + vehicles = null, + fuelEntries = null; +} + +/// Orchestrates one round of push-local/pull-remote sync against the shared +/// Drive folder: acquires the cross-device file lock, downloads and merges +/// the remote data file with local changes, uploads any pending receipt +/// photos, writes the merged data file back, then releases the lock. +class DriveSyncService { + final DriveAuthService authService; + final StorageService storageService; + + String? _appFolderId; + String? _receiptsFolderId; + String? _dataFileId; + + DriveSyncService({required this.authService, required this.storageService}); + + bool get isConfigured => _appFolderId != null; + + /// Call once a Drive app folder has been chosen (or restored at launch). + void configure(String appFolderId) { + _appFolderId = appFolderId; + _receiptsFolderId = null; + _dataFileId = null; + } + + void clearConfiguration() { + _appFolderId = null; + _receiptsFolderId = null; + _dataFileId = null; + } + + /// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a + /// folder the user picked in the Drive folder browser) and configures + /// this service to use it. Returns the resulting folder ID. + Future selectAppFolder(String parentId) async { + final client = authService.authenticatedHttpClient(); + try { + final drive = DriveService(client); + final folderId = await drive.findOrCreateAppFolder(parentId); + configure(folderId); + return folderId; + } finally { + client.close(); + } + } + + Future syncNow({ + required List localVehicles, + required List localFuelEntries, + }) async { + final appFolderId = _appFolderId; + if (!authService.isSignedIn || appFolderId == null) { + return SyncResult.skipped(); + } + + http.Client? client; + DriveService? drive; + String? lockFileId; + + try { + client = authService.authenticatedHttpClient(); + drive = DriveService(client); + + lockFileId = await _acquireLock( + drive, + appFolderId: appFolderId, + username: authService.currentAccountEmail!, + ); + + _receiptsFolderId ??= await drive.findOrCreateReceiptsFolder(appFolderId); + _dataFileId ??= await drive.findDataFileId(appFolderId); + + var remoteVehicles = []; + var remoteFuelEntries = []; + if (_dataFileId != null) { + final text = await drive.downloadTextFile(_dataFileId!); + if (text.trim().isNotEmpty) { + final decoded = storageService.decodeData(text); + remoteVehicles = decoded.vehicles; + remoteFuelEntries = decoded.entries; + } + } + + final mergedVehicles = mergeById( + remoteVehicles, + localVehicles, + (v) => v.id, + (v) => v.updatedAt, + ); + final mergedFuelEntries = await _uploadPendingReceiptsAndMerge( + drive: drive, + remoteFuelEntries: remoteFuelEntries, + localFuelEntries: localFuelEntries, + ); + + final jsonContent = storageService.encodeData( + vehicles: mergedVehicles, + entries: mergedFuelEntries, + ); + _dataFileId = await drive.uploadDataFile( + appFolderId: appFolderId, + existingFileId: _dataFileId, + jsonContent: jsonContent, + ); + + return SyncResult.success(vehicles: mergedVehicles, fuelEntries: mergedFuelEntries); + } catch (e) { + return SyncResult.failure(e); + } finally { + if (lockFileId != null && drive != null) { + try { + await drive.deleteFile(lockFileId); + } catch (_) { + // Best-effort: if this fails, the 10-minute staleness reap on + // other devices' next sync attempt will clean it up. + } + } + client?.close(); + } + } + + Future> _uploadPendingReceiptsAndMerge({ + required DriveService drive, + required List remoteFuelEntries, + required List localFuelEntries, + }) async { + final merged = mergeById( + remoteFuelEntries, + localFuelEntries, + (e) => e.id, + (e) => e.updatedAt, + ); + + final result = []; + for (final entry in merged) { + if (!entry.hasPendingLocalReceipt) { + result.add(entry); + continue; + } + + final localPath = entry.receiptImagePath!; + final localFile = File(localPath); + if (!await localFile.exists()) { + result.add(entry); + continue; + } + + final driveFileId = await drive.uploadReceiptImage( + receiptsFolderId: _receiptsFolderId!, + fileName: '${entry.id}${p.extension(localPath)}', + imageFile: localFile, + ); + await storageService.deleteReceiptImage(localPath); + result.add(entry.copyWith(receiptDriveFileId: driveFileId, clearReceiptImagePath: true)); + } + return result; + } + + Future _acquireLock( + DriveService drive, { + required String appFolderId, + required String username, + }) { + return lock.acquireLock( + username: username, + createLock: (name) => drive.createLockFile(appFolderId: appFolderId, name: name), + listLocks: () => drive.listLockFiles(appFolderId), + deleteLock: drive.deleteFile, + ); + } +} diff --git a/lib/services/lock_coordinator.dart b/lib/services/lock_coordinator.dart new file mode 100644 index 0000000..0a729a8 --- /dev/null +++ b/lib/services/lock_coordinator.dart @@ -0,0 +1,48 @@ +import 'drive_service.dart' show DriveLockFile; + +/// Ticket-based mutex using timestamped lock files as the coordination +/// point: create a lock named after our own timestamp, then wait until no +/// *other* lock older than ours remains — reaping ("deleting") any it +/// finds older than [staleAge] along the way, as a backstop against a +/// device that crashed/went offline before releasing its own lock. +/// +/// Pure orchestration over injected operations (rather than a concrete +/// Drive dependency) so the state machine is unit-testable without a real +/// network connection. +Future acquireLock({ + required String username, + required Future Function(String lockName) createLock, + required Future> Function() listLocks, + required Future Function(String lockId) deleteLock, + DateTime Function()? nowUtc, + Future Function(Duration)? delay, + Duration staleAge = const Duration(minutes: 10), + Duration pollInterval = const Duration(seconds: 1), +}) async { + final now = nowUtc ?? () => DateTime.now().toUtc(); + final wait = delay ?? Future.delayed; + + final epochMillis = now().millisecondsSinceEpoch; + final lockName = '$username-$epochMillis.lock'; + final lockId = await createLock(lockName); + + while (true) { + final locks = await listLocks(); + final others = locks.where((l) => l.id != lockId); + final olderOthers = + others.where((l) => l.createdAtUtc.millisecondsSinceEpoch < epochMillis).toList(); + + if (olderOthers.isEmpty) break; + + final current = now(); + for (final stale in olderOthers) { + if (current.difference(stale.createdAtUtc) > staleAge) { + await deleteLock(stale.id); + } + } + + await wait(pollInterval); + } + + return lockId; +} diff --git a/lib/services/merge_utils.dart b/lib/services/merge_utils.dart new file mode 100644 index 0000000..abc8641 --- /dev/null +++ b/lib/services/merge_utils.dart @@ -0,0 +1,23 @@ +/// Unions [remote] and [local] by ID, keeping whichever copy of each +/// record has the newer `updatedAt` when a record exists on both sides. +/// This lets two devices' independent new records both survive a sync; +/// it does not propagate deletions (see plan doc's Known limitations). +List mergeById( + List remote, + List local, + String Function(T) idOf, + DateTime Function(T) updatedAtOf, +) { + final merged = {}; + for (final item in remote) { + merged[idOf(item)] = item; + } + for (final item in local) { + final id = idOf(item); + final existing = merged[id]; + if (existing == null || !updatedAtOf(existing).isAfter(updatedAtOf(item))) { + merged[id] = item; + } + } + return merged.values.toList(); +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index d12524a..3fc9905 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -3,17 +3,17 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import '../models/fuel_entry.dart'; import '../models/vehicle.dart'; -/// Owns the on-disk layout for the app: a `data.json` file holding vehicles -/// and fuel entries, plus a `receipts/` subfolder holding receipt photos. -/// The parent directory containing both is user-configurable (see -/// [setSaveDirectory]) and persisted across launches via SharedPreferences. +/// Owns the local, offline staging area: a `data.json` file holding +/// vehicles and fuel entries, plus a `receipts/` subfolder holding receipt +/// photos not yet uploaded to Drive. This is always +/// `ApplicationDocumentsDirectory/FuelTaxTracker` — not user-configurable — +/// since Drive (via [DriveSyncService]) is now the real shared destination +/// and this is just a local cache/holding area used while offline. class StorageService { - static const _prefsKey = 'save_directory_path'; static const _dataFileName = 'fuel_tax_data.json'; static const _receiptsFolderName = 'receipts'; @@ -24,36 +24,31 @@ class StorageService { Directory get receiptsDirectory => Directory(p.join(_saveDirectory.path, _receiptsFolderName)); - File get _dataFile => File(p.join(_saveDirectory.path, _dataFileName)); + File get dataFile => File(p.join(_saveDirectory.path, _dataFileName)); - /// Must be called once before any other method. Loads the previously - /// chosen save directory, falling back to the app's own documents - /// directory the first time the app runs. + /// Must be called once before any other method. Future init() async { - final prefs = await SharedPreferences.getInstance(); - final storedPath = prefs.getString(_prefsKey); - - if (storedPath != null && await Directory(storedPath).exists()) { - _saveDirectory = Directory(storedPath); - } else { - final docsDir = await getApplicationDocumentsDirectory(); - _saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker')); - } + final docsDir = await getApplicationDocumentsDirectory(); + _saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker')); await _saveDirectory.create(recursive: true); await receiptsDirectory.create(recursive: true); } Future<({List vehicles, List entries})> loadData() async { - if (!await _dataFile.exists()) { + if (!await dataFile.exists()) { return (vehicles: [], entries: []); } - final raw = await _dataFile.readAsString(); + final raw = await dataFile.readAsString(); if (raw.trim().isEmpty) { return (vehicles: [], entries: []); } + return decodeData(raw); + } + + ({List vehicles, List entries}) decodeData(String raw) { final json = jsonDecode(raw) as Map; final vehicles = (json['vehicles'] as List? ?? []) .map((v) => Vehicle.fromJson(v as Map)) @@ -65,19 +60,26 @@ class StorageService { return (vehicles: vehicles, entries: entries); } - Future saveData({ + String encodeData({ required List vehicles, required List entries, - }) async { + }) { final json = { 'vehicles': vehicles.map((v) => v.toJson()).toList(), 'fuelEntries': entries.map((e) => e.toJson()).toList(), }; - await _dataFile.writeAsString(const JsonEncoder.withIndent(' ').convert(json)); + return const JsonEncoder.withIndent(' ').convert(json); } - /// Copies a captured receipt image into the receipts folder and returns - /// the path it was stored at. + Future saveData({ + required List vehicles, + required List entries, + }) async { + await dataFile.writeAsString(encodeData(vehicles: vehicles, entries: entries)); + } + + /// Copies a captured receipt image into the local receipts folder and + /// returns the path it was stored at, pending upload to Drive. Future storeReceiptImage(File sourceImage, String fuelEntryId) async { final ext = p.extension(sourceImage.path); final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext'); @@ -92,37 +94,4 @@ class StorageService { await file.delete(); } } - - /// Changes where the data file and receipt images live, moving any - /// existing data/images from the old location into the new one. - Future setSaveDirectory(String newPath) async { - final newDir = Directory(newPath); - await newDir.create(recursive: true); - final newReceiptsDir = Directory(p.join(newPath, _receiptsFolderName)); - await newReceiptsDir.create(recursive: true); - - final oldDataFile = _dataFile; - final oldReceiptsDir = receiptsDirectory; - - if (await oldDataFile.exists() && - p.equals(oldDataFile.path, p.join(newPath, _dataFileName)) == false) { - await oldDataFile.copy(p.join(newPath, _dataFileName)); - } - - if (await oldReceiptsDir.exists()) { - await for (final entity in oldReceiptsDir.list()) { - if (entity is File) { - final destPath = p.join(newReceiptsDir.path, p.basename(entity.path)); - if (!p.equals(entity.path, destPath)) { - await entity.copy(destPath); - } - } - } - } - - _saveDirectory = newDir; - - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_prefsKey, newPath); - } } diff --git a/pubspec.lock b/pubspec.lock index 605d832..b8bcfb2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _discoveryapis_commons: + dependency: transitive + description: + name: _discoveryapis_commons + sha256: "113c4100b90a5b70a983541782431b82168b3cae166ab130649c36eb3559d498" + url: "https://pub.dev" + source: hosted + version: "1.0.7" args: dependency: transitive description: @@ -57,6 +65,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" cross_file: dependency: transitive description: @@ -113,14 +137,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - file_picker: - dependency: "direct main" - description: - name: file_picker - sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" - url: "https://pub.dev" - source: hosted - version: "10.3.10" file_selector_linux: dependency: transitive description: @@ -192,6 +208,14 @@ packages: description: flutter source: sdk version: "0.0.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" google_mlkit_commons: dependency: transitive description: @@ -208,6 +232,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e" + url: "https://pub.dev" + source: hosted + version: "7.2.16" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + googleapis: + dependency: "direct main" + description: + name: googleapis + sha256: "5c9e0f25be1dec13d8d2158263141104c51b5ba83487537c17a2330581e505ee" + url: "https://pub.dev" + source: hosted + version: "14.0.0" hooks: dependency: transitive description: @@ -217,7 +289,7 @@ packages: source: hosted version: "2.0.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -408,6 +480,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" objective_c: dependency: transitive description: @@ -677,14 +757,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6bfd01d..b0d5ff1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,21 +44,10 @@ dependencies: # On-device OCR text recognition google_mlkit_text_recognition: ^0.15.0 - # Directory picker for configurable save location. - # Pinned below 11.x: file_picker 11.0.0-11.0.3 skip applying the Kotlin - # Gradle plugin under AGP 9+ assuming AGP's built-in Kotlin support covers - # it, but that isn't actually wired up yet for library modules in this - # Flutter/AGP combo, so FilePickerPlugin.kt never gets compiled. 10.3.11 - # still compiles against compileSdk 36 (matches flutter.compileSdkVersion, - # satisfying flutter_plugin_android_lifecycle's requirement) but applies - # its own Kotlin plugin unconditionally, which works. (10.3.11 itself is - # retracted on pub.dev, hence pinning to 10.3.10.) - file_picker: 10.3.10 - - # App sandbox paths (default save location, temp files) + # App sandbox paths (local offline staging area) path_provider: ^2.1.5 - # Persist small settings like chosen save directory + # Persist small settings (Drive connection state, last synced time) shared_preferences: ^2.3.4 # Unique IDs for vehicles/fuel entries @@ -70,6 +59,18 @@ dependencies: # Path joining/manipulation helpers path: ^1.9.0 + # Google OAuth sign-in for Drive access + google_sign_in: ^7.1.1 + + # Google Drive API client + googleapis: ^14.0.0 + + # HTTP client interface (for the authenticated client we hand to DriveApi) + http: ^1.2.2 + + # Detect connectivity changes to trigger background sync + connectivity_plus: ^6.1.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/test/lock_coordinator_test.dart b/test/lock_coordinator_test.dart new file mode 100644 index 0000000..79fc9cb --- /dev/null +++ b/test/lock_coordinator_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/services/drive_service.dart' show DriveLockFile; +import 'package:fuel_tax_tracker/services/lock_coordinator.dart'; + +void main() { + group('acquireLock', () { + test('creates a lock file named {username}-{epochMillis}.lock', () async { + final now = DateTime.utc(2024, 1, 1, 12, 0, 0); + String? capturedName; + + await acquireLock( + username: 'me@example.com', + nowUtc: () => now, + createLock: (name) async { + capturedName = name; + return 'id'; + }, + listLocks: () async => + [DriveLockFile(id: 'id', username: 'me@example.com', createdAtUtc: now)], + deleteLock: (_) async {}, + delay: (_) async {}, + ); + + expect(capturedName, 'me@example.com-${now.millisecondsSinceEpoch}.lock'); + }); + + test('proceeds immediately when no other lock is older', () async { + var listCallCount = 0; + final delayCalls = []; + final now = DateTime.utc(2024, 1, 1, 12, 0, 0); + + final lockId = await acquireLock( + username: 'me', + nowUtc: () => now, + createLock: (_) async => 'my-lock-id', + listLocks: () async { + listCallCount++; + return [DriveLockFile(id: 'my-lock-id', username: 'me', createdAtUtc: now)]; + }, + deleteLock: (_) async {}, + delay: (d) async => delayCalls.add(d), + ); + + expect(lockId, 'my-lock-id'); + expect(listCallCount, 1); + expect(delayCalls, isEmpty); + }); + + test('a newer lock does not block acquisition', () async { + final now = DateTime.utc(2024, 1, 1, 12, 0, 0); + final delayCalls = []; + + final lockId = await acquireLock( + username: 'me', + nowUtc: () => now, + createLock: (_) async => 'mine', + listLocks: () async => [ + DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now), + DriveLockFile( + id: 'newer', username: 'other', createdAtUtc: now.add(const Duration(seconds: 5))), + ], + deleteLock: (_) async {}, + delay: (d) async => delayCalls.add(d), + ); + + expect(lockId, 'mine'); + expect(delayCalls, isEmpty); + }); + + test('waits for an older, non-stale lock, then proceeds once it is gone', () async { + final now = DateTime.utc(2024, 1, 1, 12, 0, 0); + var listCallCount = 0; + final delayCalls = []; + final deleteCalls = []; + + final lockId = await acquireLock( + username: 'me', + nowUtc: () => now, + createLock: (_) async => 'mine', + listLocks: () async { + listCallCount++; + if (listCallCount == 1) { + return [ + DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now), + DriveLockFile( + id: 'other', + username: 'other', + createdAtUtc: now.subtract(const Duration(seconds: 5))), + ]; + } + return [DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now)]; + }, + deleteLock: (id) async => deleteCalls.add(id), + delay: (d) async => delayCalls.add(d), + ); + + expect(lockId, 'mine'); + expect(listCallCount, 2); + expect(delayCalls.length, 1); + expect(deleteCalls, isEmpty, reason: 'a non-stale older lock should not be deleted'); + }); + + test('deletes an older lock once it exceeds the staleness threshold', () async { + final now = DateTime.utc(2024, 1, 1, 12, 0, 0); + var listCallCount = 0; + final deleteCalls = []; + + await acquireLock( + username: 'me', + nowUtc: () => now, + staleAge: const Duration(minutes: 10), + createLock: (_) async => 'mine', + listLocks: () async { + listCallCount++; + if (listCallCount == 1) { + return [ + DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now), + DriveLockFile( + id: 'stale', + username: 'ghost', + createdAtUtc: now.subtract(const Duration(minutes: 15))), + ]; + } + return [DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now)]; + }, + deleteLock: (id) async => deleteCalls.add(id), + delay: (_) async {}, + ); + + expect(deleteCalls, ['stale']); + }); + }); +} diff --git a/test/merge_utils_test.dart b/test/merge_utils_test.dart new file mode 100644 index 0000000..0aa8374 --- /dev/null +++ b/test/merge_utils_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/services/merge_utils.dart'; + +class _Record { + final String id; + final String value; + final DateTime updatedAt; + + _Record(this.id, this.value, this.updatedAt); +} + +void main() { + group('mergeById', () { + test('unions records that only exist on one side', () { + final remote = [_Record('a', 'remote-a', DateTime(2024, 1, 1))]; + final local = [_Record('b', 'local-b', DateTime(2024, 1, 1))]; + + final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt); + + expect(result.map((r) => r.id), containsAll(['a', 'b'])); + expect(result.length, 2); + }); + + test('keeps the newer updatedAt when a record exists on both sides', () { + final remote = [_Record('a', 'remote-old', DateTime(2024, 1, 1))]; + final local = [_Record('a', 'local-new', DateTime(2024, 6, 1))]; + + final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt); + + expect(result.length, 1); + expect(result.first.value, 'local-new'); + }); + + test('keeps the remote copy when it is newer than local', () { + final remote = [_Record('a', 'remote-new', DateTime(2024, 6, 1))]; + final local = [_Record('a', 'local-old', DateTime(2024, 1, 1))]; + + final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt); + + expect(result.length, 1); + expect(result.first.value, 'remote-new'); + }); + + test('local wins ties', () { + final sameTime = DateTime(2024, 1, 1); + final remote = [_Record('a', 'remote', sameTime)]; + final local = [_Record('a', 'local', sameTime)]; + + final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt); + + expect(result.first.value, 'local'); + }); + }); +} From f01186df79037104d94692a759f62ae2c07c944c Mon Sep 17 00:00:00 2001 From: Courtney Arnold Date: Sat, 8 Aug 2026 16:55:40 -0500 Subject: [PATCH 2/3] Added sqlite --- README.md | 102 ++++++++----- lib/models/fuel_entry.dart | 53 ++++--- lib/models/vehicle.dart | 36 +++-- lib/services/app_state.dart | 68 ++++----- lib/services/database_service.dart | 146 +++++++++++++++++++ lib/services/db_schema.dart | 61 ++++++++ lib/services/drive_service.dart | 58 +++++--- lib/services/drive_sync_service.dart | 209 ++++++++++++++++----------- lib/services/merge_utils.dart | 23 --- lib/services/storage_service.dart | 97 ------------- pubspec.lock | 80 ++++++++++ pubspec.yaml | 7 + test/db_merge_test.dart | 172 ++++++++++++++++++++++ test/merge_utils_test.dart | 54 ------- 14 files changed, 784 insertions(+), 382 deletions(-) create mode 100644 lib/services/database_service.dart create mode 100644 lib/services/db_schema.dart delete mode 100644 lib/services/merge_utils.dart delete mode 100644 lib/services/storage_service.dart create mode 100644 test/db_merge_test.dart delete mode 100644 test/merge_utils_test.dart diff --git a/README.md b/README.md index f5ae7f6..ae46090 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,9 @@ A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device, you confirm or correct the numbers, and it's saved alongside the receipt -photo. Vehicles and fuel entries live in a shared Google Drive folder, so -multiple people can log fuel against the same pool of vehicles from their -own phones; each device also keeps a local offline copy and syncs when it -can. +photo. Vehicles and fuel entries live in a local SQLite database that's kept +in sync with a shared Google Drive folder, so multiple people can log fuel +against the same pool of vehicles from their own phones, offline or online. ## Features @@ -22,8 +21,9 @@ can. location. The app looks for (or creates) a `MO-Fuel-Tax-Back` subfolder there, so anyone pointed at the same shared parent converges on the same data automatically. -- Works fully offline: writes always land locally first; a background sync - pushes changes to Drive and pulls others' changes down once online. +- Works fully offline: writes always land in the local SQLite database + first; a background sync pushes changes to Drive and pulls others' + changes down once online. - Once a receipt photo is uploaded to Drive, the local copy is deleted; viewing it later downloads it fresh from Drive on demand. @@ -31,25 +31,30 @@ can. ``` lib/ - models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization. - Both carry `updatedAt` (for merge conflict resolution); FuelEntry + models/ Vehicle, FuelEntry — plain data classes with SQLite row (de)serialization + (toMap/fromMap). Both carry `updatedAt` (merge conflict resolution) and + `deletedAt` (a soft-delete tombstone, so deletions sync too). FuelEntry carries either a local receiptImagePath (not yet uploaded) or a receiptDriveFileId (uploaded, local copy removed). services/ - app_state.dart In-memory state + CRUD, exposed via Provider. Stamps + app_state.dart In-memory read cache + CRUD, exposed via Provider. Stamps updatedAt on mutations and triggers background sync. - storage_service.dart Local offline staging area: data.json + receipts/, - always at ApplicationDocumentsDirectory/FuelTaxTracker. + database_service.dart Owns the local SQLite database (vehicles + fuel_entries) + and the receipts/ folder, always at + ApplicationDocumentsDirectory/FuelTaxTracker. + db_schema.dart CREATE TABLE statements and the merge SQL — shared between + the app and its tests so they can never drift apart. drive_auth_service.dart Google sign-in + builds an authenticated http.Client for the Drive API. drive_service.dart Raw Drive API calls: browse folders, find-or-create the MO-Fuel-Tax-Back folder, upload/download files, - lock file operations. + lock file operations, cheap md5-based change detection. drive_sync_service.dart Orchestrates one sync round: acquire the cross-device - lock, download + merge the remote data file, upload - pending receipt photos, write the merge back, release - the lock. - merge_utils.dart Pure by-ID union merge logic (independently unit-tested). + lock, ATTACH + merge the remote database into the local + one, upload pending receipt photos, push the local + database back up, release the lock. + lock_coordinator.dart The ticket-based lock-file mutex, as pure injectable + logic (independently unit-tested without real Drive). drive_oauth_config.dart Fill in your OAuth client IDs here — see setup below. ocr_service.dart Thin wrapper around google_mlkit_text_recognition. receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text. @@ -114,38 +119,55 @@ OAuth setup above before it can be exercised end-to-end. - **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and `android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already set up for receipt capture. -- **Network/Drive**: no Android manifest changes are needed for - `google_sign_in` when not using `google-services.json` — see the manual - setup section above instead. +- **Network/Drive**: `INTERNET` and `ACCESS_NETWORK_STATE` are declared in + the main Android manifest (needed for release builds; debug builds get + `INTERNET` for free). No manifest changes are needed for `google_sign_in` + itself when not using `google-services.json` — see the manual setup + section above instead. ## How sync works Every local change (add/edit a vehicle, log a fuel entry) writes to the -local `fuel_tax_data.json` immediately, then triggers a best-effort -background sync — also triggered whenever connectivity comes back. Sync: +local SQLite database immediately, then triggers a best-effort background +sync — also triggered whenever connectivity comes back or the app starts. +Every row carries `updated_at` (for merge resolution), `deleted_at` (a +soft-delete tombstone — see below), and a local-only `dirty` flag (pending +push to Drive, never itself treated as meaningful sync data). Sync: 1. Creates a lock file `{email}-{utcEpochMillis}.lock` in the shared Drive folder, then waits until no *other* lock file older than its own remains (polling every second, deleting any it finds older than 10 minutes as - orphaned/stale) — a simple ticket-based mutex using the Drive folder - itself as the coordination point, so two devices never overwrite each - other's edits to the shared data file mid-write. -2. Downloads the current remote data file and merges it with local changes: - vehicles and fuel entries are unioned by ID, with the newer `updatedAt` - winning when a record exists on both sides. -3. Uploads any locally-pending receipt photos, then deletes the local copy. -4. Writes the merged data back to Drive, then deletes its own lock file. + orphaned/stale) — a ticket-based mutex using the Drive folder itself as + the coordination point, so two devices never overwrite each other's + edits to the shared database mid-write. +2. Checks the remote database file's md5 checksum against the last one seen + (a metadata-only call, no content download) to decide if a pull is even + needed. If it changed: downloads it to a temp file, `ATTACH`es it to the + local database, and runs one `INSERT OR REPLACE ... SELECT ... WHERE + local.id IS NULL OR remote.updated_at > local.updated_at` per table. + Rows that statement doesn't match — including this device's own + not-yet-pushed edits — are left untouched, so no separate "keep local" + step is needed. +3. Uploads any locally-pending receipt photos (rows still holding a local + file path), clearing that path and recording the Drive file ID instead. + If any upload fails, the push step below is skipped entirely this cycle + — a row is never pushed while it still holds a local-only path. +4. Reads the local database file directly (sqflite's default journal mode + isn't WAL, so the file is complete and consistent as soon as the last + write's `Future` resolves; `PRAGMA wal_checkpoint` runs first anyway as + cheap insurance) and uploads it as the new remote copy, then clears the + `dirty` flag on every row now that local matches what's on Drive. +5. Releases the lock. + +**Deletions propagate correctly**, unlike a naive "union records" merge: +deleting sets `deleted_at` instead of removing the row, so a deletion is +just another change with its own `updated_at`, and rides the same +newest-wins rule as any edit — no separate deletion-handling logic needed. ## Known caveats / things to revisit -- **Deletions don't propagate through the merge.** If one device deletes a - vehicle/entry before another device has seen that deletion, the deleting - change can be resurrected by the other device's next sync. Fixing this - properly needs tombstones (tracking deleted-record IDs for a retention - window) — deferred for now; the by-ID union merge is intentionally simple - and expected to evolve. - **Field-level conflicts aren't merged.** Two people editing the *same* - record at the same time: whole record, newer `updatedAt` wins — not a + record at the same time: whole record, newer `updated_at` wins — not a field-by-field merge. - **Lock acquisition has no hard timeout** beyond the 10-minute staleness reap. Fine at the scale this is built for (a handful of people); could in @@ -162,5 +184,9 @@ background sync — also triggered whenever connectivity comes back. Sync: or correct whatever wasn't found. - No automated tests exercise the OCR, camera, or real Drive API calls (those need a real device/emulator and live credentials); `receipt_parser_test.dart`, - `merge_utils_test.dart`, and `drive_lock_test.dart` cover the pure logic - pieces against fixed inputs. + `lock_coordinator_test.dart`, and `db_merge_test.dart` (using + `sqflite_common_ffi` to run the real merge SQL against temp SQLite files + on the Dart VM) cover the pure logic pieces against fixed inputs. +- No migration path exists from the earlier JSON-file storage format — + not needed since no real data had accumulated under it yet, but flag it + if that's no longer true for you. diff --git a/lib/models/fuel_entry.dart b/lib/models/fuel_entry.dart index 580b80c..e500e0e 100644 --- a/lib/models/fuel_entry.dart +++ b/lib/models/fuel_entry.dart @@ -9,6 +9,10 @@ class FuelEntry { final String? receiptDriveFileId; final DateTime updatedAt; + /// Soft-delete tombstone: null means active. See [Vehicle.deletedAt] for + /// why this is a flag rather than an actual row deletion. + final DateTime? deletedAt; + FuelEntry({ required this.id, required this.vehicleId, @@ -19,6 +23,7 @@ class FuelEntry { required this.updatedAt, this.receiptImagePath, this.receiptDriveFileId, + this.deletedAt, }); /// True once the receipt photo has been uploaded to Drive and the local @@ -27,13 +32,16 @@ class FuelEntry { receiptImagePath == null && receiptDriveFileId != null; /// True while a receipt photo exists only locally and still needs to be - /// uploaded next sync. + /// uploaded next sync. A row in this state is never pushed to Drive as-is + /// (a local file path is meaningless on another device) — sync uploads + /// the image first, which clears this. bool get hasPendingLocalReceipt => receiptImagePath != null; FuelEntry copyWith({ String? receiptImagePath, String? receiptDriveFileId, DateTime? updatedAt, + DateTime? deletedAt, bool clearReceiptImagePath = false, }) { return FuelEntry( @@ -47,32 +55,35 @@ class FuelEntry { receiptImagePath: clearReceiptImagePath ? null : (receiptImagePath ?? this.receiptImagePath), receiptDriveFileId: receiptDriveFileId ?? this.receiptDriveFileId, + deletedAt: deletedAt ?? this.deletedAt, ); } - Map toJson() => { + Map toMap() => { 'id': id, - 'vehicleId': vehicleId, - 'date': date.toIso8601String(), + 'vehicle_id': vehicleId, + 'date': date.millisecondsSinceEpoch, 'gallons': gallons, - 'pricePerGallon': pricePerGallon, - 'totalCost': totalCost, - 'receiptImagePath': receiptImagePath, - 'receiptDriveFileId': receiptDriveFileId, - 'updatedAt': updatedAt.toIso8601String(), + 'price_per_gallon': pricePerGallon, + 'total_cost': totalCost, + 'receipt_image_path': receiptImagePath, + 'receipt_drive_file_id': receiptDriveFileId, + 'updated_at': updatedAt.millisecondsSinceEpoch, + 'deleted_at': deletedAt?.millisecondsSinceEpoch, }; - factory FuelEntry.fromJson(Map json) => FuelEntry( - id: json['id'] as String, - vehicleId: json['vehicleId'] as String, - date: DateTime.parse(json['date'] as String), - gallons: (json['gallons'] as num).toDouble(), - pricePerGallon: (json['pricePerGallon'] as num).toDouble(), - totalCost: (json['totalCost'] as num).toDouble(), - receiptImagePath: json['receiptImagePath'] as String?, - receiptDriveFileId: json['receiptDriveFileId'] as String?, - updatedAt: json['updatedAt'] != null - ? DateTime.parse(json['updatedAt'] as String) - : DateTime.fromMillisecondsSinceEpoch(0), + factory FuelEntry.fromMap(Map map) => FuelEntry( + id: map['id'] as String, + vehicleId: map['vehicle_id'] as String, + date: DateTime.fromMillisecondsSinceEpoch(map['date'] as int), + gallons: (map['gallons'] as num).toDouble(), + pricePerGallon: (map['price_per_gallon'] as num).toDouble(), + totalCost: (map['total_cost'] as num).toDouble(), + receiptImagePath: map['receipt_image_path'] as String?, + receiptDriveFileId: map['receipt_drive_file_id'] as String?, + updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true), + deletedAt: map['deleted_at'] != null + ? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true) + : null, ); } diff --git a/lib/models/vehicle.dart b/lib/models/vehicle.dart index 9c483e0..b1a1e76 100644 --- a/lib/models/vehicle.dart +++ b/lib/models/vehicle.dart @@ -6,6 +6,13 @@ class Vehicle { final String licensePlate; final DateTime updatedAt; + /// Soft-delete tombstone: null means active. Deleting sets this instead + /// of removing the row, so the deletion itself can be merged/synced like + /// any other change (newest `updatedAt` wins) instead of silently + /// disappearing and later being resurrected by a device that hasn't seen + /// the deletion yet. + final DateTime? deletedAt; + Vehicle({ required this.id, required this.make, @@ -13,6 +20,7 @@ class Vehicle { required this.color, required this.licensePlate, required this.updatedAt, + this.deletedAt, }); String get displayName => '$color $make $model ($licensePlate)'; @@ -23,6 +31,7 @@ class Vehicle { String? color, String? licensePlate, DateTime? updatedAt, + DateTime? deletedAt, }) { return Vehicle( id: id, @@ -31,26 +40,29 @@ class Vehicle { color: color ?? this.color, licensePlate: licensePlate ?? this.licensePlate, updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, ); } - Map toJson() => { + Map toMap() => { 'id': id, 'make': make, 'model': model, 'color': color, - 'licensePlate': licensePlate, - 'updatedAt': updatedAt.toIso8601String(), + 'license_plate': licensePlate, + 'updated_at': updatedAt.millisecondsSinceEpoch, + 'deleted_at': deletedAt?.millisecondsSinceEpoch, }; - factory Vehicle.fromJson(Map json) => Vehicle( - id: json['id'] as String, - make: json['make'] as String, - model: json['model'] as String, - color: json['color'] as String, - licensePlate: json['licensePlate'] as String, - updatedAt: json['updatedAt'] != null - ? DateTime.parse(json['updatedAt'] as String) - : DateTime.fromMillisecondsSinceEpoch(0), + factory Vehicle.fromMap(Map map) => Vehicle( + id: map['id'] as String, + make: map['make'] as String, + model: map['model'] as String, + color: map['color'] as String, + licensePlate: map['license_plate'] as String, + updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true), + deletedAt: map['deleted_at'] != null + ? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true) + : null, ); } diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart index a45cf98..ff91eae 100644 --- a/lib/services/app_state.dart +++ b/lib/services/app_state.dart @@ -8,24 +8,29 @@ import 'package:uuid/uuid.dart'; import '../models/fuel_entry.dart'; import '../models/vehicle.dart'; +import 'database_service.dart'; import 'drive_auth_service.dart'; import 'drive_sync_service.dart'; -import 'storage_service.dart'; const _prefsKeyDriveFolderId = 'drive_app_folder_id'; const _prefsKeyDriveFolderPath = 'drive_app_folder_path'; /// Single source of truth for the app's in-memory data (vehicles + fuel -/// entries), backed by [StorageService] for local persistence and +/// entries), backed by [DatabaseService] (local SQLite) for persistence and /// [DriveSyncService] for pushing/pulling the shared Drive copy. Screens /// read from this via Provider and call its mutating methods, which write -/// through to local disk immediately and kick off a best-effort background -/// sync to Drive. +/// through to the local database immediately and kick off a best-effort +/// background sync to Drive. +/// +/// The `vehicles`/`fuelEntries` lists are an in-memory read cache of the +/// database, refreshed after every mutation and after every sync (since +/// sync's merge happens as SQL directly against the database, not by +/// handing back updated Dart objects). class AppState extends ChangeNotifier { - final StorageService storage = StorageService(); + final DatabaseService database = DatabaseService(); final DriveAuthService driveAuth = DriveAuthService(); late final DriveSyncService driveSync = - DriveSyncService(authService: driveAuth, storageService: storage); + DriveSyncService(authService: driveAuth, databaseService: database); final _uuid = const Uuid(); List vehicles = []; @@ -42,10 +47,8 @@ class AppState extends ChangeNotifier { StreamSubscription>? _connectivitySubscription; Future init() async { - await storage.init(); - final data = await storage.loadData(); - vehicles = data.vehicles; - fuelEntries = data.entries; + await database.init(); + await _refreshFromDatabase(); isLoading = false; notifyListeners(); @@ -64,6 +67,11 @@ class AppState extends ChangeNotifier { super.dispose(); } + Future _refreshFromDatabase() async { + vehicles = await database.getVehicles(); + fuelEntries = await database.getFuelEntries(); + } + Future _restoreDriveConnection() async { final signedIn = await driveAuth.attemptSilentSignIn(); if (!signedIn) return; @@ -125,15 +133,10 @@ class AppState extends ChangeNotifier { isSyncing = true; notifyListeners(); - final result = await driveSync.syncNow( - localVehicles: vehicles, - localFuelEntries: fuelEntries, - ); + final result = await driveSync.syncNow(); if (result.ranSync) { - vehicles = result.vehicles!; - fuelEntries = result.fuelEntries!; - await storage.saveData(vehicles: vehicles, entries: fuelEntries); + await _refreshFromDatabase(); lastSyncedAt = DateTime.now(); lastSyncError = null; } else if (result.error != null) { @@ -150,31 +153,30 @@ class AppState extends ChangeNotifier { required String color, required String licensePlate, }) async { - vehicles.add(Vehicle( + final vehicle = Vehicle( id: _uuid.v4(), make: make, model: model, color: color, licensePlate: licensePlate, updatedAt: DateTime.now().toUtc(), - )); + ); + await database.saveVehicle(vehicle); await _persist(); } Future updateVehicle(Vehicle updated) async { - final index = vehicles.indexWhere((v) => v.id == updated.id); - if (index != -1) { - vehicles[index] = updated.copyWith(updatedAt: DateTime.now().toUtc()); - await _persist(); - } + await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc())); + await _persist(); } Future deleteVehicle(String vehicleId) async { - for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) { - await storage.deleteReceiptImage(entry.receiptImagePath); + final now = DateTime.now().toUtc(); + final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(vehicleId, now); + for (final path in orphanedLocalPaths) { + await database.deleteReceiptImageFile(path); } - fuelEntries.removeWhere((e) => e.vehicleId == vehicleId); - vehicles.removeWhere((v) => v.id == vehicleId); + await database.softDeleteVehicle(vehicleId, now); await _persist(); } @@ -189,7 +191,7 @@ class AppState extends ChangeNotifier { final id = _uuid.v4(); String? storedImagePath; if (receiptImage != null) { - storedImagePath = await storage.storeReceiptImage(receiptImage, id); + storedImagePath = await database.storeReceiptImage(receiptImage, id); } final entry = FuelEntry( @@ -202,15 +204,15 @@ class AppState extends ChangeNotifier { receiptImagePath: storedImagePath, updatedAt: DateTime.now().toUtc(), ); - fuelEntries.add(entry); + await database.saveFuelEntry(entry); await _persist(); return entry; } Future deleteFuelEntry(String entryId) async { final entry = fuelEntries.firstWhere((e) => e.id == entryId); - await storage.deleteReceiptImage(entry.receiptImagePath); - fuelEntries.removeWhere((e) => e.id == entryId); + await database.deleteReceiptImageFile(entry.receiptImagePath); + await database.softDeleteFuelEntry(entryId, DateTime.now().toUtc()); await _persist(); } @@ -236,7 +238,7 @@ class AppState extends ChangeNotifier { } Future _persist() async { - await storage.saveData(vehicles: vehicles, entries: fuelEntries); + await _refreshFromDatabase(); notifyListeners(); unawaited(syncNow()); } diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart new file mode 100644 index 0000000..c764c45 --- /dev/null +++ b/lib/services/database_service.dart @@ -0,0 +1,146 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; + +import '../models/fuel_entry.dart'; +import '../models/vehicle.dart'; +import 'db_schema.dart'; + +const dbFileName = 'fuel_tax_tracker.db'; +const receiptsFolderName = 'receipts'; + +/// Owns the local SQLite database (vehicles + fuel_entries) and the +/// `receipts/` folder of not-yet-uploaded receipt photos, both under +/// `ApplicationDocumentsDirectory/FuelTaxTracker`. +/// +/// Every row carries `updated_at` (for newest-wins merging), `deleted_at` +/// (a soft-delete tombstone — see [Vehicle.deletedAt]/[FuelEntry.deletedAt] +/// for why deletes aren't real `DELETE`s), and `dirty` (local-only: "not +/// yet pushed to Drive"). `dirty` is intentionally not exposed on the +/// domain model classes — it's sync bookkeeping the UI layer never needs to +/// know about; only [DriveSyncService] reads/clears it. +class DatabaseService { + late Directory _rootDirectory; + late Database _db; + + Directory get rootDirectory => _rootDirectory; + + Directory get receiptsDirectory => + Directory(p.join(_rootDirectory.path, receiptsFolderName)); + + /// Raw handle for [DriveSyncService], which needs to run ATTACH-based + /// merge SQL and VACUUM INTO snapshots that go beyond simple CRUD. + Database get rawDb => _db; + + String get databasePath => _db.path; + + Future init() async { + final docsDir = await getApplicationDocumentsDirectory(); + _rootDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker')); + await _rootDirectory.create(recursive: true); + await receiptsDirectory.create(recursive: true); + + final dbPath = p.join(_rootDirectory.path, dbFileName); + _db = await openDatabase(dbPath, version: 1, onCreate: _onCreate); + } + + Future _onCreate(Database db, int version) async { + await db.execute(createVehiclesTableSql); + await db.execute(createFuelEntriesTableSql); + await db.execute(createFuelEntriesIndexSql); + } + + Future> getVehicles() async { + final rows = await _db.query('vehicles', where: 'deleted_at IS NULL'); + return rows.map(Vehicle.fromMap).toList(); + } + + Future> getFuelEntries() async { + final rows = await _db.query('fuel_entries', where: 'deleted_at IS NULL'); + return rows.map(FuelEntry.fromMap).toList(); + } + + /// Inserts or fully overwrites a vehicle row and marks it dirty (pending + /// push to Drive). + Future saveVehicle(Vehicle vehicle) async { + final map = Map.from(vehicle.toMap())..['dirty'] = 1; + await _db.insert('vehicles', map, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future saveFuelEntry(FuelEntry entry) async { + final map = Map.from(entry.toMap())..['dirty'] = 1; + await _db.insert('fuel_entries', map, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future softDeleteVehicle(String id, DateTime deletedAt) async { + await _db.update( + 'vehicles', + { + 'deleted_at': deletedAt.millisecondsSinceEpoch, + 'updated_at': deletedAt.millisecondsSinceEpoch, + 'dirty': 1, + }, + where: 'id = ?', + whereArgs: [id], + ); + } + + Future softDeleteFuelEntry(String id, DateTime deletedAt) async { + await _db.update( + 'fuel_entries', + { + 'deleted_at': deletedAt.millisecondsSinceEpoch, + 'updated_at': deletedAt.millisecondsSinceEpoch, + 'dirty': 1, + }, + where: 'id = ?', + whereArgs: [id], + ); + } + + /// Soft-deletes every active fuel entry for [vehicleId] (cascade for a + /// vehicle deletion) and returns their local receipt image paths, if + /// any, so the caller can clean those files up too. + Future> softDeleteFuelEntriesForVehicle( + String vehicleId, + DateTime deletedAt, + ) async { + final rows = await _db.query( + 'fuel_entries', + where: 'vehicle_id = ? AND deleted_at IS NULL', + whereArgs: [vehicleId], + ); + final localPaths = + rows.map((r) => r['receipt_image_path'] as String?).whereType().toList(); + + await _db.update( + 'fuel_entries', + { + 'deleted_at': deletedAt.millisecondsSinceEpoch, + 'updated_at': deletedAt.millisecondsSinceEpoch, + 'dirty': 1, + }, + where: 'vehicle_id = ? AND deleted_at IS NULL', + whereArgs: [vehicleId], + ); + + return localPaths; + } + + Future storeReceiptImage(File sourceImage, String fuelEntryId) async { + final ext = p.extension(sourceImage.path); + final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext'); + final copied = await sourceImage.copy(destPath); + return copied.path; + } + + Future deleteReceiptImageFile(String? path) async { + if (path == null) return; + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } +} diff --git a/lib/services/db_schema.dart b/lib/services/db_schema.dart new file mode 100644 index 0000000..1ac90ce --- /dev/null +++ b/lib/services/db_schema.dart @@ -0,0 +1,61 @@ +const createVehiclesTableSql = ''' + CREATE TABLE vehicles ( + id TEXT PRIMARY KEY, + make TEXT NOT NULL, + model TEXT NOT NULL, + color TEXT NOT NULL, + license_plate TEXT NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + dirty INTEGER NOT NULL DEFAULT 1 + ) +'''; + +const createFuelEntriesTableSql = ''' + CREATE TABLE fuel_entries ( + id TEXT PRIMARY KEY, + vehicle_id TEXT NOT NULL, + date INTEGER NOT NULL, + gallons REAL NOT NULL, + price_per_gallon REAL NOT NULL, + total_cost REAL NOT NULL, + receipt_image_path TEXT, + receipt_drive_file_id TEXT, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + dirty INTEGER NOT NULL DEFAULT 1 + ) +'''; + +const createFuelEntriesIndexSql = + 'CREATE INDEX idx_fuel_entries_vehicle_id ON fuel_entries(vehicle_id)'; + +/// Merges attached `remote_db` rows into `main` (the live local database): +/// any remote row that's new to us, or newer than our copy, replaces ours. +/// Rows this doesn't touch — including our own not-yet-pushed edits — are +/// left alone, since the WHERE clause only matches rows remote should win; +/// no separate "keep local" statement is needed. +const mergeVehiclesSql = ''' + INSERT OR REPLACE INTO main.vehicles + (id, make, model, color, license_plate, updated_at, deleted_at, dirty) + SELECT r.id, r.make, r.model, r.color, r.license_plate, r.updated_at, r.deleted_at, 0 + FROM remote_db.vehicles r + LEFT JOIN main.vehicles l ON l.id = r.id + WHERE l.id IS NULL OR r.updated_at > l.updated_at +'''; + +/// Same rule as [mergeVehiclesSql]. `receipt_image_path` is always forced +/// to NULL on the merged-in copy — it's a local filesystem path, meaningless +/// (and never valid) on another device, and rows are only ever pushed to +/// Drive once their pending receipt has already been uploaded there, so a +/// remote row should never legitimately have one anyway. +const mergeFuelEntriesSql = ''' + INSERT OR REPLACE INTO main.fuel_entries + (id, vehicle_id, date, gallons, price_per_gallon, total_cost, + receipt_image_path, receipt_drive_file_id, updated_at, deleted_at, dirty) + SELECT r.id, r.vehicle_id, r.date, r.gallons, r.price_per_gallon, r.total_cost, + NULL, r.receipt_drive_file_id, r.updated_at, r.deleted_at, 0 + FROM remote_db.fuel_entries r + LEFT JOIN main.fuel_entries l ON l.id = r.id + WHERE l.id IS NULL OR r.updated_at > l.updated_at +'''; diff --git a/lib/services/drive_service.dart b/lib/services/drive_service.dart index a81c222..3a02a3a 100644 --- a/lib/services/drive_service.dart +++ b/lib/services/drive_service.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'package:googleapis/drive/v3.dart' as drive; @@ -6,7 +5,7 @@ import 'package:http/http.dart' as http; const appFolderName = 'MO-Fuel-Tax-Back'; const receiptsFolderName = 'receipts'; -const dataFileName = 'fuel_tax_data.json'; +const dataFileName = 'fuel_tax_tracker.db'; const _folderMimeType = 'application/vnd.google-apps.folder'; @@ -17,6 +16,16 @@ class DriveFolder { DriveFolder({required this.id, required this.name}); } +class DriveDataFileInfo { + final String id; + + /// Cheap change-detection signal: compare against the last value seen to + /// decide whether a pull is actually needed, without downloading content. + final String? md5Checksum; + + DriveDataFileInfo({required this.id, required this.md5Checksum}); +} + class DriveLockFile { final String id; final String username; @@ -87,23 +96,21 @@ class DriveService { return created.id!; } - Future findDataFileId(String appFolderId) async { + /// Looks up the shared data file's ID and md5 checksum without + /// downloading its content, so sync can cheaply decide whether a pull is + /// actually needed. + Future findDataFile(String appFolderId) async { final result = await _api.files.list( q: "'$appFolderId' in parents and trashed=false and name='$dataFileName'", orderBy: 'modifiedTime desc', - $fields: 'files(id,name)', + $fields: 'files(id,name,md5Checksum)', spaces: 'drive', ); - return (result.files ?? []).firstOrNullWithId(); - } - - Future downloadTextFile(String fileId) async { - final media = await _api.files.get( - fileId, - downloadOptions: drive.DownloadOptions.fullMedia, - ) as drive.Media; - final bytes = await _collectBytes(media.stream); - return utf8.decode(bytes); + final files = result.files ?? []; + if (files.isEmpty) return null; + final first = files.first; + if (first.id == null) return null; + return DriveDataFileInfo(id: first.id!, md5Checksum: first.md5Checksum); } Future> downloadFileBytes(String fileId) async { @@ -115,17 +122,20 @@ class DriveService { } /// Creates the data file if [existingFileId] is null, otherwise - /// overwrites its content. Returns the (possibly new) file ID. - Future uploadDataFile({ + /// overwrites its content with the bytes of the local sqlite database + /// file (see [DriveSyncService]). Returns the (possibly new) file ID and + /// its fresh md5 checksum, so the caller can remember it for + /// change-detection on the next sync without an extra round-trip. + Future uploadDataFile({ required String appFolderId, required String? existingFileId, - required String jsonContent, + required File localSnapshotFile, }) async { - final bytes = utf8.encode(jsonContent); + final length = await localSnapshotFile.length(); final media = drive.Media( - Stream.value(bytes), - bytes.length, - contentType: 'application/json', + localSnapshotFile.openRead(), + length, + contentType: 'application/x-sqlite3', ); if (existingFileId != null) { @@ -133,8 +143,9 @@ class DriveService { drive.File(), existingFileId, uploadMedia: media, + $fields: 'id,md5Checksum', ); - return updated.id ?? existingFileId; + return DriveDataFileInfo(id: updated.id ?? existingFileId, md5Checksum: updated.md5Checksum); } final created = await _api.files.create( @@ -142,8 +153,9 @@ class DriveService { ..name = dataFileName ..parents = [appFolderId], uploadMedia: media, + $fields: 'id,md5Checksum', ); - return created.id!; + return DriveDataFileInfo(id: created.id!, md5Checksum: created.md5Checksum); } Future uploadReceiptImage({ diff --git a/lib/services/drive_sync_service.dart b/lib/services/drive_sync_service.dart index e2958e6..955cb72 100644 --- a/lib/services/drive_sync_service.dart +++ b/lib/services/drive_sync_service.dart @@ -2,50 +2,51 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; -import '../models/fuel_entry.dart'; -import '../models/vehicle.dart'; +import 'database_service.dart'; +import 'db_schema.dart'; import 'drive_auth_service.dart'; import 'drive_service.dart'; import 'lock_coordinator.dart' as lock; -import 'merge_utils.dart'; -import 'storage_service.dart'; class SyncResult { final bool ranSync; - final List? vehicles; - final List? fuelEntries; final Object? error; SyncResult.skipped() : ranSync = false, - vehicles = null, - fuelEntries = null, error = null; - SyncResult.success({required this.vehicles, required this.fuelEntries}) + SyncResult.success() : ranSync = true, error = null; - SyncResult.failure(this.error) - : ranSync = false, - vehicles = null, - fuelEntries = null; + SyncResult.failure(this.error) : ranSync = false; } -/// Orchestrates one round of push-local/pull-remote sync against the shared -/// Drive folder: acquires the cross-device file lock, downloads and merges -/// the remote data file with local changes, uploads any pending receipt -/// photos, writes the merged data file back, then releases the lock. +/// Orchestrates one round of sync against the shared Drive folder: +/// acquires the cross-device lock, pulls + merges the remote database if +/// it changed, uploads any pending receipt photos, pushes the local +/// database back up, then releases the lock. +/// +/// The merge itself runs as SQL directly against the local database with +/// the downloaded remote copy `ATTACH`ed, rather than decoding records into +/// Dart objects: for each table, `INSERT OR REPLACE` any remote row that's +/// new to us or has a newer `updated_at` than our copy. Rows we haven't +/// touched (including our own not-yet-pushed edits) are left alone by that +/// statement, so no separate "keep local" step is needed — see the README +/// for the full reasoning. class DriveSyncService { final DriveAuthService authService; - final StorageService storageService; + final DatabaseService databaseService; String? _appFolderId; String? _receiptsFolderId; String? _dataFileId; + String? _lastKnownRemoteMd5; - DriveSyncService({required this.authService, required this.storageService}); + DriveSyncService({required this.authService, required this.databaseService}); bool get isConfigured => _appFolderId != null; @@ -54,12 +55,14 @@ class DriveSyncService { _appFolderId = appFolderId; _receiptsFolderId = null; _dataFileId = null; + _lastKnownRemoteMd5 = null; } void clearConfiguration() { _appFolderId = null; _receiptsFolderId = null; _dataFileId = null; + _lastKnownRemoteMd5 = null; } /// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a @@ -77,10 +80,7 @@ class DriveSyncService { } } - Future syncNow({ - required List localVehicles, - required List localFuelEntries, - }) async { + Future syncNow() async { final appFolderId = _appFolderId; if (!authService.isSignedIn || appFolderId == null) { return SyncResult.skipped(); @@ -101,42 +101,24 @@ class DriveSyncService { ); _receiptsFolderId ??= await drive.findOrCreateReceiptsFolder(appFolderId); - _dataFileId ??= await drive.findDataFileId(appFolderId); - var remoteVehicles = []; - var remoteFuelEntries = []; - if (_dataFileId != null) { - final text = await drive.downloadTextFile(_dataFileId!); - if (text.trim().isNotEmpty) { - final decoded = storageService.decodeData(text); - remoteVehicles = decoded.vehicles; - remoteFuelEntries = decoded.entries; - } + final remoteInfo = await drive.findDataFile(appFolderId); + _dataFileId = remoteInfo?.id; + if (remoteInfo != null && remoteInfo.md5Checksum != _lastKnownRemoteMd5) { + await _pullAndMerge(drive, remoteInfo.id); } - final mergedVehicles = mergeById( - remoteVehicles, - localVehicles, - (v) => v.id, - (v) => v.updatedAt, - ); - final mergedFuelEntries = await _uploadPendingReceiptsAndMerge( - drive: drive, - remoteFuelEntries: remoteFuelEntries, - localFuelEntries: localFuelEntries, - ); + final allReceiptsUploaded = await _uploadPendingReceipts(drive); - final jsonContent = storageService.encodeData( - vehicles: mergedVehicles, - entries: mergedFuelEntries, - ); - _dataFileId = await drive.uploadDataFile( - appFolderId: appFolderId, - existingFileId: _dataFileId, - jsonContent: jsonContent, - ); + // Only push if every pending receipt made it up — otherwise we'd + // either upload a row with a local-only file path (meaningless on + // another device) or prematurely mark it clean. + if (allReceiptsUploaded) { + final pushedInfo = await _pushSnapshot(drive, appFolderId); + _lastKnownRemoteMd5 = pushedInfo.md5Checksum; + } - return SyncResult.success(vehicles: mergedVehicles, fuelEntries: mergedFuelEntries); + return SyncResult.success(); } catch (e) { return SyncResult.failure(e); } finally { @@ -152,43 +134,108 @@ class DriveSyncService { } } - Future> _uploadPendingReceiptsAndMerge({ - required DriveService drive, - required List remoteFuelEntries, - required List localFuelEntries, - }) async { - final merged = mergeById( - remoteFuelEntries, - localFuelEntries, - (e) => e.id, - (e) => e.updatedAt, + Future _pullAndMerge(DriveService drive, String remoteFileId) async { + final bytes = await drive.downloadFileBytes(remoteFileId); + final tempDir = await getTemporaryDirectory(); + final tempPath = + p.join(tempDir.path, 'drive_pull_${DateTime.now().microsecondsSinceEpoch}.db'); + final tempFile = File(tempPath); + await tempFile.writeAsBytes(bytes, flush: true); + + try { + final db = databaseService.rawDb; + await db.execute("ATTACH DATABASE '${_escapeSqlLiteral(tempPath)}' AS remote_db"); + try { + await db.execute(mergeVehiclesSql); + await db.execute(mergeFuelEntriesSql); + } finally { + try { + await db.execute('DETACH DATABASE remote_db'); + } catch (_) { + // Don't let a detach failure mask a real merge error above. + } + } + } finally { + if (await tempFile.exists()) { + await tempFile.delete(); + } + } + } + + /// Uploads any locally-pending receipt images (dirty fuel entries that + /// still have a local path, not yet a Drive file ID). Returns true only + /// if every pending image made it up — see [syncNow] for why a partial + /// failure here blocks the push step entirely rather than pushing + /// something with a leftover local path. + Future _uploadPendingReceipts(DriveService drive) async { + final db = databaseService.rawDb; + final rows = await db.query( + 'fuel_entries', + where: 'dirty = 1 AND receipt_image_path IS NOT NULL AND deleted_at IS NULL', ); - final result = []; - for (final entry in merged) { - if (!entry.hasPendingLocalReceipt) { - result.add(entry); - continue; - } - - final localPath = entry.receiptImagePath!; + var allSucceeded = true; + for (final row in rows) { + final id = row['id'] as String; + final localPath = row['receipt_image_path'] as String; final localFile = File(localPath); + if (!await localFile.exists()) { - result.add(entry); + // Nothing left to upload; clear the dangling reference. + await db.update('fuel_entries', {'receipt_image_path': null}, + where: 'id = ?', whereArgs: [id]); continue; } - final driveFileId = await drive.uploadReceiptImage( - receiptsFolderId: _receiptsFolderId!, - fileName: '${entry.id}${p.extension(localPath)}', - imageFile: localFile, - ); - await storageService.deleteReceiptImage(localPath); - result.add(entry.copyWith(receiptDriveFileId: driveFileId, clearReceiptImagePath: true)); + try { + final driveFileId = await drive.uploadReceiptImage( + receiptsFolderId: _receiptsFolderId!, + fileName: '$id${p.extension(localPath)}', + imageFile: localFile, + ); + await databaseService.deleteReceiptImageFile(localPath); + await db.update( + 'fuel_entries', + {'receipt_drive_file_id': driveFileId, 'receipt_image_path': null}, + where: 'id = ?', + whereArgs: [id], + ); + } catch (_) { + allSucceeded = false; + } } - return result; + return allSucceeded; } + /// Uploads the current local database file as the new remote copy, then + /// clears the dirty flag on every row now that local matches Drive. + /// + /// Reads the live database file directly rather than a `VACUUM INTO` + /// snapshot: sqflite uses SQLite's default rollback-journal mode (not + /// WAL) unless explicitly configured otherwise, so the main file is a + /// complete, valid database as soon as the last write's Future + /// completes. `wal_checkpoint` is run first anyway as cheap insurance in + /// case that ever changes. This also sidesteps `VACUUM INTO` needing + /// SQLite 3.27+, which isn't guaranteed on very old Android versions. + Future _pushSnapshot(DriveService drive, String appFolderId) async { + final db = databaseService.rawDb; + await db.execute('PRAGMA wal_checkpoint(TRUNCATE)'); + + final info = await drive.uploadDataFile( + appFolderId: appFolderId, + existingFileId: _dataFileId, + localSnapshotFile: File(databaseService.databasePath), + ); + _dataFileId = info.id; + + await db.update('vehicles', {'dirty': 0}); + await db.update('fuel_entries', {'dirty': 0}); + + return info; + } + + String _escapeSqlLiteral(String value) => value.replaceAll("'", "''"); + Future _acquireLock( DriveService drive, { required String appFolderId, diff --git a/lib/services/merge_utils.dart b/lib/services/merge_utils.dart deleted file mode 100644 index abc8641..0000000 --- a/lib/services/merge_utils.dart +++ /dev/null @@ -1,23 +0,0 @@ -/// Unions [remote] and [local] by ID, keeping whichever copy of each -/// record has the newer `updatedAt` when a record exists on both sides. -/// This lets two devices' independent new records both survive a sync; -/// it does not propagate deletions (see plan doc's Known limitations). -List mergeById( - List remote, - List local, - String Function(T) idOf, - DateTime Function(T) updatedAtOf, -) { - final merged = {}; - for (final item in remote) { - merged[idOf(item)] = item; - } - for (final item in local) { - final id = idOf(item); - final existing = merged[id]; - if (existing == null || !updatedAtOf(existing).isAfter(updatedAtOf(item))) { - merged[id] = item; - } - } - return merged.values.toList(); -} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart deleted file mode 100644 index 3fc9905..0000000 --- a/lib/services/storage_service.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -import '../models/fuel_entry.dart'; -import '../models/vehicle.dart'; - -/// Owns the local, offline staging area: a `data.json` file holding -/// vehicles and fuel entries, plus a `receipts/` subfolder holding receipt -/// photos not yet uploaded to Drive. This is always -/// `ApplicationDocumentsDirectory/FuelTaxTracker` — not user-configurable — -/// since Drive (via [DriveSyncService]) is now the real shared destination -/// and this is just a local cache/holding area used while offline. -class StorageService { - static const _dataFileName = 'fuel_tax_data.json'; - static const _receiptsFolderName = 'receipts'; - - late Directory _saveDirectory; - - Directory get saveDirectory => _saveDirectory; - - Directory get receiptsDirectory => - Directory(p.join(_saveDirectory.path, _receiptsFolderName)); - - File get dataFile => File(p.join(_saveDirectory.path, _dataFileName)); - - /// Must be called once before any other method. - Future init() async { - final docsDir = await getApplicationDocumentsDirectory(); - _saveDirectory = Directory(p.join(docsDir.path, 'FuelTaxTracker')); - - await _saveDirectory.create(recursive: true); - await receiptsDirectory.create(recursive: true); - } - - Future<({List vehicles, List entries})> loadData() async { - if (!await dataFile.exists()) { - return (vehicles: [], entries: []); - } - - final raw = await dataFile.readAsString(); - if (raw.trim().isEmpty) { - return (vehicles: [], entries: []); - } - - return decodeData(raw); - } - - ({List vehicles, List entries}) decodeData(String raw) { - final json = jsonDecode(raw) as Map; - final vehicles = (json['vehicles'] as List? ?? []) - .map((v) => Vehicle.fromJson(v as Map)) - .toList(); - final entries = (json['fuelEntries'] as List? ?? []) - .map((e) => FuelEntry.fromJson(e as Map)) - .toList(); - - return (vehicles: vehicles, entries: entries); - } - - String encodeData({ - required List vehicles, - required List entries, - }) { - final json = { - 'vehicles': vehicles.map((v) => v.toJson()).toList(), - 'fuelEntries': entries.map((e) => e.toJson()).toList(), - }; - return const JsonEncoder.withIndent(' ').convert(json); - } - - Future saveData({ - required List vehicles, - required List entries, - }) async { - await dataFile.writeAsString(encodeData(vehicles: vehicles, entries: entries)); - } - - /// Copies a captured receipt image into the local receipts folder and - /// returns the path it was stored at, pending upload to Drive. - Future storeReceiptImage(File sourceImage, String fuelEntryId) async { - final ext = p.extension(sourceImage.path); - final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext'); - final copied = await sourceImage.copy(destPath); - return copied.path; - } - - Future deleteReceiptImage(String? path) async { - if (path == null) return; - final file = File(path); - if (await file.exists()) { - await file.delete(); - } - } -} diff --git a/pubspec.lock b/pubspec.lock index b8bcfb2..e270ecb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -208,6 +208,14 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" google_identity_services_web: dependency: transitive description: @@ -472,6 +480,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" nested: dependency: transitive description: @@ -677,6 +693,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_common_ffi: + dependency: "direct dev" + description: + name: sqflite_common_ffi + sha256: "5ccd38136edb9beb3213f6927775d52db70dfdadcdb28dad1f625ca9f2b9824f" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478" + url: "https://pub.dev" + source: hosted + version: "3.5.1" stack_trace: dependency: transitive description: @@ -701,6 +773,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" + url: "https://pub.dev" + source: hosted + version: "3.4.1+1" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b0d5ff1..85baed6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,10 +71,17 @@ dependencies: # Detect connectivity changes to trigger background sync connectivity_plus: ^6.1.0 + # Local SQLite database + sqflite: ^2.4.1 + dev_dependencies: flutter_test: sdk: flutter + # Pure-Dart/FFI SQLite backend so database logic (the ATTACH-based merge) + # can be unit tested on the Dart VM, without a real Android/iOS device. + sqflite_common_ffi: ^2.3.4+4 + # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your diff --git a/test/db_merge_test.dart b/test/db_merge_test.dart new file mode 100644 index 0000000..8acea9d --- /dev/null +++ b/test/db_merge_test.dart @@ -0,0 +1,172 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/services/db_schema.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Exercises the exact ATTACH + INSERT OR REPLACE merge SQL the app runs +/// (see [db_schema.dart] / DriveSyncService._pullAndMerge), against real +/// temporary SQLite files via sqflite_common_ffi — the pure-Dart backend +/// that lets sqflite run on the Dart VM for tests, without a real device. +void main() { + late Directory tempDir; + late Database local; + late Database remote; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('db_merge_test_'); + local = await _openFreshDb(p.join(tempDir.path, 'local.db')); + remote = await _openFreshDb(p.join(tempDir.path, 'remote.db')); + }); + + tearDown(() async { + await local.close(); + await remote.close(); + await tempDir.delete(recursive: true); + }); + + Future merge() async { + await local.execute("ATTACH DATABASE '${remote.path}' AS remote_db"); + try { + await local.execute(mergeVehiclesSql); + await local.execute(mergeFuelEntriesSql); + } finally { + await local.execute('DETACH DATABASE remote_db'); + } + } + + group('vehicle merge', () { + test('pulls in a vehicle that only exists on remote', () async { + await remote.insert('vehicles', _vehicleRow(id: 'v1', updatedAt: 1000)); + + await merge(); + + final rows = await local.query('vehicles'); + expect(rows, hasLength(1)); + expect(rows.first['id'], 'v1'); + expect(rows.first['dirty'], 0); + }); + + test('leaves a local-only dirty vehicle untouched', () async { + await local.insert('vehicles', _vehicleRow(id: 'v1', updatedAt: 1000, dirty: 1)); + + await merge(); + + final rows = await local.query('vehicles'); + expect(rows, hasLength(1)); + expect(rows.first['dirty'], 1); + }); + + test('remote wins when strictly newer than local', () async { + await local.insert( + 'vehicles', _vehicleRow(id: 'v1', make: 'OldMake', updatedAt: 1000, dirty: 1)); + await remote.insert('vehicles', _vehicleRow(id: 'v1', make: 'NewMake', updatedAt: 2000)); + + await merge(); + + final rows = await local.query('vehicles'); + expect(rows, hasLength(1)); + expect(rows.first['make'], 'NewMake'); + expect(rows.first['dirty'], 0); + }); + + test('local wins on a tie or when strictly newer', () async { + await local.insert( + 'vehicles', _vehicleRow(id: 'v1', make: 'MineNewer', updatedAt: 2000, dirty: 1)); + await remote.insert('vehicles', _vehicleRow(id: 'v1', make: 'TheirsOlder', updatedAt: 1000)); + + await merge(); + + final rows = await local.query('vehicles'); + expect(rows, hasLength(1)); + expect(rows.first['make'], 'MineNewer'); + expect(rows.first['dirty'], 1, reason: 'still pending push since local was not overwritten'); + }); + }); + + group('fuel entry merge', () { + test('adopts a newer tombstone from remote (deletion propagates)', () async { + await local.insert('fuel_entries', + _fuelEntryRow(id: 'f1', vehicleId: 'v1', updatedAt: 1000, dirty: 0)); + await remote.insert( + 'fuel_entries', + _fuelEntryRow(id: 'f1', vehicleId: 'v1', updatedAt: 2000, deletedAt: 2000), + ); + + await merge(); + + final rows = await local.query('fuel_entries'); + expect(rows, hasLength(1)); + expect(rows.first['deleted_at'], 2000); + }); + + test('never adopts a receipt_image_path value from remote', () async { + // Defensive case: even if a remote row somehow had a local-looking + // path in that column, the merge must not copy it onto this device. + await remote.insert( + 'fuel_entries', + _fuelEntryRow(id: 'f1', vehicleId: 'v1', updatedAt: 1000) + ..['receipt_image_path'] = '/some/other/devices/path.jpg', + ); + + await merge(); + + final rows = await local.query('fuel_entries'); + expect(rows, hasLength(1)); + expect(rows.first['receipt_image_path'], isNull); + }); + }); +} + +Future _openFreshDb(String path) async { + final db = await databaseFactory.openDatabase(path); + await db.execute(createVehiclesTableSql); + await db.execute(createFuelEntriesTableSql); + await db.execute(createFuelEntriesIndexSql); + return db; +} + +Map _vehicleRow({ + required String id, + String make = 'Ford', + int updatedAt = 0, + int? deletedAt, + int dirty = 0, +}) => + { + 'id': id, + 'make': make, + 'model': 'F-150', + 'color': 'Red', + 'license_plate': 'ABC123', + 'updated_at': updatedAt, + 'deleted_at': deletedAt, + 'dirty': dirty, + }; + +Map _fuelEntryRow({ + required String id, + required String vehicleId, + int updatedAt = 0, + int? deletedAt, + int dirty = 0, +}) => + { + 'id': id, + 'vehicle_id': vehicleId, + 'date': updatedAt, + 'gallons': 10.0, + 'price_per_gallon': 3.5, + 'total_cost': 35.0, + 'receipt_image_path': null, + 'receipt_drive_file_id': null, + 'updated_at': updatedAt, + 'deleted_at': deletedAt, + 'dirty': dirty, + }; diff --git a/test/merge_utils_test.dart b/test/merge_utils_test.dart deleted file mode 100644 index 0aa8374..0000000 --- a/test/merge_utils_test.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:fuel_tax_tracker/services/merge_utils.dart'; - -class _Record { - final String id; - final String value; - final DateTime updatedAt; - - _Record(this.id, this.value, this.updatedAt); -} - -void main() { - group('mergeById', () { - test('unions records that only exist on one side', () { - final remote = [_Record('a', 'remote-a', DateTime(2024, 1, 1))]; - final local = [_Record('b', 'local-b', DateTime(2024, 1, 1))]; - - final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt); - - expect(result.map((r) => r.id), containsAll(['a', 'b'])); - expect(result.length, 2); - }); - - test('keeps the newer updatedAt when a record exists on both sides', () { - final remote = [_Record('a', 'remote-old', DateTime(2024, 1, 1))]; - final local = [_Record('a', 'local-new', DateTime(2024, 6, 1))]; - - final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt); - - expect(result.length, 1); - expect(result.first.value, 'local-new'); - }); - - test('keeps the remote copy when it is newer than local', () { - final remote = [_Record('a', 'remote-new', DateTime(2024, 6, 1))]; - final local = [_Record('a', 'local-old', DateTime(2024, 1, 1))]; - - final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt); - - expect(result.length, 1); - expect(result.first.value, 'remote-new'); - }); - - test('local wins ties', () { - final sameTime = DateTime(2024, 1, 1); - final remote = [_Record('a', 'remote', sameTime)]; - final local = [_Record('a', 'local', sameTime)]; - - final result = mergeById(remote, local, (r) => r.id, (r) => r.updatedAt); - - expect(result.first.value, 'local'); - }); - }); -} From 483fbeafb6b81de8c256b6480c3dda93ec71770c Mon Sep 17 00:00:00 2001 From: Courtney Arnold Date: Thu, 13 Aug 2026 17:05:10 -0500 Subject: [PATCH 3/3] Remote sync of webdav --- README.md | 329 +++++++++++--- android/app/src/main/AndroidManifest.xml | 16 + ios/Runner/Info.plist | 21 + lib/main.dart | 26 ++ lib/models/fuel_entry.dart | 27 +- lib/models/vehicle.dart | 61 +-- lib/screens/add_edit_vehicle_screen.dart | 175 +++++--- ....dart => cloud_folder_browser_screen.dart} | 76 ++-- lib/screens/confirm_fuel_entry_screen.dart | 19 +- lib/screens/home_screen.dart | 18 +- lib/screens/receipt_image_screen.dart | 22 +- lib/screens/settings_screen.dart | 219 +++++++++- lib/screens/vehicle_detail_screen.dart | 58 ++- lib/services/app_state.dart | 282 +++++++++--- .../cloud/cloud_storage_provider.dart | 153 +++++++ lib/services/cloud/dropbox_provider.dart | 364 ++++++++++++++++ lib/services/cloud/google_drive_provider.dart | 263 +++++++++++ lib/services/cloud/oauth_pkce.dart | 31 ++ lib/services/cloud/onedrive_provider.dart | 350 +++++++++++++++ lib/services/cloud/webdav_provider.dart | 390 +++++++++++++++++ lib/services/cloud_oauth_config.dart | 52 +++ lib/services/cloud_sync_service.dart | 324 ++++++++++++++ lib/services/database_service.dart | 147 ++++++- lib/services/db_schema.dart | 28 +- lib/services/drive_auth_service.dart | 108 ----- lib/services/drive_oauth_config.dart | 20 - lib/services/drive_service.dart | 241 ---------- lib/services/drive_sync_service.dart | 251 ----------- lib/services/lock_coordinator.dart | 9 +- lib/services/receipt_parser.dart | 186 +++++++- lib/services/vin_parser.dart | 31 ++ lib/widgets/image_source_sheet.dart | 28 ++ pubspec.lock | 168 ++++++- pubspec.yaml | 25 ++ test/cloud_sync_service_test.dart | 196 +++++++++ test/db_merge_test.dart | 62 ++- test/lock_coordinator_test.dart | 22 +- test/pending_receipt_upload_test.dart | 98 +++++ test/receipt_parser_test.dart | 411 ++++++++++++++++++ test/receipt_storage_test.dart | 89 ++++ test/vehicle_id_migration_test.dart | 137 ++++++ test/vin_parser_test.dart | 47 ++ test/webdav_provider_credentials_test.dart | 115 +++++ test/webdav_provider_test.dart | 122 ++++++ 44 files changed, 4842 insertions(+), 975 deletions(-) rename lib/screens/{drive_folder_browser_screen.dart => cloud_folder_browser_screen.dart} (68%) create mode 100644 lib/services/cloud/cloud_storage_provider.dart create mode 100644 lib/services/cloud/dropbox_provider.dart create mode 100644 lib/services/cloud/google_drive_provider.dart create mode 100644 lib/services/cloud/oauth_pkce.dart create mode 100644 lib/services/cloud/onedrive_provider.dart create mode 100644 lib/services/cloud/webdav_provider.dart create mode 100644 lib/services/cloud_oauth_config.dart create mode 100644 lib/services/cloud_sync_service.dart delete mode 100644 lib/services/drive_auth_service.dart delete mode 100644 lib/services/drive_oauth_config.dart delete mode 100644 lib/services/drive_service.dart delete mode 100644 lib/services/drive_sync_service.dart create mode 100644 lib/services/vin_parser.dart create mode 100644 lib/widgets/image_source_sheet.dart create mode 100644 test/cloud_sync_service_test.dart create mode 100644 test/pending_receipt_upload_test.dart create mode 100644 test/receipt_storage_test.dart create mode 100644 test/vehicle_id_migration_test.dart create mode 100644 test/vin_parser_test.dart create mode 100644 test/webdav_provider_credentials_test.dart create mode 100644 test/webdav_provider_test.dart diff --git a/README.md b/README.md index ae46090..4c430f0 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,48 @@ A Flutter app (Android + iOS) for logging fuel purchases per vehicle. Snap a photo of a gas receipt, it OCRs the gallons/price-per-gallon/total on-device, you confirm or correct the numbers, and it's saved alongside the receipt photo. Vehicles and fuel entries live in a local SQLite database that's kept -in sync with a shared Google Drive folder, so multiple people can log fuel -against the same pool of vehicles from their own phones, offline or online. +in sync with a shared folder on **Google Drive, Dropbox, OneDrive, or your +own WebDAV server** (your choice), so multiple people can log fuel against +the same pool of vehicles from their own phones, offline or online. ## Features -- Manage a list of vehicles (make, model, color, license plate). -- Capture a fuel receipt photo per vehicle and OCR it on-device with Google - ML Kit — no internet connection or API key required for the OCR itself. -- Review/edit the parsed gallons, price per gallon, and total cost before - saving (OCR on printed receipts is usually good but not perfect). +- Manage a list of vehicles, identified by **VIN** (required, and unique + across active vehicles — but editable, e.g. to fix a typo from a misread + scan; scan it from a photo of the door-jamb sticker or dashboard plate + instead of typing all 17 characters). An optional **nickname** ("Mom's + Car", "Red Ford F-150") is what's shown as the primary label everywhere; + without one, the VIN is shown instead. +- Capture a fuel receipt photo per vehicle — from the camera or an existing + photo in your library — and OCR it on-device with Google ML Kit — no + internet connection or API key required for the OCR itself. +- If a receipt's address shows a state other than Missouri, a warning + explains that the fuel tax refund only covers Missouri purchases and lets + you choose whether to log the entry anyway. +- Review/edit the parsed gallons, price per gallon, total cost, *and* + date/time before saving (OCR on printed receipts is usually good but not + perfect; the date/time picker is always available as a manual fallback + when a date can't be found on the receipt). - Per-vehicle fuel log with running gallons total, tap-to-zoom receipt photos, and delete. -- Connect Google Drive and pick any folder you have access to — including - one someone else created and shared with you — as the shared storage - location. The app looks for (or creates) a `MO-Fuel-Tax-Back` subfolder - there, so anyone pointed at the same shared parent converges on the same - data automatically. +- Connect **Google Drive, Dropbox, OneDrive, or a self-hosted WebDAV + server** (Nextcloud, ownCloud, a Synology NAS, or any generic WebDAV + endpoint) and pick any folder you have access to — including one someone + else created and shared with you — as the shared storage location. The + app looks for (or creates) a `MO-Fuel-Tax-Back` subfolder there, so + anyone pointed at the same shared parent converges on the same data + automatically, regardless of which of the four providers each person is + using. WebDAV is the only one of the four that needs no developer-console + setup at all — just a server URL, username, and password. - Works fully offline: writes always land in the local SQLite database - first; a background sync pushes changes to Drive and pulls others' + first; a background sync pushes changes to the cloud and pulls others' changes down once online. -- Once a receipt photo is uploaded to Drive, the local copy is deleted; - viewing it later downloads it fresh from Drive on demand. +- Settings lets you choose whether a receipt photo's local copy is deleted + once it's safely uploaded to the cloud (the default — keeps the phone's + storage footprint small) or kept on the phone for offline viewing. +- Settings → Advanced lets you tune the sync lock's staleness timeout (how + long before another device's abandoned lock is cleared so sync isn't + stuck waiting forever), from 1–60 minutes. ## Project layout @@ -33,9 +53,14 @@ against the same pool of vehicles from their own phones, offline or online. lib/ models/ Vehicle, FuelEntry — plain data classes with SQLite row (de)serialization (toMap/fromMap). Both carry `updatedAt` (merge conflict resolution) and - `deletedAt` (a soft-delete tombstone, so deletions sync too). FuelEntry - carries either a local receiptImagePath (not yet uploaded) or a - receiptDriveFileId (uploaded, local copy removed). + `deletedAt` (a soft-delete tombstone, so deletions sync too). Vehicle's + `id` is a hidden, generated, immutable primary key — never shown in the + UI — that FuelEntry.vehicleId references and sync merges on; `vin` is a + required, unique-among-active-vehicles, but user-editable field, and + `nickname` is optional. FuelEntry can hold a local `receiptImagePath`, a + `receiptDriveFileId`, or both at once if "keep photos on this phone" is + on — see `needsReceiptUpload` vs. `isReceiptUploadedToDrive` for the + distinction. services/ app_state.dart In-memory read cache + CRUD, exposed via Provider. Stamps updatedAt on mutations and triggers background sync. @@ -44,28 +69,59 @@ lib/ ApplicationDocumentsDirectory/FuelTaxTracker. db_schema.dart CREATE TABLE statements and the merge SQL — shared between the app and its tests so they can never drift apart. - drive_auth_service.dart Google sign-in + builds an authenticated http.Client - for the Drive API. - drive_service.dart Raw Drive API calls: browse folders, find-or-create - the MO-Fuel-Tax-Back folder, upload/download files, - lock file operations, cheap md5-based change detection. - drive_sync_service.dart Orchestrates one sync round: acquire the cross-device - lock, ATTACH + merge the remote database into the local - one, upload pending receipt photos, push the local - database back up, release the lock. + cloud/ + cloud_storage_provider.dart The CloudStorageProvider/CloudStorageSession + interface every backend implements (auth, folder + browsing, file upload/download, lock file ops) — + this is what cloud_sync_service.dart and the rest + of the app talk to; they never know which of the + three providers below is actually active. + google_drive_provider.dart Google sign-in + the googleapis DriveApi client. + dropbox_provider.dart Hand-rolled OAuth2 PKCE + Dropbox's path-addressed + REST API (files/list_folder, files/upload, etc). + onedrive_provider.dart Hand-rolled OAuth2 PKCE + Microsoft Graph + (/me/drive/...), ID-addressed like Drive. + webdav_provider.dart Generic WebDAV (PROPFIND/MKCOL/PUT/GET/DELETE) + over HTTP Basic Auth — no OAuth, no developer + console, just a server URL + username + password. + For self-hosted servers (Nextcloud, ownCloud, a + Synology NAS, etc). Path-addressed like Dropbox; + uses the resource's ETag as its versionTag. + oauth_pkce.dart PKCE code_verifier/code_challenge generation, shared + by the Dropbox and OneDrive providers (Google's own + SDK handles its OAuth flow itself, and WebDAV uses + plain Basic Auth, so neither needs this). + cloud_oauth_config.dart Fill in your OAuth client IDs/keys here for whichever + provider(s) you want to use — see setup below. + cloud_sync_service.dart Orchestrates one sync round against whichever + CloudStorageProvider is active: acquire the + cross-device lock, ATTACH + merge the remote database + into the local one, upload pending receipt photos, + push the local database back up, release the lock. lock_coordinator.dart The ticket-based lock-file mutex, as pure injectable - logic (independently unit-tested without real Drive). - drive_oauth_config.dart Fill in your OAuth client IDs here — see setup below. + logic (independently unit-tested without a real backend). ocr_service.dart Thin wrapper around google_mlkit_text_recognition. - receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text. + receipt_parser.dart Regex-based extraction of gallons/price/total/date from OCR + text — falls back to manual entry (date picker, or the + gallons*price derivation) for whatever isn't found. screens/ One file per screen (vehicle list, add/edit vehicle, vehicle detail, - confirm fuel entry, receipt viewer, settings, Drive folder browser). + confirm fuel entry, receipt viewer, settings, cloud folder browser). ``` -## Manual setup required (Google Cloud Console) +## Manual setup required (cloud storage) -This app needs OAuth credentials you create yourself — I can't provision -cloud resources on your behalf. In [Google Cloud Console](https://console.cloud.google.com/): +You only need to complete setup for whichever provider(s) you actually want +to offer in the app. Google Drive, Dropbox, and OneDrive all follow the same +shape: I can't provision cloud resources on your behalf, so each needs an +app/OAuth client you create yourself in that provider's own developer +console, with the resulting IDs pasted into +`lib/services/cloud_oauth_config.dart` — Settings only shows a "Connect" +button for one of these three once its config fields are filled in with +real values. **WebDAV needs none of this** — see its section below. + +### Google Drive (Google Cloud Console) + +In [Google Cloud Console](https://console.cloud.google.com/): 1. Create/select a project, enable the **Google Drive API** (APIs & Services → Library). @@ -89,12 +145,84 @@ cloud resources on your behalf. In [Google Cloud Console](https://console.cloud. `serverClientId`, even for a mobile app). - **iOS**: bundle ID matching the Xcode project. Copy its **Client ID** and note the reversed form (`com.googleusercontent.apps.<...>`). -5. Fill in `lib/services/drive_oauth_config.dart`: - - `androidServerClientId` ← the **Web application** client's ID. - - `iosClientId` ← the **iOS** client's ID. +5. Fill in `lib/services/cloud_oauth_config.dart`: + - `googleAndroidServerClientId` ← the **Web application** client's ID. + - `googleIosClientId` ← the **iOS** client's ID. 6. Replace the placeholder in `ios/Runner/Info.plist`'s `CFBundleURLTypes` → `CFBundleURLSchemes` with your iOS client's reversed ID. +### Dropbox (Dropbox App Console) + +Dropbox has no official Flutter sign-in SDK, so this uses a hand-built +OAuth2 Authorization Code + PKCE flow (no client secret needed — safe for a +public/mobile app) via `flutter_web_auth_2`, which opens the system browser +and catches the redirect through a custom URL scheme already registered in +`AndroidManifest.xml` / `Info.plist`. + +1. Create an app at [dropbox.com/developers/apps](https://www.dropbox.com/developers/apps). +2. Access type: **Full Dropbox** (not "App folder") — needed because + browsing to and reusing a folder someone *else* created and shared + requires seeing the whole account, the same reasoning as Google's full + `drive` scope above. +3. Under **OAuth 2** → **Redirect URIs**, add + `mofueltaxback-dropbox://oauth2redirect` (this exact scheme is already + wired up in the Android manifest and iOS Info.plist; use a different one + only if you also update those two files to match). +4. Copy the app's **App key** from the Settings tab. +5. Fill in `lib/services/cloud_oauth_config.dart`: + - `dropboxAppKey` ← the App key. + - `dropboxRedirectUri` ← `mofueltaxback-dropbox://oauth2redirect`. +6. While the app is in **Development** status, only your own Dropbox + account can sign in; add teammates under the app's **Permissions** / + member-access settings, or apply for **Production** status, once you're + ready to share it with others. + +### OneDrive (Azure Portal / Microsoft Graph) + +Same PKCE approach as Dropbox, against the Microsoft identity platform and +Microsoft Graph. + +1. In [Azure Portal](https://portal.azure.com/) → **Microsoft Entra ID** → + **App registrations** → **New registration**. +2. Supported account types: **Personal Microsoft accounts only** (or "any + organizational directory and personal Microsoft accounts" if you also + want work/school accounts to be able to sign in — those may additionally + need their tenant admin's consent for the scopes below). +3. Under **Authentication** → **Add a platform** → **Mobile and desktop + applications**, add the redirect URI `mofueltaxback-onedrive://auth` + (this exact scheme is already wired up in the Android manifest and iOS + Info.plist; use a different one only if you also update those two files + to match). +4. Under **API permissions**, add Microsoft Graph **delegated** permissions + `Files.ReadWrite.All` and `offline_access` (the latter is required to get + a refresh token back from the token endpoint). +5. Copy the **Application (client) ID** from the Overview page. +6. Fill in `lib/services/cloud_oauth_config.dart`: + - `oneDriveClientId` ← the Application (client) ID. + - `oneDriveRedirectUri` ← `mofueltaxback-onedrive://auth`. + +### WebDAV (self-hosted — no developer console needed) + +This is the option for a personal cloud server you already run — Nextcloud, +ownCloud, a Synology/QNAP NAS's built-in WebDAV support, or a bare +Apache/nginx WebDAV endpoint. There's no OAuth app to register anywhere; +tapping "Connect WebDAV" in Settings just opens a form asking for: + +- **Server URL** — the full WebDAV endpoint, e.g. + `https://cloud.example.com/remote.php/dav/files/yourusername/` for + Nextcloud/ownCloud, or whatever your NAS's WebDAV documentation gives you. + `https://` is assumed if you omit the scheme. +- **Username** and **Password** — for servers that support app-specific + passwords (Nextcloud: Settings → Security → "Create new app password"), + use one of those instead of your real account password, so this app can + be revoked independently later. + +The app verifies the URL and credentials with a harmless `PROPFIND` request +before treating you as signed in, so a typo or wrong password fails +immediately with a clear error rather than surfacing later during sync. +Nothing needs to be filled in `cloud_oauth_config.dart` or the +Android/iOS manifest files for this provider. + ## Running it ``` @@ -111,19 +239,27 @@ flutter build ios --release # iOS (requires a full Xcode install + signing This was scaffolded and verified with Flutter 3.44.9. `flutter analyze` and `flutter test` are clean, and `flutter build apk --debug` has been confirmed -to produce a working APK. The Drive sign-in/sync path needs the manual -OAuth setup above before it can be exercised end-to-end. +to produce a working APK. None of the three providers' sign-in/sync paths +can be exercised end-to-end until you've done that provider's manual OAuth +setup above. ## Permissions - **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and `android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already set up for receipt capture. -- **Network/Drive**: `INTERNET` and `ACCESS_NETWORK_STATE` are declared in - the main Android manifest (needed for release builds; debug builds get - `INTERNET` for free). No manifest changes are needed for `google_sign_in` - itself when not using `google-services.json` — see the manual setup - section above instead. +- **Network**: `INTERNET` and `ACCESS_NETWORK_STATE` are declared in the + main Android manifest (needed for release builds; debug builds get + `INTERNET` for free). +- **OAuth redirects**: no manifest changes are needed for `google_sign_in` + itself when not using `google-services.json` — see the Google setup + section above instead. Dropbox and OneDrive's browser-based OAuth redirect + is already wired up via a `flutter_web_auth_2` callback activity + (Android) / extra `CFBundleURLTypes` entries (iOS) for the + `mofueltaxback-dropbox://` and `mofueltaxback-onedrive://` schemes — you + only need to register the matching redirect URI in each provider's own + console (see setup above), not touch these files, unless you deliberately + choose different scheme names. ## How sync works @@ -134,29 +270,37 @@ Every row carries `updated_at` (for merge resolution), `deleted_at` (a soft-delete tombstone — see below), and a local-only `dirty` flag (pending push to Drive, never itself treated as meaningful sync data). Sync: -1. Creates a lock file `{email}-{utcEpochMillis}.lock` in the shared Drive +1. Creates a lock file `{email}-{utcEpochMillis}.lock` in the shared cloud folder, then waits until no *other* lock file older than its own remains - (polling every second, deleting any it finds older than 10 minutes as - orphaned/stale) — a ticket-based mutex using the Drive folder itself as + (polling every second, deleting any it finds older than the configured + staleness timeout — Settings → Advanced, 1–60 minutes, default 10 — as + orphaned/stale) — a ticket-based mutex using the cloud folder itself as the coordination point, so two devices never overwrite each other's - edits to the shared database mid-write. -2. Checks the remote database file's md5 checksum against the last one seen - (a metadata-only call, no content download) to decide if a pull is even - needed. If it changed: downloads it to a temp file, `ATTACH`es it to the - local database, and runs one `INSERT OR REPLACE ... SELECT ... WHERE - local.id IS NULL OR remote.updated_at > local.updated_at` per table. - Rows that statement doesn't match — including this device's own - not-yet-pushed edits — are left untouched, so no separate "keep local" - step is needed. -3. Uploads any locally-pending receipt photos (rows still holding a local - file path), clearing that path and recording the Drive file ID instead. - If any upload fails, the push step below is skipped entirely this cycle - — a row is never pushed while it still holds a local-only path. + edits to the shared database mid-write. This logic (`lock_coordinator.dart`) + is identical regardless of which of the four providers is active. +2. Checks the remote database file's change-detection tag (Drive's + `md5Checksum`, Dropbox's `content_hash`, OneDrive's `cTag`, or a WebDAV + resource's `ETag` — an opaque `versionTag` as far as the sync engine is + concerned) against the last one seen (a metadata-only call, no content + download) to decide if a pull is even needed. If it changed: downloads + it to a temp file, `ATTACH`es it to + the local database, and runs one `INSERT OR REPLACE ... SELECT ... WHERE + local. IS NULL OR remote.updated_at > local.updated_at` per table + (`id` for both tables — vehicles' hidden generated id, not the + user-editable VIN). Rows that statement doesn't match — including this + device's own not-yet-pushed edits — are left untouched, so no separate + "keep local" step is needed. +3. Uploads any receipt photos still needing it (a local path but no cloud + file ID yet — see `FuelEntry.needsReceiptUpload`), recording the cloud + file ID. The local copy is then deleted or kept depending on the + Settings "keep photos on this phone" toggle. If any upload fails, the + push step below is skipped entirely this cycle — a row is never pushed + while it still holds a local-only, not-yet-uploaded path. 4. Reads the local database file directly (sqflite's default journal mode isn't WAL, so the file is complete and consistent as soon as the last write's `Future` resolves; `PRAGMA wal_checkpoint` runs first anyway as cheap insurance) and uploads it as the new remote copy, then clears the - `dirty` flag on every row now that local matches what's on Drive. + `dirty` flag on every row now that local matches what's on the cloud. 5. Releases the lock. **Deletions propagate correctly**, unlike a naive "union records" merge: @@ -169,24 +313,71 @@ newest-wins rule as any edit — no separate deletion-handling logic needed. - **Field-level conflicts aren't merged.** Two people editing the *same* record at the same time: whole record, newer `updated_at` wins — not a field-by-field merge. -- **Lock acquisition has no hard timeout** beyond the 10-minute staleness - reap. Fine at the scale this is built for (a handful of people); could in - theory spin under many simultaneous contenders. +- **Lock acquisition has no hard timeout** beyond the configurable + staleness reap (Settings → Advanced). Fine at the scale this is built for + (a handful of people); could in theory spin under many simultaneous + contenders. +- **VIN uniqueness is checked, but not race-proof across devices.** Editing + or adding a vehicle checks for an existing active vehicle with the same + VIN before saving, but two offline devices could still each independently + create (or rename into) the same VIN before either has seen the other's + change — same category of accepted limitation as "field-level conflicts + aren't merged" above. `mergeVehiclesSql` merges on the hidden `id`, not + `vin`, specifically so this doesn't corrupt the merge itself if it + happens — you'd just end up with two vehicle rows sharing a VIN until + someone notices and fixes it by hand. - **`google_sign_in` v7's Android path requires a *Web* OAuth client ID** (`serverClientId`), not just the Android client's SHA-1 registration — see the manual setup section. This is a quirk of the Credential Manager-based implementation and easy to miss if you're used to older `google_sign_in` versions. +- **Dropbox and OneDrive's sign-in isn't restored across a cold app + restart.** Their OAuth refresh tokens are kept in memory only for now + (Google's own SDK handles its own persistent session separately, and + WebDAV persists its credentials to OS-encrypted storage — see below — so + this only affects these two hand-built OAuth providers) — + `attemptSilentSignIn()` always returns false for them, so you'll need to + reconnect once per app launch until refresh-token persistence is added. +- **WebDAV credentials are stored via `flutter_secure_storage`** (Keystore + on Android, Keychain on iOS) so `attemptSilentSignIn()` can restore the + session after a cold restart — it re-verifies them with the same PROPFIND + check `signInWithCredentials` uses rather than trusting the stored values + blindly, since the server or password could have changed since. A wrong + or revoked password just silently fails the restore (same as no + connection ever having been made); there's no proactive UI nudge to + reconnect beyond the sync error that shows up on the next sync attempt. +- **No automatic retry-on-401 for Dropbox/OneDrive.** Each session checks + the access token's expiry before every call and refreshes proactively, + but a token revoked or invalidated out-of-band (e.g. from that provider's + own "manage app access" page) surfaces as a failed sync (visible in + Settings as "Last sync failed") rather than prompting a fresh sign-in + automatically. +- **Dropbox has no distinct "Shared with me" tab** in the folder browser + (the interface's `sharedWithMe` parameter is a no-op for + `DropboxProvider`) — Dropbox auto-mounts *accepted* shares directly into + the account's normal folder tree, so they already show up under "My + Files" in the common case. Revisit if an unmounted/pending share needs to + be browsable directly. +- **WebDAV has no "Shared with me" concept at all** (it's not part of the + base WebDAV protocol) — `WebDavProvider.supportsSharedWithMe` is false, + same as Dropbox, and it's up to the server/user to point the app at + whatever path a share is mounted under. WebDAV credentials are also sent + as HTTP Basic Auth on every request, so an **HTTPS server URL is + effectively required** — the app doesn't block a plain `http://` URL, but + it would send the password in the clear. - **Receipt parsing is best-effort regex matching** on the OCR'd text (`lib/services/receipt_parser.dart`), tuned against common receipt phrasing ("GALLONS", "PRICE/GAL", "PPG", "TOTAL", etc.). Unusual receipt layouts may parse partially or not at all — the confirm screen always lets you fill in or correct whatever wasn't found. -- No automated tests exercise the OCR, camera, or real Drive API calls - (those need a real device/emulator and live credentials); `receipt_parser_test.dart`, - `lock_coordinator_test.dart`, and `db_merge_test.dart` (using - `sqflite_common_ffi` to run the real merge SQL against temp SQLite files - on the Dart VM) cover the pure logic pieces against fixed inputs. +- No automated tests exercise the OCR, camera/gallery picker, or real Drive + API calls (those need a real device/emulator and live credentials); + `receipt_parser_test.dart`, `lock_coordinator_test.dart`, + `db_merge_test.dart`, and `pending_receipt_upload_test.dart` (the latter + two using `sqflite_common_ffi` to run real SQL against temp SQLite files + on the Dart VM) cover the pure logic pieces against fixed inputs — + `receipt_parser_test.dart` in particular is transcribed from 13 real + photographed receipts across different gas station chains. - No migration path exists from the earlier JSON-file storage format — not needed since no real data had accumulated under it yet, but flag it if that's no longer true for you. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 830a402..df2009b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -32,6 +32,22 @@ + + + + + + + + + + $(DEVELOPMENT_LANGUAGE) NSCameraUsageDescription Fuel Tax Tracker uses the camera to take photos of fuel receipts. + NSPhotoLibraryUsageDescription + Fuel Tax Tracker uses your photo library so you can attach an existing photo of a fuel receipt. CFBundleDisplayName Fuel Tax Tracker CFBundleExecutable @@ -84,6 +86,25 @@ com.googleusercontent.apps.TODO-REPLACE-WITH-REVERSED-CLIENT-ID + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + mofueltaxback-dropbox + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + mofueltaxback-onedrive + + diff --git a/lib/main.dart b/lib/main.dart index 5733b13..ff1f44e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -41,6 +41,32 @@ class AppRoot extends StatelessWidget { body: Center(child: CircularProgressIndicator()), ); } + if (appState.initError != null) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + size: 48, color: Theme.of(context).colorScheme.error), + const SizedBox(height: 16), + Text( + 'Could not start the app:\n${appState.initError}', + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => appState.init(), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } return const HomeScreen(); }, ); diff --git a/lib/models/fuel_entry.dart b/lib/models/fuel_entry.dart index e500e0e..730c886 100644 --- a/lib/models/fuel_entry.dart +++ b/lib/models/fuel_entry.dart @@ -1,6 +1,10 @@ class FuelEntry { final String id; + + /// References [Vehicle.id] (the hidden, immutable identifier) — not the + /// VIN, which is user-editable and so unsuitable as a foreign key. final String vehicleId; + final DateTime date; final double gallons; final double pricePerGallon; @@ -26,16 +30,21 @@ class FuelEntry { this.deletedAt, }); - /// True once the receipt photo has been uploaded to Drive and the local - /// copy removed. Viewing it then requires downloading it on demand. - bool get isReceiptUploadedToDrive => - receiptImagePath == null && receiptDriveFileId != null; + /// True once the receipt photo has been uploaded to Drive, regardless of + /// whether a local copy is also being kept (see the "keep photos on this + /// phone" setting). Viewing it locally is preferred when a local copy + /// exists; otherwise it requires downloading from Drive on demand. + bool get isReceiptUploadedToDrive => receiptDriveFileId != null; - /// True while a receipt photo exists only locally and still needs to be - /// uploaded next sync. A row in this state is never pushed to Drive as-is - /// (a local file path is meaningless on another device) — sync uploads - /// the image first, which clears this. - bool get hasPendingLocalReceipt => receiptImagePath != null; + /// True while a receipt photo still needs to be uploaded to Drive: a + /// local file exists but hasn't made it there yet. Once + /// [receiptDriveFileId] is set this is false — even if a local copy is + /// also being kept — since that field alone is what marks "no longer + /// needs uploading", independent of local retention. A row in this state + /// is never pushed to Drive as data (a local file path is meaningless on + /// another device) — sync uploads the image first, which sets + /// [receiptDriveFileId] and clears this. + bool get needsReceiptUpload => receiptImagePath != null && receiptDriveFileId == null; FuelEntry copyWith({ String? receiptImagePath, diff --git a/lib/models/vehicle.dart b/lib/models/vehicle.dart index b1a1e76..ca3b57a 100644 --- a/lib/models/vehicle.dart +++ b/lib/models/vehicle.dart @@ -1,9 +1,19 @@ class Vehicle { + /// Hidden, immutable, generated primary key — never shown in the UI and + /// never editable. This is what fuel entries actually reference and what + /// sync merges on, so that [vin] itself is free to be edited without + /// breaking those references or losing sync history. final String id; - final String make; - final String model; - final String color; - final String licensePlate; + + /// The vehicle's identification number. Required and must be unique + /// among active (non-deleted) vehicles, but — unlike [id] — the user can + /// edit it later (e.g. to fix a typo from a misread VIN scan). + final String vin; + + /// User-chosen label, e.g. "Mom's Car" or "Red Ford F-150". Optional — + /// when absent, screens fall back to other identifying info instead. + final String? nickname; + final DateTime updatedAt; /// Soft-delete tombstone: null means active. Deleting sets this instead @@ -15,30 +25,35 @@ class Vehicle { Vehicle({ required this.id, - required this.make, - required this.model, - required this.color, - required this.licensePlate, + required this.vin, required this.updatedAt, + this.nickname, this.deletedAt, }); - String get displayName => '$color $make $model ($licensePlate)'; + /// What screens should show as the vehicle's primary label: the nickname + /// if one was given, otherwise the VIN itself. + String get displayLabel { + final trimmedNickname = nickname?.trim(); + if (trimmedNickname != null && trimmedNickname.isNotEmpty) { + return trimmedNickname; + } + return vin; + } + /// Note: [id] is intentionally not overridable here — it's the hidden + /// identifier, not an editable field. Vehicle copyWith({ - String? make, - String? model, - String? color, - String? licensePlate, + String? vin, + String? nickname, + bool clearNickname = false, DateTime? updatedAt, DateTime? deletedAt, }) { return Vehicle( id: id, - make: make ?? this.make, - model: model ?? this.model, - color: color ?? this.color, - licensePlate: licensePlate ?? this.licensePlate, + vin: vin ?? this.vin, + nickname: clearNickname ? null : (nickname ?? this.nickname), updatedAt: updatedAt ?? this.updatedAt, deletedAt: deletedAt ?? this.deletedAt, ); @@ -46,20 +61,16 @@ class Vehicle { Map toMap() => { 'id': id, - 'make': make, - 'model': model, - 'color': color, - 'license_plate': licensePlate, + 'vin': vin, + 'nickname': nickname, 'updated_at': updatedAt.millisecondsSinceEpoch, 'deleted_at': deletedAt?.millisecondsSinceEpoch, }; factory Vehicle.fromMap(Map map) => Vehicle( id: map['id'] as String, - make: map['make'] as String, - model: map['model'] as String, - color: map['color'] as String, - licensePlate: map['license_plate'] as String, + vin: map['vin'] as String, + nickname: map['nickname'] as String?, updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true), deletedAt: map['deleted_at'] != null ? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true) diff --git a/lib/screens/add_edit_vehicle_screen.dart b/lib/screens/add_edit_vehicle_screen.dart index 4390db9..a901d7d 100644 --- a/lib/screens/add_edit_vehicle_screen.dart +++ b/lib/screens/add_edit_vehicle_screen.dart @@ -1,8 +1,14 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; import '../models/vehicle.dart'; import '../services/app_state.dart'; +import '../services/ocr_service.dart'; +import '../services/vin_parser.dart'; +import '../widgets/image_source_sheet.dart'; /// Add/edit form for a vehicle. Pass an existing [vehicle] to edit it, or /// omit it to create a new one. @@ -17,52 +23,107 @@ class AddEditVehicleScreen extends StatefulWidget { class _AddEditVehicleScreenState extends State { final _formKey = GlobalKey(); - late final TextEditingController _makeController; - late final TextEditingController _modelController; - late final TextEditingController _colorController; - late final TextEditingController _plateController; + late final TextEditingController _nicknameController; + late final TextEditingController _vinController; + + bool _saving = false; + bool _scanningVin = false; + String? _error; bool get _isEditing => widget.vehicle != null; @override void initState() { super.initState(); - _makeController = TextEditingController(text: widget.vehicle?.make ?? ''); - _modelController = TextEditingController(text: widget.vehicle?.model ?? ''); - _colorController = TextEditingController(text: widget.vehicle?.color ?? ''); - _plateController = TextEditingController(text: widget.vehicle?.licensePlate ?? ''); + _nicknameController = TextEditingController(text: widget.vehicle?.nickname ?? ''); + _vinController = TextEditingController(text: widget.vehicle?.vin ?? ''); } @override void dispose() { - _makeController.dispose(); - _modelController.dispose(); - _colorController.dispose(); - _plateController.dispose(); + _nicknameController.dispose(); + _vinController.dispose(); super.dispose(); } + Future _scanVin() async { + final source = await chooseImageSource(context); + if (source == null || !mounted) return; + + final picker = ImagePicker(); + XFile? photo; + try { + photo = await picker.pickImage(source: source, imageQuality: 85); + } catch (e) { + if (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; + + setState(() => _scanningVin = true); + + final ocrService = OcrService(); + String recognizedText = ''; + try { + recognizedText = await ocrService.recognizeText(File(photo.path)); + } catch (_) { + recognizedText = ''; + } finally { + ocrService.dispose(); + } + + if (!mounted) return; + + final vin = VinParser.parse(recognizedText); + setState(() => _scanningVin = false); + + if (vin != null) { + _vinController.text = vin; + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Couldn't read a VIN from that photo. Please enter it manually."), + ), + ); + } + } + Future _save() async { if (!_formKey.currentState!.validate()) return; - final appState = context.read(); - if (_isEditing) { - await appState.updateVehicle(widget.vehicle!.copyWith( - make: _makeController.text.trim(), - model: _modelController.text.trim(), - color: _colorController.text.trim(), - licensePlate: _plateController.text.trim(), - )); - } else { - await appState.addVehicle( - make: _makeController.text.trim(), - model: _modelController.text.trim(), - color: _colorController.text.trim(), - licensePlate: _plateController.text.trim(), - ); - } + setState(() { + _saving = true; + _error = null; + }); - if (mounted) Navigator.of(context).pop(); + final nickname = _nicknameController.text.trim(); + final appState = context.read(); + + try { + if (_isEditing) { + await appState.updateVehicle(widget.vehicle!.copyWith( + vin: _vinController.text.trim(), + nickname: nickname.isEmpty ? null : nickname, + clearNickname: nickname.isEmpty, + )); + } else { + await appState.addVehicle( + vin: _vinController.text.trim(), + nickname: nickname.isEmpty ? null : nickname, + ); + } + + if (mounted) Navigator.of(context).pop(); + } on DuplicateVinException catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _saving = false); + } } Future _confirmDelete() async { @@ -72,7 +133,7 @@ class _AddEditVehicleScreenState extends State { title: const Text('Delete vehicle?'), content: Text( 'This will also delete all fuel entries and receipt photos logged for ' - '${widget.vehicle!.displayName}. This cannot be undone.', + '${widget.vehicle!.displayLabel}. This cannot be undone.', ), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), @@ -114,36 +175,48 @@ class _AddEditVehicleScreenState extends State { padding: const EdgeInsets.all(16), children: [ TextFormField( - controller: _makeController, - decoration: const InputDecoration(labelText: 'Make', hintText: 'e.g. Ford'), + controller: _nicknameController, + decoration: const InputDecoration( + labelText: 'Nickname (optional)', + hintText: 'e.g. Mom\'s Car, Red Ford F-150', + ), textCapitalization: TextCapitalization.words, - validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, ), const SizedBox(height: 12), TextFormField( - controller: _modelController, - decoration: const InputDecoration(labelText: 'Model', hintText: 'e.g. F-150'), - textCapitalization: TextCapitalization.words, - validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, - ), - const SizedBox(height: 12), - TextFormField( - controller: _colorController, - decoration: const InputDecoration(labelText: 'Color', hintText: 'e.g. Red'), - textCapitalization: TextCapitalization.words, - validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, - ), - const SizedBox(height: 12), - TextFormField( - controller: _plateController, - decoration: const InputDecoration(labelText: 'License Plate'), + controller: _vinController, + decoration: InputDecoration( + labelText: 'VIN *', + hintText: 'Vehicle Identification Number', + helperText: 'Required — tap the camera to scan it from a photo', + suffixIcon: _scanningVin + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.camera_alt_outlined), + tooltip: 'Scan VIN from a photo', + onPressed: _scanVin, + ), + ), textCapitalization: TextCapitalization.characters, validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], const SizedBox(height: 24), FilledButton( - onPressed: _save, - child: Text(_isEditing ? 'Save Changes' : 'Add Vehicle'), + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : Text(_isEditing ? 'Save Changes' : 'Add Vehicle'), ), ], ), diff --git a/lib/screens/drive_folder_browser_screen.dart b/lib/screens/cloud_folder_browser_screen.dart similarity index 68% rename from lib/screens/drive_folder_browser_screen.dart rename to lib/screens/cloud_folder_browser_screen.dart index e0ec3e9..117a249 100644 --- a/lib/screens/drive_folder_browser_screen.dart +++ b/lib/screens/cloud_folder_browser_screen.dart @@ -1,32 +1,31 @@ import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; import 'package:provider/provider.dart'; import '../services/app_state.dart'; -import '../services/drive_service.dart'; +import '../services/cloud/cloud_storage_provider.dart'; -enum _BrowseRoot { myDrive, sharedWithMe } +enum _BrowseRoot { myFiles, sharedWithMe } -/// Lets the user navigate their Google Drive — either "My Drive" or -/// folders others have shared with them — and pick a parent location. -/// Confirming looks for (or creates) the `MO-Fuel-Tax-Back` folder under -/// that location, so two people pointing at the same shared parent -/// converge on the same app folder. -class DriveFolderBrowserScreen extends StatefulWidget { - const DriveFolderBrowserScreen({super.key}); +/// Lets the user navigate whichever cloud storage provider is connected — +/// their own files, and (if the provider supports it) files others have +/// shared with them — and pick a parent location. Confirming looks for (or +/// creates) the `MO-Fuel-Tax-Back` folder under that location, so two +/// people pointing at the same shared parent converge on the same app +/// folder, regardless of which provider each of them is using. +class CloudFolderBrowserScreen extends StatefulWidget { + const CloudFolderBrowserScreen({super.key}); @override - State createState() => _DriveFolderBrowserScreenState(); + State createState() => _CloudFolderBrowserScreenState(); } -class _DriveFolderBrowserScreenState extends State { - late final http.Client _client; - late final DriveService _drive; +class _CloudFolderBrowserScreenState extends State { + late final CloudStorageSession _session; - _BrowseRoot _root = _BrowseRoot.myDrive; - final List _pathStack = []; + _BrowseRoot _root = _BrowseRoot.myFiles; + final List _pathStack = []; - List? _folders; + List? _folders; bool _loading = true; String? _error; bool _confirming = false; @@ -34,25 +33,24 @@ class _DriveFolderBrowserScreenState extends State { @override void initState() { super.initState(); - _client = context.read().driveAuth.authenticatedHttpClient(); - _drive = DriveService(_client); + _session = context.read().activeProvider!.beginSession(); _load(); } @override void dispose() { - _client.close(); + _session.close(); super.dispose(); } - String get _rootLabel => _root == _BrowseRoot.myDrive ? 'My Drive' : 'Shared with me'; + String get _rootLabel => _root == _BrowseRoot.myFiles ? 'My Files' : 'Shared with me'; /// The folder ID that "Use This Folder" would act on, or null if the /// current view is a virtual listing (top-level "Shared with me") rather /// than an actual folder. String? get _currentFolderId { if (_pathStack.isNotEmpty) return _pathStack.last.id; - if (_root == _BrowseRoot.myDrive) return 'root'; + if (_root == _BrowseRoot.myFiles) return 'root'; return null; } @@ -66,13 +64,13 @@ class _DriveFolderBrowserScreenState extends State { }); try { - List folders; + List folders; if (_pathStack.isNotEmpty) { - folders = await _drive.listFolders(parentId: _pathStack.last.id); - } else if (_root == _BrowseRoot.myDrive) { - folders = await _drive.listFolders(parentId: 'root'); + folders = await _session.listFolders(parentId: _pathStack.last.id); + } else if (_root == _BrowseRoot.myFiles) { + folders = await _session.listFolders(parentId: 'root'); } else { - folders = await _drive.listFolders(sharedWithMe: true); + folders = await _session.listFolders(sharedWithMe: true); } if (!mounted) return; setState(() { @@ -97,7 +95,7 @@ class _DriveFolderBrowserScreenState extends State { _load(); } - void _openFolder(DriveFolder folder) { + void _openFolder(CloudFolder folder) { setState(() => _pathStack.add(folder)); _load(); } @@ -120,7 +118,7 @@ class _DriveFolderBrowserScreenState extends State { setState(() => _confirming = true); try { - await context.read().chooseDriveFolder( + await context.read().chooseCloudFolder( parentId: parentId, breadcrumbPath: _breadcrumbPath, ); @@ -138,20 +136,22 @@ class _DriveFolderBrowserScreenState extends State { @override Widget build(BuildContext context) { + final providerName = context.read().activeProvider!.displayName; final canUseCurrentFolder = _currentFolderId != null; return Scaffold( - appBar: AppBar(title: const Text('Choose Drive Folder')), + appBar: AppBar(title: Text('Choose $providerName Folder')), body: Column( children: [ - SegmentedButton<_BrowseRoot>( - segments: const [ - ButtonSegment(value: _BrowseRoot.myDrive, label: Text('My Drive')), - ButtonSegment(value: _BrowseRoot.sharedWithMe, label: Text('Shared with me')), - ], - selected: {_root}, - onSelectionChanged: (selection) => _switchRoot(selection.first), - ), + if (_session.supportsSharedWithMe) + SegmentedButton<_BrowseRoot>( + segments: const [ + ButtonSegment(value: _BrowseRoot.myFiles, label: Text('My Files')), + ButtonSegment(value: _BrowseRoot.sharedWithMe, label: Text('Shared with me')), + ], + selected: {_root}, + onSelectionChanged: (selection) => _switchRoot(selection.first), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: SingleChildScrollView( diff --git a/lib/screens/confirm_fuel_entry_screen.dart b/lib/screens/confirm_fuel_entry_screen.dart index d5ca094..a1290bf 100644 --- a/lib/screens/confirm_fuel_entry_screen.dart +++ b/lib/screens/confirm_fuel_entry_screen.dart @@ -33,12 +33,16 @@ class _ConfirmFuelEntryScreenState extends State { late final TextEditingController _gallonsController; late final TextEditingController _priceController; late final TextEditingController _totalController; - DateTime _date = DateTime.now(); + late DateTime _date; bool _saving = false; @override void initState() { super.initState(); + // Prefer the date/time printed on the receipt; fall back to now, same + // as before, when it couldn't be parsed — the date picker below is + // always available either way for manual entry/correction. + _date = widget.parsed.date ?? DateTime.now(); _gallonsController = TextEditingController( text: widget.parsed.gallons?.toStringAsFixed(3) ?? '', ); @@ -69,11 +73,20 @@ class _ConfirmFuelEntryScreenState extends State { } Future _pickDate() async { + // initialDate must fall within [firstDate, lastDate] or the picker + // throws — widen the bounds to cover _date in case a misread date from + // the receipt landed 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: DateTime.now().subtract(const Duration(days: 365 * 5)), - lastDate: DateTime.now(), + firstDate: firstDate, + lastDate: lastDate, ); if (pickedDate == null || !mounted) return; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index cc5e1fb..0462665 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -59,18 +59,20 @@ class _VehicleCard extends StatelessWidget { @override Widget build(BuildContext context) { + final hasNickname = vehicle.nickname?.trim().isNotEmpty ?? false; + final gallonsLine = '${totalGallons.toStringAsFixed(3)} gallons logged'; + return Card( clipBehavior: Clip.antiAlias, child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - leading: CircleAvatar( - child: Text(vehicle.make.isNotEmpty ? vehicle.make[0].toUpperCase() : '?'), - ), - title: Text('${vehicle.color} ${vehicle.make} ${vehicle.model}'), - subtitle: Text( - 'Plate: ${vehicle.licensePlate}\n${totalGallons.toStringAsFixed(3)} gallons logged', - ), - isThreeLine: true, + 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), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => VehicleDetailScreen(vehicleId: vehicle.id)), diff --git a/lib/screens/receipt_image_screen.dart b/lib/screens/receipt_image_screen.dart index 64cad93..1913605 100644 --- a/lib/screens/receipt_image_screen.dart +++ b/lib/screens/receipt_image_screen.dart @@ -5,12 +5,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/app_state.dart'; -import '../services/drive_service.dart'; /// Full-screen, pinch-zoomable view of a receipt photo. Pass /// [localImagePath] for a receipt still held locally, or [driveFileId] for -/// one already uploaded to Drive and removed locally — in which case it's -/// downloaded on demand (no local caching afterward). +/// one already uploaded to the cloud and removed locally — in which case +/// it's downloaded on demand (no local caching afterward). class ReceiptImageScreen extends StatefulWidget { final String? localImagePath; final String? driveFileId; @@ -42,11 +41,18 @@ class _ReceiptImageScreenState extends State { _error = null; }); - final authService = context.read().driveAuth; - final client = authService.authenticatedHttpClient(); + final provider = context.read().activeProvider; + if (provider == null) { + setState(() { + _error = 'Not connected to a cloud storage provider.'; + _loading = false; + }); + return; + } + + final session = provider.beginSession(); try { - final drive = DriveService(client); - final bytes = await drive.downloadFileBytes(widget.driveFileId!); + final bytes = await session.downloadFileBytes(widget.driveFileId!); if (!mounted) return; setState(() { _downloadedBytes = Uint8List.fromList(bytes); @@ -59,7 +65,7 @@ class _ReceiptImageScreenState extends State { _loading = false; }); } finally { - client.close(); + session.close(); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7a89e64..5a559df 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -3,7 +3,8 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../services/app_state.dart'; -import 'drive_folder_browser_screen.dart'; +import '../services/cloud/cloud_storage_provider.dart'; +import 'cloud_folder_browser_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -16,13 +17,13 @@ class _SettingsScreenState extends State { bool _busy = false; String? _error; - Future _connect() async { + Future _connect(CloudProviderId id) async { setState(() { _busy = true; _error = null; }); try { - await context.read().connectDrive(); + await context.read().connectProvider(id); } catch (e) { setState(() => _error = 'Could not sign in: $e'); } finally { @@ -30,10 +31,35 @@ class _SettingsScreenState extends State { } } + 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().disconnectDrive(); + await context.read().disconnectCloud(); } finally { if (mounted) setState(() => _busy = false); } @@ -41,7 +67,7 @@ class _SettingsScreenState extends State { void _chooseFolder() { Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const DriveFolderBrowserScreen()), + MaterialPageRoute(builder: (_) => const CloudFolderBrowserScreen()), ); } @@ -69,12 +95,12 @@ class _SettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Google Drive', style: Theme.of(context).textTheme.titleMedium), + Text('Cloud Storage', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), - if (!appState.isDriveConnected) ...[ + if (!appState.isCloudConnected) ...[ Text( - 'Connect Google Drive to share vehicles and fuel receipts with ' - 'other people, and to keep a backup off this device.', + '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), @@ -82,18 +108,29 @@ class _SettingsScreenState extends State { Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), const SizedBox(height: 8), ], - FilledButton.icon( - onPressed: _busy ? null : _connect, - icon: const Icon(Icons.login), - label: const Text('Connect Google Drive'), + 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('Signed in as ${appState.driveAccountEmail}'), + Text('${appState.activeProvider!.displayName}: ${appState.cloudAccountLabel}'), const SizedBox(height: 4), Text( - appState.driveFolderPath == null + appState.cloudFolderPath == null ? 'No folder selected yet.' - : 'Folder: ${appState.driveFolderPath}', + : 'Folder: ${appState.cloudFolderPath}', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), @@ -104,12 +141,12 @@ class _SettingsScreenState extends State { OutlinedButton.icon( onPressed: _busy ? null : _chooseFolder, icon: const Icon(Icons.folder_open), - label: Text(appState.driveFolderPath == null + label: Text(appState.cloudFolderPath == null ? 'Choose Folder' : 'Change Folder'), ), OutlinedButton.icon( - onPressed: (_busy || appState.driveFolderPath == null) + onPressed: (_busy || appState.cloudFolderPath == null) ? null : _syncNow, icon: appState.isSyncing @@ -147,6 +184,62 @@ class _SettingsScreenState extends State { ), ), 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 SizedBox(height: 16), Card( child: Padding( padding: const EdgeInsets.all(16), @@ -167,3 +260,93 @@ 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/vehicle_detail_screen.dart b/lib/screens/vehicle_detail_screen.dart index 486df82..1b2f0d1 100644 --- a/lib/screens/vehicle_detail_screen.dart +++ b/lib/screens/vehicle_detail_screen.dart @@ -9,24 +9,42 @@ 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 'add_edit_vehicle_screen.dart'; import 'confirm_fuel_entry_screen.dart'; import 'receipt_image_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 { 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; + + final keepMaxQuality = context.read().keepMaxQualityReceiptPhotos; final picker = ImagePicker(); XFile? photo; try { - photo = await picker.pickImage(source: ImageSource.camera, imageQuality: 85); + 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 camera: $e')), + SnackBar(content: Text('Could not open $sourceLabel: $e')), ); } return; @@ -54,8 +72,16 @@ class VehicleDetailScreen extends StatelessWidget { 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).pop(); Navigator.of(context).push( MaterialPageRoute( builder: (_) => ConfirmFuelEntryScreen( @@ -68,6 +94,30 @@ class VehicleDetailScreen extends StatelessWidget { } } + /// 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; + } + Future _deleteEntry(BuildContext context, FuelEntry entry) async { final confirmed = await showDialog( context: context, @@ -136,7 +186,7 @@ class VehicleDetailScreen extends StatelessWidget { return Scaffold( appBar: AppBar( - title: Text(vehicle.displayName), + title: Text(vehicle.displayLabel), actions: [ IconButton( icon: const Icon(Icons.edit_outlined), diff --git a/lib/services/app_state.dart b/lib/services/app_state.dart index ff91eae..828a3d4 100644 --- a/lib/services/app_state.dart +++ b/lib/services/app_state.dart @@ -8,19 +8,42 @@ import 'package:uuid/uuid.dart'; import '../models/fuel_entry.dart'; import '../models/vehicle.dart'; +import 'cloud/cloud_storage_provider.dart'; +import 'cloud/dropbox_provider.dart'; +import 'cloud/google_drive_provider.dart'; +import 'cloud/onedrive_provider.dart'; +import 'cloud/webdav_provider.dart'; +import 'cloud_sync_service.dart'; import 'database_service.dart'; -import 'drive_auth_service.dart'; -import 'drive_sync_service.dart'; -const _prefsKeyDriveFolderId = 'drive_app_folder_id'; -const _prefsKeyDriveFolderPath = 'drive_app_folder_path'; +const _prefsKeyActiveProviderId = 'active_cloud_provider_id'; +const _prefsKeyCloudFolderId = 'cloud_folder_id'; +const _prefsKeyCloudFolderPath = 'cloud_folder_path'; +const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally'; +const _prefsKeyStaleLockMinutes = 'stale_lock_minutes'; +const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos'; + +const defaultStaleLockMinutes = 10; +const minStaleLockMinutes = 1; +const maxStaleLockMinutes = 60; + +/// Thrown by [AppState.addVehicle] when the given VIN already belongs to +/// an active vehicle — VIN is the primary/unique identifier, so adding a +/// duplicate should be rejected rather than silently overwriting it. +class DuplicateVinException implements Exception { + final String vin; + DuplicateVinException(this.vin); + + @override + String toString() => 'A vehicle with VIN "$vin" already exists.'; +} /// Single source of truth for the app's in-memory data (vehicles + fuel /// entries), backed by [DatabaseService] (local SQLite) for persistence and -/// [DriveSyncService] for pushing/pulling the shared Drive copy. Screens -/// read from this via Provider and call its mutating methods, which write -/// through to the local database immediately and kick off a best-effort -/// background sync to Drive. +/// a [CloudSyncService] for pushing/pulling the shared copy on whichever +/// [CloudStorageProvider] the user has connected. Screens read from this +/// via Provider and call its mutating methods, which write through to the +/// local database immediately and kick off a best-effort background sync. /// /// The `vehicles`/`fuelEntries` lists are an in-memory read cache of the /// database, refreshed after every mutation and after every sync (since @@ -28,27 +51,71 @@ const _prefsKeyDriveFolderPath = 'drive_app_folder_path'; /// handing back updated Dart objects). class AppState extends ChangeNotifier { final DatabaseService database = DatabaseService(); - final DriveAuthService driveAuth = DriveAuthService(); - late final DriveSyncService driveSync = - DriveSyncService(authService: driveAuth, databaseService: database); + + /// Every storage backend the user can choose from in Settings. + final List availableProviders = [ + GoogleDriveProvider(), + DropboxProvider(), + OneDriveProvider(), + WebDavProvider(), + ]; + + CloudStorageProvider? activeProvider; + CloudSyncService? cloudSync; + final _uuid = const Uuid(); List vehicles = []; List fuelEntries = []; bool isLoading = true; - bool isDriveConnected = false; - String? driveAccountEmail; - String? driveFolderPath; + String? cloudFolderPath; bool isSyncing = false; DateTime? lastSyncedAt; Object? lastSyncError; + bool keepReceiptPhotosLocally = false; + int staleLockMinutes = defaultStaleLockMinutes; + + /// When false (default), a newly captured receipt photo is downscaled + /// and re-compressed to [receiptImageMaxDimension]/[receiptImageQuality] + /// before it's stored — receipts are just photos of small printed text, + /// so a full-resolution original (often several MB on a modern phone + /// camera) buys nothing but cloud storage and sync bandwidth. When true, + /// the original camera/gallery image is kept as-is. + bool keepMaxQualityReceiptPhotos = false; + + /// 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. + Object? initError; + + bool get isCloudConnected => activeProvider?.isSignedIn ?? false; + String? get cloudAccountLabel => activeProvider?.accountLabel; + StreamSubscription>? _connectivitySubscription; Future init() async { - await database.init(); - await _refreshFromDatabase(); + isLoading = true; + initError = null; + notifyListeners(); + + try { + await database.init(); + await _refreshFromDatabase(); + + final prefs = await SharedPreferences.getInstance(); + keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false; + staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes; + keepMaxQualityReceiptPhotos = + prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false; + } catch (e) { + initError = e; + isLoading = false; + notifyListeners(); + return; + } + isLoading = false; notifyListeners(); @@ -58,7 +125,7 @@ class AppState extends ChangeNotifier { } }); - unawaited(_restoreDriveConnection()); + unawaited(_restoreCloudConnection()); } @override @@ -72,18 +139,34 @@ class AppState extends ChangeNotifier { fuelEntries = await database.getFuelEntries(); } - Future _restoreDriveConnection() async { - final signedIn = await driveAuth.attemptSilentSignIn(); + CloudStorageProvider? _providerById(CloudProviderId id) { + for (final provider in availableProviders) { + if (provider.id == id) return provider; + } + return null; + } + + Future _restoreCloudConnection() async { + final prefs = await SharedPreferences.getInstance(); + final storedProviderName = prefs.getString(_prefsKeyActiveProviderId); + if (storedProviderName == null) return; + + final matchingId = CloudProviderId.values + .where((id) => id.name == storedProviderName) + .firstOrNull; + final provider = matchingId == null ? null : _providerById(matchingId); + if (provider == null) return; + + final signedIn = await provider.attemptSilentSignIn(); if (!signedIn) return; - isDriveConnected = true; - driveAccountEmail = driveAuth.currentAccountEmail; + activeProvider = provider; + cloudSync = CloudSyncService(provider: provider, databaseService: database); - final prefs = await SharedPreferences.getInstance(); - final folderId = prefs.getString(_prefsKeyDriveFolderId); - driveFolderPath = prefs.getString(_prefsKeyDriveFolderPath); + final folderId = prefs.getString(_prefsKeyCloudFolderId); + cloudFolderPath = prefs.getString(_prefsKeyCloudFolderPath); if (folderId != null) { - driveSync.configure(folderId); + cloudSync!.configure(folderId); } notifyListeners(); @@ -92,48 +175,91 @@ class AppState extends ChangeNotifier { } } - Future connectDrive() async { - final email = await driveAuth.signIn(); - isDriveConnected = true; - driveAccountEmail = email; - notifyListeners(); - } + Future connectProvider(CloudProviderId id) async { + final provider = _providerById(id); + if (provider == null) return; - Future disconnectDrive() async { - await driveAuth.signOut(); - driveSync.clearConfiguration(); - isDriveConnected = false; - driveAccountEmail = null; - driveFolderPath = null; + await provider.signIn(); + activeProvider = provider; + cloudSync = CloudSyncService(provider: provider, databaseService: database); final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_prefsKeyDriveFolderId); - await prefs.remove(_prefsKeyDriveFolderPath); + await prefs.setString(_prefsKeyActiveProviderId, id.name); notifyListeners(); } - Future chooseDriveFolder({ + /// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider] + /// (currently just WebDAV) that needs a server URL/username/password + /// instead of an OAuth browser flow. The caller (Settings) is responsible + /// for collecting those from the user first. + Future connectProviderWithCredentials( + CloudProviderId id, { + required String serverUrl, + required String username, + required String password, + }) async { + final provider = _providerById(id); + if (provider == null || provider is! ManualCredentialCloudStorageProvider) return; + + await (provider as ManualCredentialCloudStorageProvider).signInWithCredentials( + serverUrl: serverUrl, + username: username, + password: password, + ); + activeProvider = provider; + cloudSync = CloudSyncService(provider: provider, databaseService: database); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKeyActiveProviderId, id.name); + notifyListeners(); + } + + Future disconnectCloud() async { + final provider = activeProvider; + if (provider == null) return; + + await provider.signOut(); + cloudSync?.clearConfiguration(); + activeProvider = null; + cloudSync = null; + cloudFolderPath = null; + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefsKeyActiveProviderId); + await prefs.remove(_prefsKeyCloudFolderId); + await prefs.remove(_prefsKeyCloudFolderPath); + notifyListeners(); + } + + Future chooseCloudFolder({ required String parentId, required String breadcrumbPath, }) async { - final folderId = await driveSync.selectAppFolder(parentId); - driveFolderPath = breadcrumbPath; + final sync = cloudSync; + if (sync == null) return; + + final folderId = await sync.selectAppFolder(parentId); + cloudFolderPath = breadcrumbPath; final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_prefsKeyDriveFolderId, folderId); - await prefs.setString(_prefsKeyDriveFolderPath, breadcrumbPath); + await prefs.setString(_prefsKeyCloudFolderId, folderId); + await prefs.setString(_prefsKeyCloudFolderPath, breadcrumbPath); notifyListeners(); unawaited(syncNow()); } Future syncNow() async { - if (isSyncing || !driveSync.isConfigured) return; + final sync = cloudSync; + if (isSyncing || sync == null || !sync.isConfigured) return; isSyncing = true; notifyListeners(); - final result = await driveSync.syncNow(); + final result = await sync.syncNow( + keepLocalReceiptCopies: keepReceiptPhotosLocally, + staleLockAge: Duration(minutes: staleLockMinutes), + ); if (result.ranSync) { await _refreshFromDatabase(); @@ -147,36 +273,71 @@ class AppState extends ChangeNotifier { notifyListeners(); } + Future setKeepReceiptPhotosLocally(bool value) async { + keepReceiptPhotosLocally = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsKeyKeepReceiptPhotosLocally, value); + notifyListeners(); + } + + Future setKeepMaxQualityReceiptPhotos(bool value) async { + keepMaxQualityReceiptPhotos = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefsKeyKeepMaxQualityReceiptPhotos, value); + 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. + Future setStaleLockMinutes(int minutes) async { + staleLockMinutes = minutes.clamp(minStaleLockMinutes, maxStaleLockMinutes); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_prefsKeyStaleLockMinutes, staleLockMinutes); + notifyListeners(); + } + + /// Throws [DuplicateVinException] if [vin] already belongs to another + /// active vehicle — VIN must stay unique even though it's editable, so + /// silently letting a duplicate through would be a real correctness bug, + /// not just a UX wrinkle. Future addVehicle({ - required String make, - required String model, - required String color, - required String licensePlate, + required String vin, + String? nickname, }) async { + if (await database.vinExists(vin)) { + throw DuplicateVinException(vin); + } final vehicle = Vehicle( id: _uuid.v4(), - make: make, - model: model, - color: color, - licensePlate: licensePlate, + vin: vin, + nickname: nickname, updatedAt: DateTime.now().toUtc(), ); await database.saveVehicle(vehicle); await _persist(); } + /// Throws [DuplicateVinException] if [updated]'s VIN now collides with + /// another active vehicle's — this is the check [addVehicle] does for a + /// new vehicle, but here it also has to exclude the vehicle being edited + /// itself (its VIN obviously still matches its own prior value if it + /// wasn't changed). Future updateVehicle(Vehicle updated) async { + if (await database.vinExists(updated.vin, excludeId: updated.id)) { + throw DuplicateVinException(updated.vin); + } await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc())); await _persist(); } - Future deleteVehicle(String vehicleId) async { + Future deleteVehicle(String id) async { final now = DateTime.now().toUtc(); - final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(vehicleId, now); + final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(id, now); for (final path in orphanedLocalPaths) { await database.deleteReceiptImageFile(path); } - await database.softDeleteVehicle(vehicleId, now); + await database.softDeleteVehicle(id, now); await _persist(); } @@ -191,7 +352,8 @@ class AppState extends ChangeNotifier { final id = _uuid.v4(); String? storedImagePath; if (receiptImage != null) { - storedImagePath = await database.storeReceiptImage(receiptImage, id); + final vin = vehicles.firstWhere((v) => v.id == vehicleId).vin; + storedImagePath = await database.storeReceiptImage(receiptImage, id, vin, date); } final entry = FuelEntry( @@ -243,3 +405,7 @@ class AppState extends ChangeNotifier { unawaited(syncNow()); } } + +extension _FirstOrNull on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/services/cloud/cloud_storage_provider.dart b/lib/services/cloud/cloud_storage_provider.dart new file mode 100644 index 0000000..3325be4 --- /dev/null +++ b/lib/services/cloud/cloud_storage_provider.dart @@ -0,0 +1,153 @@ +import 'dart:io'; + +/// Shared naming convention across all providers: whichever cloud storage +/// backend is active, the app looks for (or creates) a folder with this +/// exact name wherever the user points it, so two devices pointed at the +/// same shared parent location converge on the same data regardless of +/// which provider they're using. +const appFolderName = 'MO-Fuel-Tax-Back'; +const receiptsFolderName = 'receipts'; +const dataFileName = 'fuel_tax_tracker.db'; + +enum CloudProviderId { googleDrive, dropbox, oneDrive, webdav } + +class CloudFolder { + final String id; + final String name; + + CloudFolder({required this.id, required this.name}); +} + +class CloudFileInfo { + final String id; + + /// Opaque change-detection signal — Drive's md5Checksum, Dropbox's + /// content_hash, OneDrive's cTag all satisfy "did this change since I + /// last looked", which is the only thing callers need from it. + final String? versionTag; + + CloudFileInfo({required this.id, required this.versionTag}); +} + +class CloudLockFile { + final String id; + final String username; + final DateTime createdAtUtc; + + CloudLockFile({required this.id, required this.username, required this.createdAtUtc}); +} + +/// Thrown when an operation needs authorization that isn't currently +/// available without prompting the user, e.g. during a background sync +/// with an expired/revoked token. +class CloudNotAuthorizedException implements Exception { + final String providerName; + CloudNotAuthorizedException(this.providerName); + + @override + String toString() => '$providerName access is not currently authorized.'; +} + +/// One connected cloud storage backend (Google Drive, Dropbox, OneDrive). +/// Owns account-level identity/auth; per-sync operations go through a +/// [CloudStorageSession] obtained via [beginSession]. +abstract class CloudStorageProvider { + CloudProviderId get id; + String get displayName; + + bool get isSignedIn; + String? get accountLabel; + + /// Attempts to restore a previous sign-in without any UI. Returns true + /// if signed in and authorized. + Future attemptSilentSignIn(); + + /// Interactive sign-in. Must be called from a user-initiated action + /// (e.g. a button press). Returns a label to display (email/username). + Future signIn(); + + Future signOut(); + + /// Starts one session's worth of operations (roughly: one sync round, + /// or one folder-browsing screen visit). The caller owns its lifecycle — + /// call [CloudStorageSession.close] when done with it. + CloudStorageSession beginSession(); +} + +/// Raw operations against one cloud storage backend, scoped to a single +/// authenticated session (e.g. one HTTP client). `folderId`/`fileId` are +/// opaque per-provider — for most providers a real ID, but for a +/// path-addressed API (Dropbox) a session may internally treat the path +/// itself as the "id". Callers never need to know which. +abstract class CloudStorageSession { + /// Lists folders under [parentId], or (if [sharedWithMe] is true and the + /// provider supports it) top-level folders shared with the signed-in + /// account regardless of parent. Providers that don't have a meaningful + /// separate "shared with me" concept may just ignore [sharedWithMe] and + /// always list under [parentId]. + Future> listFolders({String? parentId, bool sharedWithMe = false}); + + /// True if this provider has a distinct "Shared with me" browsing mode + /// worth showing as a separate tab in the folder picker UI. + bool get supportsSharedWithMe; + + /// Finds a folder named [name] directly under [parentId], or creates one + /// if none exists. If duplicates exist, the earliest-created one wins. + Future findOrCreateFolder({required String parentId, required String name}); + + /// Looks up a file's ID + versionTag by name within [folderId] without + /// downloading its content, or null if no such file exists yet. + Future findFile({required String folderId, required String name}); + + Future> downloadFileBytes(String fileId); + + /// Creates the file if [existingFileId] is null, otherwise overwrites + /// its content. Returns the (possibly new) file ID and fresh versionTag. + Future uploadFile({ + required String folderId, + required String name, + String? existingFileId, + required File localFile, + required String contentType, + }); + + Future deleteFile(String fileId); + + Future createLockFile({required String folderId, required String name}); + + Future> listLockFiles(String folderId); + + /// Releases any resources (e.g. closes an underlying HTTP client). + void close(); +} + +/// Implemented by providers that need the user to type in connection +/// details (server URL, username, password) instead of completing an +/// OAuth browser flow — namely a self-hosted WebDAV server, which has no +/// central authorization server to redirect to. The Settings screen checks +/// `provider is ManualCredentialCloudStorageProvider` to decide whether +/// "Connect" opens a small credentials form instead of calling +/// [CloudStorageProvider.signIn] directly. +abstract class ManualCredentialCloudStorageProvider { + Future signInWithCredentials({ + required String serverUrl, + required String username, + required String password, + }); +} + +/// Parses a lock file named `{username}-{utcEpochMillis}.lock` — shared by +/// every provider's [CloudStorageSession.listLockFiles] implementation +/// rather than duplicated, since the lock file naming convention itself +/// (owned by `lock_coordinator.dart`) is provider-agnostic. +(String, DateTime)? parseLockFileName(String? name) { + if (name == null || !name.endsWith('.lock')) return null; + final withoutExt = name.substring(0, name.length - '.lock'.length); + final lastDash = withoutExt.lastIndexOf('-'); + if (lastDash == -1) return null; + final username = withoutExt.substring(0, lastDash); + final epochStr = withoutExt.substring(lastDash + 1); + final epoch = int.tryParse(epochStr); + if (epoch == null) return null; + return (username, DateTime.fromMillisecondsSinceEpoch(epoch, isUtc: true)); +} diff --git a/lib/services/cloud/dropbox_provider.dart b/lib/services/cloud/dropbox_provider.dart new file mode 100644 index 0000000..7971a62 --- /dev/null +++ b/lib/services/cloud/dropbox_provider.dart @@ -0,0 +1,364 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:http/http.dart' as http; + +import '../cloud_oauth_config.dart'; +import 'cloud_storage_provider.dart'; +import 'oauth_pkce.dart'; + +/// Dropbox implementation of [CloudStorageProvider]. +/// +/// Unlike Google Drive, Dropbox's API is fundamentally *path*-addressed, +/// not ID-addressed. Rather than fight that, [DropboxSession] treats a +/// folder's own Dropbox path (e.g. "/MO-Fuel-Tax-Back") as its "id" for +/// purposes of the generic [CloudStorageSession] interface — an +/// implementation detail entirely inside this file, invisible to +/// [CloudSyncService]. +class DropboxProvider implements CloudStorageProvider { + String? _accessToken; + String? _refreshToken; + DateTime? _accessTokenExpiry; + String? _accountLabel; + + @override + CloudProviderId get id => CloudProviderId.dropbox; + + @override + String get displayName => 'Dropbox'; + + @override + bool get isSignedIn => _refreshToken != null; + + @override + String? get accountLabel => _accountLabel; + + @override + Future attemptSilentSignIn() async { + // The refresh token isn't persisted across app launches in this first + // pass (kept in memory only) — see README's Known limitations. Silent + // restore always fails; the user reconnects once per cold start until + // that's added. + return false; + } + + @override + Future signIn() async { + final appKey = CloudOAuthConfig.dropboxAppKey; + final redirectUri = CloudOAuthConfig.dropboxRedirectUri; + if (appKey == null || redirectUri == null) { + throw StateError('Dropbox OAuth is not configured yet (see cloud_oauth_config.dart).'); + } + + final pkce = PkcePair.generate(); + final authUrl = Uri.https('www.dropbox.com', '/oauth2/authorize', { + 'client_id': appKey, + 'response_type': 'code', + 'code_challenge': pkce.codeChallenge, + 'code_challenge_method': 'S256', + 'redirect_uri': redirectUri, + 'token_access_type': 'offline', + }); + + final callbackUrlScheme = Uri.parse(redirectUri).scheme; + final resultUrl = await FlutterWebAuth2.authenticate( + url: authUrl.toString(), + callbackUrlScheme: callbackUrlScheme, + ); + + final code = Uri.parse(resultUrl).queryParameters['code']; + if (code == null) { + throw StateError('Dropbox sign-in did not return an authorization code.'); + } + + await _exchangeCodeForTokens( + code: code, + codeVerifier: pkce.codeVerifier, + appKey: appKey, + redirectUri: redirectUri, + ); + _accountLabel = await _fetchAccountEmail(); + return _accountLabel!; + } + + Future _exchangeCodeForTokens({ + required String code, + required String codeVerifier, + required String appKey, + required String redirectUri, + }) async { + final response = await http.post( + Uri.https('api.dropboxapi.com', '/oauth2/token'), + body: { + 'code': code, + 'grant_type': 'authorization_code', + 'client_id': appKey, + 'code_verifier': codeVerifier, + 'redirect_uri': redirectUri, + }, + ); + if (response.statusCode != 200) { + throw StateError('Dropbox token exchange failed: ${response.body}'); + } + final json = jsonDecode(response.body) as Map; + _accessToken = json['access_token'] as String; + _refreshToken = json['refresh_token'] as String?; + _accessTokenExpiry = + DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400)); + } + + Future _fetchAccountEmail() async { + final response = await http.post( + Uri.https('api.dropboxapi.com', '/2/users/get_current_account'), + headers: {'Authorization': 'Bearer $_accessToken'}, + ); + if (response.statusCode != 200) return 'Dropbox account'; + final json = jsonDecode(response.body) as Map; + return json['email'] as String? ?? 'Dropbox account'; + } + + /// Refreshes the access token if it's missing or close to expiring. + /// Never prompts for UI — suitable for background sync — so throws + /// [CloudNotAuthorizedException] if there's no refresh token to use. + Future _freshAccessToken() async { + final stillValid = _accessToken != null && + _accessTokenExpiry != null && + DateTime.now().isBefore(_accessTokenExpiry!.subtract(const Duration(minutes: 1))); + if (stillValid) return _accessToken!; + + final refreshToken = _refreshToken; + final appKey = CloudOAuthConfig.dropboxAppKey; + if (refreshToken == null || appKey == null) { + throw CloudNotAuthorizedException(displayName); + } + + final response = await http.post( + Uri.https('api.dropboxapi.com', '/oauth2/token'), + body: { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + 'client_id': appKey, + }, + ); + if (response.statusCode != 200) { + throw CloudNotAuthorizedException(displayName); + } + final json = jsonDecode(response.body) as Map; + _accessToken = json['access_token'] as String; + _accessTokenExpiry = + DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 14400)); + return _accessToken!; + } + + @override + Future signOut() async { + _accessToken = null; + _refreshToken = null; + _accessTokenExpiry = null; + _accountLabel = null; + } + + @override + CloudStorageSession beginSession() { + if (!isSignedIn) throw CloudNotAuthorizedException(displayName); + return DropboxSession(this); + } +} + +class DropboxSession implements CloudStorageSession { + final DropboxProvider _provider; + DropboxSession(this._provider); + + @override + bool get supportsSharedWithMe => false; + + Future> _authHeader() async => + {'Authorization': 'Bearer ${await _provider._freshAccessToken()}'}; + + /// Dropbox's root path is `""`, not `"/"` — our generic interface uses + /// the literal string `'root'` for "the top of the tree" (matching + /// Google Drive's convention), so translate that here. + String _normalizePath(String id) => id == 'root' ? '' : id; + + String _childPath(String parentId, String name) { + final parent = _normalizePath(parentId); + return '$parent/$name'; + } + + Future> _post(String path, Map body) async { + final response = await http.post( + Uri.https('api.dropboxapi.com', path), + headers: {...await _authHeader(), 'Content-Type': 'application/json'}, + body: jsonEncode(body), + ); + if (response.statusCode != 200) { + throw StateError('Dropbox API error ($path): ${response.statusCode} ${response.body}'); + } + return jsonDecode(response.body) as Map; + } + + /// True if a Dropbox API error response's `.tag` chain indicates "the + /// path doesn't exist" — Dropbox reports this as a normal 409 response + /// with a structured error body, not a 404, so it needs its own check + /// rather than a status-code check. + bool _isPathNotFoundError(http.Response response) { + if (response.statusCode != 409) return false; + try { + final body = jsonDecode(response.body) as Map; + return jsonEncode(body['error']).contains('not_found'); + } catch (_) { + return false; + } + } + + @override + Future> listFolders({String? parentId, bool sharedWithMe = false}) async { + final path = _normalizePath(parentId ?? 'root'); + final json = await _post('/2/files/list_folder', {'path': path}); + final entries = (json['entries'] as List? ?? []); + return entries + .cast>() + .where((e) => e['.tag'] == 'folder') + .map((e) => CloudFolder(id: e['path_display'] as String, name: e['name'] as String)) + .toList(); + } + + @override + Future findOrCreateFolder({required String parentId, required String name}) async { + final childPath = _childPath(parentId, name); + + final response = await http.post( + Uri.https('api.dropboxapi.com', '/2/files/get_metadata'), + headers: {...await _authHeader(), 'Content-Type': 'application/json'}, + body: jsonEncode({'path': childPath}), + ); + if (response.statusCode == 200) { + final json = jsonDecode(response.body) as Map; + if (json['.tag'] == 'folder') return childPath; + } else if (!_isPathNotFoundError(response)) { + throw StateError('Dropbox API error (get_metadata): ${response.statusCode} ${response.body}'); + } + + await _post('/2/files/create_folder_v2', {'path': childPath}); + return childPath; + } + + @override + Future findFile({required String folderId, required String name}) async { + final filePath = _childPath(folderId, name); + + final response = await http.post( + Uri.https('api.dropboxapi.com', '/2/files/get_metadata'), + headers: {...await _authHeader(), 'Content-Type': 'application/json'}, + body: jsonEncode({'path': filePath}), + ); + if (_isPathNotFoundError(response)) return null; + if (response.statusCode != 200) { + throw StateError('Dropbox API error (get_metadata): ${response.statusCode} ${response.body}'); + } + + final json = jsonDecode(response.body) as Map; + if (json['.tag'] != 'file') return null; + return CloudFileInfo(id: filePath, versionTag: json['content_hash'] as String?); + } + + @override + Future> downloadFileBytes(String fileId) async { + final response = await http.post( + Uri.https('content.dropboxapi.com', '/2/files/download'), + headers: { + ...await _authHeader(), + 'Dropbox-API-Arg': jsonEncode({'path': fileId}), + }, + ); + if (response.statusCode != 200) { + throw StateError('Dropbox download failed: ${response.statusCode} ${response.body}'); + } + return response.bodyBytes; + } + + @override + Future uploadFile({ + required String folderId, + required String name, + String? existingFileId, + required File localFile, + required String contentType, + }) async { + final targetPath = existingFileId ?? _childPath(folderId, name); + final bytes = await localFile.readAsBytes(); + + final response = await http.post( + Uri.https('content.dropboxapi.com', '/2/files/upload'), + headers: { + ...await _authHeader(), + 'Dropbox-API-Arg': jsonEncode({'path': targetPath, 'mode': 'overwrite'}), + 'Content-Type': 'application/octet-stream', + }, + body: bytes, + ); + if (response.statusCode != 200) { + throw StateError('Dropbox upload failed: ${response.statusCode} ${response.body}'); + } + + final json = jsonDecode(response.body) as Map; + return CloudFileInfo( + id: json['path_display'] as String? ?? targetPath, + versionTag: json['content_hash'] as String?, + ); + } + + @override + Future deleteFile(String fileId) async { + final response = await http.post( + Uri.https('api.dropboxapi.com', '/2/files/delete_v2'), + headers: {...await _authHeader(), 'Content-Type': 'application/json'}, + body: jsonEncode({'path': fileId}), + ); + if (response.statusCode != 200 && !_isPathNotFoundError(response)) { + throw StateError('Dropbox delete failed: ${response.statusCode} ${response.body}'); + } + } + + @override + Future createLockFile({required String folderId, required String name}) async { + final path = _childPath(folderId, name); + final response = await http.post( + Uri.https('content.dropboxapi.com', '/2/files/upload'), + headers: { + ...await _authHeader(), + 'Dropbox-API-Arg': jsonEncode({'path': path, 'mode': 'overwrite'}), + 'Content-Type': 'application/octet-stream', + }, + body: const [], + ); + if (response.statusCode != 200) { + throw StateError('Dropbox lock creation failed: ${response.statusCode} ${response.body}'); + } + return path; + } + + @override + Future> listLockFiles(String folderId) async { + final json = await _post('/2/files/list_folder', {'path': _normalizePath(folderId)}); + final entries = (json['entries'] as List? ?? []); + + final locks = []; + for (final entry in entries.cast>()) { + if (entry['.tag'] != 'file') continue; + final parsed = parseLockFileName(entry['name'] as String?); + if (parsed != null) { + locks.add(CloudLockFile( + id: entry['path_display'] as String, + username: parsed.$1, + createdAtUtc: parsed.$2, + )); + } + } + return locks; + } + + @override + void close() {} +} diff --git a/lib/services/cloud/google_drive_provider.dart b/lib/services/cloud/google_drive_provider.dart new file mode 100644 index 0000000..98825bc --- /dev/null +++ b/lib/services/cloud/google_drive_provider.dart @@ -0,0 +1,263 @@ +import 'dart:async'; +import 'dart:io' show File, Platform; + +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:googleapis/drive/v3.dart' as drive; +import 'package:http/http.dart' as http; + +import '../cloud_oauth_config.dart'; +import 'cloud_storage_provider.dart'; + +/// Full Drive access is required (not the narrower `drive.file` scope) +/// because users need to browse to and reuse folders that someone else +/// created and shared with them, not just folders/files this app itself +/// created. See the plan doc for the tradeoffs (this requires Google +/// Cloud Console "Testing" mode with explicit test users, to avoid needing +/// a full OAuth verification review). +const _driveScopes = ['https://www.googleapis.com/auth/drive']; + +const _folderMimeType = 'application/vnd.google-apps.folder'; + +/// Google Drive implementation of [CloudStorageProvider], via +/// `google_sign_in` for auth and the `googleapis` `DriveApi` client for +/// everything else. +class GoogleDriveProvider implements CloudStorageProvider { + bool _initialized = false; + GoogleSignInAccount? _account; + + @override + CloudProviderId get id => CloudProviderId.googleDrive; + + @override + String get displayName => 'Google Drive'; + + @override + bool get isSignedIn => _account != null; + + @override + String? get accountLabel => _account?.email; + + Future _ensureInitialized() async { + if (_initialized) return; + await GoogleSignIn.instance.initialize( + clientId: Platform.isIOS ? CloudOAuthConfig.googleIosClientId : null, + serverClientId: Platform.isAndroid ? CloudOAuthConfig.googleAndroidServerClientId : null, + ); + _initialized = true; + } + + @override + Future attemptSilentSignIn() async { + await _ensureInitialized(); + final account = await GoogleSignIn.instance.attemptLightweightAuthentication(); + _account = account; + if (account == null) return false; + + final authorization = + await account.authorizationClient.authorizationForScopes(_driveScopes); + return authorization != null; + } + + @override + Future signIn() async { + await _ensureInitialized(); + final account = await GoogleSignIn.instance.authenticate(scopeHint: _driveScopes); + _account = account; + await account.authorizationClient.authorizeScopes(_driveScopes); + return account.email; + } + + @override + Future signOut() async { + await GoogleSignIn.instance.signOut(); + _account = null; + } + + @override + CloudStorageSession beginSession() { + final account = _account; + if (account == null) { + throw CloudNotAuthorizedException(displayName); + } + final client = _GoogleAuthHttpClient(account.authorizationClient); + return GoogleDriveSession(client); + } +} + +class _GoogleAuthHttpClient extends http.BaseClient { + final GoogleSignInAuthorizationClient _authClient; + final http.Client _inner = http.Client(); + + _GoogleAuthHttpClient(this._authClient); + + @override + Future send(http.BaseRequest request) async { + final headers = + await _authClient.authorizationHeaders(_driveScopes, promptIfNecessary: false); + if (headers == null) { + throw CloudNotAuthorizedException('Google Drive'); + } + request.headers.addAll(headers); + return _inner.send(request); + } + + @override + void close() { + _inner.close(); + super.close(); + } +} + +class GoogleDriveSession implements CloudStorageSession { + final http.Client _client; + final drive.DriveApi _api; + + GoogleDriveSession(this._client) : _api = drive.DriveApi(_client); + + @override + bool get supportsSharedWithMe => true; + + @override + Future> listFolders({String? parentId, bool sharedWithMe = false}) async { + final query = sharedWithMe + ? "sharedWithMe=true and mimeType='$_folderMimeType' and trashed=false" + : "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false"; + + final result = await _api.files.list( + q: query, + orderBy: 'name', + $fields: 'files(id,name)', + spaces: 'drive', + ); + + return (result.files ?? []) + .where((f) => f.id != null && f.name != null) + .map((f) => CloudFolder(id: f.id!, name: f.name!)) + .toList(); + } + + @override + Future findOrCreateFolder({required String parentId, required String name}) async { + final existing = await _api.files.list( + q: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false and name='$name'", + orderBy: 'createdTime', + $fields: 'files(id,name)', + spaces: 'drive', + ); + + final files = existing.files ?? []; + if (files.isNotEmpty && files.first.id != null) return files.first.id!; + + final created = await _api.files.create( + drive.File() + ..name = name + ..mimeType = _folderMimeType + ..parents = [parentId], + ); + return created.id!; + } + + @override + Future findFile({required String folderId, required String name}) async { + final result = await _api.files.list( + q: "'$folderId' in parents and trashed=false and name='$name'", + orderBy: 'modifiedTime desc', + $fields: 'files(id,name,md5Checksum)', + spaces: 'drive', + ); + final files = result.files ?? []; + if (files.isEmpty || files.first.id == null) return null; + return CloudFileInfo(id: files.first.id!, versionTag: files.first.md5Checksum); + } + + @override + Future> downloadFileBytes(String fileId) async { + final media = await _api.files.get( + fileId, + downloadOptions: drive.DownloadOptions.fullMedia, + ) as drive.Media; + return _collectBytes(media.stream); + } + + @override + Future uploadFile({ + required String folderId, + required String name, + String? existingFileId, + required File localFile, + required String contentType, + }) async { + final length = await localFile.length(); + final media = drive.Media(localFile.openRead(), length, contentType: contentType); + + if (existingFileId != null) { + final updated = await _api.files.update( + drive.File(), + existingFileId, + uploadMedia: media, + $fields: 'id,md5Checksum', + ); + return CloudFileInfo(id: updated.id ?? existingFileId, versionTag: updated.md5Checksum); + } + + final created = await _api.files.create( + drive.File() + ..name = name + ..parents = [folderId], + uploadMedia: media, + $fields: 'id,md5Checksum', + ); + return CloudFileInfo(id: created.id!, versionTag: created.md5Checksum); + } + + @override + Future deleteFile(String fileId) async { + try { + await _api.files.delete(fileId); + } on drive.DetailedApiRequestError catch (e) { + // Already gone (e.g. deleted by another device) — not an error for + // our purposes. + if (e.status != 404) rethrow; + } + } + + @override + Future createLockFile({required String folderId, required String name}) async { + final created = await _api.files.create( + drive.File() + ..name = name + ..parents = [folderId], + uploadMedia: drive.Media(Stream.value(const []), 0, contentType: 'text/plain'), + ); + return created.id!; + } + + @override + Future> listLockFiles(String folderId) async { + final result = await _api.files.list( + q: "'$folderId' in parents and trashed=false and name contains '.lock'", + $fields: 'files(id,name)', + spaces: 'drive', + ); + + final locks = []; + for (final f in result.files ?? []) { + final parsed = parseLockFileName(f.name); + if (f.id != null && parsed != null) { + locks.add(CloudLockFile(id: f.id!, username: parsed.$1, createdAtUtc: parsed.$2)); + } + } + return locks; + } + + Future> _collectBytes(Stream> stream) async { + final bytes = []; + await for (final chunk in stream) { + bytes.addAll(chunk); + } + return bytes; + } + + @override + void close() => _client.close(); +} diff --git a/lib/services/cloud/oauth_pkce.dart b/lib/services/cloud/oauth_pkce.dart new file mode 100644 index 0000000..b4e801b --- /dev/null +++ b/lib/services/cloud/oauth_pkce.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; + +/// PKCE (RFC 7636) verifier/challenge pair for Dropbox's and OneDrive's +/// OAuth2 Authorization Code flow — proves to the token endpoint that the +/// app completing the exchange is the same one that started the browser +/// redirect, without needing an embedded client secret (appropriate for a +/// public/mobile client, since a secret can't actually be kept secret in +/// a distributed app binary). +class PkcePair { + final String codeVerifier; + final String codeChallenge; + + PkcePair._(this.codeVerifier, this.codeChallenge); + + factory PkcePair.generate() { + final verifier = _randomUrlSafeString(64); + final challenge = + base64Url.encode(sha256.convert(utf8.encode(verifier)).bytes).replaceAll('=', ''); + return PkcePair._(verifier, challenge); + } + + static String _randomUrlSafeString(int length) { + // RFC 7636's unreserved character set for a code_verifier. + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + final random = Random.secure(); + return List.generate(length, (_) => chars[random.nextInt(chars.length)]).join(); + } +} diff --git a/lib/services/cloud/onedrive_provider.dart b/lib/services/cloud/onedrive_provider.dart new file mode 100644 index 0000000..18c8259 --- /dev/null +++ b/lib/services/cloud/onedrive_provider.dart @@ -0,0 +1,350 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:http/http.dart' as http; + +import '../cloud_oauth_config.dart'; +import 'cloud_storage_provider.dart'; +import 'oauth_pkce.dart'; + +const _graphScopes = 'offline_access Files.ReadWrite.All'; + +/// OneDrive implementation of [CloudStorageProvider], via Microsoft Graph. +/// Unlike Dropbox, Graph is ID-addressed like Drive, so it fits the +/// interface directly with no path-based workaround. +class OneDriveProvider implements CloudStorageProvider { + String? _accessToken; + String? _refreshToken; + DateTime? _accessTokenExpiry; + String? _accountLabel; + + @override + CloudProviderId get id => CloudProviderId.oneDrive; + + @override + String get displayName => 'OneDrive'; + + @override + bool get isSignedIn => _refreshToken != null; + + @override + String? get accountLabel => _accountLabel; + + @override + Future attemptSilentSignIn() async { + // As with Dropbox, the refresh token is kept in memory only in this + // first pass — see README's Known limitations. + return false; + } + + @override + Future signIn() async { + final clientId = CloudOAuthConfig.oneDriveClientId; + final redirectUri = CloudOAuthConfig.oneDriveRedirectUri; + if (clientId == null || redirectUri == null) { + throw StateError('OneDrive OAuth is not configured yet (see cloud_oauth_config.dart).'); + } + + final pkce = PkcePair.generate(); + final authUrl = Uri.https( + 'login.microsoftonline.com', + '/common/oauth2/v2.0/authorize', + { + 'client_id': clientId, + 'response_type': 'code', + 'redirect_uri': redirectUri, + 'response_mode': 'query', + 'scope': _graphScopes, + 'code_challenge': pkce.codeChallenge, + 'code_challenge_method': 'S256', + }, + ); + + final callbackUrlScheme = Uri.parse(redirectUri).scheme; + final resultUrl = await FlutterWebAuth2.authenticate( + url: authUrl.toString(), + callbackUrlScheme: callbackUrlScheme, + ); + + final code = Uri.parse(resultUrl).queryParameters['code']; + if (code == null) { + throw StateError('OneDrive sign-in did not return an authorization code.'); + } + + await _exchangeCodeForTokens( + code: code, + codeVerifier: pkce.codeVerifier, + clientId: clientId, + redirectUri: redirectUri, + ); + _accountLabel = await _fetchAccountEmail(); + return _accountLabel!; + } + + Future _exchangeCodeForTokens({ + required String code, + required String codeVerifier, + required String clientId, + required String redirectUri, + }) async { + final response = await http.post( + Uri.https('login.microsoftonline.com', '/common/oauth2/v2.0/token'), + body: { + 'client_id': clientId, + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': redirectUri, + 'code_verifier': codeVerifier, + 'scope': _graphScopes, + }, + ); + if (response.statusCode != 200) { + throw StateError('OneDrive token exchange failed: ${response.body}'); + } + final json = jsonDecode(response.body) as Map; + _accessToken = json['access_token'] as String; + _refreshToken = json['refresh_token'] as String?; + _accessTokenExpiry = + DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 3600)); + } + + Future _fetchAccountEmail() async { + final response = await http.get( + Uri.https('graph.microsoft.com', '/v1.0/me'), + headers: {'Authorization': 'Bearer $_accessToken'}, + ); + if (response.statusCode != 200) return 'OneDrive account'; + final json = jsonDecode(response.body) as Map; + return (json['mail'] as String?) ?? + (json['userPrincipalName'] as String?) ?? + 'OneDrive account'; + } + + Future _freshAccessToken() async { + final stillValid = _accessToken != null && + _accessTokenExpiry != null && + DateTime.now().isBefore(_accessTokenExpiry!.subtract(const Duration(minutes: 1))); + if (stillValid) return _accessToken!; + + final refreshToken = _refreshToken; + final clientId = CloudOAuthConfig.oneDriveClientId; + if (refreshToken == null || clientId == null) { + throw CloudNotAuthorizedException(displayName); + } + + final response = await http.post( + Uri.https('login.microsoftonline.com', '/common/oauth2/v2.0/token'), + body: { + 'client_id': clientId, + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + 'scope': _graphScopes, + }, + ); + if (response.statusCode != 200) { + throw CloudNotAuthorizedException(displayName); + } + final json = jsonDecode(response.body) as Map; + _accessToken = json['access_token'] as String; + _refreshToken = json['refresh_token'] as String? ?? _refreshToken; + _accessTokenExpiry = + DateTime.now().add(Duration(seconds: json['expires_in'] as int? ?? 3600)); + return _accessToken!; + } + + @override + Future signOut() async { + _accessToken = null; + _refreshToken = null; + _accessTokenExpiry = null; + _accountLabel = null; + } + + @override + CloudStorageSession beginSession() { + if (!isSignedIn) throw CloudNotAuthorizedException(displayName); + return OneDriveSession(this); + } +} + +class OneDriveSession implements CloudStorageSession { + final OneDriveProvider _provider; + OneDriveSession(this._provider); + + @override + bool get supportsSharedWithMe => true; + + Future> _authHeader() async => + {'Authorization': 'Bearer ${await _provider._freshAccessToken()}'}; + + Uri _graph(String path) => Uri.parse('https://graph.microsoft.com/v1.0$path'); + + /// The interface's `'root'` sentinel (matching Google's convention) maps + /// to Graph's own `/me/drive/root` special item. + String _itemSegment(String id) => id == 'root' ? 'root' : 'items/$id'; + + @override + Future> listFolders({String? parentId, bool sharedWithMe = false}) async { + final uri = sharedWithMe + ? _graph('/me/drive/sharedWithMe') + : _graph('/me/drive/${_itemSegment(parentId ?? 'root')}/children'); + + final response = await http.get(uri, headers: await _authHeader()); + if (response.statusCode != 200) { + throw StateError('OneDrive API error (listFolders): ${response.statusCode} ${response.body}'); + } + final json = jsonDecode(response.body) as Map; + final entries = (json['value'] as List? ?? []).cast>(); + + final folders = []; + for (final entry in entries) { + if (entry['folder'] == null) continue; + // "Shared with me" items carry the shared item's own id under + // `remoteItem`, not the top-level entry id. + final remoteItem = entry['remoteItem'] as Map?; + final id = (remoteItem?['id'] ?? entry['id']) as String?; + final name = entry['name'] as String?; + if (id != null && name != null) { + folders.add(CloudFolder(id: id, name: name)); + } + } + return folders; + } + + @override + Future findOrCreateFolder({required String parentId, required String name}) async { + final childrenUri = _graph('/me/drive/${_itemSegment(parentId)}/children'); + final listResponse = await http.get(childrenUri, headers: await _authHeader()); + if (listResponse.statusCode != 200) { + throw StateError( + 'OneDrive API error (findOrCreateFolder list): ${listResponse.statusCode} ${listResponse.body}'); + } + final listJson = jsonDecode(listResponse.body) as Map; + final entries = (listJson['value'] as List? ?? []).cast>(); + for (final entry in entries) { + if (entry['folder'] != null && entry['name'] == name) { + return entry['id'] as String; + } + } + + final createResponse = await http.post( + childrenUri, + headers: {...await _authHeader(), 'Content-Type': 'application/json'}, + body: jsonEncode({ + 'name': name, + 'folder': {}, + '@microsoft.graph.conflictBehavior': 'fail', + }), + ); + if (createResponse.statusCode != 201) { + throw StateError( + 'OneDrive API error (findOrCreateFolder create): ${createResponse.statusCode} ${createResponse.body}'); + } + final created = jsonDecode(createResponse.body) as Map; + return created['id'] as String; + } + + @override + Future findFile({required String folderId, required String name}) async { + final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name'); + final response = await http.get(uri, headers: await _authHeader()); + if (response.statusCode == 404) return null; + if (response.statusCode != 200) { + throw StateError('OneDrive API error (findFile): ${response.statusCode} ${response.body}'); + } + final json = jsonDecode(response.body) as Map; + return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?); + } + + @override + Future> downloadFileBytes(String fileId) async { + final response = + await http.get(_graph('/me/drive/items/$fileId/content'), headers: await _authHeader()); + if (response.statusCode != 200) { + throw StateError('OneDrive download failed: ${response.statusCode} ${response.body}'); + } + return response.bodyBytes; + } + + @override + Future uploadFile({ + required String folderId, + required String name, + String? existingFileId, + required File localFile, + required String contentType, + }) async { + final bytes = await localFile.readAsBytes(); + // Graph's simple upload endpoint (content < 4MB, which every receipt + // photo and the sqlite data file comfortably are) — no upload session + // needed. + final uri = existingFileId != null + ? _graph('/me/drive/items/$existingFileId/content') + : _graph('/me/drive/${_itemSegment(folderId)}:/$name:/content'); + + final response = await http.put( + uri, + headers: {...await _authHeader(), 'Content-Type': contentType}, + body: bytes, + ); + if (response.statusCode != 200 && response.statusCode != 201) { + throw StateError('OneDrive upload failed: ${response.statusCode} ${response.body}'); + } + final json = jsonDecode(response.body) as Map; + return CloudFileInfo(id: json['id'] as String, versionTag: json['cTag'] as String?); + } + + @override + Future deleteFile(String fileId) async { + final response = + await http.delete(_graph('/me/drive/items/$fileId'), headers: await _authHeader()); + if (response.statusCode != 204 && response.statusCode != 404) { + throw StateError('OneDrive delete failed: ${response.statusCode} ${response.body}'); + } + } + + @override + Future createLockFile({required String folderId, required String name}) async { + final uri = _graph('/me/drive/${_itemSegment(folderId)}:/$name:/content'); + final response = await http.put( + uri, + headers: {...await _authHeader(), 'Content-Type': 'text/plain'}, + body: const [], + ); + if (response.statusCode != 200 && response.statusCode != 201) { + throw StateError('OneDrive lock creation failed: ${response.statusCode} ${response.body}'); + } + final json = jsonDecode(response.body) as Map; + return json['id'] as String; + } + + @override + Future> listLockFiles(String folderId) async { + final response = await http.get( + _graph('/me/drive/${_itemSegment(folderId)}/children'), + headers: await _authHeader(), + ); + if (response.statusCode != 200) { + throw StateError('OneDrive API error (listLockFiles): ${response.statusCode} ${response.body}'); + } + final json = jsonDecode(response.body) as Map; + final entries = (json['value'] as List? ?? []).cast>(); + + final locks = []; + for (final entry in entries) { + final parsed = parseLockFileName(entry['name'] as String?); + if (parsed != null) { + locks.add(CloudLockFile( + id: entry['id'] as String, + username: parsed.$1, + createdAtUtc: parsed.$2, + )); + } + } + return locks; + } + + @override + void close() {} +} diff --git a/lib/services/cloud/webdav_provider.dart b/lib/services/cloud/webdav_provider.dart new file mode 100644 index 0000000..1fd91f8 --- /dev/null +++ b/lib/services/cloud/webdav_provider.dart @@ -0,0 +1,390 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:xml/xml.dart'; + +import 'cloud_storage_provider.dart'; + +const _secureStorageKeyServerUrl = 'webdav_server_url'; +const _secureStorageKeyUsername = 'webdav_username'; +const _secureStorageKeyPassword = 'webdav_password'; + +const _propfindRequestBody = ''' + + + + + +'''; + +/// One `` entry from a WebDAV PROPFIND multistatus response. +/// Not private, and [parseWebDavMultistatus] is a free function, purely so +/// the XML parsing can be unit-tested directly against sample responses +/// from different server implementations — real WebDAV servers vary in +/// namespace prefix (Nextcloud uses `d:`, others `D:` or none at all), +/// which is exactly the kind of real-world format variance this app has +/// been burned by before with format-specific assumptions. +class WebDavEntry { + final String path; + final bool isCollection; + final String? etag; + WebDavEntry({required this.path, required this.isCollection, required this.etag}); +} + +/// Parses a WebDAV PROPFIND multistatus XML body into [WebDavEntry]s, with +/// each entry's href resolved to an absolute path against [baseUrl]. +/// Matches elements by local name only (ignoring namespace prefix), since +/// that's the part that varies across server implementations. +List parseWebDavMultistatus(String xmlBody, Uri baseUrl) { + final document = XmlDocument.parse(xmlBody); + return _byLocalName(document, 'response').map((responseEl) { + final href = _byLocalName(responseEl, 'href').first.innerText; + final isCollection = _byLocalName(responseEl, 'collection').isNotEmpty; + final etagEls = _byLocalName(responseEl, 'getetag').toList(); + return WebDavEntry( + path: baseUrl.resolve(href).path, + isCollection: isCollection, + etag: etagEls.isEmpty ? null : etagEls.first.innerText, + ); + }).toList(); +} + +Iterable _byLocalName(XmlNode node, String localName) => + node.descendants.whereType().where((e) => e.name.local == localName); + +class _RawResponse { + final int statusCode; + final String body; + final Map headers; + _RawResponse({required this.statusCode, required this.body, required this.headers}); +} + +/// Sends a request with an arbitrary HTTP method (PROPFIND, MKCOL) that +/// `package:http`'s GET/PUT/DELETE convenience functions don't support. +Future<_RawResponse> _send(String method, Uri uri, {Map? headers, Object? body}) async { + final client = http.Client(); + try { + final request = http.Request(method, uri); + if (headers != null) request.headers.addAll(headers); + if (body is String) request.body = body; + if (body is List) request.bodyBytes = body; + final streamed = await client.send(request); + final responseBody = await streamed.stream.bytesToString(); + return _RawResponse(statusCode: streamed.statusCode, body: responseBody, headers: streamed.headers); + } finally { + client.close(); + } +} + +String _basicAuthHeader(String username, String password) => + 'Basic ${base64Encode(utf8.encode('$username:$password'))}'; + +String _normalizedPath(String path) => path.endsWith('/') ? path.substring(0, path.length - 1) : path; + +String _nameFromPath(String path) { + final segments = _normalizedPath(path).split('/').where((s) => s.isNotEmpty); + return segments.isEmpty ? '' : Uri.decodeComponent(segments.last); +} + +/// WebDAV implementation of [CloudStorageProvider], for self-hosted +/// personal cloud servers (Nextcloud, ownCloud, a Synology NAS, or any +/// generic WebDAV server) rather than a named commercial provider. Unlike +/// the OAuth-based providers, there's no browser sign-in flow and no +/// developer-console app to register ahead of time — the user supplies a +/// server URL, username, and password (an app-specific password is +/// recommended on servers that support one, e.g. Nextcloud's Security +/// settings) directly via [signInWithCredentials]. +class WebDavProvider implements CloudStorageProvider, ManualCredentialCloudStorageProvider { + final FlutterSecureStorage _secureStorage; + + Uri? _baseUrl; + String? _username; + String? _password; + + WebDavProvider({FlutterSecureStorage? secureStorage}) + : _secureStorage = secureStorage ?? const FlutterSecureStorage(); + + @override + CloudProviderId get id => CloudProviderId.webdav; + + @override + String get displayName => 'WebDAV'; + + @override + bool get isSignedIn => _baseUrl != null; + + @override + String? get accountLabel => isSignedIn ? '$_username@${_baseUrl!.host}' : null; + + /// Restores a session from credentials saved on a previous + /// [signInWithCredentials] call (OS-encrypted storage — Keystore on + /// Android, Keychain on iOS), re-verifying them with the same PROPFIND + /// check rather than trusting them blindly, since the server config or + /// password could have changed since. Any failure here — wrong/expired + /// credentials, no network, nothing stored yet — just means "not signed + /// in"; this never throws; a real error from a user-initiated attempt + /// belongs to [signInWithCredentials], not this silent path. + @override + Future attemptSilentSignIn() async { + try { + final serverUrl = await _secureStorage.read(key: _secureStorageKeyServerUrl); + final username = await _secureStorage.read(key: _secureStorageKeyUsername); + final password = await _secureStorage.read(key: _secureStorageKeyPassword); + if (serverUrl == null || username == null || password == null) return false; + + await _verifiedSignIn(serverUrl: serverUrl, username: username, password: password); + return true; + } catch (_) { + return false; + } + } + + @override + Future signIn() { + throw UnsupportedError( + 'WebDAV needs a server URL and credentials — use signInWithCredentials instead.'); + } + + @override + Future signInWithCredentials({ + required String serverUrl, + required String username, + required String password, + }) async { + final label = + await _verifiedSignIn(serverUrl: serverUrl, username: username, password: password); + + await _secureStorage.write(key: _secureStorageKeyServerUrl, value: _baseUrl!.toString()); + await _secureStorage.write(key: _secureStorageKeyUsername, value: username); + await _secureStorage.write(key: _secureStorageKeyPassword, value: password); + + return label; + } + + /// Shared by [signInWithCredentials] and [attemptSilentSignIn]: normalizes + /// the URL, does a side-effect-free PROPFIND on the root to confirm the + /// URL and credentials actually work, and — only once that's confirmed — + /// sets this instance's session fields. + Future _verifiedSignIn({ + required String serverUrl, + required String username, + required String password, + }) async { + var normalized = serverUrl.trim(); + if (!normalized.contains('://')) normalized = 'https://$normalized'; + if (!normalized.endsWith('/')) normalized += '/'; + final baseUrl = Uri.parse(normalized); + + final response = await _send('PROPFIND', baseUrl, headers: { + 'Authorization': _basicAuthHeader(username, password), + 'Depth': '0', + 'Content-Type': 'application/xml; charset=utf-8', + }, body: _propfindRequestBody); + + if (response.statusCode == 401) { + throw StateError('WebDAV sign-in failed: invalid username or password.'); + } + if (response.statusCode != 207 && response.statusCode != 200) { + throw StateError('WebDAV sign-in failed: ${response.statusCode} ${response.body}'); + } + + _baseUrl = baseUrl; + _username = username; + _password = password; + return accountLabel!; + } + + @override + Future signOut() async { + _baseUrl = null; + _username = null; + _password = null; + await _secureStorage.delete(key: _secureStorageKeyServerUrl); + await _secureStorage.delete(key: _secureStorageKeyUsername); + await _secureStorage.delete(key: _secureStorageKeyPassword); + } + + @override + CloudStorageSession beginSession() { + if (!isSignedIn) throw CloudNotAuthorizedException(displayName); + return WebDavSession(this); + } + + String get _authHeader => _basicAuthHeader(_username!, _password!); +} + +class _WebDavNotFoundException implements Exception {} + +class WebDavSession implements CloudStorageSession { + final WebDavProvider _provider; + WebDavSession(this._provider); + + @override + bool get supportsSharedWithMe => false; + + Uri _uriFor(String id) => id == 'root' ? _provider._baseUrl! : _provider._baseUrl!.replace(path: id); + + Uri _childUri(Uri parent, String name) { + final parentUri = parent.path.endsWith('/') ? parent : parent.replace(path: '${parent.path}/'); + return parentUri.resolve(Uri.encodeComponent(name)); + } + + Future> _propfind(Uri uri, {required String depth}) async { + final response = await _send('PROPFIND', uri, headers: { + 'Authorization': _provider._authHeader, + 'Depth': depth, + 'Content-Type': 'application/xml; charset=utf-8', + }, body: _propfindRequestBody); + + if (response.statusCode == 404) throw _WebDavNotFoundException(); + if (response.statusCode != 207) { + throw StateError('WebDAV PROPFIND failed: ${response.statusCode} ${response.body}'); + } + return parseWebDavMultistatus(response.body, _provider._baseUrl!); + } + + @override + Future> listFolders({String? parentId, bool sharedWithMe = false}) async { + final uri = _uriFor(parentId ?? 'root'); + final selfPath = _normalizedPath(uri.path); + + List entries; + try { + entries = await _propfind(uri, depth: '1'); + } on _WebDavNotFoundException { + return []; + } + + return entries + .where((e) => e.isCollection && _normalizedPath(e.path) != selfPath) + .map((e) => CloudFolder(id: e.path, name: _nameFromPath(e.path))) + .toList(); + } + + @override + Future findOrCreateFolder({required String parentId, required String name}) async { + final childUri = _childUri(_uriFor(parentId), name); + + try { + final entries = await _propfind(childUri, depth: '0'); + if (entries.isNotEmpty && entries.first.isCollection) return childUri.path; + } on _WebDavNotFoundException { + // Falls through to create it below. + } + + final response = + await _send('MKCOL', childUri, headers: {'Authorization': _provider._authHeader}); + if (response.statusCode != 200 && response.statusCode != 201) { + throw StateError('WebDAV MKCOL failed: ${response.statusCode} ${response.body}'); + } + return childUri.path; + } + + @override + Future findFile({required String folderId, required String name}) async { + final fileUri = _childUri(_uriFor(folderId), name); + List entries; + try { + entries = await _propfind(fileUri, depth: '0'); + } on _WebDavNotFoundException { + return null; + } + if (entries.isEmpty) return null; + return CloudFileInfo(id: fileUri.path, versionTag: entries.first.etag); + } + + @override + Future> downloadFileBytes(String fileId) async { + final response = + await http.get(_uriFor(fileId), headers: {'Authorization': _provider._authHeader}); + if (response.statusCode != 200) { + throw StateError('WebDAV download failed: ${response.statusCode} ${response.body}'); + } + return response.bodyBytes; + } + + @override + Future uploadFile({ + required String folderId, + required String name, + String? existingFileId, + required File localFile, + required String contentType, + }) async { + final uri = existingFileId != null ? _uriFor(existingFileId) : _childUri(_uriFor(folderId), name); + final bytes = await localFile.readAsBytes(); + + final response = await http.put( + uri, + headers: {'Authorization': _provider._authHeader, 'Content-Type': contentType}, + body: bytes, + ); + if (response.statusCode != 200 && response.statusCode != 201 && response.statusCode != 204) { + throw StateError('WebDAV upload failed: ${response.statusCode} ${response.body}'); + } + + // Many servers return the new ETag directly on the PUT response; fall + // back to a follow-up PROPFIND only if it's missing. + var etag = response.headers['etag']; + if (etag == null) { + try { + final entries = await _propfind(uri, depth: '0'); + etag = entries.isEmpty ? null : entries.first.etag; + } on _WebDavNotFoundException { + etag = null; + } + } + return CloudFileInfo(id: uri.path, versionTag: etag); + } + + @override + Future deleteFile(String fileId) async { + final response = + await http.delete(_uriFor(fileId), headers: {'Authorization': _provider._authHeader}); + if (response.statusCode != 200 && response.statusCode != 204 && response.statusCode != 404) { + throw StateError('WebDAV delete failed: ${response.statusCode} ${response.body}'); + } + } + + @override + Future createLockFile({required String folderId, required String name}) async { + final uri = _childUri(_uriFor(folderId), name); + final response = await http.put( + uri, + headers: {'Authorization': _provider._authHeader, 'Content-Type': 'text/plain'}, + body: const [], + ); + if (response.statusCode != 200 && response.statusCode != 201 && response.statusCode != 204) { + throw StateError('WebDAV lock creation failed: ${response.statusCode} ${response.body}'); + } + return uri.path; + } + + @override + Future> listLockFiles(String folderId) async { + final uri = _uriFor(folderId); + final selfPath = _normalizedPath(uri.path); + + List entries; + try { + entries = await _propfind(uri, depth: '1'); + } on _WebDavNotFoundException { + return []; + } + + final locks = []; + for (final entry in entries) { + if (_normalizedPath(entry.path) == selfPath) continue; + final parsed = parseLockFileName(_nameFromPath(entry.path)); + if (parsed != null) { + locks.add(CloudLockFile(id: entry.path, username: parsed.$1, createdAtUtc: parsed.$2)); + } + } + return locks; + } + + @override + void close() {} +} diff --git a/lib/services/cloud_oauth_config.dart b/lib/services/cloud_oauth_config.dart new file mode 100644 index 0000000..811c963 --- /dev/null +++ b/lib/services/cloud_oauth_config.dart @@ -0,0 +1,52 @@ +/// Fill these in once the corresponding OAuth apps/registrations exist — +/// see README.md "Manual setup required" for exact steps per provider. +class CloudOAuthConfig { + // --- Google Drive --- + // + // - Android sign-in (Credential Manager-based, as of google_sign_in v7) + // authenticates using a *Web application* type OAuth client's ID, not + // the Android client's own ID. The separate Android OAuth client + // (registered with the package name + debug/release SHA-1) is still + // required, but only to let Credential Manager verify this specific + // signed app — its client ID itself is never referenced here. + // - iOS uses its own iOS-type OAuth client ID directly. + + /// The Web application OAuth client ID. Required for sign-in to work on + /// Android. + static const String? googleAndroidServerClientId = null; // TODO: fill in + + /// The iOS OAuth client ID. Leave null if GIDClientID is instead set + /// directly in ios/Runner/Info.plist. + static const String? googleIosClientId = null; // TODO: fill in + + // --- Dropbox --- + // + // From a "Full Dropbox" access app at dropbox.com/developers/apps. + + /// The app's key (client ID for OAuth2 PKCE — no secret needed). + static const String? dropboxAppKey = null; // TODO: fill in + + /// Custom URL scheme redirect registered in the Dropbox app console. + /// The scheme "mofueltaxback-dropbox" is already wired up in + /// AndroidManifest.xml / Info.plist, so unless you have a reason to pick + /// a different scheme, use exactly: + /// "mofueltaxback-dropbox://oauth2redirect" + static const String? dropboxRedirectUri = null; // TODO: fill in + + // --- OneDrive (Microsoft Graph) --- + // + // From an app registration in Azure Portal → Entra ID → App + // registrations, with Graph delegated permissions Files.ReadWrite.All + + // offline_access, redirect URI registered as a "Mobile and desktop + // application" platform. + + /// The Application (client) ID from the Azure app registration. + static const String? oneDriveClientId = null; // TODO: fill in + + /// Custom URL scheme redirect registered in Azure. The scheme + /// "mofueltaxback-onedrive" is already wired up in AndroidManifest.xml / + /// Info.plist, so unless you have a reason to pick a different scheme, + /// register and use exactly: + /// "mofueltaxback-onedrive://auth" + static const String? oneDriveRedirectUri = null; // TODO: fill in +} diff --git a/lib/services/cloud_sync_service.dart b/lib/services/cloud_sync_service.dart new file mode 100644 index 0000000..22b8fd1 --- /dev/null +++ b/lib/services/cloud_sync_service.dart @@ -0,0 +1,324 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'cloud/cloud_storage_provider.dart'; +import 'database_service.dart'; +import 'db_schema.dart'; +import 'lock_coordinator.dart' as lock; + +class SyncResult { + final bool ranSync; + final Object? error; + + SyncResult.skipped() + : ranSync = false, + error = null; + + SyncResult.success() + : ranSync = true, + error = null; + + SyncResult.failure(this.error) : ranSync = false; +} + +/// Orchestrates one round of sync against the shared cloud folder — same +/// behavior no matter which [CloudStorageProvider] it's wired to: acquires +/// the cross-device lock, pulls + merges the remote database if it +/// changed, uploads any pending receipt photos, pushes the local database +/// back up, then releases the lock. +/// +/// The merge itself runs as SQL directly against the local database with +/// the downloaded remote copy `ATTACH`ed, rather than decoding records into +/// Dart objects: for each table, `INSERT OR REPLACE` any remote row that's +/// new to us or has a newer `updated_at` than our copy. Rows we haven't +/// touched (including our own not-yet-pushed edits) are left alone by that +/// statement, so no separate "keep local" step is needed — see the README +/// for the full reasoning. +class CloudSyncService { + final CloudStorageProvider provider; + final DatabaseService databaseService; + + String? _appFolderId; + String? _receiptsFolderId; + final Map _vinFolderIds = {}; + final Map _monthFolderIds = {}; + String? _dataFileId; + String? _lastKnownRemoteVersionTag; + + CloudSyncService({required this.provider, required this.databaseService}); + + bool get isConfigured => _appFolderId != null; + + /// Call once a cloud app folder has been chosen (or restored at launch). + void configure(String appFolderId) { + _appFolderId = appFolderId; + _receiptsFolderId = null; + _vinFolderIds.clear(); + _monthFolderIds.clear(); + _dataFileId = null; + _lastKnownRemoteVersionTag = null; + } + + void clearConfiguration() { + _appFolderId = null; + _receiptsFolderId = null; + _vinFolderIds.clear(); + _monthFolderIds.clear(); + _dataFileId = null; + _lastKnownRemoteVersionTag = null; + } + + /// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a + /// folder the user picked in the folder browser) and configures this + /// service to use it. Returns the resulting folder ID. + Future selectAppFolder(String parentId) async { + final session = provider.beginSession(); + try { + final folderId = + await session.findOrCreateFolder(parentId: parentId, name: appFolderName); + configure(folderId); + return folderId; + } finally { + session.close(); + } + } + + /// [keepLocalReceiptCopies] mirrors the Settings toggle: when true, a + /// receipt photo's local copy is left in place after it's uploaded + /// (useful for offline viewing / an on-device backup); when false + /// (default), it's deleted once the cloud has it, matching the original + /// "local is just a staging area" design. + /// + /// [staleLockAge] mirrors the Settings "Advanced" stale-lock timeout: + /// how old another device's lock file has to be before this device + /// treats it as abandoned (e.g. that device crashed or went offline + /// mid-sync) and deletes it rather than waiting forever. Defaults to 10 + /// minutes, matching [defaultStaleLockMinutes] in app_state.dart. + Future syncNow({ + bool keepLocalReceiptCopies = false, + Duration staleLockAge = const Duration(minutes: 10), + }) async { + final appFolderId = _appFolderId; + if (!provider.isSignedIn || appFolderId == null) { + return SyncResult.skipped(); + } + + CloudStorageSession? session; + String? lockFileId; + + try { + session = provider.beginSession(); + + lockFileId = await _acquireLock( + session, + appFolderId: appFolderId, + username: provider.accountLabel!, + staleAge: staleLockAge, + ); + + _receiptsFolderId ??= + await session.findOrCreateFolder(parentId: appFolderId, name: receiptsFolderName); + + final remoteInfo = await session.findFile(folderId: appFolderId, name: dataFileName); + _dataFileId = remoteInfo?.id; + if (remoteInfo != null && remoteInfo.versionTag != _lastKnownRemoteVersionTag) { + await _pullAndMerge(session, remoteInfo.id); + } + + final allReceiptsUploaded = + await _uploadPendingReceipts(session, keepLocalCopies: keepLocalReceiptCopies); + + // Only push if every pending receipt made it up — otherwise we'd + // either upload a row with a local-only file path (meaningless on + // another device) or prematurely mark it clean. + if (allReceiptsUploaded) { + final pushedInfo = await _pushSnapshot(session, appFolderId); + _lastKnownRemoteVersionTag = pushedInfo.versionTag; + } + + return SyncResult.success(); + } catch (e) { + return SyncResult.failure(e); + } finally { + if (lockFileId != null && session != null) { + try { + await session.deleteFile(lockFileId); + } catch (_) { + // Best-effort: if this fails, the staleness reap on other + // devices' next sync attempt will clean it up. + } + } + session?.close(); + } + } + + Future _pullAndMerge(CloudStorageSession session, String remoteFileId) async { + final bytes = await session.downloadFileBytes(remoteFileId); + final tempDir = await getTemporaryDirectory(); + final tempPath = + p.join(tempDir.path, 'cloud_pull_${DateTime.now().microsecondsSinceEpoch}.db'); + final tempFile = File(tempPath); + await tempFile.writeAsBytes(bytes, flush: true); + + try { + final db = databaseService.rawDb; + await db.execute("ATTACH DATABASE '${_escapeSqlLiteral(tempPath)}' AS remote_db"); + try { + await db.execute(mergeVehiclesSql); + await db.execute(mergeFuelEntriesSql); + } finally { + try { + await db.execute('DETACH DATABASE remote_db'); + } catch (_) { + // Don't let a detach failure mask a real merge error above. + } + } + } finally { + if (await tempFile.exists()) { + await tempFile.delete(); + } + } + } + + /// Uploads any receipt images still needing it (a local path but no + /// cloud file ID yet — see [FuelEntry.needsReceiptUpload]). Returns true + /// only if every pending image made it up — see [syncNow] for why a + /// partial failure here blocks the push step entirely rather than + /// pushing something with a leftover local-only path. + /// + /// Deliberately filters on `receipt_drive_file_id IS NULL` (see + /// [pendingReceiptUploadWhereClause]), not just "has a local path": once + /// [keepLocalCopies] is honored below, an already-uploaded row can still + /// have a local path (kept on purpose), and re-matching it here would + /// re-upload the same photo as a duplicate cloud file on every sync. + Future _uploadPendingReceipts( + CloudStorageSession session, { + required bool keepLocalCopies, + }) async { + final db = databaseService.rawDb; + final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause); + if (rows.isEmpty) return true; + + final vehicleRows = await db.query('vehicles', columns: ['id', 'vin']); + final vinByVehicleId = {for (final v in vehicleRows) v['id'] as String: v['vin'] as String}; + + var allSucceeded = true; + for (final row in rows) { + final id = row['id'] as String; + final localPath = row['receipt_image_path'] as String; + final localFile = File(localPath); + + if (!await localFile.exists()) { + // Nothing left to upload; clear the dangling reference. + await db.update('fuel_entries', {'receipt_image_path': null}, + where: 'id = ?', whereArgs: [id]); + continue; + } + + final vin = vinByVehicleId[row['vehicle_id']]; + if (vin == null) { + // Vehicle row is missing outright (shouldn't normally happen); + // nothing sane to file this under. + allSucceeded = false; + continue; + } + final date = DateTime.fromMillisecondsSinceEpoch(row['date'] as int); + + try { + final folderId = await _receiptFolderFor(session, vin, date); + final uploaded = await session.uploadFile( + folderId: folderId, + name: '$id${p.extension(localPath)}', + localFile: localFile, + contentType: 'image/jpeg', + ); + if (!keepLocalCopies) { + await databaseService.deleteReceiptImageFile(localPath); + } + await db.update( + 'fuel_entries', + { + 'receipt_drive_file_id': uploaded.id, + 'receipt_image_path': keepLocalCopies ? localPath : null, + }, + where: 'id = ?', + whereArgs: [id], + ); + } catch (_) { + allSucceeded = false; + } + } + return allSucceeded; + } + + /// Finds-or-creates the `Receipts//` subfolder for [vin] and + /// [date] (the fuel entry's purchase date, not upload time), caching both + /// levels for the rest of this [CloudSyncService]'s lifetime (cleared by + /// [configure]/[clearConfiguration]) so repeated uploads for the same + /// vehicle/month in one sync — or across syncs — don't re-issue the + /// lookup. + Future _receiptFolderFor(CloudStorageSession session, String vin, DateTime date) async { + final vinFolderId = _vinFolderIds[vin] ?? + await session.findOrCreateFolder( + parentId: _receiptsFolderId!, + name: sanitizedPathSegment(vin), + ); + _vinFolderIds[vin] = vinFolderId; + + final month = monthFolderName(date); + final monthCacheKey = '$vin/$month'; + final monthFolderId = _monthFolderIds[monthCacheKey] ?? + await session.findOrCreateFolder(parentId: vinFolderId, name: month); + _monthFolderIds[monthCacheKey] = monthFolderId; + + return monthFolderId; + } + + /// Uploads the current local database file as the new remote copy, then + /// clears the dirty flag on every row now that local matches the cloud. + /// + /// Reads the live database file directly rather than a `VACUUM INTO` + /// snapshot: sqflite uses SQLite's default rollback-journal mode (not + /// WAL) unless explicitly configured otherwise, so the main file is a + /// complete, valid database as soon as the last write's Future + /// completes. `wal_checkpoint` is run first anyway as cheap insurance in + /// case that ever changes. This also sidesteps `VACUUM INTO` needing + /// SQLite 3.27+, which isn't guaranteed on very old Android versions. + Future _pushSnapshot(CloudStorageSession session, String appFolderId) async { + final db = databaseService.rawDb; + await db.rawQuery('PRAGMA wal_checkpoint(TRUNCATE)'); + + final info = await session.uploadFile( + folderId: appFolderId, + name: dataFileName, + existingFileId: _dataFileId, + localFile: File(databaseService.databasePath), + contentType: 'application/x-sqlite3', + ); + _dataFileId = info.id; + + await db.update('vehicles', {'dirty': 0}); + await db.update('fuel_entries', {'dirty': 0}); + + return info; + } + + String _escapeSqlLiteral(String value) => value.replaceAll("'", "''"); + + Future _acquireLock( + CloudStorageSession session, { + required String appFolderId, + required String username, + required Duration staleAge, + }) { + return lock.acquireLock( + username: username, + createLock: (name) => session.createLockFile(folderId: appFolderId, name: name), + listLocks: () => session.listLockFiles(appFolderId), + deleteLock: session.deleteFile, + staleAge: staleAge, + ); + } +} diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart index c764c45..cbf1716 100644 --- a/lib/services/database_service.dart +++ b/lib/services/database_service.dart @@ -3,13 +3,33 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; +import 'package:uuid/uuid.dart'; import '../models/fuel_entry.dart'; import '../models/vehicle.dart'; import 'db_schema.dart'; const dbFileName = 'fuel_tax_tracker.db'; -const receiptsFolderName = 'receipts'; + +/// OCR-scanned VINs are a fixed alphanumeric charset, but manual entry in +/// the vehicle form doesn't enforce that — so anything outside +/// `[A-Za-z0-9_-]` is replaced before a VIN is used as a local directory +/// name or cloud folder name, to keep it a safe single path segment (no +/// `/`, `..`, etc.). +String sanitizedPathSegment(String value) => value.trim().replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_'); + +/// `yyyy.mm` for the given (local) date — the subfolder a receipt is filed +/// under within its vehicle's folder, e.g. `2026.08`. Based on the fuel +/// entry's purchase date ([FuelEntry.date]), not when the photo happened to +/// be taken or synced. +String monthFolderName(DateTime date) => + '${date.year.toString().padLeft(4, '0')}.${date.month.toString().padLeft(2, '0')}'; + +/// Name of the *local* on-device staging folder for not-yet-uploaded +/// receipt photos — distinct from (though coincidentally the same string +/// as) `receiptsFolderName` in `cloud/cloud_storage_provider.dart`, which +/// names the corresponding subfolder inside the shared cloud app folder. +const localReceiptsFolderName = 'receipts'; /// Owns the local SQLite database (vehicles + fuel_entries) and the /// `receipts/` folder of not-yet-uploaded receipt photos, both under @@ -18,9 +38,9 @@ const receiptsFolderName = 'receipts'; /// Every row carries `updated_at` (for newest-wins merging), `deleted_at` /// (a soft-delete tombstone — see [Vehicle.deletedAt]/[FuelEntry.deletedAt] /// for why deletes aren't real `DELETE`s), and `dirty` (local-only: "not -/// yet pushed to Drive"). `dirty` is intentionally not exposed on the +/// yet pushed to the cloud"). `dirty` is intentionally not exposed on the /// domain model classes — it's sync bookkeeping the UI layer never needs to -/// know about; only [DriveSyncService] reads/clears it. +/// know about; only [CloudSyncService] reads/clears it. class DatabaseService { late Directory _rootDirectory; late Database _db; @@ -28,9 +48,9 @@ class DatabaseService { Directory get rootDirectory => _rootDirectory; Directory get receiptsDirectory => - Directory(p.join(_rootDirectory.path, receiptsFolderName)); + Directory(p.join(_rootDirectory.path, localReceiptsFolderName)); - /// Raw handle for [DriveSyncService], which needs to run ATTACH-based + /// Raw handle for [CloudSyncService], which needs to run ATTACH-based /// merge SQL and VACUUM INTO snapshots that go beyond simple CRUD. Database get rawDb => _db; @@ -43,7 +63,12 @@ class DatabaseService { await receiptsDirectory.create(recursive: true); final dbPath = p.join(_rootDirectory.path, dbFileName); - _db = await openDatabase(dbPath, version: 1, onCreate: _onCreate); + _db = await openDatabase( + dbPath, + version: 4, + onCreate: _onCreate, + onUpgrade: _onUpgrade, + ); } Future _onCreate(Database db, int version) async { @@ -52,6 +77,76 @@ class DatabaseService { await db.execute(createFuelEntriesIndexSql); } + /// Versions 2/3 (VIN as primary key, then dropping license plate) were + /// rebuilt fresh on upgrade rather than migrated, since at the time that + /// only affected local, not-yet-synced test data. Version 4 (VIN becomes + /// editable, with a new hidden `id` taking over as the actual primary + /// key/merge key) is the first schema change made after real usage had + /// likely accumulated, so this one preserves existing rows instead of + /// dropping them: each vehicle gets a freshly generated `id`, and + /// `fuel_entries.vehicle_id` is repointed from the old `vehicle_vin` via + /// that mapping. + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 4) { + await _migrateToVehicleIdSchema(db); + } + } + + Future _migrateToVehicleIdSchema(Database db) async { + const uuid = Uuid(); + + final oldVehicles = await db.query('vehicles'); + final vinToId = {}; + + await db.execute('ALTER TABLE vehicles RENAME TO vehicles_old'); + await db.execute(createVehiclesTableSql); + for (final row in oldVehicles) { + final vin = row['vin'] as String; + // An already-upgraded-then-reverted-then-upgraded-again edge case + // could in theory produce duplicate vin rows pre-migration; keep the + // first id assigned per vin so fuel_entries below has a single, + // unambiguous target. + final id = vinToId.putIfAbsent(vin, uuid.v4); + await db.insert('vehicles', { + 'id': id, + 'vin': vin, + 'nickname': row['nickname'], + 'updated_at': row['updated_at'], + 'deleted_at': row['deleted_at'], + // Force a re-push under the new schema — the shape of what's on + // the cloud (if anything's been synced yet) still reflects the old + // schema and needs to be overwritten with this one. + 'dirty': 1, + }); + } + await db.execute('DROP TABLE vehicles_old'); + + final oldFuelEntries = await db.query('fuel_entries'); + await db.execute('ALTER TABLE fuel_entries RENAME TO fuel_entries_old'); + await db.execute(createFuelEntriesTableSql); + await db.execute(createFuelEntriesIndexSql); + for (final row in oldFuelEntries) { + final vehicleId = vinToId[row['vehicle_vin'] as String]; + // No matching vehicle row (shouldn't normally happen) — drop rather + // than insert a fuel entry with a dangling reference. + if (vehicleId == null) continue; + await db.insert('fuel_entries', { + 'id': row['id'], + 'vehicle_id': vehicleId, + 'date': row['date'], + 'gallons': row['gallons'], + 'price_per_gallon': row['price_per_gallon'], + 'total_cost': row['total_cost'], + 'receipt_image_path': row['receipt_image_path'], + 'receipt_drive_file_id': row['receipt_drive_file_id'], + 'updated_at': row['updated_at'], + 'deleted_at': row['deleted_at'], + 'dirty': 1, + }); + } + await db.execute('DROP TABLE fuel_entries_old'); + } + Future> getVehicles() async { final rows = await _db.query('vehicles', where: 'deleted_at IS NULL'); return rows.map(Vehicle.fromMap).toList(); @@ -62,6 +157,27 @@ class DatabaseService { return rows.map(FuelEntry.fromMap).toList(); } + /// True if an active (non-deleted) vehicle other than [excludeId] already + /// has this VIN. VIN must stay unique even though it's editable, so + /// callers adding a new vehicle (no [excludeId]) or changing an existing + /// one's VIN (passing its own [id][Vehicle.id] as [excludeId], so it + /// doesn't collide with itself) should check this first. + Future vinExists(String vin, {String? excludeId}) async { + final where = StringBuffer('vin = ? AND deleted_at IS NULL'); + final whereArgs = [vin]; + if (excludeId != null) { + where.write(' AND id != ?'); + whereArgs.add(excludeId); + } + final rows = await _db.query( + 'vehicles', + where: where.toString(), + whereArgs: whereArgs, + limit: 1, + ); + return rows.isNotEmpty; + } + /// Inserts or fully overwrites a vehicle row and marks it dirty (pending /// push to Drive). Future saveVehicle(Vehicle vehicle) async { @@ -129,9 +245,24 @@ class DatabaseService { return localPaths; } - Future storeReceiptImage(File sourceImage, String fuelEntryId) async { + /// Stores under `receipts///`, mirroring the per-vehicle, + /// per-month folder structure used on the cloud side (see + /// [CloudSyncService]'s `_receiptFolderFor`), so a receipt's local + /// staging path already shows which vehicle and month it belongs to. + Future storeReceiptImage( + File sourceImage, + String fuelEntryId, + String vin, + DateTime date, + ) async { final ext = p.extension(sourceImage.path); - final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext'); + final entryDir = Directory(p.join( + receiptsDirectory.path, + sanitizedPathSegment(vin), + monthFolderName(date), + )); + await entryDir.create(recursive: true); + final destPath = p.join(entryDir.path, '$fuelEntryId$ext'); final copied = await sourceImage.copy(destPath); return copied.path; } diff --git a/lib/services/db_schema.dart b/lib/services/db_schema.dart index 1ac90ce..1082cf3 100644 --- a/lib/services/db_schema.dart +++ b/lib/services/db_schema.dart @@ -1,10 +1,8 @@ const createVehiclesTableSql = ''' CREATE TABLE vehicles ( id TEXT PRIMARY KEY, - make TEXT NOT NULL, - model TEXT NOT NULL, - color TEXT NOT NULL, - license_plate TEXT NOT NULL, + vin TEXT NOT NULL, + nickname TEXT, updated_at INTEGER NOT NULL, deleted_at INTEGER, dirty INTEGER NOT NULL DEFAULT 1 @@ -30,15 +28,33 @@ const createFuelEntriesTableSql = ''' const createFuelEntriesIndexSql = 'CREATE INDEX idx_fuel_entries_vehicle_id ON fuel_entries(vehicle_id)'; +/// Selects fuel entries whose receipt photo still needs uploading to +/// Drive. Deliberately requires `receipt_drive_file_id IS NULL`, not just +/// "has a local path": once a row is uploaded, its local copy may still be +/// kept around (see the "keep photos on this phone" setting) — matching on +/// the local path alone would re-upload that same photo as a duplicate +/// Drive file on every subsequent sync. +const pendingReceiptUploadWhereClause = 'dirty = 1 AND receipt_image_path IS NOT NULL ' + 'AND receipt_drive_file_id IS NULL AND deleted_at IS NULL'; + /// Merges attached `remote_db` rows into `main` (the live local database): /// any remote row that's new to us, or newer than our copy, replaces ours. /// Rows this doesn't touch — including our own not-yet-pushed edits — are /// left alone, since the WHERE clause only matches rows remote should win; /// no separate "keep local" statement is needed. +/// +/// Merges on `id` (the hidden, immutable identifier), not `vin` — VIN is +/// user-editable, so it can't be relied on as a stable merge key. VIN +/// uniqueness among active vehicles is enforced at the app layer instead +/// (see `DatabaseService.vinExists`), not by a database constraint here, +/// since two devices could in principle each independently add a vehicle +/// with the same VIN while offline; that's an accepted rare-conflict edge +/// case (see the "field-level conflicts aren't merged" caveat in the +/// README) rather than something this merge statement tries to resolve. const mergeVehiclesSql = ''' INSERT OR REPLACE INTO main.vehicles - (id, make, model, color, license_plate, updated_at, deleted_at, dirty) - SELECT r.id, r.make, r.model, r.color, r.license_plate, r.updated_at, r.deleted_at, 0 + (id, vin, nickname, updated_at, deleted_at, dirty) + SELECT r.id, r.vin, r.nickname, r.updated_at, r.deleted_at, 0 FROM remote_db.vehicles r LEFT JOIN main.vehicles l ON l.id = r.id WHERE l.id IS NULL OR r.updated_at > l.updated_at diff --git a/lib/services/drive_auth_service.dart b/lib/services/drive_auth_service.dart deleted file mode 100644 index 7bbf814..0000000 --- a/lib/services/drive_auth_service.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'dart:async'; -import 'dart:io' show Platform; - -import 'package:google_sign_in/google_sign_in.dart'; -import 'package:http/http.dart' as http; - -import 'drive_oauth_config.dart'; - -/// Full Drive access is required (not the narrower `drive.file` scope) -/// because users need to browse to and reuse folders that someone else -/// created and shared with them, not just folders/files this app itself -/// created. See the plan doc for the tradeoffs (this requires Google -/// Cloud Console "Testing" mode with explicit test users, to avoid needing -/// a full OAuth verification review). -const driveScopes = ['https://www.googleapis.com/auth/drive']; - -/// Thrown when a Drive API call needs authorization that isn't currently -/// available without prompting the user, e.g. during a background sync. -class DriveNotAuthorizedException implements Exception { - @override - String toString() => 'Drive access is not currently authorized.'; -} - -/// Wraps `google_sign_in` for authenticating with Google and producing an -/// authenticated [http.Client] for the Drive API. -class DriveAuthService { - bool _initialized = false; - GoogleSignInAccount? _account; - - bool get isSignedIn => _account != null; - - String? get currentAccountEmail => _account?.email; - - Future _ensureInitialized() async { - if (_initialized) return; - await GoogleSignIn.instance.initialize( - clientId: Platform.isIOS ? DriveOAuthConfig.iosClientId : null, - serverClientId: Platform.isAndroid ? DriveOAuthConfig.androidServerClientId : null, - ); - _initialized = true; - } - - /// Attempts to restore a previous sign-in without any UI. Returns true if - /// the user is signed in and Drive access is already authorized. - Future attemptSilentSignIn() async { - await _ensureInitialized(); - final account = await GoogleSignIn.instance.attemptLightweightAuthentication(); - _account = account; - if (account == null) return false; - - final authorization = - await account.authorizationClient.authorizationForScopes(driveScopes); - return authorization != null; - } - - /// Interactive sign-in + Drive scope authorization. Must be called from a - /// user-initiated action (e.g. a button press). - Future signIn() async { - await _ensureInitialized(); - final account = await GoogleSignIn.instance.authenticate(scopeHint: driveScopes); - _account = account; - await account.authorizationClient.authorizeScopes(driveScopes); - return account.email; - } - - Future signOut() async { - await GoogleSignIn.instance.signOut(); - _account = null; - } - - /// Builds an [http.Client] that attaches a fresh Drive authorization - /// header to every request. Fetches headers per-request (rather than - /// once) so a client that lives across a long sync doesn't use a stale, - /// expired token. Never prompts for UI — suitable for background sync — - /// so throws [DriveNotAuthorizedException] if authorization isn't already - /// in place (the caller should treat that as "Drive is disconnected"). - http.Client authenticatedHttpClient() { - final account = _account; - if (account == null) { - throw DriveNotAuthorizedException(); - } - return _DriveHttpClient(account.authorizationClient); - } -} - -class _DriveHttpClient extends http.BaseClient { - final GoogleSignInAuthorizationClient _authClient; - final http.Client _inner = http.Client(); - - _DriveHttpClient(this._authClient); - - @override - Future send(http.BaseRequest request) async { - final headers = - await _authClient.authorizationHeaders(driveScopes, promptIfNecessary: false); - if (headers == null) { - throw DriveNotAuthorizedException(); - } - request.headers.addAll(headers); - return _inner.send(request); - } - - @override - void close() { - _inner.close(); - super.close(); - } -} diff --git a/lib/services/drive_oauth_config.dart b/lib/services/drive_oauth_config.dart deleted file mode 100644 index deff9c8..0000000 --- a/lib/services/drive_oauth_config.dart +++ /dev/null @@ -1,20 +0,0 @@ -/// Fill these in once the corresponding OAuth clients exist in Google Cloud -/// Console (see README.md "Manual setup required"). Both are needed even -/// though only one app runs on each platform: -/// -/// - Android sign-in (Credential Manager-based, as of google_sign_in v7) -/// authenticates using a *Web application* type OAuth client's ID, not the -/// Android client's own ID. The separate Android OAuth client (registered -/// with the package name + debug/release SHA-1) is still required, but -/// only to let Credential Manager verify this specific signed app — its -/// client ID itself is never referenced here. -/// - iOS uses its own iOS-type OAuth client ID directly. -class DriveOAuthConfig { - /// The Web application OAuth client ID. Required for sign-in to work on - /// Android. - static const String? androidServerClientId = null; // TODO: fill in - - /// The iOS OAuth client ID. Leave null if GIDClientID is instead set - /// directly in ios/Runner/Info.plist. - static const String? iosClientId = null; // TODO: fill in -} diff --git a/lib/services/drive_service.dart b/lib/services/drive_service.dart deleted file mode 100644 index 3a02a3a..0000000 --- a/lib/services/drive_service.dart +++ /dev/null @@ -1,241 +0,0 @@ -import 'dart:io'; - -import 'package:googleapis/drive/v3.dart' as drive; -import 'package:http/http.dart' as http; - -const appFolderName = 'MO-Fuel-Tax-Back'; -const receiptsFolderName = 'receipts'; -const dataFileName = 'fuel_tax_tracker.db'; - -const _folderMimeType = 'application/vnd.google-apps.folder'; - -class DriveFolder { - final String id; - final String name; - - DriveFolder({required this.id, required this.name}); -} - -class DriveDataFileInfo { - final String id; - - /// Cheap change-detection signal: compare against the last value seen to - /// decide whether a pull is actually needed, without downloading content. - final String? md5Checksum; - - DriveDataFileInfo({required this.id, required this.md5Checksum}); -} - -class DriveLockFile { - final String id; - final String username; - final DateTime createdAtUtc; - - DriveLockFile({required this.id, required this.username, required this.createdAtUtc}); -} - -/// Raw Google Drive API operations, built on top of an already-authenticated -/// [http.Client] (see [DriveAuthService.authenticatedHttpClient]). Callers -/// own the client's lifecycle (create it, use a [DriveService] instance for -/// the duration of one sync, then close it). -class DriveService { - final drive.DriveApi _api; - - DriveService(http.Client authenticatedClient) : _api = drive.DriveApi(authenticatedClient); - - /// Lists folders under [parentId], or (if [sharedWithMe] is true) the - /// top-level folders that other users have shared with the signed-in - /// account, regardless of parent. - Future> listFolders({String? parentId, bool sharedWithMe = false}) async { - final query = sharedWithMe - ? "sharedWithMe=true and mimeType='$_folderMimeType' and trashed=false" - : "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false"; - - final result = await _api.files.list( - q: query, - orderBy: 'name', - $fields: 'files(id,name)', - spaces: 'drive', - ); - - return (result.files ?? []) - .where((f) => f.id != null && f.name != null) - .map((f) => DriveFolder(id: f.id!, name: f.name!)) - .toList(); - } - - /// Finds a folder named [appFolderName] under [parentId], or creates one - /// if none exists. Multiple devices pointed at the same shared parent - /// converge on the same folder this way. If duplicates exist (Drive - /// allows same-named folders), the earliest-created one wins. - Future findOrCreateAppFolder(String parentId) { - return _findOrCreateFolder(name: appFolderName, parentId: parentId); - } - - Future findOrCreateReceiptsFolder(String appFolderId) { - return _findOrCreateFolder(name: receiptsFolderName, parentId: appFolderId); - } - - Future _findOrCreateFolder({required String name, required String parentId}) async { - final existing = await _api.files.list( - q: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false and name='$name'", - orderBy: 'createdTime', - $fields: 'files(id,name)', - spaces: 'drive', - ); - - final firstMatch = (existing.files ?? []).firstOrNullWithId(); - if (firstMatch != null) return firstMatch; - - final created = await _api.files.create( - drive.File() - ..name = name - ..mimeType = _folderMimeType - ..parents = [parentId], - ); - return created.id!; - } - - /// Looks up the shared data file's ID and md5 checksum without - /// downloading its content, so sync can cheaply decide whether a pull is - /// actually needed. - Future findDataFile(String appFolderId) async { - final result = await _api.files.list( - q: "'$appFolderId' in parents and trashed=false and name='$dataFileName'", - orderBy: 'modifiedTime desc', - $fields: 'files(id,name,md5Checksum)', - spaces: 'drive', - ); - final files = result.files ?? []; - if (files.isEmpty) return null; - final first = files.first; - if (first.id == null) return null; - return DriveDataFileInfo(id: first.id!, md5Checksum: first.md5Checksum); - } - - Future> downloadFileBytes(String fileId) async { - final media = await _api.files.get( - fileId, - downloadOptions: drive.DownloadOptions.fullMedia, - ) as drive.Media; - return _collectBytes(media.stream); - } - - /// Creates the data file if [existingFileId] is null, otherwise - /// overwrites its content with the bytes of the local sqlite database - /// file (see [DriveSyncService]). Returns the (possibly new) file ID and - /// its fresh md5 checksum, so the caller can remember it for - /// change-detection on the next sync without an extra round-trip. - Future uploadDataFile({ - required String appFolderId, - required String? existingFileId, - required File localSnapshotFile, - }) async { - final length = await localSnapshotFile.length(); - final media = drive.Media( - localSnapshotFile.openRead(), - length, - contentType: 'application/x-sqlite3', - ); - - if (existingFileId != null) { - final updated = await _api.files.update( - drive.File(), - existingFileId, - uploadMedia: media, - $fields: 'id,md5Checksum', - ); - return DriveDataFileInfo(id: updated.id ?? existingFileId, md5Checksum: updated.md5Checksum); - } - - final created = await _api.files.create( - drive.File() - ..name = dataFileName - ..parents = [appFolderId], - uploadMedia: media, - $fields: 'id,md5Checksum', - ); - return DriveDataFileInfo(id: created.id!, md5Checksum: created.md5Checksum); - } - - Future uploadReceiptImage({ - required String receiptsFolderId, - required String fileName, - required File imageFile, - }) async { - final length = await imageFile.length(); - final media = drive.Media( - imageFile.openRead(), - length, - contentType: 'image/jpeg', - ); - final created = await _api.files.create( - drive.File() - ..name = fileName - ..parents = [receiptsFolderId], - uploadMedia: media, - ); - return created.id!; - } - - Future deleteFile(String fileId) async { - try { - await _api.files.delete(fileId); - } on drive.DetailedApiRequestError catch (e) { - // Already gone (e.g. deleted by another device) — not an error for - // our purposes. - if (e.status != 404) rethrow; - } - } - - Future createLockFile({required String appFolderId, required String name}) async { - final created = await _api.files.create( - drive.File() - ..name = name - ..parents = [appFolderId], - uploadMedia: drive.Media(Stream.value(const []), 0, contentType: 'text/plain'), - ); - return created.id!; - } - - Future> listLockFiles(String appFolderId) async { - final result = await _api.files.list( - q: "'$appFolderId' in parents and trashed=false and name contains '.lock'", - $fields: 'files(id,name)', - spaces: 'drive', - ); - - final locks = []; - for (final f in result.files ?? []) { - final parsed = _parseLockFileName(f.name); - if (f.id != null && parsed != null) { - locks.add(DriveLockFile(id: f.id!, username: parsed.$1, createdAtUtc: parsed.$2)); - } - } - return locks; - } - - static (String, DateTime)? _parseLockFileName(String? name) { - if (name == null || !name.endsWith('.lock')) return null; - final withoutExt = name.substring(0, name.length - '.lock'.length); - final lastDash = withoutExt.lastIndexOf('-'); - if (lastDash == -1) return null; - final username = withoutExt.substring(0, lastDash); - final epochStr = withoutExt.substring(lastDash + 1); - final epoch = int.tryParse(epochStr); - if (epoch == null) return null; - return (username, DateTime.fromMillisecondsSinceEpoch(epoch, isUtc: true)); - } - - Future> _collectBytes(Stream> stream) async { - final bytes = []; - await for (final chunk in stream) { - bytes.addAll(chunk); - } - return bytes; - } -} - -extension _FirstMatchExtension on List { - String? firstOrNullWithId() => isEmpty ? null : first.id; -} diff --git a/lib/services/drive_sync_service.dart b/lib/services/drive_sync_service.dart deleted file mode 100644 index 955cb72..0000000 --- a/lib/services/drive_sync_service.dart +++ /dev/null @@ -1,251 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; - -import 'database_service.dart'; -import 'db_schema.dart'; -import 'drive_auth_service.dart'; -import 'drive_service.dart'; -import 'lock_coordinator.dart' as lock; - -class SyncResult { - final bool ranSync; - final Object? error; - - SyncResult.skipped() - : ranSync = false, - error = null; - - SyncResult.success() - : ranSync = true, - error = null; - - SyncResult.failure(this.error) : ranSync = false; -} - -/// Orchestrates one round of sync against the shared Drive folder: -/// acquires the cross-device lock, pulls + merges the remote database if -/// it changed, uploads any pending receipt photos, pushes the local -/// database back up, then releases the lock. -/// -/// The merge itself runs as SQL directly against the local database with -/// the downloaded remote copy `ATTACH`ed, rather than decoding records into -/// Dart objects: for each table, `INSERT OR REPLACE` any remote row that's -/// new to us or has a newer `updated_at` than our copy. Rows we haven't -/// touched (including our own not-yet-pushed edits) are left alone by that -/// statement, so no separate "keep local" step is needed — see the README -/// for the full reasoning. -class DriveSyncService { - final DriveAuthService authService; - final DatabaseService databaseService; - - String? _appFolderId; - String? _receiptsFolderId; - String? _dataFileId; - String? _lastKnownRemoteMd5; - - DriveSyncService({required this.authService, required this.databaseService}); - - bool get isConfigured => _appFolderId != null; - - /// Call once a Drive app folder has been chosen (or restored at launch). - void configure(String appFolderId) { - _appFolderId = appFolderId; - _receiptsFolderId = null; - _dataFileId = null; - _lastKnownRemoteMd5 = null; - } - - void clearConfiguration() { - _appFolderId = null; - _receiptsFolderId = null; - _dataFileId = null; - _lastKnownRemoteMd5 = null; - } - - /// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a - /// folder the user picked in the Drive folder browser) and configures - /// this service to use it. Returns the resulting folder ID. - Future selectAppFolder(String parentId) async { - final client = authService.authenticatedHttpClient(); - try { - final drive = DriveService(client); - final folderId = await drive.findOrCreateAppFolder(parentId); - configure(folderId); - return folderId; - } finally { - client.close(); - } - } - - Future syncNow() async { - final appFolderId = _appFolderId; - if (!authService.isSignedIn || appFolderId == null) { - return SyncResult.skipped(); - } - - http.Client? client; - DriveService? drive; - String? lockFileId; - - try { - client = authService.authenticatedHttpClient(); - drive = DriveService(client); - - lockFileId = await _acquireLock( - drive, - appFolderId: appFolderId, - username: authService.currentAccountEmail!, - ); - - _receiptsFolderId ??= await drive.findOrCreateReceiptsFolder(appFolderId); - - final remoteInfo = await drive.findDataFile(appFolderId); - _dataFileId = remoteInfo?.id; - if (remoteInfo != null && remoteInfo.md5Checksum != _lastKnownRemoteMd5) { - await _pullAndMerge(drive, remoteInfo.id); - } - - final allReceiptsUploaded = await _uploadPendingReceipts(drive); - - // Only push if every pending receipt made it up — otherwise we'd - // either upload a row with a local-only file path (meaningless on - // another device) or prematurely mark it clean. - if (allReceiptsUploaded) { - final pushedInfo = await _pushSnapshot(drive, appFolderId); - _lastKnownRemoteMd5 = pushedInfo.md5Checksum; - } - - return SyncResult.success(); - } catch (e) { - return SyncResult.failure(e); - } finally { - if (lockFileId != null && drive != null) { - try { - await drive.deleteFile(lockFileId); - } catch (_) { - // Best-effort: if this fails, the 10-minute staleness reap on - // other devices' next sync attempt will clean it up. - } - } - client?.close(); - } - } - - Future _pullAndMerge(DriveService drive, String remoteFileId) async { - final bytes = await drive.downloadFileBytes(remoteFileId); - final tempDir = await getTemporaryDirectory(); - final tempPath = - p.join(tempDir.path, 'drive_pull_${DateTime.now().microsecondsSinceEpoch}.db'); - final tempFile = File(tempPath); - await tempFile.writeAsBytes(bytes, flush: true); - - try { - final db = databaseService.rawDb; - await db.execute("ATTACH DATABASE '${_escapeSqlLiteral(tempPath)}' AS remote_db"); - try { - await db.execute(mergeVehiclesSql); - await db.execute(mergeFuelEntriesSql); - } finally { - try { - await db.execute('DETACH DATABASE remote_db'); - } catch (_) { - // Don't let a detach failure mask a real merge error above. - } - } - } finally { - if (await tempFile.exists()) { - await tempFile.delete(); - } - } - } - - /// Uploads any locally-pending receipt images (dirty fuel entries that - /// still have a local path, not yet a Drive file ID). Returns true only - /// if every pending image made it up — see [syncNow] for why a partial - /// failure here blocks the push step entirely rather than pushing - /// something with a leftover local path. - Future _uploadPendingReceipts(DriveService drive) async { - final db = databaseService.rawDb; - final rows = await db.query( - 'fuel_entries', - where: 'dirty = 1 AND receipt_image_path IS NOT NULL AND deleted_at IS NULL', - ); - - var allSucceeded = true; - for (final row in rows) { - final id = row['id'] as String; - final localPath = row['receipt_image_path'] as String; - final localFile = File(localPath); - - if (!await localFile.exists()) { - // Nothing left to upload; clear the dangling reference. - await db.update('fuel_entries', {'receipt_image_path': null}, - where: 'id = ?', whereArgs: [id]); - continue; - } - - try { - final driveFileId = await drive.uploadReceiptImage( - receiptsFolderId: _receiptsFolderId!, - fileName: '$id${p.extension(localPath)}', - imageFile: localFile, - ); - await databaseService.deleteReceiptImageFile(localPath); - await db.update( - 'fuel_entries', - {'receipt_drive_file_id': driveFileId, 'receipt_image_path': null}, - where: 'id = ?', - whereArgs: [id], - ); - } catch (_) { - allSucceeded = false; - } - } - return allSucceeded; - } - - /// Uploads the current local database file as the new remote copy, then - /// clears the dirty flag on every row now that local matches Drive. - /// - /// Reads the live database file directly rather than a `VACUUM INTO` - /// snapshot: sqflite uses SQLite's default rollback-journal mode (not - /// WAL) unless explicitly configured otherwise, so the main file is a - /// complete, valid database as soon as the last write's Future - /// completes. `wal_checkpoint` is run first anyway as cheap insurance in - /// case that ever changes. This also sidesteps `VACUUM INTO` needing - /// SQLite 3.27+, which isn't guaranteed on very old Android versions. - Future _pushSnapshot(DriveService drive, String appFolderId) async { - final db = databaseService.rawDb; - await db.execute('PRAGMA wal_checkpoint(TRUNCATE)'); - - final info = await drive.uploadDataFile( - appFolderId: appFolderId, - existingFileId: _dataFileId, - localSnapshotFile: File(databaseService.databasePath), - ); - _dataFileId = info.id; - - await db.update('vehicles', {'dirty': 0}); - await db.update('fuel_entries', {'dirty': 0}); - - return info; - } - - String _escapeSqlLiteral(String value) => value.replaceAll("'", "''"); - - Future _acquireLock( - DriveService drive, { - required String appFolderId, - required String username, - }) { - return lock.acquireLock( - username: username, - createLock: (name) => drive.createLockFile(appFolderId: appFolderId, name: name), - listLocks: () => drive.listLockFiles(appFolderId), - deleteLock: drive.deleteFile, - ); - } -} diff --git a/lib/services/lock_coordinator.dart b/lib/services/lock_coordinator.dart index 0a729a8..ffe2df2 100644 --- a/lib/services/lock_coordinator.dart +++ b/lib/services/lock_coordinator.dart @@ -1,4 +1,4 @@ -import 'drive_service.dart' show DriveLockFile; +import 'cloud/cloud_storage_provider.dart' show CloudLockFile; /// Ticket-based mutex using timestamped lock files as the coordination /// point: create a lock named after our own timestamp, then wait until no @@ -7,12 +7,13 @@ import 'drive_service.dart' show DriveLockFile; /// device that crashed/went offline before releasing its own lock. /// /// Pure orchestration over injected operations (rather than a concrete -/// Drive dependency) so the state machine is unit-testable without a real -/// network connection. +/// cloud storage dependency) so the state machine is unit-testable without +/// a real network connection, and works identically no matter which +/// [CloudStorageProvider] it's wired to. Future acquireLock({ required String username, required Future Function(String lockName) createLock, - required Future> Function() listLocks, + required Future> Function() listLocks, required Future Function(String lockId) deleteLock, DateTime Function()? nowUtc, Future Function(Duration)? delay, diff --git a/lib/services/receipt_parser.dart b/lib/services/receipt_parser.dart index 1e6d17f..07740a4 100644 --- a/lib/services/receipt_parser.dart +++ b/lib/services/receipt_parser.dart @@ -1,3 +1,25 @@ +/// Two-letter USPS abbreviation to full name, for the 50 states + DC — +/// deliberately excludes territories (PR, VI, GU, ...): "VI" in particular +/// shows up on real receipts as a "Visa" abbreviation, and including it as +/// a valid state code would misfire on that. +const usStateNames = { + 'AL': 'Alabama', 'AK': 'Alaska', 'AZ': 'Arizona', 'AR': 'Arkansas', + 'CA': 'California', 'CO': 'Colorado', 'CT': 'Connecticut', 'DE': 'Delaware', + 'FL': 'Florida', 'GA': 'Georgia', 'HI': 'Hawaii', 'ID': 'Idaho', + 'IL': 'Illinois', 'IN': 'Indiana', 'IA': 'Iowa', 'KS': 'Kansas', + 'KY': 'Kentucky', 'LA': 'Louisiana', 'ME': 'Maine', 'MD': 'Maryland', + 'MA': 'Massachusetts', 'MI': 'Michigan', 'MN': 'Minnesota', + 'MS': 'Mississippi', 'MO': 'Missouri', 'MT': 'Montana', 'NE': 'Nebraska', + 'NV': 'Nevada', 'NH': 'New Hampshire', 'NJ': 'New Jersey', + 'NM': 'New Mexico', 'NY': 'New York', 'NC': 'North Carolina', + 'ND': 'North Dakota', 'OH': 'Ohio', 'OK': 'Oklahoma', 'OR': 'Oregon', + 'PA': 'Pennsylvania', 'RI': 'Rhode Island', 'SC': 'South Carolina', + 'SD': 'South Dakota', 'TN': 'Tennessee', 'TX': 'Texas', 'UT': 'Utah', + 'VT': 'Vermont', 'VA': 'Virginia', 'WA': 'Washington', + 'WV': 'West Virginia', 'WI': 'Wisconsin', 'WY': 'Wyoming', + 'DC': 'District of Columbia', +}; + /// Best-effort values pulled out of OCR'd receipt text. Any field can be /// null if it couldn't be found, and the confirm screen lets the user fill /// in or correct whatever the parser got wrong. @@ -5,12 +27,25 @@ class ParsedReceipt { final double? gallons; final double? pricePerGallon; final double? totalCost; + + /// The transaction date/time printed on the receipt, if found. Null + /// means the confirm screen should fall back to manual entry (it + /// defaults to "now" and lets the user pick a different date/time). + final DateTime? date; + + /// Two-letter state abbreviation of the station's address, if found + /// (e.g. "MO", "TX"). Null means no state could be confidently + /// identified — callers should treat that as "unknown", not "Missouri". + final String? state; + final String rawText; ParsedReceipt({ this.gallons, this.pricePerGallon, this.totalCost, + this.date, + this.state, required this.rawText, }); } @@ -23,36 +58,91 @@ class ParsedReceipt { /// to deriving a missing value from the other two when exactly one is /// missing (total = gallons * price, etc). class ReceiptParser { + // These use [ \t]* rather than \s* between a label and its value: \s + // matches newlines too, which let a number on one line match a + // completely unrelated label several lines further down (e.g. a price + // value followed, many lines later, by an incidental "Gallons" heading + // for a different column) — a real bug this surfaced against an actual + // receipt where "$1.999" ended up matching "...Gallons" two lines below + // it. A label and its own value are always on the same line. static final _gallonsPatterns = [ - RegExp(r'GALLONS?\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false), - RegExp(r'\bGAL\b\s*[:\-]?\s*(\d+\.\d{2,3})', caseSensitive: false), - RegExp(r'(\d+\.\d{2,3})\s*GAL(?:LONS)?\b', caseSensitive: false), + RegExp(r'GALLONS?[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), + RegExp(r'\bGAL\b[ \t]*[:\-]?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), + RegExp(r'(\d+\.\d{2,3})[ \t]*GAL(?:LONS)?\b', caseSensitive: false), ]; static final _pricePerGallonPatterns = [ - RegExp(r'PRICE\s*/?\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', + RegExp(r'PRICE[ \t]*/?[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), - RegExp(r'\bPPG\b\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', caseSensitive: false), - RegExp(r'PER\s*GAL(?:LON)?\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', + RegExp(r'\bPPG\b[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), + RegExp(r'PER[ \t]*GAL(?:LON)?[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), - RegExp(r'\$\s*/\s*GAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2,3})', + RegExp(r'\$[ \t]*/[ \t]*GAL[ \t]*[:\-]?[ \t]*\$?[ \t]*(\d+\.\d{2,3})', caseSensitive: false), ]; static final _totalPatterns = [ - RegExp(r'FUEL\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false), - RegExp(r'SALE\s*TOTAL\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false), - RegExp(r'AMOUNT\s*DUE\s*[:\-]?\s*\$?\s*(\d+\.\d{2})', caseSensitive: false), - RegExp(r'(? 12 || day < 1 || day > 31) return null; + if (year < 100) year += 2000; + + final windowEnd = (dateMatch.end + 40).clamp(0, text.length); + final nearbyText = text.substring(dateMatch.end, windowEnd); + final timeMatch = _timePattern.firstMatch(nearbyText); + + var hour = 0; + var minute = 0; + if (timeMatch != null) { + hour = int.tryParse(timeMatch.group(1)!) ?? 0; + minute = int.tryParse(timeMatch.group(2)!) ?? 0; + final meridiem = timeMatch.group(3)?.toUpperCase(); + if (meridiem == 'PM' && hour != 12) hour += 12; + if (meridiem == 'AM' && hour == 12) hour = 0; + if (hour > 23 || minute > 59) { + hour = 0; + minute = 0; + } + } + + try { + return DateTime(year, month, day, hour, minute); + } catch (_) { + return null; + } + } + static double? _firstMatch(List patterns, String text) { for (final pattern in patterns) { final match = pattern.firstMatch(text); diff --git a/lib/services/vin_parser.dart b/lib/services/vin_parser.dart new file mode 100644 index 0000000..6ca6949 --- /dev/null +++ b/lib/services/vin_parser.dart @@ -0,0 +1,31 @@ +/// Extracts a 17-character VIN from OCR'd text (a photo of a door-jamb +/// 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). +class VinParser { + static final _labeledVinPattern = + RegExp(r'VIN[:\s]*([A-HJ-NPR-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); + + /// Returns the VIN in uppercase, or null if nothing matching the VIN + /// charset/length was found. A labeled "VIN: ..." match is preferred + /// over a bare 17-character token, in case a busy sticker has more than + /// 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(); + + final bare = _bareVinPattern.firstMatch(text); + if (bare != null) return bare.group(1)!.toUpperCase(); + + return null; + } +} diff --git a/lib/widgets/image_source_sheet.dart b/lib/widgets/image_source_sheet.dart new file mode 100644 index 0000000..df5beba --- /dev/null +++ b/lib/widgets/image_source_sheet.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +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) { + return showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('Take Photo'), + onTap: () => Navigator.of(context).pop(ImageSource.camera), + ), + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('Choose from Gallery'), + onTap: () => Navigator.of(context).pop(ImageSource.gallery), + ), + ], + ), + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock index e270ecb..b945cb3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -90,7 +90,7 @@ packages: source: hosted version: "0.3.5+4" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -113,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.14" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" + url: "https://pub.dev" + source: hosted + version: "0.2.3" fake_async: dependency: transitive description: @@ -129,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -198,11 +214,75 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.35" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_secure_storage_platform_interface: + dependency: "direct dev" + description: + name: flutter_secure_storage_platform_interface + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_auth_2: + dependency: "direct main" + description: + name: flutter_web_auth_2 + sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d + url: "https://pub.dev" + source: hosted + version: "4.1.0" flutter_web_plugins: dependency: transitive description: flutter @@ -561,7 +641,7 @@ packages: source: hosted version: "2.2.2" path_provider_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: path_provider_platform_interface sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" @@ -593,7 +673,7 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" @@ -805,6 +885,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: @@ -837,6 +981,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241" + url: "https://pub.dev" + source: hosted + version: "0.0.4" xdg_directories: dependency: transitive description: @@ -846,7 +1006,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/pubspec.yaml b/pubspec.yaml index 85baed6..3209e7d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -74,6 +74,22 @@ dependencies: # Local SQLite database sqflite: ^2.4.1 + # Browser-based OAuth2 (PKCE) redirect capture for Dropbox/OneDrive — + # unlike Google, neither has an official Flutter sign-in package. + flutter_web_auth_2: ^4.1.0 + + # PKCE code_verifier/code_challenge generation (SHA-256 + base64url) + crypto: ^3.0.5 + + # WebDAV multistatus (PROPFIND) response parsing — for self-hosted + # personal cloud servers (Nextcloud, ownCloud, a bare Apache/nginx WebDAV + # server, etc). + xml: ^6.6.1 + + # OS-backed encrypted storage (Keystore/Keychain) for WebDAV credentials, + # so sign-in survives a cold app restart instead of living in memory only. + flutter_secure_storage: ^10.0.0 + dev_dependencies: flutter_test: sdk: flutter @@ -82,6 +98,15 @@ dev_dependencies: # can be unit tested on the Dart VM, without a real Android/iOS device. sqflite_common_ffi: ^2.3.4+4 + # Platform-interface for faking FlutterSecureStoragePlatform.instance in + # webdav_provider_credentials_test.dart, same pattern as faking + # PathProviderPlatform.instance elsewhere. + flutter_secure_storage_platform_interface: ^2.0.3 + + # For faking PathProviderPlatform in cloud_sync_service_test.dart + path_provider_platform_interface: ^2.1.2 + plugin_platform_interface: ^2.1.8 + # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your diff --git a/test/cloud_sync_service_test.dart b/test/cloud_sync_service_test.dart new file mode 100644 index 0000000..71e9e41 --- /dev/null +++ b/test/cloud_sync_service_test.dart @@ -0,0 +1,196 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart'; +import 'package:fuel_tax_tracker/services/cloud_sync_service.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'; + +/// Confirms [CloudSyncService]'s orchestration (lock → check remote → +/// upload → release) works against *any* [CloudStorageProvider], using a +/// fake one — coverage for the provider-agnostic logic itself, independent +/// of which real backend (Google Drive, Dropbox, OneDrive) it's wired to. +void main() { + late Directory tempDir; + late DatabaseService databaseService; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('cloud_sync_service_test_'); + PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); + databaseService = DatabaseService(); + await databaseService.init(); + }); + + tearDown(() async { + await databaseService.rawDb.close(); + await tempDir.delete(recursive: true); + }); + + test('syncNow acquires a lock, uploads the data file, and releases the lock', () async { + final session = _FakeSession(); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + syncService.configure('app-folder-id'); + + final result = await syncService.syncNow(); + + expect(result.ranSync, isTrue); + expect(session.createdLockNames, hasLength(1)); + expect(session.createdLockNames.first, startsWith('tester@example.com-')); + expect(session.uploadedFileNames, contains(dataFileName)); + expect(session.deletedFileIds, ['lock-id'], reason: 'the lock must be released afterward'); + }); + + test('syncNow uploads a pending receipt into Receipts//', () async { + final session = _FakeSession(); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + syncService.configure('app-folder-id'); + + final db = databaseService.rawDb; + await db.insert('vehicles', { + 'id': 'vehicle-1', + 'vin': '1FMPU18L1TLB51349', + 'nickname': null, + 'updated_at': 0, + 'deleted_at': null, + 'dirty': 0, + }); + final receiptFile = File(p.join(tempDir.path, 'receipt.jpg'))..writeAsBytesSync([1, 2, 3]); + await db.insert('fuel_entries', { + 'id': 'entry-1', + 'vehicle_id': 'vehicle-1', + // Mid-month/mid-year so DateTime.fromMillisecondsSinceEpoch's local + // interpretation can't drift into a different month depending on the + // machine's timezone (unlike epoch 0, which can land in Dec 1969). + 'date': DateTime(2026, 3, 15).millisecondsSinceEpoch, + 'gallons': 10.0, + 'price_per_gallon': 3.5, + 'total_cost': 35.0, + 'receipt_image_path': receiptFile.path, + 'receipt_drive_file_id': null, + 'updated_at': 0, + 'deleted_at': null, + 'dirty': 1, + }); + + final result = await syncService.syncNow(keepLocalReceiptCopies: true); + + expect(result.ranSync, isTrue); + expect(session.uploadedFileFolders['entry-1.jpg'], + 'app-folder-id/$receiptsFolderName/1FMPU18L1TLB51349/2026.03'); + }); + + test('syncNow is a no-op when not configured with a folder yet', () async { + final session = _FakeSession(); + final provider = _FakeProvider(session); + final syncService = CloudSyncService(provider: provider, databaseService: databaseService); + + final result = await syncService.syncNow(); + + expect(result.ranSync, isFalse); + expect(session.createdLockNames, isEmpty); + }); +} + +class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin { + final String tempPath; + _FakePathProviderPlatform(this.tempPath); + + @override + Future getApplicationDocumentsPath() async => tempPath; + + @override + Future getTemporaryPath() async => tempPath; +} + +class _FakeSession implements CloudStorageSession { + final List createdLockNames = []; + final List deletedFileIds = []; + final List uploadedFileNames = []; + final Map uploadedFileFolders = {}; + CloudFileInfo? existingDataFile; + + @override + bool get supportsSharedWithMe => false; + + @override + Future> listFolders({String? parentId, bool sharedWithMe = false}) async => []; + + @override + Future findOrCreateFolder({required String parentId, required String name}) async => + '$parentId/$name'; + + @override + Future findFile({required String folderId, required String name}) async { + return name == dataFileName ? existingDataFile : null; + } + + @override + Future> downloadFileBytes(String fileId) async => []; + + @override + Future uploadFile({ + required String folderId, + required String name, + String? existingFileId, + required File localFile, + required String contentType, + }) async { + uploadedFileNames.add(name); + uploadedFileFolders[name] = folderId; + return CloudFileInfo(id: 'uploaded-$name', versionTag: 'v1'); + } + + @override + Future deleteFile(String fileId) async => deletedFileIds.add(fileId); + + @override + Future createLockFile({required String folderId, required String name}) async { + createdLockNames.add(name); + return 'lock-id'; + } + + @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/db_merge_test.dart b/test/db_merge_test.dart index 8acea9d..17bddda 100644 --- a/test/db_merge_test.dart +++ b/test/db_merge_test.dart @@ -6,7 +6,7 @@ import 'package:path/path.dart' as p; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; /// Exercises the exact ATTACH + INSERT OR REPLACE merge SQL the app runs -/// (see [db_schema.dart] / DriveSyncService._pullAndMerge), against real +/// (see [db_schema.dart] / CloudSyncService._pullAndMerge), against real /// temporary SQLite files via sqflite_common_ffi — the pure-Dart backend /// that lets sqflite run on the Dart VM for tests, without a real device. void main() { @@ -43,18 +43,19 @@ void main() { group('vehicle merge', () { test('pulls in a vehicle that only exists on remote', () async { - await remote.insert('vehicles', _vehicleRow(id: 'v1', updatedAt: 1000)); + await remote.insert('vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', updatedAt: 1000)); await merge(); final rows = await local.query('vehicles'); expect(rows, hasLength(1)); expect(rows.first['id'], 'v1'); + expect(rows.first['vin'], 'VIN1'); expect(rows.first['dirty'], 0); }); test('leaves a local-only dirty vehicle untouched', () async { - await local.insert('vehicles', _vehicleRow(id: 'v1', updatedAt: 1000, dirty: 1)); + await local.insert('vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', updatedAt: 1000, dirty: 1)); await merge(); @@ -64,30 +65,60 @@ void main() { }); test('remote wins when strictly newer than local', () async { - await local.insert( - 'vehicles', _vehicleRow(id: 'v1', make: 'OldMake', updatedAt: 1000, dirty: 1)); - await remote.insert('vehicles', _vehicleRow(id: 'v1', make: 'NewMake', updatedAt: 2000)); + await local.insert('vehicles', + _vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'OldNickname', updatedAt: 1000, dirty: 1)); + await remote.insert('vehicles', + _vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'NewNickname', updatedAt: 2000)); await merge(); final rows = await local.query('vehicles'); expect(rows, hasLength(1)); - expect(rows.first['make'], 'NewMake'); + expect(rows.first['nickname'], 'NewNickname'); expect(rows.first['dirty'], 0); }); test('local wins on a tie or when strictly newer', () async { - await local.insert( - 'vehicles', _vehicleRow(id: 'v1', make: 'MineNewer', updatedAt: 2000, dirty: 1)); - await remote.insert('vehicles', _vehicleRow(id: 'v1', make: 'TheirsOlder', updatedAt: 1000)); + await local.insert('vehicles', + _vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'MineNewer', updatedAt: 2000, dirty: 1)); + await remote.insert('vehicles', + _vehicleRow(id: 'v1', vin: 'VIN1', nickname: 'TheirsOlder', updatedAt: 1000)); await merge(); final rows = await local.query('vehicles'); expect(rows, hasLength(1)); - expect(rows.first['make'], 'MineNewer'); + expect(rows.first['nickname'], 'MineNewer'); expect(rows.first['dirty'], 1, reason: 'still pending push since local was not overwritten'); }); + + test('a null nickname merges in fine (nickname is optional)', () async { + await remote.insert( + 'vehicles', _vehicleRow(id: 'v1', vin: 'VIN1', nickname: null, updatedAt: 1000)); + + await merge(); + + final rows = await local.query('vehicles'); + expect(rows, hasLength(1)); + expect(rows.first['nickname'], isNull); + }); + + test('the same vehicle edited on two devices merges by id, not vin', () async { + // VIN is user-editable, so it can't be the merge key — this is the + // scenario that motivated switching the merge key to a hidden id: + // the local device renamed the VIN (a legitimate edit) while the + // remote copy still has the old VIN and an older updated_at. + await local.insert('vehicles', + _vehicleRow(id: 'v1', vin: 'VIN1-CORRECTED', nickname: 'Mine', updatedAt: 2000, dirty: 1)); + await remote.insert( + 'vehicles', _vehicleRow(id: 'v1', vin: 'VIN1-TYPO', nickname: 'Mine', updatedAt: 1000)); + + await merge(); + + final rows = await local.query('vehicles'); + expect(rows, hasLength(1)); + expect(rows.first['vin'], 'VIN1-CORRECTED', reason: 'local was newer, so its VIN edit wins'); + }); }); group('fuel entry merge', () { @@ -134,17 +165,16 @@ Future _openFreshDb(String path) async { Map _vehicleRow({ required String id, - String make = 'Ford', + required String vin, + String? nickname = 'Test Vehicle', int updatedAt = 0, int? deletedAt, int dirty = 0, }) => { 'id': id, - 'make': make, - 'model': 'F-150', - 'color': 'Red', - 'license_plate': 'ABC123', + 'vin': vin, + 'nickname': nickname, 'updated_at': updatedAt, 'deleted_at': deletedAt, 'dirty': dirty, diff --git a/test/lock_coordinator_test.dart b/test/lock_coordinator_test.dart index 79fc9cb..d50b55e 100644 --- a/test/lock_coordinator_test.dart +++ b/test/lock_coordinator_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:fuel_tax_tracker/services/drive_service.dart' show DriveLockFile; +import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart' show CloudLockFile; import 'package:fuel_tax_tracker/services/lock_coordinator.dart'; void main() { @@ -16,7 +16,7 @@ void main() { return 'id'; }, listLocks: () async => - [DriveLockFile(id: 'id', username: 'me@example.com', createdAtUtc: now)], + [CloudLockFile(id: 'id', username: 'me@example.com', createdAtUtc: now)], deleteLock: (_) async {}, delay: (_) async {}, ); @@ -35,7 +35,7 @@ void main() { createLock: (_) async => 'my-lock-id', listLocks: () async { listCallCount++; - return [DriveLockFile(id: 'my-lock-id', username: 'me', createdAtUtc: now)]; + return [CloudLockFile(id: 'my-lock-id', username: 'me', createdAtUtc: now)]; }, deleteLock: (_) async {}, delay: (d) async => delayCalls.add(d), @@ -55,8 +55,8 @@ void main() { nowUtc: () => now, createLock: (_) async => 'mine', listLocks: () async => [ - DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now), - DriveLockFile( + CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now), + CloudLockFile( id: 'newer', username: 'other', createdAtUtc: now.add(const Duration(seconds: 5))), ], deleteLock: (_) async {}, @@ -81,14 +81,14 @@ void main() { listCallCount++; if (listCallCount == 1) { return [ - DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now), - DriveLockFile( + CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now), + CloudLockFile( id: 'other', username: 'other', createdAtUtc: now.subtract(const Duration(seconds: 5))), ]; } - return [DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now)]; + return [CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now)]; }, deleteLock: (id) async => deleteCalls.add(id), delay: (d) async => delayCalls.add(d), @@ -114,14 +114,14 @@ void main() { listCallCount++; if (listCallCount == 1) { return [ - DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now), - DriveLockFile( + CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now), + CloudLockFile( id: 'stale', username: 'ghost', createdAtUtc: now.subtract(const Duration(minutes: 15))), ]; } - return [DriveLockFile(id: 'mine', username: 'me', createdAtUtc: now)]; + return [CloudLockFile(id: 'mine', username: 'me', createdAtUtc: now)]; }, deleteLock: (id) async => deleteCalls.add(id), delay: (_) async {}, diff --git a/test/pending_receipt_upload_test.dart b/test/pending_receipt_upload_test.dart new file mode 100644 index 0000000..29ec5f3 --- /dev/null +++ b/test/pending_receipt_upload_test.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/services/db_schema.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Regression coverage for a real bug: the "needs upload" query used to +/// match on `receipt_image_path IS NOT NULL` alone, which would have kept +/// re-uploading the same photo as a duplicate Drive file on every sync +/// once a "keep photos on this phone" option let an already-uploaded row +/// keep its local path. See [pendingReceiptUploadWhereClause]. +void main() { + late Directory tempDir; + late Database db; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('pending_receipt_test_'); + db = await databaseFactory.openDatabase(p.join(tempDir.path, 'test.db')); + await db.execute(createFuelEntriesTableSql); + }); + + tearDown(() async { + await db.close(); + await tempDir.delete(recursive: true); + }); + + Future insertEntry( + String id, { + String? receiptImagePath, + String? receiptDriveFileId, + int dirty = 1, + int? deletedAt, + }) { + return db.insert('fuel_entries', { + 'id': id, + 'vehicle_id': 'v1', + 'date': 0, + 'gallons': 10.0, + 'price_per_gallon': 3.5, + 'total_cost': 35.0, + 'receipt_image_path': receiptImagePath, + 'receipt_drive_file_id': receiptDriveFileId, + 'updated_at': 0, + 'deleted_at': deletedAt, + 'dirty': dirty, + }); + } + + test('matches a row with a local photo not yet uploaded', () async { + await insertEntry('needs-upload', receiptImagePath: '/local/a.jpg'); + + final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause); + + expect(rows.map((r) => r['id']), ['needs-upload']); + }); + + test('excludes a row already uploaded, even if the local copy was kept', () async { + await insertEntry( + 'kept-after-upload', + receiptImagePath: '/local/a.jpg', + receiptDriveFileId: 'drive-file-1', + ); + + final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause); + + expect(rows, isEmpty); + }); + + test('excludes a row with no local photo at all', () async { + await insertEntry('no-receipt'); + + final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause); + + expect(rows, isEmpty); + }); + + test('excludes a soft-deleted row even if it has a pending local photo', () async { + await insertEntry('deleted', receiptImagePath: '/local/a.jpg', deletedAt: 123); + + final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause); + + expect(rows, isEmpty); + }); + + test('excludes a clean (already-synced) row', () async { + await insertEntry('clean', receiptImagePath: '/local/a.jpg', dirty: 0); + + final rows = await db.query('fuel_entries', where: pendingReceiptUploadWhereClause); + + expect(rows, isEmpty); + }); +} diff --git a/test/receipt_parser_test.dart b/test/receipt_parser_test.dart index 8aeda50..3753534 100644 --- a/test/receipt_parser_test.dart +++ b/test/receipt_parser_test.dart @@ -21,6 +21,37 @@ void main() { expect(result.totalCost, 44.44); }); + test('parses a tabular receipt where labels and values sit on separate lines', () { + // Mirrors a real Casey's receipt: "Pump / Gallons / Price" is a + // header row, with the actual values on the next line, and the + // total is phrased "Total Sale" rather than a bare "Total". + const receipt = ''' + Casey's + Store #4021 + 420 W. 6TH STREET + KEARNEY, MO 64060 + Date 08/08/2026 + Time 02:21 + VI + ########8533 + Pump Gallons Price + 07 32.115 \$ 3.759 + Product Amount + 87E10 \$ 120.72 + Total Sale \$ 120.72 + Auth # + Visa + Seq # 008299 + 98 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 32.115); + expect(result.pricePerGallon, 3.759); + expect(result.totalCost, 120.72); + }); + test('does not confuse SUBTOTAL with TOTAL', () { const receipt = ''' SUBTOTAL 10.00 @@ -67,6 +98,386 @@ void main() { expect(result.gallons, isNull); expect(result.pricePerGallon, isNull); expect(result.totalCost, isNull); + expect(result.date, isNull); + }); + }); + + group('ReceiptParser state parsing', () { + test('detects a state abbreviation directly before a ZIP code', () { + const receipt = ''' + Casey's + 420 W. 6TH STREET + KEARNEY, MO 64060 + '''; + + expect(ReceiptParser.parse(receipt).state, 'MO'); + }); + + test('detects a non-Missouri state the same way', () { + const receipt = ''' + Swift Stop #3 + 1809 Hobbs Hwy + Seminole TX 79360 + '''; + + expect(ReceiptParser.parse(receipt).state, 'TX'); + }); + + test('falls back to a state right after a comma when no ZIP is nearby', () { + const receipt = ''' + Buc-ee's + 6988 Buc-ee's Blvd + Leeds, AL + '''; + + expect(ReceiptParser.parse(receipt).state, 'AL'); + }); + + test('does not mistake "VI" (Visa) for the Virgin Islands', () { + const receipt = ''' + Casey's + VI + ########8533 + Pump Gallons Price + 07 32.115 \$ 3.759 + '''; + + expect(ReceiptParser.parse(receipt).state, isNull); + }); + + test('does not mistake the word "In" for Indiana', () { + const receipt = ''' + Gallons 10.000 + Price/Gal \$3.399 + Total Fuel \$33.99 + All Taxes Included + In Fuel Price. + '''; + + expect(ReceiptParser.parse(receipt).state, isNull); + }); + + test('returns null when no address-like state pattern is present', () { + expect(ReceiptParser.parse('THANK YOU FOR YOUR PURCHASE').state, isNull); + }); + }); + + group('ReceiptParser date parsing', () { + test('parses "Date: M/D/YYYY" + "Time: H:MM:SS AM/PM" on separate lines', () { + const receipt = ''' + Date: 8/22/2024 + Time: 10:11:00 AM + Gallons 10.000 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.date, DateTime(2024, 8, 22, 10, 11)); + }); + + test('parses "DATE M/D/YY H:MM" on the same line', () { + const receipt = ''' + DATE 3/26/22 18:12 + PUMP# 06 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.date, DateTime(2022, 3, 26, 18, 12)); + }); + + test('parses a 2-digit year and a no-space AM/PM time', () { + const receipt = ''' + Date 01/23/20 + Time 02:11PM + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.date, DateTime(2020, 1, 23, 14, 11)); + }); + + test('12 AM and 12 PM convert correctly (midnight/noon edge case)', () { + expect(ReceiptParser.parse('Date 1/1/24 12:00 AM').date, DateTime(2024, 1, 1, 0, 0)); + expect(ReceiptParser.parse('Date 1/1/24 12:30 PM').date, DateTime(2024, 1, 1, 12, 30)); + }); + + test('falls back to midnight when a date is found but no time is nearby', () { + // Comfortably more than the 40-character "nearby" search window + // between the date and the unrelated text before a time appears + // much later, regardless of how this string literal is indented. + final filler = 'x' * 100; + final receipt = 'Date 12/14/16\n$filler\nTime 5:11 PM'; + + final result = ReceiptParser.parse(receipt); + + // Too far from the date to count as "nearby", so this should be + // date-only at midnight — still better than no date at all, and the + // confirm screen lets the user fix the time manually either way. + expect(result.date, isNotNull); + expect(result.date!.year, 2016); + expect(result.date!.month, 12); + expect(result.date!.day, 14); + expect(result.date!.hour, 0); + expect(result.date!.minute, 0); + }); + + test('returns null when no date-like pattern is present', () { + final result = ReceiptParser.parse('GALLONS 10.000\nPPG 3.500'); + + expect(result.date, isNull); + }); + }); + + // Transcribed from a batch of real photographed receipts across different + // gas station chains, to catch label/layout variations the handful of + // hand-written cases above don't happen to cover. + group('ReceiptParser against real receipt layouts', () { + test('Break Time — labels adjacent to values on the same line', () { + const receipt = ''' + Date: 8/22/2024 + Time: 10:11:00 AM + OXY87 + Pump Number 22 + Gallons 10.000 + Price/Gal \$3.399 + Total Fuel \$33.99 + Total Sale \$33.99 + Visa \$33.99 + All Taxes Included + In Fuel Price. + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 10.000); + expect(result.pricePerGallon, 3.399); + expect(result.totalCost, 33.99); + }); + + test('Break Time — tabular Pump/Gallons/Price header row', () { + const receipt = ''' + Member# 111795637651 + Invoice# 11733 + Date: 03/23/22 + Time: 10:05 + Auth# 020413 + VI Acct # + ************0344 + Pump Gallons Price + 14 5.116 \$ 5.499 + Product Amount + Regular \$ 28.13 + Total Sale \$ 28.13 + SALE- Contactless + Approved + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 5.116); + expect(result.pricePerGallon, 5.499); + expect(result.totalCost, 28.13); + }); + + test('Buc-ee\'s — abbreviated "PRICE/G" label', () { + const receipt = ''' + BUC-EE'S + 6988 Buc-ee's Blvd + Leeds AL + PUMP No. 63 + GALLONS 9.999 + PRICE/G \$2.499 + TOTAL FUEL \$24.99 + Regular + TOTAL SALE \$24.99 + Visa \$24.99 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 9.999); + expect(result.pricePerGallon, 2.499); + expect(result.totalCost, 24.99); + }); + + test('Costco — tabular header row, "Total Sale" phrasing', () { + const receipt = ''' + Costco #01448 + 1524 Beasie RD + Murfreesboro TN + Date: 01/03/24 + Time: 19:31 + Pump Gallons Price + 11 12.598 \$ 2.399 + Product Amount + Regular \$ 30.22 + Total Sale \$ 30.22 + SALE- Contactless + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 12.598); + expect(result.pricePerGallon, 2.399); + expect(result.totalCost, 30.22); + }); + + test('Murphy USA — "QTY(GAL)" label instead of "GALLONS"', () { + const receipt = ''' + PUMP: 8 + PROD: UNLEAD + PRICE/GAL: \$1.799 + NET/GAL: \$1.799 + QTY(GAL): 13.164 + FUEL TOTAL: \$23.68 + NET TOTAL: \$23.68 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 13.164); + expect(result.pricePerGallon, 1.799); + expect(result.totalCost, 23.68); + }); + + test('Phillips 66 — "PRICE/G" label and "FUEL SALE" as the total', () { + const receipt = ''' + WELCOME + LAWNSIDE PHILLIPS 66 + 355 Warrington Ave + Lawnside NJ 08045 + DATE 3/26/22 18:12 + TRAN# 060757 + PUMP# 06 + SERVICE LEVEL: FULL + PRODUCT: REGULAR + GALLONS 3.642 + PRICE/G \$4.119 + FUEL SALE \$15.00 + CREDIT \$15.00 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 3.642); + expect(result.pricePerGallon, 4.119); + expect(result.totalCost, 15.00); + }); + + test('Phillips 66 — second "FUEL SALE" example', () { + const receipt = ''' + WELCOME + SWIFT STOP #3 + 1809 HOBBS HWY + SEMINOLE TX 79360 + DATE 5/9/23 11:16 + TRAN#9103742 + PUMP# 10 + SERVICE LEVEL: SELF + PRODUCT: DIESEL 2 + GALLONS: 29.854 + PRICE/G: \$3.499 + FUEL SALE \$181.66 + CREDIT \$181.66 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 29.854); + expect(result.pricePerGallon, 3.499); + expect(result.totalCost, 181.66); + }); + + test('Phillips 66 / Murphy-style — "QTY(GAL)" plus "FUEL TOTAL"', () { + const receipt = ''' + Verified by PIN + PIN USED + PUMP: 5 + PROD: UNLEAD + PRICE/GAL: \$3.959 + NET/GAL: \$3.959 + QTY(GAL): 11.068 + FUEL TOTAL: \$43.82 + NET TOTAL: \$43.82 + GET REWARDED! + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 11.068); + expect(result.pricePerGallon, 3.959); + expect(result.totalCost, 43.82); + }); + + test('QuikTrip — tabular header row, total not printed on receipt (derived)', () { + const receipt = ''' + QUIKTRIP #01012 + 383 George Liles PWK + Concord, NC + Invoice # 0000000 + Date 01/23/20 + Time 02:11PM + Auth # 03581I + Acct # + ************1821 + Pump Gallons Price + 14 10.496 \$2.359 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 10.496); + expect(result.pricePerGallon, 2.359); + // Not printed within the visible receipt text — derived from + // gallons * price rather than found via a label. + expect(result.totalCost, closeTo(10.496 * 2.359, 0.01)); + }); + + test('Sinclair — unit letter attached with no space, and a negative ' + 'per-gallon discount line that must not be mistaken for gallons', () { + const receipt = ''' + PUMP# 3 + UNLEADED CR 17.364G + PRICE/GAL \$2.649 + DISCOUNTS BEFORE + FUELING + Debit Di/GAL \$-0.100 + FUEL TOTAL \$46.00 + DEBIT \$46.00 + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 17.364); + expect(result.pricePerGallon, 2.649); + expect(result.totalCost, 46.00); + }); + + test('Sinclair — PPG and GALLONS/FUEL TOTAL both split across header/data rows', () { + const receipt = ''' + SINCLAIR + 5905 NW 72ND ST + KANSAS CITY MO + Date 12/14/16 + Time 5:11 PM + Auth 068113 + VISA + Pump Product PPG + 05 UNLD \$1.999 + Gallons Fuel Total + 9.505 \$19.00 + Thank you + '''; + + final result = ReceiptParser.parse(receipt); + + expect(result.gallons, 9.505); + expect(result.pricePerGallon, 1.999); + // "Fuel Total" is split from its value by a newline in this layout, + // so it isn't label-matched — derived from gallons * price instead, + // landing within a cent of the printed $19.00. + expect(result.totalCost, closeTo(19.00, 0.01)); }); }); } diff --git a/test/receipt_storage_test.dart b/test/receipt_storage_test.dart new file mode 100644 index 0000000..252ee5e --- /dev/null +++ b/test/receipt_storage_test.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.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'; + +/// Receipts are filed under `receipts///` locally (mirroring +/// the `Receipts//` layout the cloud side uses — see +/// `cloud_sync_service_test.dart`), so browsing either one shows which +/// vehicle and month a photo belongs to. +void main() { + late Directory tempDir; + late DatabaseService database; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('receipt_storage_test_'); + PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); + database = DatabaseService(); + await database.init(); + }); + + tearDown(() async { + await database.rawDb.close(); + await tempDir.delete(recursive: true); + }); + + test('storeReceiptImage files the photo under receipts///', () async { + final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]); + + final storedPath = await database.storeReceiptImage( + source, + 'entry-1', + '1FMPU18L1TLB51349', + DateTime(2026, 8, 13), + ); + + expect( + p.dirname(storedPath), + p.join(database.receiptsDirectory.path, '1FMPU18L1TLB51349', '2026.08'), + ); + expect(p.basename(storedPath), 'entry-1.jpg'); + expect(await File(storedPath).exists(), isTrue); + }); + + test('storeReceiptImage sanitizes a VIN with characters unsafe in a path', () async { + final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]); + + final storedPath = await database.storeReceiptImage( + source, + 'entry-2', + '../etc/passwd', + DateTime(2026, 1, 5), + ); + + expect( + p.dirname(storedPath), + p.join( + database.receiptsDirectory.path, + sanitizedPathSegment('../etc/passwd'), + '2026.01', + ), + ); + expect(p.isWithin(database.receiptsDirectory.path, storedPath), isTrue); + }); + + test('monthFolderName pads single-digit months', () { + expect(monthFolderName(DateTime(2026, 1, 5)), '2026.01'); + expect(monthFolderName(DateTime(2026, 12, 5)), '2026.12'); + }); +} + +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/vehicle_id_migration_test.dart b/test/vehicle_id_migration_test.dart new file mode 100644 index 0000000..c6451a5 --- /dev/null +++ b/test/vehicle_id_migration_test.dart @@ -0,0 +1,137 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.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'; + +/// Confirms the version 3 → 4 migration (VIN becomes editable; a hidden +/// `id` takes over as vehicles' primary key/merge key) preserves existing +/// local data rather than dropping it, unlike the earlier VIN-as-primary-key +/// migration which was a from-scratch rebuild. Builds a real on-disk +/// version-3 database by hand (the old schema, pre-dating this change), then +/// runs [DatabaseService.init] against it and checks what comes out. +void main() { + late Directory tempDir; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('vehicle_id_migration_test_'); + PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path); + }); + + tearDown(() async { + await tempDir.delete(recursive: true); + }); + + test('migrates a version-3 database, preserving vehicles and fuel entries', () async { + final dbDir = Directory(p.join(tempDir.path, 'FuelTaxTracker')); + await dbDir.create(recursive: true); + final dbPath = p.join(dbDir.path, dbFileName); + + final oldDb = await databaseFactory.openDatabase( + dbPath, + options: OpenDatabaseOptions( + version: 3, + onCreate: (db, version) async { + await db.execute(''' + CREATE TABLE vehicles ( + vin TEXT PRIMARY KEY, + nickname TEXT, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + dirty INTEGER NOT NULL DEFAULT 1 + ) + '''); + await db.execute(''' + CREATE TABLE fuel_entries ( + id TEXT PRIMARY KEY, + vehicle_vin TEXT NOT NULL, + date INTEGER NOT NULL, + gallons REAL NOT NULL, + price_per_gallon REAL NOT NULL, + total_cost REAL NOT NULL, + receipt_image_path TEXT, + receipt_drive_file_id TEXT, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + dirty INTEGER NOT NULL DEFAULT 1 + ) + '''); + }, + ), + ); + await oldDb.insert('vehicles', { + 'vin': 'VIN1', + 'nickname': "Mom's Car", + 'updated_at': 1000, + 'deleted_at': null, + 'dirty': 0, + }); + await oldDb.insert('vehicles', { + 'vin': 'VIN2', + 'nickname': null, + 'updated_at': 1500, + 'deleted_at': 2000, // a soft-deleted vehicle, should still migrate + 'dirty': 0, + }); + await oldDb.insert('fuel_entries', { + 'id': 'f1', + 'vehicle_vin': 'VIN1', + 'date': 1200, + 'gallons': 12.5, + 'price_per_gallon': 3.2, + 'total_cost': 40.0, + 'receipt_image_path': '/local/receipt.jpg', + 'receipt_drive_file_id': null, + 'updated_at': 1200, + 'deleted_at': null, + 'dirty': 0, + }); + await oldDb.close(); + + final databaseService = DatabaseService(); + await databaseService.init(); + addTearDown(() => databaseService.rawDb.close()); + + final vehicles = await databaseService.getVehicles(); + expect(vehicles, hasLength(1), reason: 'the soft-deleted vehicle should not show as active'); + expect(vehicles.first.vin, 'VIN1'); + expect(vehicles.first.nickname, "Mom's Car"); + expect(vehicles.first.id, isNotEmpty); + + final allVehicleRows = await databaseService.rawDb.query('vehicles'); + expect(allVehicleRows, hasLength(2), reason: 'the soft-deleted vehicle should still exist'); + final ids = {for (final row in allVehicleRows) row['vin']: row['id'] as String}; + expect(ids['VIN1'], isNotEmpty); + expect(ids['VIN2'], isNotEmpty); + expect(ids['VIN1'], isNot(ids['VIN2']), reason: 'each vehicle gets its own generated id'); + for (final row in allVehicleRows) { + expect(row['dirty'], 1, reason: 'migrated rows must be re-pushed under the new schema'); + } + + final fuelEntries = await databaseService.getFuelEntries(); + expect(fuelEntries, hasLength(1)); + expect(fuelEntries.first.vehicleId, ids['VIN1'], + reason: "the fuel entry's foreign key should now point at VIN1's new id"); + expect(fuelEntries.first.gallons, 12.5); + expect(fuelEntries.first.receiptImagePath, '/local/receipt.jpg'); + }); +} + +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/vin_parser_test.dart b/test/vin_parser_test.dart new file mode 100644 index 0000000..a91a426 --- /dev/null +++ b/test/vin_parser_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/services/vin_parser.dart'; + +void main() { + group('VinParser', () { + test('parses a bare 17-character VIN', () { + const text = ''' + VEHICLE IDENTIFICATION NUMBER + 1HGCM82633A004352 + MFD BY HONDA MFG CO + '''; + + expect(VinParser.parse(text), '1HGCM82633A004352'); + }); + + test('prefers a "VIN:" labeled match over another candidate elsewhere', () { + const text = ''' + PART NO 4F2CZ58899BA12345 + VIN: 1HGCM82633A004352 + '''; + + expect(VinParser.parse(text), '1HGCM82633A004352'); + }); + + test('normalizes to uppercase', () { + 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('does not match a 16-character (too short) run', () { + expect(VinParser.parse('1HGCM82633A00435'), isNull); + }); + + test('does not match an 18-character (too long) run', () { + expect(VinParser.parse('1HGCM82633A0043521'), isNull); + }); + + test('returns null when nothing matches', () { + expect(VinParser.parse('THANK YOU FOR YOUR PURCHASE'), isNull); + }); + }); +} diff --git a/test/webdav_provider_credentials_test.dart b/test/webdav_provider_credentials_test.dart new file mode 100644 index 0000000..15371ab --- /dev/null +++ b/test/webdav_provider_credentials_test.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_secure_storage_platform_interface/flutter_secure_storage_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fuel_tax_tracker/services/cloud/webdav_provider.dart'; + +const _multistatusBody = ''' + + + / + + + HTTP/1.1 200 OK + + +'''; + +/// Confirms sign-in survives a cold app restart: [WebDavProvider] persists +/// verified credentials to secure storage on [signInWithCredentials], and a +/// *fresh* instance (an in-memory field reset, standing in for a relaunched +/// app) can restore the session from them via [attemptSilentSignIn] — the +/// gap flagged in README's "Known limitations" and fixed here. +void main() { + late HttpServer server; + late String serverUrl; + const username = 'alice'; + const password = 'hunter2'; + + setUp(() async { + FlutterSecureStoragePlatform.instance = _FakeSecureStoragePlatform(); + + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + serverUrl = 'http://127.0.0.1:${server.port}/'; + unawaited(server.forEach((request) async { + final auth = request.headers.value('authorization'); + final expected = 'Basic ${base64Encode(utf8.encode('$username:$password'))}'; + if (request.method == 'PROPFIND' && auth == expected) { + request.response.statusCode = 207; + request.response.headers.contentType = ContentType('application', 'xml'); + request.response.write(_multistatusBody); + } else if (request.method == 'PROPFIND') { + request.response.statusCode = 401; + } else { + request.response.statusCode = 404; + } + await request.response.close(); + })); + }); + + tearDown(() async { + await server.close(force: true); + }); + + test('signInWithCredentials persists credentials that a fresh instance can restore', () async { + final first = WebDavProvider(); + await first.signInWithCredentials(serverUrl: serverUrl, username: username, password: password); + expect(first.isSignedIn, isTrue); + + // A brand new instance has no in-memory session — simulates the app + // process having been killed and relaunched. + final afterRestart = WebDavProvider(); + expect(afterRestart.isSignedIn, isFalse); + + final restored = await afterRestart.attemptSilentSignIn(); + + expect(restored, isTrue); + expect(afterRestart.isSignedIn, isTrue); + expect(afterRestart.accountLabel, first.accountLabel); + }); + + test('attemptSilentSignIn returns false, not throws, when nothing was ever stored', () async { + final provider = WebDavProvider(); + + expect(await provider.attemptSilentSignIn(), isFalse); + expect(provider.isSignedIn, isFalse); + }); + + test('signOut clears stored credentials so a later restart no longer restores', () async { + final first = WebDavProvider(); + await first.signInWithCredentials(serverUrl: serverUrl, username: username, password: password); + await first.signOut(); + + final afterRestart = WebDavProvider(); + expect(await afterRestart.attemptSilentSignIn(), isFalse); + }); +} + +class _FakeSecureStoragePlatform extends FlutterSecureStoragePlatform { + final Map _store = {}; + + @override + Future write({required String key, required String value, required Map options}) async { + _store[key] = value; + } + + @override + Future read({required String key, required Map options}) async => _store[key]; + + @override + Future containsKey({required String key, required Map options}) async => + _store.containsKey(key); + + @override + Future delete({required String key, required Map options}) async { + _store.remove(key); + } + + @override + Future> readAll({required Map options}) async => Map.of(_store); + + @override + Future deleteAll({required Map options}) async => _store.clear(); +} diff --git a/test/webdav_provider_test.dart b/test/webdav_provider_test.dart new file mode 100644 index 0000000..414a5e8 --- /dev/null +++ b/test/webdav_provider_test.dart @@ -0,0 +1,122 @@ +import 'package:fuel_tax_tracker/services/cloud/webdav_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final baseUrl = Uri.parse('https://cloud.example.com/'); + + group('parseWebDavMultistatus', () { + // Real WebDAV servers disagree on namespace prefix for the same "DAV:" + // namespace — Nextcloud defaults to `d:`, many Apache mod_dav setups + // use `D:`, and some use no prefix at all with a default xmlns. All + // three must parse identically since the parser matches by local name. + test('parses a lowercase d: prefixed response (Nextcloud-style)', () { + final xml = ''' + + + /remote.php/dav/files/user/MO-Fuel-Tax-Back/ + + + + "abc123" + + HTTP/1.1 200 OK + + + + /remote.php/dav/files/user/MO-Fuel-Tax-Back/receipts/ + + + + "def456" + + HTTP/1.1 200 OK + + + + /remote.php/dav/files/user/MO-Fuel-Tax-Back/fuel_tax_tracker.db + + + + "ghi789" + + HTTP/1.1 200 OK + + +'''; + + final entries = parseWebDavMultistatus(xml, baseUrl); + + expect(entries, hasLength(3)); + expect(entries[0].path, '/remote.php/dav/files/user/MO-Fuel-Tax-Back/'); + expect(entries[0].isCollection, isTrue); + expect(entries[0].etag, '"abc123"'); + expect(entries[1].isCollection, isTrue); + expect(entries[2].path, endsWith('fuel_tax_tracker.db')); + expect(entries[2].isCollection, isFalse); + expect(entries[2].etag, '"ghi789"'); + }); + + test('parses an uppercase D: prefixed response identically', () { + final xml = ''' + + + /dav/MO-Fuel-Tax-Back/ + + + + "xyz111" + + HTTP/1.1 200 OK + + +'''; + + final entries = parseWebDavMultistatus(xml, baseUrl); + + expect(entries, hasLength(1)); + expect(entries.first.isCollection, isTrue); + expect(entries.first.etag, '"xyz111"'); + }); + + test('parses an unprefixed default-namespace response identically', () { + final xml = ''' + + + /dav/MO-Fuel-Tax-Back/lock-file.lock + + + + "lock999" + + HTTP/1.1 200 OK + + +'''; + + final entries = parseWebDavMultistatus(xml, baseUrl); + + expect(entries, hasLength(1)); + expect(entries.first.isCollection, isFalse); + expect(entries.first.path, '/dav/MO-Fuel-Tax-Back/lock-file.lock'); + }); + + test('a file with no getetag prop yields a null etag', () { + final xml = ''' + + + /dav/no-etag.txt + + + + + HTTP/1.1 200 OK + + +'''; + + final entries = parseWebDavMultistatus(xml, baseUrl); + + expect(entries.single.etag, isNull); + }); + }); +}