Remote sync of webdav
This commit is contained in:
parent
f01186df79
commit
483fbeafb6
44 changed files with 4842 additions and 975 deletions
|
|
@ -8,19 +8,42 @@ import 'package:uuid/uuid.dart';
|
|||
|
||||
import '../models/fuel_entry.dart';
|
||||
import '../models/vehicle.dart';
|
||||
import 'cloud/cloud_storage_provider.dart';
|
||||
import 'cloud/dropbox_provider.dart';
|
||||
import 'cloud/google_drive_provider.dart';
|
||||
import 'cloud/onedrive_provider.dart';
|
||||
import 'cloud/webdav_provider.dart';
|
||||
import 'cloud_sync_service.dart';
|
||||
import 'database_service.dart';
|
||||
import 'drive_auth_service.dart';
|
||||
import 'drive_sync_service.dart';
|
||||
|
||||
const _prefsKeyDriveFolderId = 'drive_app_folder_id';
|
||||
const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
|
||||
const _prefsKeyActiveProviderId = 'active_cloud_provider_id';
|
||||
const _prefsKeyCloudFolderId = 'cloud_folder_id';
|
||||
const _prefsKeyCloudFolderPath = 'cloud_folder_path';
|
||||
const _prefsKeyKeepReceiptPhotosLocally = 'keep_receipt_photos_locally';
|
||||
const _prefsKeyStaleLockMinutes = 'stale_lock_minutes';
|
||||
const _prefsKeyKeepMaxQualityReceiptPhotos = 'keep_max_quality_receipt_photos';
|
||||
|
||||
const defaultStaleLockMinutes = 10;
|
||||
const minStaleLockMinutes = 1;
|
||||
const maxStaleLockMinutes = 60;
|
||||
|
||||
/// Thrown by [AppState.addVehicle] when the given VIN already belongs to
|
||||
/// an active vehicle — VIN is the primary/unique identifier, so adding a
|
||||
/// duplicate should be rejected rather than silently overwriting it.
|
||||
class DuplicateVinException implements Exception {
|
||||
final String vin;
|
||||
DuplicateVinException(this.vin);
|
||||
|
||||
@override
|
||||
String toString() => 'A vehicle with VIN "$vin" already exists.';
|
||||
}
|
||||
|
||||
/// Single source of truth for the app's in-memory data (vehicles + fuel
|
||||
/// 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 the local database immediately and kick off a best-effort
|
||||
/// background sync to Drive.
|
||||
/// a [CloudSyncService] for pushing/pulling the shared copy on whichever
|
||||
/// [CloudStorageProvider] the user has connected. Screens read from this
|
||||
/// via Provider and call its mutating methods, which write through to the
|
||||
/// local database immediately and kick off a best-effort background sync.
|
||||
///
|
||||
/// The `vehicles`/`fuelEntries` lists are an in-memory read cache of the
|
||||
/// database, refreshed after every mutation and after every sync (since
|
||||
|
|
@ -28,27 +51,71 @@ const _prefsKeyDriveFolderPath = 'drive_app_folder_path';
|
|||
/// handing back updated Dart objects).
|
||||
class AppState extends ChangeNotifier {
|
||||
final DatabaseService database = DatabaseService();
|
||||
final DriveAuthService driveAuth = DriveAuthService();
|
||||
late final DriveSyncService driveSync =
|
||||
DriveSyncService(authService: driveAuth, databaseService: database);
|
||||
|
||||
/// Every storage backend the user can choose from in Settings.
|
||||
final List<CloudStorageProvider> availableProviders = [
|
||||
GoogleDriveProvider(),
|
||||
DropboxProvider(),
|
||||
OneDriveProvider(),
|
||||
WebDavProvider(),
|
||||
];
|
||||
|
||||
CloudStorageProvider? activeProvider;
|
||||
CloudSyncService? cloudSync;
|
||||
|
||||
final _uuid = const Uuid();
|
||||
|
||||
List<Vehicle> vehicles = [];
|
||||
List<FuelEntry> fuelEntries = [];
|
||||
bool isLoading = true;
|
||||
|
||||
bool isDriveConnected = false;
|
||||
String? driveAccountEmail;
|
||||
String? driveFolderPath;
|
||||
String? cloudFolderPath;
|
||||
bool isSyncing = false;
|
||||
DateTime? lastSyncedAt;
|
||||
Object? lastSyncError;
|
||||
|
||||
bool keepReceiptPhotosLocally = false;
|
||||
int staleLockMinutes = defaultStaleLockMinutes;
|
||||
|
||||
/// When false (default), a newly captured receipt photo is downscaled
|
||||
/// and re-compressed to [receiptImageMaxDimension]/[receiptImageQuality]
|
||||
/// before it's stored — receipts are just photos of small printed text,
|
||||
/// so a full-resolution original (often several MB on a modern phone
|
||||
/// camera) buys nothing but cloud storage and sync bandwidth. When true,
|
||||
/// the original camera/gallery image is kept as-is.
|
||||
bool keepMaxQualityReceiptPhotos = false;
|
||||
|
||||
/// Set if [init] fails. The UI shows this (with a retry option) instead
|
||||
/// of spinning forever — an unhandled exception here previously left
|
||||
/// `isLoading` stuck at true with no feedback at all.
|
||||
Object? initError;
|
||||
|
||||
bool get isCloudConnected => activeProvider?.isSignedIn ?? false;
|
||||
String? get cloudAccountLabel => activeProvider?.accountLabel;
|
||||
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
||||
|
||||
Future<void> init() async {
|
||||
await database.init();
|
||||
await _refreshFromDatabase();
|
||||
isLoading = true;
|
||||
initError = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await database.init();
|
||||
await _refreshFromDatabase();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
keepReceiptPhotosLocally = prefs.getBool(_prefsKeyKeepReceiptPhotosLocally) ?? false;
|
||||
staleLockMinutes = prefs.getInt(_prefsKeyStaleLockMinutes) ?? defaultStaleLockMinutes;
|
||||
keepMaxQualityReceiptPhotos =
|
||||
prefs.getBool(_prefsKeyKeepMaxQualityReceiptPhotos) ?? false;
|
||||
} catch (e) {
|
||||
initError = e;
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
|
|
@ -58,7 +125,7 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
});
|
||||
|
||||
unawaited(_restoreDriveConnection());
|
||||
unawaited(_restoreCloudConnection());
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -72,18 +139,34 @@ class AppState extends ChangeNotifier {
|
|||
fuelEntries = await database.getFuelEntries();
|
||||
}
|
||||
|
||||
Future<void> _restoreDriveConnection() async {
|
||||
final signedIn = await driveAuth.attemptSilentSignIn();
|
||||
CloudStorageProvider? _providerById(CloudProviderId id) {
|
||||
for (final provider in availableProviders) {
|
||||
if (provider.id == id) return provider;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _restoreCloudConnection() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final storedProviderName = prefs.getString(_prefsKeyActiveProviderId);
|
||||
if (storedProviderName == null) return;
|
||||
|
||||
final matchingId = CloudProviderId.values
|
||||
.where((id) => id.name == storedProviderName)
|
||||
.firstOrNull;
|
||||
final provider = matchingId == null ? null : _providerById(matchingId);
|
||||
if (provider == null) return;
|
||||
|
||||
final signedIn = await provider.attemptSilentSignIn();
|
||||
if (!signedIn) return;
|
||||
|
||||
isDriveConnected = true;
|
||||
driveAccountEmail = driveAuth.currentAccountEmail;
|
||||
activeProvider = provider;
|
||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final folderId = prefs.getString(_prefsKeyDriveFolderId);
|
||||
driveFolderPath = prefs.getString(_prefsKeyDriveFolderPath);
|
||||
final folderId = prefs.getString(_prefsKeyCloudFolderId);
|
||||
cloudFolderPath = prefs.getString(_prefsKeyCloudFolderPath);
|
||||
if (folderId != null) {
|
||||
driveSync.configure(folderId);
|
||||
cloudSync!.configure(folderId);
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
|
|
@ -92,48 +175,91 @@ class AppState extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> connectDrive() async {
|
||||
final email = await driveAuth.signIn();
|
||||
isDriveConnected = true;
|
||||
driveAccountEmail = email;
|
||||
notifyListeners();
|
||||
}
|
||||
Future<void> connectProvider(CloudProviderId id) async {
|
||||
final provider = _providerById(id);
|
||||
if (provider == null) return;
|
||||
|
||||
Future<void> disconnectDrive() async {
|
||||
await driveAuth.signOut();
|
||||
driveSync.clearConfiguration();
|
||||
isDriveConnected = false;
|
||||
driveAccountEmail = null;
|
||||
driveFolderPath = null;
|
||||
await provider.signIn();
|
||||
activeProvider = provider;
|
||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefsKeyDriveFolderId);
|
||||
await prefs.remove(_prefsKeyDriveFolderPath);
|
||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> chooseDriveFolder({
|
||||
/// Like [connectProvider], but for a [ManualCredentialCloudStorageProvider]
|
||||
/// (currently just WebDAV) that needs a server URL/username/password
|
||||
/// instead of an OAuth browser flow. The caller (Settings) is responsible
|
||||
/// for collecting those from the user first.
|
||||
Future<void> connectProviderWithCredentials(
|
||||
CloudProviderId id, {
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final provider = _providerById(id);
|
||||
if (provider == null || provider is! ManualCredentialCloudStorageProvider) return;
|
||||
|
||||
await (provider as ManualCredentialCloudStorageProvider).signInWithCredentials(
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
activeProvider = provider;
|
||||
cloudSync = CloudSyncService(provider: provider, databaseService: database);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKeyActiveProviderId, id.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> disconnectCloud() async {
|
||||
final provider = activeProvider;
|
||||
if (provider == null) return;
|
||||
|
||||
await provider.signOut();
|
||||
cloudSync?.clearConfiguration();
|
||||
activeProvider = null;
|
||||
cloudSync = null;
|
||||
cloudFolderPath = null;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_prefsKeyActiveProviderId);
|
||||
await prefs.remove(_prefsKeyCloudFolderId);
|
||||
await prefs.remove(_prefsKeyCloudFolderPath);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> chooseCloudFolder({
|
||||
required String parentId,
|
||||
required String breadcrumbPath,
|
||||
}) async {
|
||||
final folderId = await driveSync.selectAppFolder(parentId);
|
||||
driveFolderPath = breadcrumbPath;
|
||||
final sync = cloudSync;
|
||||
if (sync == null) return;
|
||||
|
||||
final folderId = await sync.selectAppFolder(parentId);
|
||||
cloudFolderPath = breadcrumbPath;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKeyDriveFolderId, folderId);
|
||||
await prefs.setString(_prefsKeyDriveFolderPath, breadcrumbPath);
|
||||
await prefs.setString(_prefsKeyCloudFolderId, folderId);
|
||||
await prefs.setString(_prefsKeyCloudFolderPath, breadcrumbPath);
|
||||
notifyListeners();
|
||||
|
||||
unawaited(syncNow());
|
||||
}
|
||||
|
||||
Future<void> syncNow() async {
|
||||
if (isSyncing || !driveSync.isConfigured) return;
|
||||
final sync = cloudSync;
|
||||
if (isSyncing || sync == null || !sync.isConfigured) return;
|
||||
|
||||
isSyncing = true;
|
||||
notifyListeners();
|
||||
|
||||
final result = await driveSync.syncNow();
|
||||
final result = await sync.syncNow(
|
||||
keepLocalReceiptCopies: keepReceiptPhotosLocally,
|
||||
staleLockAge: Duration(minutes: staleLockMinutes),
|
||||
);
|
||||
|
||||
if (result.ranSync) {
|
||||
await _refreshFromDatabase();
|
||||
|
|
@ -147,36 +273,71 @@ class AppState extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setKeepReceiptPhotosLocally(bool value) async {
|
||||
keepReceiptPhotosLocally = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKeyKeepReceiptPhotosLocally, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setKeepMaxQualityReceiptPhotos(bool value) async {
|
||||
keepMaxQualityReceiptPhotos = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKeyKeepMaxQualityReceiptPhotos, value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clamped to [minStaleLockMinutes, maxStaleLockMinutes] — see the
|
||||
/// Settings "Advanced" section, which restricts the picker to that range
|
||||
/// anyway; this is a defensive backstop for any other caller.
|
||||
Future<void> setStaleLockMinutes(int minutes) async {
|
||||
staleLockMinutes = minutes.clamp(minStaleLockMinutes, maxStaleLockMinutes);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_prefsKeyStaleLockMinutes, staleLockMinutes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Throws [DuplicateVinException] if [vin] already belongs to another
|
||||
/// active vehicle — VIN must stay unique even though it's editable, so
|
||||
/// silently letting a duplicate through would be a real correctness bug,
|
||||
/// not just a UX wrinkle.
|
||||
Future<void> addVehicle({
|
||||
required String make,
|
||||
required String model,
|
||||
required String color,
|
||||
required String licensePlate,
|
||||
required String vin,
|
||||
String? nickname,
|
||||
}) async {
|
||||
if (await database.vinExists(vin)) {
|
||||
throw DuplicateVinException(vin);
|
||||
}
|
||||
final vehicle = Vehicle(
|
||||
id: _uuid.v4(),
|
||||
make: make,
|
||||
model: model,
|
||||
color: color,
|
||||
licensePlate: licensePlate,
|
||||
vin: vin,
|
||||
nickname: nickname,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
);
|
||||
await database.saveVehicle(vehicle);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
/// Throws [DuplicateVinException] if [updated]'s VIN now collides with
|
||||
/// another active vehicle's — this is the check [addVehicle] does for a
|
||||
/// new vehicle, but here it also has to exclude the vehicle being edited
|
||||
/// itself (its VIN obviously still matches its own prior value if it
|
||||
/// wasn't changed).
|
||||
Future<void> updateVehicle(Vehicle updated) async {
|
||||
if (await database.vinExists(updated.vin, excludeId: updated.id)) {
|
||||
throw DuplicateVinException(updated.vin);
|
||||
}
|
||||
await database.saveVehicle(updated.copyWith(updatedAt: DateTime.now().toUtc()));
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> deleteVehicle(String vehicleId) async {
|
||||
Future<void> deleteVehicle(String id) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(vehicleId, now);
|
||||
final orphanedLocalPaths = await database.softDeleteFuelEntriesForVehicle(id, now);
|
||||
for (final path in orphanedLocalPaths) {
|
||||
await database.deleteReceiptImageFile(path);
|
||||
}
|
||||
await database.softDeleteVehicle(vehicleId, now);
|
||||
await database.softDeleteVehicle(id, now);
|
||||
await _persist();
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +352,8 @@ class AppState extends ChangeNotifier {
|
|||
final id = _uuid.v4();
|
||||
String? storedImagePath;
|
||||
if (receiptImage != null) {
|
||||
storedImagePath = await database.storeReceiptImage(receiptImage, id);
|
||||
final vin = vehicles.firstWhere((v) => v.id == vehicleId).vin;
|
||||
storedImagePath = await database.storeReceiptImage(receiptImage, id, vin, date);
|
||||
}
|
||||
|
||||
final entry = FuelEntry(
|
||||
|
|
@ -243,3 +405,7 @@ class AppState extends ChangeNotifier {
|
|||
unawaited(syncNow());
|
||||
}
|
||||
}
|
||||
|
||||
extension _FirstOrNull<T> on Iterable<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue