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

View file

@ -2,50 +2,51 @@ import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import 'database_service.dart';
import 'db_schema.dart';
import 'drive_auth_service.dart';
import 'drive_service.dart';
import 'lock_coordinator.dart' as lock;
import 'merge_utils.dart';
import 'storage_service.dart';
class SyncResult {
final bool ranSync;
final List<Vehicle>? vehicles;
final List<FuelEntry>? fuelEntries;
final Object? error;
SyncResult.skipped()
: ranSync = false,
vehicles = null,
fuelEntries = null,
error = null;
SyncResult.success({required this.vehicles, required this.fuelEntries})
SyncResult.success()
: ranSync = true,
error = null;
SyncResult.failure(this.error)
: ranSync = false,
vehicles = null,
fuelEntries = null;
SyncResult.failure(this.error) : ranSync = false;
}
/// Orchestrates one round of push-local/pull-remote sync against the shared
/// Drive folder: acquires the cross-device file lock, downloads and merges
/// the remote data file with local changes, uploads any pending receipt
/// photos, writes the merged data file back, then releases the lock.
/// Orchestrates one round of sync against the shared Drive folder:
/// acquires the cross-device lock, pulls + merges the remote database if
/// it changed, uploads any pending receipt photos, pushes the local
/// database back up, then releases the lock.
///
/// The merge itself runs as SQL directly against the local database with
/// the downloaded remote copy `ATTACH`ed, rather than decoding records into
/// Dart objects: for each table, `INSERT OR REPLACE` any remote row that's
/// new to us or has a newer `updated_at` than our copy. Rows we haven't
/// touched (including our own not-yet-pushed edits) are left alone by that
/// statement, so no separate "keep local" step is needed — see the README
/// for the full reasoning.
class DriveSyncService {
final DriveAuthService authService;
final StorageService storageService;
final DatabaseService databaseService;
String? _appFolderId;
String? _receiptsFolderId;
String? _dataFileId;
String? _lastKnownRemoteMd5;
DriveSyncService({required this.authService, required this.storageService});
DriveSyncService({required this.authService, required this.databaseService});
bool get isConfigured => _appFolderId != null;
@ -54,12 +55,14 @@ class DriveSyncService {
_appFolderId = appFolderId;
_receiptsFolderId = null;
_dataFileId = null;
_lastKnownRemoteMd5 = null;
}
void clearConfiguration() {
_appFolderId = null;
_receiptsFolderId = null;
_dataFileId = null;
_lastKnownRemoteMd5 = null;
}
/// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a
@ -77,10 +80,7 @@ class DriveSyncService {
}
}
Future<SyncResult> syncNow({
required List<Vehicle> localVehicles,
required List<FuelEntry> localFuelEntries,
}) async {
Future<SyncResult> syncNow() async {
final appFolderId = _appFolderId;
if (!authService.isSignedIn || appFolderId == null) {
return SyncResult.skipped();
@ -101,42 +101,24 @@ class DriveSyncService {
);
_receiptsFolderId ??= await drive.findOrCreateReceiptsFolder(appFolderId);
_dataFileId ??= await drive.findDataFileId(appFolderId);
var remoteVehicles = <Vehicle>[];
var remoteFuelEntries = <FuelEntry>[];
if (_dataFileId != null) {
final text = await drive.downloadTextFile(_dataFileId!);
if (text.trim().isNotEmpty) {
final decoded = storageService.decodeData(text);
remoteVehicles = decoded.vehicles;
remoteFuelEntries = decoded.entries;
}
final remoteInfo = await drive.findDataFile(appFolderId);
_dataFileId = remoteInfo?.id;
if (remoteInfo != null && remoteInfo.md5Checksum != _lastKnownRemoteMd5) {
await _pullAndMerge(drive, remoteInfo.id);
}
final mergedVehicles = mergeById(
remoteVehicles,
localVehicles,
(v) => v.id,
(v) => v.updatedAt,
);
final mergedFuelEntries = await _uploadPendingReceiptsAndMerge(
drive: drive,
remoteFuelEntries: remoteFuelEntries,
localFuelEntries: localFuelEntries,
);
final allReceiptsUploaded = await _uploadPendingReceipts(drive);
final jsonContent = storageService.encodeData(
vehicles: mergedVehicles,
entries: mergedFuelEntries,
);
_dataFileId = await drive.uploadDataFile(
appFolderId: appFolderId,
existingFileId: _dataFileId,
jsonContent: jsonContent,
);
// Only push if every pending receipt made it up — otherwise we'd
// either upload a row with a local-only file path (meaningless on
// another device) or prematurely mark it clean.
if (allReceiptsUploaded) {
final pushedInfo = await _pushSnapshot(drive, appFolderId);
_lastKnownRemoteMd5 = pushedInfo.md5Checksum;
}
return SyncResult.success(vehicles: mergedVehicles, fuelEntries: mergedFuelEntries);
return SyncResult.success();
} catch (e) {
return SyncResult.failure(e);
} finally {
@ -152,43 +134,108 @@ class DriveSyncService {
}
}
Future<List<FuelEntry>> _uploadPendingReceiptsAndMerge({
required DriveService drive,
required List<FuelEntry> remoteFuelEntries,
required List<FuelEntry> localFuelEntries,
}) async {
final merged = mergeById(
remoteFuelEntries,
localFuelEntries,
(e) => e.id,
(e) => e.updatedAt,
Future<void> _pullAndMerge(DriveService drive, String remoteFileId) async {
final bytes = await drive.downloadFileBytes(remoteFileId);
final tempDir = await getTemporaryDirectory();
final tempPath =
p.join(tempDir.path, 'drive_pull_${DateTime.now().microsecondsSinceEpoch}.db');
final tempFile = File(tempPath);
await tempFile.writeAsBytes(bytes, flush: true);
try {
final db = databaseService.rawDb;
await db.execute("ATTACH DATABASE '${_escapeSqlLiteral(tempPath)}' AS remote_db");
try {
await db.execute(mergeVehiclesSql);
await db.execute(mergeFuelEntriesSql);
} finally {
try {
await db.execute('DETACH DATABASE remote_db');
} catch (_) {
// Don't let a detach failure mask a real merge error above.
}
}
} finally {
if (await tempFile.exists()) {
await tempFile.delete();
}
}
}
/// Uploads any locally-pending receipt images (dirty fuel entries that
/// still have a local path, not yet a Drive file ID). Returns true only
/// if every pending image made it up — see [syncNow] for why a partial
/// failure here blocks the push step entirely rather than pushing
/// something with a leftover local path.
Future<bool> _uploadPendingReceipts(DriveService drive) async {
final db = databaseService.rawDb;
final rows = await db.query(
'fuel_entries',
where: 'dirty = 1 AND receipt_image_path IS NOT NULL AND deleted_at IS NULL',
);
final result = <FuelEntry>[];
for (final entry in merged) {
if (!entry.hasPendingLocalReceipt) {
result.add(entry);
continue;
}
final localPath = entry.receiptImagePath!;
var allSucceeded = true;
for (final row in rows) {
final id = row['id'] as String;
final localPath = row['receipt_image_path'] as String;
final localFile = File(localPath);
if (!await localFile.exists()) {
result.add(entry);
// Nothing left to upload; clear the dangling reference.
await db.update('fuel_entries', {'receipt_image_path': null},
where: 'id = ?', whereArgs: [id]);
continue;
}
final driveFileId = await drive.uploadReceiptImage(
receiptsFolderId: _receiptsFolderId!,
fileName: '${entry.id}${p.extension(localPath)}',
imageFile: localFile,
);
await storageService.deleteReceiptImage(localPath);
result.add(entry.copyWith(receiptDriveFileId: driveFileId, clearReceiptImagePath: true));
try {
final driveFileId = await drive.uploadReceiptImage(
receiptsFolderId: _receiptsFolderId!,
fileName: '$id${p.extension(localPath)}',
imageFile: localFile,
);
await databaseService.deleteReceiptImageFile(localPath);
await db.update(
'fuel_entries',
{'receipt_drive_file_id': driveFileId, 'receipt_image_path': null},
where: 'id = ?',
whereArgs: [id],
);
} catch (_) {
allSucceeded = false;
}
}
return result;
return allSucceeded;
}
/// Uploads the current local database file as the new remote copy, then
/// clears the dirty flag on every row now that local matches Drive.
///
/// Reads the live database file directly rather than a `VACUUM INTO`
/// snapshot: sqflite uses SQLite's default rollback-journal mode (not
/// WAL) unless explicitly configured otherwise, so the main file is a
/// complete, valid database as soon as the last write's Future
/// completes. `wal_checkpoint` is run first anyway as cheap insurance in
/// case that ever changes. This also sidesteps `VACUUM INTO` needing
/// SQLite 3.27+, which isn't guaranteed on very old Android versions.
Future<DriveDataFileInfo> _pushSnapshot(DriveService drive, String appFolderId) async {
final db = databaseService.rawDb;
await db.execute('PRAGMA wal_checkpoint(TRUNCATE)');
final info = await drive.uploadDataFile(
appFolderId: appFolderId,
existingFileId: _dataFileId,
localSnapshotFile: File(databaseService.databasePath),
);
_dataFileId = info.id;
await db.update('vehicles', {'dirty': 0});
await db.update('fuel_entries', {'dirty': 0});
return info;
}
String _escapeSqlLiteral(String value) => value.replaceAll("'", "''");
Future<String> _acquireLock(
DriveService drive, {
required String appFolderId,