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 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, 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 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 photo. Vehicles and fuel entries live in a local SQLite database that's kept
multiple people can log fuel against the same pool of vehicles from their in sync with a shared Google Drive folder, so multiple people can log fuel
own phones; each device also keeps a local offline copy and syncs when it against the same pool of vehicles from their own phones, offline or online.
can.
## Features ## Features
@ -22,8 +21,9 @@ can.
location. The app looks for (or creates) a `MO-Fuel-Tax-Back` subfolder 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 there, so anyone pointed at the same shared parent converges on the same
data automatically. data automatically.
- Works fully offline: writes always land locally first; a background sync - Works fully offline: writes always land in the local SQLite database
pushes changes to Drive and pulls others' changes down once online. 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; - Once a receipt photo is uploaded to Drive, the local copy is deleted;
viewing it later downloads it fresh from Drive on demand. viewing it later downloads it fresh from Drive on demand.
@ -31,25 +31,30 @@ can.
``` ```
lib/ lib/
models/ Vehicle, FuelEntry — plain data classes with JSON (de)serialization. models/ Vehicle, FuelEntry — plain data classes with SQLite row (de)serialization
Both carry `updatedAt` (for merge conflict resolution); FuelEntry (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 carries either a local receiptImagePath (not yet uploaded) or a
receiptDriveFileId (uploaded, local copy removed). receiptDriveFileId (uploaded, local copy removed).
services/ 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. updatedAt on mutations and triggers background sync.
storage_service.dart Local offline staging area: data.json + receipts/, database_service.dart Owns the local SQLite database (vehicles + fuel_entries)
always at ApplicationDocumentsDirectory/FuelTaxTracker. 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 drive_auth_service.dart Google sign-in + builds an authenticated http.Client
for the Drive API. for the Drive API.
drive_service.dart Raw Drive API calls: browse folders, find-or-create drive_service.dart Raw Drive API calls: browse folders, find-or-create
the MO-Fuel-Tax-Back folder, upload/download files, 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 drive_sync_service.dart Orchestrates one sync round: acquire the cross-device
lock, download + merge the remote data file, upload lock, ATTACH + merge the remote database into the local
pending receipt photos, write the merge back, release one, upload pending receipt photos, push the local
the lock. database back up, release the lock.
merge_utils.dart Pure by-ID union merge logic (independently unit-tested). 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. drive_oauth_config.dart Fill in your OAuth client IDs here — see setup below.
ocr_service.dart Thin wrapper around google_mlkit_text_recognition. ocr_service.dart Thin wrapper around google_mlkit_text_recognition.
receipt_parser.dart Regex-based extraction of gallons/price/total from OCR text. receipt_parser.dart Regex-based extraction of gallons/price/total 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 - **Camera**: `NSCameraUsageDescription` (iOS, `ios/Runner/Info.plist`) and
`android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already `android.permission.CAMERA` (Android, `AndroidManifest.xml`) are already
set up for receipt capture. set up for receipt capture.
- **Network/Drive**: no Android manifest changes are needed for - **Network/Drive**: `INTERNET` and `ACCESS_NETWORK_STATE` are declared in
`google_sign_in` when not using `google-services.json` — see the manual the main Android manifest (needed for release builds; debug builds get
setup section above instead. `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 ## How sync works
Every local change (add/edit a vehicle, log a fuel entry) writes to the 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 local SQLite database immediately, then triggers a best-effort background
background sync — also triggered whenever connectivity comes back. Sync: 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 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 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 (polling every second, deleting any it finds older than 10 minutes as
orphaned/stale) — a simple ticket-based mutex using the Drive folder orphaned/stale) — a ticket-based mutex using the Drive folder itself as
itself as the coordination point, so two devices never overwrite each the coordination point, so two devices never overwrite each other's
other's edits to the shared data file mid-write. edits to the shared database mid-write.
2. Downloads the current remote data file and merges it with local changes: 2. Checks the remote database file's md5 checksum against the last one seen
vehicles and fuel entries are unioned by ID, with the newer `updatedAt` (a metadata-only call, no content download) to decide if a pull is even
winning when a record exists on both sides. needed. If it changed: downloads it to a temp file, `ATTACH`es it to the
3. Uploads any locally-pending receipt photos, then deletes the local copy. local database, and runs one `INSERT OR REPLACE ... SELECT ... WHERE
4. Writes the merged data back to Drive, then deletes its own lock file. 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 ## 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* - **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. field-by-field merge.
- **Lock acquisition has no hard timeout** beyond the 10-minute staleness - **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 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. or correct whatever wasn't found.
- No automated tests exercise the OCR, camera, or real Drive API calls - 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`, (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 `lock_coordinator_test.dart`, and `db_merge_test.dart` (using
pieces against fixed inputs. `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 String? receiptDriveFileId;
final DateTime updatedAt; 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({ FuelEntry({
required this.id, required this.id,
required this.vehicleId, required this.vehicleId,
@ -19,6 +23,7 @@ class FuelEntry {
required this.updatedAt, required this.updatedAt,
this.receiptImagePath, this.receiptImagePath,
this.receiptDriveFileId, this.receiptDriveFileId,
this.deletedAt,
}); });
/// True once the receipt photo has been uploaded to Drive and the local /// True once the receipt photo has been uploaded to Drive and the local
@ -27,13 +32,16 @@ class FuelEntry {
receiptImagePath == null && receiptDriveFileId != null; receiptImagePath == null && receiptDriveFileId != null;
/// True while a receipt photo exists only locally and still needs to be /// 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; bool get hasPendingLocalReceipt => receiptImagePath != null;
FuelEntry copyWith({ FuelEntry copyWith({
String? receiptImagePath, String? receiptImagePath,
String? receiptDriveFileId, String? receiptDriveFileId,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? deletedAt,
bool clearReceiptImagePath = false, bool clearReceiptImagePath = false,
}) { }) {
return FuelEntry( return FuelEntry(
@ -47,32 +55,35 @@ class FuelEntry {
receiptImagePath: receiptImagePath:
clearReceiptImagePath ? null : (receiptImagePath ?? this.receiptImagePath), clearReceiptImagePath ? null : (receiptImagePath ?? this.receiptImagePath),
receiptDriveFileId: receiptDriveFileId ?? this.receiptDriveFileId, receiptDriveFileId: receiptDriveFileId ?? this.receiptDriveFileId,
deletedAt: deletedAt ?? this.deletedAt,
); );
} }
Map<String, dynamic> toJson() => { Map<String, Object?> toMap() => {
'id': id, 'id': id,
'vehicleId': vehicleId, 'vehicle_id': vehicleId,
'date': date.toIso8601String(), 'date': date.millisecondsSinceEpoch,
'gallons': gallons, 'gallons': gallons,
'pricePerGallon': pricePerGallon, 'price_per_gallon': pricePerGallon,
'totalCost': totalCost, 'total_cost': totalCost,
'receiptImagePath': receiptImagePath, 'receipt_image_path': receiptImagePath,
'receiptDriveFileId': receiptDriveFileId, 'receipt_drive_file_id': receiptDriveFileId,
'updatedAt': updatedAt.toIso8601String(), 'updated_at': updatedAt.millisecondsSinceEpoch,
'deleted_at': deletedAt?.millisecondsSinceEpoch,
}; };
factory FuelEntry.fromJson(Map<String, dynamic> json) => FuelEntry( factory FuelEntry.fromMap(Map<String, Object?> map) => FuelEntry(
id: json['id'] as String, id: map['id'] as String,
vehicleId: json['vehicleId'] as String, vehicleId: map['vehicle_id'] as String,
date: DateTime.parse(json['date'] as String), date: DateTime.fromMillisecondsSinceEpoch(map['date'] as int),
gallons: (json['gallons'] as num).toDouble(), gallons: (map['gallons'] as num).toDouble(),
pricePerGallon: (json['pricePerGallon'] as num).toDouble(), pricePerGallon: (map['price_per_gallon'] as num).toDouble(),
totalCost: (json['totalCost'] as num).toDouble(), totalCost: (map['total_cost'] as num).toDouble(),
receiptImagePath: json['receiptImagePath'] as String?, receiptImagePath: map['receipt_image_path'] as String?,
receiptDriveFileId: json['receiptDriveFileId'] as String?, receiptDriveFileId: map['receipt_drive_file_id'] as String?,
updatedAt: json['updatedAt'] != null updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true),
? DateTime.parse(json['updatedAt'] as String) deletedAt: map['deleted_at'] != null
: DateTime.fromMillisecondsSinceEpoch(0), ? DateTime.fromMillisecondsSinceEpoch(map['deleted_at'] as int, isUtc: true)
: null,
); );
} }

View file

@ -6,6 +6,13 @@ class Vehicle {
final String licensePlate; final String licensePlate;
final DateTime updatedAt; 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({ Vehicle({
required this.id, required this.id,
required this.make, required this.make,
@ -13,6 +20,7 @@ class Vehicle {
required this.color, required this.color,
required this.licensePlate, required this.licensePlate,
required this.updatedAt, required this.updatedAt,
this.deletedAt,
}); });
String get displayName => '$color $make $model ($licensePlate)'; String get displayName => '$color $make $model ($licensePlate)';
@ -23,6 +31,7 @@ class Vehicle {
String? color, String? color,
String? licensePlate, String? licensePlate,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? deletedAt,
}) { }) {
return Vehicle( return Vehicle(
id: id, id: id,
@ -31,26 +40,29 @@ class Vehicle {
color: color ?? this.color, color: color ?? this.color,
licensePlate: licensePlate ?? this.licensePlate, licensePlate: licensePlate ?? this.licensePlate,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
); );
} }
Map<String, dynamic> toJson() => { Map<String, Object?> toMap() => {
'id': id, 'id': id,
'make': make, 'make': make,
'model': model, 'model': model,
'color': color, 'color': color,
'licensePlate': licensePlate, 'license_plate': licensePlate,
'updatedAt': updatedAt.toIso8601String(), 'updated_at': updatedAt.millisecondsSinceEpoch,
'deleted_at': deletedAt?.millisecondsSinceEpoch,
}; };
factory Vehicle.fromJson(Map<String, dynamic> json) => Vehicle( factory Vehicle.fromMap(Map<String, Object?> map) => Vehicle(
id: json['id'] as String, id: map['id'] as String,
make: json['make'] as String, make: map['make'] as String,
model: json['model'] as String, model: map['model'] as String,
color: json['color'] as String, color: map['color'] as String,
licensePlate: json['licensePlate'] as String, licensePlate: map['license_plate'] as String,
updatedAt: json['updatedAt'] != null updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updated_at'] as int, isUtc: true),
? DateTime.parse(json['updatedAt'] as String) deletedAt: map['deleted_at'] != null
: DateTime.fromMillisecondsSinceEpoch(0), ? 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/fuel_entry.dart';
import '../models/vehicle.dart'; import '../models/vehicle.dart';
import 'database_service.dart';
import 'drive_auth_service.dart'; import 'drive_auth_service.dart';
import 'drive_sync_service.dart'; import 'drive_sync_service.dart';
import 'storage_service.dart';
const _prefsKeyDriveFolderId = 'drive_app_folder_id'; const _prefsKeyDriveFolderId = 'drive_app_folder_id';
const _prefsKeyDriveFolderPath = 'drive_app_folder_path'; const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
/// Single source of truth for the app's in-memory data (vehicles + fuel /// 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 /// [DriveSyncService] for pushing/pulling the shared Drive copy. Screens
/// read from this via Provider and call its mutating methods, which write /// read from this via Provider and call its mutating methods, which write
/// through to local disk immediately and kick off a best-effort background /// through to the local database immediately and kick off a best-effort
/// sync to Drive. /// 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 { class AppState extends ChangeNotifier {
final StorageService storage = StorageService(); final DatabaseService database = DatabaseService();
final DriveAuthService driveAuth = DriveAuthService(); final DriveAuthService driveAuth = DriveAuthService();
late final DriveSyncService driveSync = late final DriveSyncService driveSync =
DriveSyncService(authService: driveAuth, storageService: storage); DriveSyncService(authService: driveAuth, databaseService: database);
final _uuid = const Uuid(); final _uuid = const Uuid();
List<Vehicle> vehicles = []; List<Vehicle> vehicles = [];
@ -42,10 +47,8 @@ class AppState extends ChangeNotifier {
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription; StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
Future<void> init() async { Future<void> init() async {
await storage.init(); await database.init();
final data = await storage.loadData(); await _refreshFromDatabase();
vehicles = data.vehicles;
fuelEntries = data.entries;
isLoading = false; isLoading = false;
notifyListeners(); notifyListeners();
@ -64,6 +67,11 @@ class AppState extends ChangeNotifier {
super.dispose(); super.dispose();
} }
Future<void> _refreshFromDatabase() async {
vehicles = await database.getVehicles();
fuelEntries = await database.getFuelEntries();
}
Future<void> _restoreDriveConnection() async { Future<void> _restoreDriveConnection() async {
final signedIn = await driveAuth.attemptSilentSignIn(); final signedIn = await driveAuth.attemptSilentSignIn();
if (!signedIn) return; if (!signedIn) return;
@ -125,15 +133,10 @@ class AppState extends ChangeNotifier {
isSyncing = true; isSyncing = true;
notifyListeners(); notifyListeners();
final result = await driveSync.syncNow( final result = await driveSync.syncNow();
localVehicles: vehicles,
localFuelEntries: fuelEntries,
);
if (result.ranSync) { if (result.ranSync) {
vehicles = result.vehicles!; await _refreshFromDatabase();
fuelEntries = result.fuelEntries!;
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
lastSyncedAt = DateTime.now(); lastSyncedAt = DateTime.now();
lastSyncError = null; lastSyncError = null;
} else if (result.error != null) { } else if (result.error != null) {
@ -150,31 +153,30 @@ class AppState extends ChangeNotifier {
required String color, required String color,
required String licensePlate, required String licensePlate,
}) async { }) async {
vehicles.add(Vehicle( final vehicle = Vehicle(
id: _uuid.v4(), id: _uuid.v4(),
make: make, make: make,
model: model, model: model,
color: color, color: color,
licensePlate: licensePlate, licensePlate: licensePlate,
updatedAt: DateTime.now().toUtc(), updatedAt: DateTime.now().toUtc(),
)); );
await database.saveVehicle(vehicle);
await _persist(); await _persist();
} }
Future<void> updateVehicle(Vehicle updated) async { Future<void> updateVehicle(Vehicle updated) async {
final index = vehicles.indexWhere((v) => v.id == updated.id); await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc()));
if (index != -1) { await _persist();
vehicles[index] = updated.copyWith(updatedAt: DateTime.now().toUtc());
await _persist();
}
} }
Future<void> deleteVehicle(String vehicleId) async { Future<void> deleteVehicle(String vehicleId) async {
for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) { final now = DateTime.now().toUtc();
await storage.deleteReceiptImage(entry.receiptImagePath); final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(vehicleId, now);
for (final path in orphanedLocalPaths) {
await database.deleteReceiptImageFile(path);
} }
fuelEntries.removeWhere((e) => e.vehicleId == vehicleId); await database.softDeleteVehicle(vehicleId, now);
vehicles.removeWhere((v) => v.id == vehicleId);
await _persist(); await _persist();
} }
@ -189,7 +191,7 @@ class AppState extends ChangeNotifier {
final id = _uuid.v4(); final id = _uuid.v4();
String? storedImagePath; String? storedImagePath;
if (receiptImage != null) { if (receiptImage != null) {
storedImagePath = await storage.storeReceiptImage(receiptImage, id); storedImagePath = await database.storeReceiptImage(receiptImage, id);
} }
final entry = FuelEntry( final entry = FuelEntry(
@ -202,15 +204,15 @@ class AppState extends ChangeNotifier {
receiptImagePath: storedImagePath, receiptImagePath: storedImagePath,
updatedAt: DateTime.now().toUtc(), updatedAt: DateTime.now().toUtc(),
); );
fuelEntries.add(entry); await database.saveFuelEntry(entry);
await _persist(); await _persist();
return entry; return entry;
} }
Future<void> deleteFuelEntry(String entryId) async { Future<void> deleteFuelEntry(String entryId) async {
final entry = fuelEntries.firstWhere((e) => e.id == entryId); final entry = fuelEntries.firstWhere((e) => e.id == entryId);
await storage.deleteReceiptImage(entry.receiptImagePath); await database.deleteReceiptImageFile(entry.receiptImagePath);
fuelEntries.removeWhere((e) => e.id == entryId); await database.softDeleteFuelEntry(entryId, DateTime.now().toUtc());
await _persist(); await _persist();
} }
@ -236,7 +238,7 @@ class AppState extends ChangeNotifier {
} }
Future<void> _persist() async { Future<void> _persist() async {
await storage.saveData(vehicles: vehicles, entries: fuelEntries); await _refreshFromDatabase();
notifyListeners(); notifyListeners();
unawaited(syncNow()); 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 'dart:io';
import 'package:googleapis/drive/v3.dart' as drive; 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 appFolderName = 'MO-Fuel-Tax-Back';
const receiptsFolderName = 'receipts'; const receiptsFolderName = 'receipts';
const dataFileName = 'fuel_tax_data.json'; const dataFileName = 'fuel_tax_tracker.db';
const _folderMimeType = 'application/vnd.google-apps.folder'; const _folderMimeType = 'application/vnd.google-apps.folder';
@ -17,6 +16,16 @@ class DriveFolder {
DriveFolder({required this.id, required this.name}); DriveFolder({required this.id, required this.name});
} }
class DriveDataFileInfo {
final String id;
/// Cheap change-detection signal: compare against the last value seen to
/// decide whether a pull is actually needed, without downloading content.
final String? md5Checksum;
DriveDataFileInfo({required this.id, required this.md5Checksum});
}
class DriveLockFile { class DriveLockFile {
final String id; final String id;
final String username; final String username;
@ -87,23 +96,21 @@ class DriveService {
return created.id!; 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( final result = await _api.files.list(
q: "'$appFolderId' in parents and trashed=false and name='$dataFileName'", q: "'$appFolderId' in parents and trashed=false and name='$dataFileName'",
orderBy: 'modifiedTime desc', orderBy: 'modifiedTime desc',
$fields: 'files(id,name)', $fields: 'files(id,name,md5Checksum)',
spaces: 'drive', spaces: 'drive',
); );
return (result.files ?? []).firstOrNullWithId(); final files = result.files ?? <drive.File>[];
} if (files.isEmpty) return null;
final first = files.first;
Future<String> downloadTextFile(String fileId) async { if (first.id == null) return null;
final media = await _api.files.get( return DriveDataFileInfo(id: first.id!, md5Checksum: first.md5Checksum);
fileId,
downloadOptions: drive.DownloadOptions.fullMedia,
) as drive.Media;
final bytes = await _collectBytes(media.stream);
return utf8.decode(bytes);
} }
Future<List<int>> downloadFileBytes(String fileId) async { Future<List<int>> downloadFileBytes(String fileId) async {
@ -115,17 +122,20 @@ class DriveService {
} }
/// Creates the data file if [existingFileId] is null, otherwise /// Creates the data file if [existingFileId] is null, otherwise
/// overwrites its content. Returns the (possibly new) file ID. /// overwrites its content with the bytes of the local sqlite database
Future<String> uploadDataFile({ /// 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 appFolderId,
required String? existingFileId, required String? existingFileId,
required String jsonContent, required File localSnapshotFile,
}) async { }) async {
final bytes = utf8.encode(jsonContent); final length = await localSnapshotFile.length();
final media = drive.Media( final media = drive.Media(
Stream.value(bytes), localSnapshotFile.openRead(),
bytes.length, length,
contentType: 'application/json', contentType: 'application/x-sqlite3',
); );
if (existingFileId != null) { if (existingFileId != null) {
@ -133,8 +143,9 @@ class DriveService {
drive.File(), drive.File(),
existingFileId, existingFileId,
uploadMedia: media, uploadMedia: media,
$fields: 'id,md5Checksum',
); );
return updated.id ?? existingFileId; return DriveDataFileInfo(id: updated.id ?? existingFileId, md5Checksum: updated.md5Checksum);
} }
final created = await _api.files.create( final created = await _api.files.create(
@ -142,8 +153,9 @@ class DriveService {
..name = dataFileName ..name = dataFileName
..parents = [appFolderId], ..parents = [appFolderId],
uploadMedia: media, uploadMedia: media,
$fields: 'id,md5Checksum',
); );
return created.id!; return DriveDataFileInfo(id: created.id!, md5Checksum: created.md5Checksum);
} }
Future<String> uploadReceiptImage({ Future<String> uploadReceiptImage({

View file

@ -2,50 +2,51 @@ import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../models/fuel_entry.dart'; import 'database_service.dart';
import '../models/vehicle.dart'; import 'db_schema.dart';
import 'drive_auth_service.dart'; import 'drive_auth_service.dart';
import 'drive_service.dart'; import 'drive_service.dart';
import 'lock_coordinator.dart' as lock; import 'lock_coordinator.dart' as lock;
import 'merge_utils.dart';
import 'storage_service.dart';
class SyncResult { class SyncResult {
final bool ranSync; final bool ranSync;
final List<Vehicle>? vehicles;
final List<FuelEntry>? fuelEntries;
final Object? error; final Object? error;
SyncResult.skipped() SyncResult.skipped()
: ranSync = false, : ranSync = false,
vehicles = null,
fuelEntries = null,
error = null; error = null;
SyncResult.success({required this.vehicles, required this.fuelEntries}) SyncResult.success()
: ranSync = true, : ranSync = true,
error = null; error = null;
SyncResult.failure(this.error) SyncResult.failure(this.error) : ranSync = false;
: ranSync = false,
vehicles = null,
fuelEntries = null;
} }
/// Orchestrates one round of push-local/pull-remote sync against the shared /// Orchestrates one round of sync against the shared Drive folder:
/// Drive folder: acquires the cross-device file lock, downloads and merges /// acquires the cross-device lock, pulls + merges the remote database if
/// the remote data file with local changes, uploads any pending receipt /// it changed, uploads any pending receipt photos, pushes the local
/// photos, writes the merged data file back, then releases the lock. /// 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 { class DriveSyncService {
final DriveAuthService authService; final DriveAuthService authService;
final StorageService storageService; final DatabaseService databaseService;
String? _appFolderId; String? _appFolderId;
String? _receiptsFolderId; String? _receiptsFolderId;
String? _dataFileId; String? _dataFileId;
String? _lastKnownRemoteMd5;
DriveSyncService({required this.authService, required this.storageService}); DriveSyncService({required this.authService, required this.databaseService});
bool get isConfigured => _appFolderId != null; bool get isConfigured => _appFolderId != null;
@ -54,12 +55,14 @@ class DriveSyncService {
_appFolderId = appFolderId; _appFolderId = appFolderId;
_receiptsFolderId = null; _receiptsFolderId = null;
_dataFileId = null; _dataFileId = null;
_lastKnownRemoteMd5 = null;
} }
void clearConfiguration() { void clearConfiguration() {
_appFolderId = null; _appFolderId = null;
_receiptsFolderId = null; _receiptsFolderId = null;
_dataFileId = null; _dataFileId = null;
_lastKnownRemoteMd5 = null;
} }
/// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a /// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a
@ -77,10 +80,7 @@ class DriveSyncService {
} }
} }
Future<SyncResult> syncNow({ Future<SyncResult> syncNow() async {
required List<Vehicle> localVehicles,
required List<FuelEntry> localFuelEntries,
}) async {
final appFolderId = _appFolderId; final appFolderId = _appFolderId;
if (!authService.isSignedIn || appFolderId == null) { if (!authService.isSignedIn || appFolderId == null) {
return SyncResult.skipped(); return SyncResult.skipped();
@ -101,42 +101,24 @@ class DriveSyncService {
); );
_receiptsFolderId ??= await drive.findOrCreateReceiptsFolder(appFolderId); _receiptsFolderId ??= await drive.findOrCreateReceiptsFolder(appFolderId);
_dataFileId ??= await drive.findDataFileId(appFolderId);
var remoteVehicles = <Vehicle>[]; final remoteInfo = await drive.findDataFile(appFolderId);
var remoteFuelEntries = <FuelEntry>[]; _dataFileId = remoteInfo?.id;
if (_dataFileId != null) { if (remoteInfo != null && remoteInfo.md5Checksum != _lastKnownRemoteMd5) {
final text = await drive.downloadTextFile(_dataFileId!); await _pullAndMerge(drive, remoteInfo.id);
if (text.trim().isNotEmpty) {
final decoded = storageService.decodeData(text);
remoteVehicles = decoded.vehicles;
remoteFuelEntries = decoded.entries;
}
} }
final mergedVehicles = mergeById( final allReceiptsUploaded = await _uploadPendingReceipts(drive);
remoteVehicles,
localVehicles,
(v) => v.id,
(v) => v.updatedAt,
);
final mergedFuelEntries = await _uploadPendingReceiptsAndMerge(
drive: drive,
remoteFuelEntries: remoteFuelEntries,
localFuelEntries: localFuelEntries,
);
final jsonContent = storageService.encodeData( // Only push if every pending receipt made it up — otherwise we'd
vehicles: mergedVehicles, // either upload a row with a local-only file path (meaningless on
entries: mergedFuelEntries, // another device) or prematurely mark it clean.
); if (allReceiptsUploaded) {
_dataFileId = await drive.uploadDataFile( final pushedInfo = await _pushSnapshot(drive, appFolderId);
appFolderId: appFolderId, _lastKnownRemoteMd5 = pushedInfo.md5Checksum;
existingFileId: _dataFileId, }
jsonContent: jsonContent,
);
return SyncResult.success(vehicles: mergedVehicles, fuelEntries: mergedFuelEntries); return SyncResult.success();
} catch (e) { } catch (e) {
return SyncResult.failure(e); return SyncResult.failure(e);
} finally { } finally {
@ -152,43 +134,108 @@ class DriveSyncService {
} }
} }
Future<List<FuelEntry>> _uploadPendingReceiptsAndMerge({ Future<void> _pullAndMerge(DriveService drive, String remoteFileId) async {
required DriveService drive, final bytes = await drive.downloadFileBytes(remoteFileId);
required List<FuelEntry> remoteFuelEntries, final tempDir = await getTemporaryDirectory();
required List<FuelEntry> localFuelEntries, final tempPath =
}) async { p.join(tempDir.path, 'drive_pull_${DateTime.now().microsecondsSinceEpoch}.db');
final merged = mergeById( final tempFile = File(tempPath);
remoteFuelEntries, await tempFile.writeAsBytes(bytes, flush: true);
localFuelEntries,
(e) => e.id, try {
(e) => e.updatedAt, 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>[]; var allSucceeded = true;
for (final entry in merged) { for (final row in rows) {
if (!entry.hasPendingLocalReceipt) { final id = row['id'] as String;
result.add(entry); final localPath = row['receipt_image_path'] as String;
continue;
}
final localPath = entry.receiptImagePath!;
final localFile = File(localPath); final localFile = File(localPath);
if (!await localFile.exists()) { 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; continue;
} }
final driveFileId = await drive.uploadReceiptImage( try {
receiptsFolderId: _receiptsFolderId!, final driveFileId = await drive.uploadReceiptImage(
fileName: '${entry.id}${p.extension(localPath)}', receiptsFolderId: _receiptsFolderId!,
imageFile: localFile, fileName: '$id${p.extension(localPath)}',
); imageFile: localFile,
await storageService.deleteReceiptImage(localPath); );
result.add(entry.copyWith(receiptDriveFileId: driveFileId, clearReceiptImagePath: true)); 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( Future<String> _acquireLock(
DriveService drive, { DriveService drive, {
required String appFolderId, 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 description: flutter
source: sdk source: sdk
version: "0.0.0" 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: google_identity_services_web:
dependency: transitive dependency: transitive
description: description:
@ -472,6 +480,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" 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: nested:
dependency: transitive dependency: transitive
description: description:
@ -677,6 +693,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.2" 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: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -701,6 +773,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" 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: term_glyph:
dependency: transitive dependency: transitive
description: description:

View file

@ -71,10 +71,17 @@ dependencies:
# Detect connectivity changes to trigger background sync # Detect connectivity changes to trigger background sync
connectivity_plus: ^6.1.0 connectivity_plus: ^6.1.0
# Local SQLite database
sqflite: ^2.4.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter 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 # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # 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 # 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');
});
});
}