Remote sync of webdav

This commit is contained in:
Courtney Arnold 2026-08-13 17:05:10 -05:00
parent f01186df79
commit 483fbeafb6
44 changed files with 4842 additions and 975 deletions

View file

@ -3,13 +3,33 @@ import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'package:uuid/uuid.dart';
import '../models/fuel_entry.dart';
import '../models/vehicle.dart';
import 'db_schema.dart';
const dbFileName = 'fuel_tax_tracker.db';
const receiptsFolderName = 'receipts';
/// OCR-scanned VINs are a fixed alphanumeric charset, but manual entry in
/// the vehicle form doesn't enforce that — so anything outside
/// `[A-Za-z0-9_-]` is replaced before a VIN is used as a local directory
/// name or cloud folder name, to keep it a safe single path segment (no
/// `/`, `..`, etc.).
String sanitizedPathSegment(String value) => value.trim().replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
/// `yyyy.mm` for the given (local) date — the subfolder a receipt is filed
/// under within its vehicle's folder, e.g. `2026.08`. Based on the fuel
/// entry's purchase date ([FuelEntry.date]), not when the photo happened to
/// be taken or synced.
String monthFolderName(DateTime date) =>
'${date.year.toString().padLeft(4, '0')}.${date.month.toString().padLeft(2, '0')}';
/// Name of the *local* on-device staging folder for not-yet-uploaded
/// receipt photos — distinct from (though coincidentally the same string
/// as) `receiptsFolderName` in `cloud/cloud_storage_provider.dart`, which
/// names the corresponding subfolder inside the shared cloud app folder.
const localReceiptsFolderName = 'receipts';
/// Owns the local SQLite database (vehicles + fuel_entries) and the
/// `receipts/` folder of not-yet-uploaded receipt photos, both under
@ -18,9 +38,9 @@ const receiptsFolderName = 'receipts';
/// 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
/// yet pushed to the cloud"). `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.
/// know about; only [CloudSyncService] reads/clears it.
class DatabaseService {
late Directory _rootDirectory;
late Database _db;
@ -28,9 +48,9 @@ class DatabaseService {
Directory get rootDirectory => _rootDirectory;
Directory get receiptsDirectory =>
Directory(p.join(_rootDirectory.path, receiptsFolderName));
Directory(p.join(_rootDirectory.path, localReceiptsFolderName));
/// Raw handle for [DriveSyncService], which needs to run ATTACH-based
/// Raw handle for [CloudSyncService], which needs to run ATTACH-based
/// merge SQL and VACUUM INTO snapshots that go beyond simple CRUD.
Database get rawDb => _db;
@ -43,7 +63,12 @@ class DatabaseService {
await receiptsDirectory.create(recursive: true);
final dbPath = p.join(_rootDirectory.path, dbFileName);
_db = await openDatabase(dbPath, version: 1, onCreate: _onCreate);
_db = await openDatabase(
dbPath,
version: 4,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
Future<void> _onCreate(Database db, int version) async {
@ -52,6 +77,76 @@ class DatabaseService {
await db.execute(createFuelEntriesIndexSql);
}
/// Versions 2/3 (VIN as primary key, then dropping license plate) were
/// rebuilt fresh on upgrade rather than migrated, since at the time that
/// only affected local, not-yet-synced test data. Version 4 (VIN becomes
/// editable, with a new hidden `id` taking over as the actual primary
/// key/merge key) is the first schema change made after real usage had
/// likely accumulated, so this one preserves existing rows instead of
/// dropping them: each vehicle gets a freshly generated `id`, and
/// `fuel_entries.vehicle_id` is repointed from the old `vehicle_vin` via
/// that mapping.
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 4) {
await _migrateToVehicleIdSchema(db);
}
}
Future<void> _migrateToVehicleIdSchema(Database db) async {
const uuid = Uuid();
final oldVehicles = await db.query('vehicles');
final vinToId = <String, String>{};
await db.execute('ALTER TABLE vehicles RENAME TO vehicles_old');
await db.execute(createVehiclesTableSql);
for (final row in oldVehicles) {
final vin = row['vin'] as String;
// An already-upgraded-then-reverted-then-upgraded-again edge case
// could in theory produce duplicate vin rows pre-migration; keep the
// first id assigned per vin so fuel_entries below has a single,
// unambiguous target.
final id = vinToId.putIfAbsent(vin, uuid.v4);
await db.insert('vehicles', {
'id': id,
'vin': vin,
'nickname': row['nickname'],
'updated_at': row['updated_at'],
'deleted_at': row['deleted_at'],
// Force a re-push under the new schema — the shape of what's on
// the cloud (if anything's been synced yet) still reflects the old
// schema and needs to be overwritten with this one.
'dirty': 1,
});
}
await db.execute('DROP TABLE vehicles_old');
final oldFuelEntries = await db.query('fuel_entries');
await db.execute('ALTER TABLE fuel_entries RENAME TO fuel_entries_old');
await db.execute(createFuelEntriesTableSql);
await db.execute(createFuelEntriesIndexSql);
for (final row in oldFuelEntries) {
final vehicleId = vinToId[row['vehicle_vin'] as String];
// No matching vehicle row (shouldn't normally happen) — drop rather
// than insert a fuel entry with a dangling reference.
if (vehicleId == null) continue;
await db.insert('fuel_entries', {
'id': row['id'],
'vehicle_id': vehicleId,
'date': row['date'],
'gallons': row['gallons'],
'price_per_gallon': row['price_per_gallon'],
'total_cost': row['total_cost'],
'receipt_image_path': row['receipt_image_path'],
'receipt_drive_file_id': row['receipt_drive_file_id'],
'updated_at': row['updated_at'],
'deleted_at': row['deleted_at'],
'dirty': 1,
});
}
await db.execute('DROP TABLE fuel_entries_old');
}
Future<List<Vehicle>> getVehicles() async {
final rows = await _db.query('vehicles', where: 'deleted_at IS NULL');
return rows.map(Vehicle.fromMap).toList();
@ -62,6 +157,27 @@ class DatabaseService {
return rows.map(FuelEntry.fromMap).toList();
}
/// True if an active (non-deleted) vehicle other than [excludeId] already
/// has this VIN. VIN must stay unique even though it's editable, so
/// callers adding a new vehicle (no [excludeId]) or changing an existing
/// one's VIN (passing its own [id][Vehicle.id] as [excludeId], so it
/// doesn't collide with itself) should check this first.
Future<bool> vinExists(String vin, {String? excludeId}) async {
final where = StringBuffer('vin = ? AND deleted_at IS NULL');
final whereArgs = <Object?>[vin];
if (excludeId != null) {
where.write(' AND id != ?');
whereArgs.add(excludeId);
}
final rows = await _db.query(
'vehicles',
where: where.toString(),
whereArgs: whereArgs,
limit: 1,
);
return rows.isNotEmpty;
}
/// Inserts or fully overwrites a vehicle row and marks it dirty (pending
/// push to Drive).
Future<void> saveVehicle(Vehicle vehicle) async {
@ -129,9 +245,24 @@ class DatabaseService {
return localPaths;
}
Future<String> storeReceiptImage(File sourceImage, String fuelEntryId) async {
/// Stores under `receipts/<vin>/<yyyy.mm>/`, mirroring the per-vehicle,
/// per-month folder structure used on the cloud side (see
/// [CloudSyncService]'s `_receiptFolderFor`), so a receipt's local
/// staging path already shows which vehicle and month it belongs to.
Future<String> storeReceiptImage(
File sourceImage,
String fuelEntryId,
String vin,
DateTime date,
) async {
final ext = p.extension(sourceImage.path);
final destPath = p.join(receiptsDirectory.path, '$fuelEntryId$ext');
final entryDir = Directory(p.join(
receiptsDirectory.path,
sanitizedPathSegment(vin),
monthFolderName(date),
));
await entryDir.create(recursive: true);
final destPath = p.join(entryDir.path, '$fuelEntryId$ext');
final copied = await sourceImage.copy(destPath);
return copied.path;
}