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'); + }); + }); +}