Added sqlite
This commit is contained in:
parent
73a7c93e64
commit
f01186df79
14 changed files with 784 additions and 382 deletions
|
|
@ -8,24 +8,29 @@ import 'package:uuid/uuid.dart';
|
|||
|
||||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
import 'database_service.dart';
|
||||
import 'drive_auth_service.dart';
|
||||
import 'drive_sync_service.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
const _prefsKeyDriveFolderId = 'drive_app_folder_id';
|
||||
const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
|
||||
|
||||
/// Single source of truth for the app's in-memory data (vehicles + fuel
|
||||
/// entries), backed by [StorageService] for local persistence and
|
||||
/// entries), backed by [DatabaseService] (local SQLite) for persistence and
|
||||
/// [DriveSyncService] for pushing/pulling the shared Drive copy. Screens
|
||||
/// read from this via Provider and call its mutating methods, which write
|
||||
/// through to local disk immediately and kick off a best-effort background
|
||||
/// sync to Drive.
|
||||
/// through to the local database immediately and kick off a best-effort
|
||||
/// background sync to Drive.
|
||||
///
|
||||
/// The `vehicles`/`fuelEntries` lists are an in-memory read cache of the
|
||||
/// database, refreshed after every mutation and after every sync (since
|
||||
/// sync's merge happens as SQL directly against the database, not by
|
||||
/// handing back updated Dart objects).
|
||||
class AppState extends ChangeNotifier {
|
||||
final StorageService storage = StorageService();
|
||||
final DatabaseService database = DatabaseService();
|
||||
final DriveAuthService driveAuth = DriveAuthService();
|
||||
late final DriveSyncService driveSync =
|
||||
DriveSyncService(authService: driveAuth, storageService: storage);
|
||||
DriveSyncService(authService: driveAuth, databaseService: database);
|
||||
final _uuid = const Uuid();
|
||||
|
||||
List<Vehicle> vehicles = [];
|
||||
|
|
@ -42,10 +47,8 @@ class AppState extends ChangeNotifier {
|
|||
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
||||
|
||||
Future<void> init() async {
|
||||
await storage.init();
|
||||
final data = await storage.loadData();
|
||||
vehicles = data.vehicles;
|
||||
fuelEntries = data.entries;
|
||||
await database.init();
|
||||
await _refreshFromDatabase();
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
|
|
@ -64,6 +67,11 @@ class AppState extends ChangeNotifier {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refreshFromDatabase() async {
|
||||
vehicles = await database.getVehicles();
|
||||
fuelEntries = await database.getFuelEntries();
|
||||
}
|
||||
|
||||
Future<void> _restoreDriveConnection() async {
|
||||
final signedIn = await driveAuth.attemptSilentSignIn();
|
||||
if (!signedIn) return;
|
||||
|
|
@ -125,15 +133,10 @@ class AppState extends ChangeNotifier {
|
|||
isSyncing = true;
|
||||
notifyListeners();
|
||||
|
||||
final result = await driveSync.syncNow(
|
||||
localVehicles: vehicles,
|
||||
localFuelEntries: fuelEntries,
|
||||
);
|
||||
final result = await driveSync.syncNow();
|
||||
|
||||
if (result.ranSync) {
|
||||
vehicles = result.vehicles!;
|
||||
fuelEntries = result.fuelEntries!;
|
||||
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
|
||||
await _refreshFromDatabase();
|
||||
lastSyncedAt = DateTime.now();
|
||||
lastSyncError = null;
|
||||
} else if (result.error != null) {
|
||||
|
|
@ -150,31 +153,30 @@ class AppState extends ChangeNotifier {
|
|||
required String color,
|
||||
required String licensePlate,
|
||||
}) async {
|
||||
vehicles.add(Vehicle(
|
||||
final vehicle = Vehicle(
|
||||
id: _uuid.v4(),
|
||||
make: make,
|
||||
model: model,
|
||||
color: color,
|
||||
licensePlate: licensePlate,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
));
|
||||
);
|
||||
await database.saveVehicle(vehicle);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> updateVehicle(Vehicle updated) async {
|
||||
final index = vehicles.indexWhere((v) => v.id == updated.id);
|
||||
if (index != -1) {
|
||||
vehicles[index] = updated.copyWith(updatedAt: DateTime.now().toUtc());
|
||||
await _persist();
|
||||
}
|
||||
await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc()));
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> deleteVehicle(String vehicleId) async {
|
||||
for (final entry in fuelEntries.where((e) => e.vehicleId == vehicleId)) {
|
||||
await storage.deleteReceiptImage(entry.receiptImagePath);
|
||||
final now = DateTime.now().toUtc();
|
||||
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(vehicleId, now);
|
||||
for (final path in orphanedLocalPaths) {
|
||||
await database.deleteReceiptImageFile(path);
|
||||
}
|
||||
fuelEntries.removeWhere((e) => e.vehicleId == vehicleId);
|
||||
vehicles.removeWhere((v) => v.id == vehicleId);
|
||||
await database.softDeleteVehicle(vehicleId, now);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +191,7 @@ class AppState extends ChangeNotifier {
|
|||
final id = _uuid.v4();
|
||||
String? storedImagePath;
|
||||
if (receiptImage != null) {
|
||||
storedImagePath = await storage.storeReceiptImage(receiptImage, id);
|
||||
storedImagePath = await database.storeReceiptImage(receiptImage, id);
|
||||
}
|
||||
|
||||
final entry = FuelEntry(
|
||||
|
|
@ -202,15 +204,15 @@ class AppState extends ChangeNotifier {
|
|||
receiptImagePath: storedImagePath,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
);
|
||||
fuelEntries.add(entry);
|
||||
await database.saveFuelEntry(entry);
|
||||
await _persist();
|
||||
return entry;
|
||||
}
|
||||
|
||||
Future<void> deleteFuelEntry(String entryId) async {
|
||||
final entry = fuelEntries.firstWhere((e) => e.id == entryId);
|
||||
await storage.deleteReceiptImage(entry.receiptImagePath);
|
||||
fuelEntries.removeWhere((e) => e.id == entryId);
|
||||
await database.deleteReceiptImageFile(entry.receiptImagePath);
|
||||
await database.softDeleteFuelEntry(entryId, DateTime.now().toUtc());
|
||||
await _persist();
|
||||
}
|
||||
|
||||
|
|
@ -236,7 +238,7 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
await storage.saveData(vehicles: vehicles, entries: fuelEntries);
|
||||
await _refreshFromDatabase();
|
||||
notifyListeners();
|
||||
unawaited(syncNow());
|
||||
}
|
||||
|
|
|
|||
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 'package:googleapis/drive/v3.dart' as drive;
|
||||
|
|
@ -6,7 +5,7 @@ import 'package:http/http.dart' as http;
|
|||
|
||||
const appFolderName = 'MO-Fuel-Tax-Back';
|
||||
const receiptsFolderName = 'receipts';
|
||||
const dataFileName = 'fuel_tax_data.json';
|
||||
const dataFileName = 'fuel_tax_tracker.db';
|
||||
|
||||
const _folderMimeType = 'application/vnd.google-apps.folder';
|
||||
|
||||
|
|
@ -17,6 +16,16 @@ class DriveFolder {
|
|||
DriveFolder({required this.id, required this.name});
|
||||
}
|
||||
|
||||
class DriveDataFileInfo {
|
||||
final String id;
|
||||
|
||||
/// Cheap change-detection signal: compare against the last value seen to
|
||||
/// decide whether a pull is actually needed, without downloading content.
|
||||
final String? md5Checksum;
|
||||
|
||||
DriveDataFileInfo({required this.id, required this.md5Checksum});
|
||||
}
|
||||
|
||||
class DriveLockFile {
|
||||
final String id;
|
||||
final String username;
|
||||
|
|
@ -87,23 +96,21 @@ class DriveService {
|
|||
return created.id!;
|
||||
}
|
||||
|
||||
Future<String?> findDataFileId(String appFolderId) async {
|
||||
/// Looks up the shared data file's ID and md5 checksum without
|
||||
/// downloading its content, so sync can cheaply decide whether a pull is
|
||||
/// actually needed.
|
||||
Future<DriveDataFileInfo?> findDataFile(String appFolderId) async {
|
||||
final result = await _api.files.list(
|
||||
q: "'$appFolderId' in parents and trashed=false and name='$dataFileName'",
|
||||
orderBy: 'modifiedTime desc',
|
||||
$fields: 'files(id,name)',
|
||||
$fields: 'files(id,name,md5Checksum)',
|
||||
spaces: 'drive',
|
||||
);
|
||||
return (result.files ?? []).firstOrNullWithId();
|
||||
}
|
||||
|
||||
Future<String> downloadTextFile(String fileId) async {
|
||||
final media = await _api.files.get(
|
||||
fileId,
|
||||
downloadOptions: drive.DownloadOptions.fullMedia,
|
||||
) as drive.Media;
|
||||
final bytes = await _collectBytes(media.stream);
|
||||
return utf8.decode(bytes);
|
||||
final files = result.files ?? <drive.File>[];
|
||||
if (files.isEmpty) return null;
|
||||
final first = files.first;
|
||||
if (first.id == null) return null;
|
||||
return DriveDataFileInfo(id: first.id!, md5Checksum: first.md5Checksum);
|
||||
}
|
||||
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
|
|
@ -115,17 +122,20 @@ class DriveService {
|
|||
}
|
||||
|
||||
/// Creates the data file if [existingFileId] is null, otherwise
|
||||
/// overwrites its content. Returns the (possibly new) file ID.
|
||||
Future<String> uploadDataFile({
|
||||
/// overwrites its content with the bytes of the local sqlite database
|
||||
/// file (see [DriveSyncService]). Returns the (possibly new) file ID and
|
||||
/// its fresh md5 checksum, so the caller can remember it for
|
||||
/// change-detection on the next sync without an extra round-trip.
|
||||
Future<DriveDataFileInfo> uploadDataFile({
|
||||
required String appFolderId,
|
||||
required String? existingFileId,
|
||||
required String jsonContent,
|
||||
required File localSnapshotFile,
|
||||
}) async {
|
||||
final bytes = utf8.encode(jsonContent);
|
||||
final length = await localSnapshotFile.length();
|
||||
final media = drive.Media(
|
||||
Stream.value(bytes),
|
||||
bytes.length,
|
||||
contentType: 'application/json',
|
||||
localSnapshotFile.openRead(),
|
||||
length,
|
||||
contentType: 'application/x-sqlite3',
|
||||
);
|
||||
|
||||
if (existingFileId != null) {
|
||||
|
|
@ -133,8 +143,9 @@ class DriveService {
|
|||
drive.File(),
|
||||
existingFileId,
|
||||
uploadMedia: media,
|
||||
$fields: 'id,md5Checksum',
|
||||
);
|
||||
return updated.id ?? existingFileId;
|
||||
return DriveDataFileInfo(id: updated.id ?? existingFileId, md5Checksum: updated.md5Checksum);
|
||||
}
|
||||
|
||||
final created = await _api.files.create(
|
||||
|
|
@ -142,8 +153,9 @@ class DriveService {
|
|||
..name = dataFileName
|
||||
..parents = [appFolderId],
|
||||
uploadMedia: media,
|
||||
$fields: 'id,md5Checksum',
|
||||
);
|
||||
return created.id!;
|
||||
return DriveDataFileInfo(id: created.id!, md5Checksum: created.md5Checksum);
|
||||
}
|
||||
|
||||
Future<String> uploadReceiptImage({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue