Added sqlite
This commit is contained in:
parent
73a7c93e64
commit
f01186df79
14 changed files with 784 additions and 382 deletions
102
README.md
102
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
146
lib/services/database_service.dart
Normal file
146
lib/services/database_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
lib/services/db_schema.dart
Normal file
61
lib/services/db_schema.dart
Normal 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
|
||||||
|
''';
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
80
pubspec.lock
80
pubspec.lock
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
172
test/db_merge_test.dart
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue