Remote sync of webdav
This commit is contained in:
parent
f01186df79
commit
483fbeafb6
44 changed files with 4842 additions and 975 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue