Added sqlite

This commit is contained in:
Courtney Arnold 2026-08-08 16:55:40 -05:00
parent 73a7c93e64
commit f01186df79
14 changed files with 784 additions and 382 deletions

102
README.md
View file

@ -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.

View file

@ -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<String, dynamic> toJson() => {
Map<String, Object?> 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<String, dynamic> 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<String, Object?> 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,
);
}

View file

@ -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<String, dynamic> toJson() => {
Map<String, Object?> 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<String, dynamic> 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<String, Object?> 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,
);
}

View file

@ -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<Vehicle> vehicles = [];
@ -42,10 +47,8 @@ class AppState extends ChangeNotifier {
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
Future<void> 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<void> _refreshFromDatabase() async {
vehicles = await database.getVehicles();
fuelEntries = await database.getFuelEntries();
}
Future<void> _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<void> 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<void> 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<void> 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<void> _persist() async {
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
await _refreshFromDatabase();
notifyListeners();
unawaited(syncNow());
}

View file

@ -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<void> 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<void> _onCreate(Database db, int version) async {
await db.execute(createVehiclesTableSql);
await db.execute(createFuelEntriesTableSql);
await db.execute(createFuelEntriesIndexSql);
}
Future<List<Vehicle>> getVehicles() async {
final rows = await _db.query('vehicles', where: 'deleted_at IS NULL');
return rows.map(Vehicle.fromMap).toList();
}
Future<List<FuelEntry>> 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<void> saveVehicle(Vehicle vehicle) async {
final map = Map<String, Object?>.from(vehicle.toMap())..['dirty'] = 1;
await _db.insert('vehicles', map, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> saveFuelEntry(FuelEntry entry) async {
final map = Map<String, Object?>.from(entry.toMap())..['dirty'] = 1;
await _db.insert('fuel_entries', map, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> 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<void> 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<List<String>> 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<String>().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<String> 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<void> deleteReceiptImageFile(String? path) async {
if (path == null) return;
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
}

View file

@ -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
''';

View file

@ -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<String?> 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<DriveDataFileInfo?> 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<String> 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 ?? <drive.File>[];
if (files.isEmpty) return null;
final first = files.first;
if (first.id == null) return null;
return DriveDataFileInfo(id: first.id!, md5Checksum: first.md5Checksum);
}
Future<List<int>> 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<String> 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<DriveDataFileInfo> 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<String> uploadReceiptImage({

View file

@ -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<Vehicle>? vehicles;
final List<FuelEntry>? 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<SyncResult> syncNow({
required List<Vehicle> localVehicles,
required List<FuelEntry> localFuelEntries,
}) async {
Future<SyncResult> 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 = <Vehicle>[];
var remoteFuelEntries = <FuelEntry>[];
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<List<FuelEntry>> _uploadPendingReceiptsAndMerge({
required DriveService drive,
required List<FuelEntry> remoteFuelEntries,
required List<FuelEntry> localFuelEntries,
}) async {
final merged = mergeById(
remoteFuelEntries,
localFuelEntries,
(e) => e.id,
(e) => e.updatedAt,
Future<void> _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<bool> _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 = <FuelEntry>[];
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<DriveDataFileInfo> _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<String> _acquireLock(
DriveService drive, {
required String appFolderId,

View file

@ -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<T> mergeById<T>(
List<T> remote,
List<T> local,
String Function(T) idOf,
DateTime Function(T) updatedAtOf,
) {
final merged = <String, T>{};
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();
}

View file

@ -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<void> 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<Vehicle> vehicles, List<FuelEntry> entries})> loadData() async {
if (!await dataFile.exists()) {
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
}
final raw = await dataFile.readAsString();
if (raw.trim().isEmpty) {
return (vehicles: <Vehicle>[], entries: <FuelEntry>[]);
}
return decodeData(raw);
}
({List<Vehicle> vehicles, List<FuelEntry> entries}) decodeData(String raw) {
final json = jsonDecode(raw) as Map<String, dynamic>;
final vehicles = (json['vehicles'] as List<dynamic>? ?? [])
.map((v) => Vehicle.fromJson(v as Map<String, dynamic>))
.toList();
final entries = (json['fuelEntries'] as List<dynamic>? ?? [])
.map((e) => FuelEntry.fromJson(e as Map<String, dynamic>))
.toList();
return (vehicles: vehicles, entries: entries);
}
String encodeData({
required List<Vehicle> vehicles,
required List<FuelEntry> entries,
}) {
final json = {
'vehicles': vehicles.map((v) => v.toJson()).toList(),
'fuelEntries': entries.map((e) => e.toJson()).toList(),
};
return const JsonEncoder.withIndent(' ').convert(json);
}
Future<void> saveData({
required List<Vehicle> vehicles,
required List<FuelEntry> 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<String> 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<void> deleteReceiptImage(String? path) async {
if (path == null) return;
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
}

View file

@ -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:

View file

@ -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

172
test/db_merge_test.dart Normal file
View file

@ -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<void> 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<Database> _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<String, Object?> _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<String, Object?> _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,
};

View file

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