From f01186df79037104d94692a759f62ae2c07c944c Mon Sep 17 00:00:00 2001 From: Courtney Arnold Date: Sat, 8 Aug 2026 16:55:40 -0500 Subject: [PATCH] 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'); - }); - }); -}