import 'dart:io'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import '../models/fuel_entry.dart'; import '../models/vehicle.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? vehicles; final List? fuelEntries; final Object? error; SyncResult.skipped() : ranSync = false, vehicles = null, fuelEntries = null, error = null; SyncResult.success({required this.vehicles, required this.fuelEntries}) : ranSync = true, error = null; SyncResult.failure(this.error) : ranSync = false, vehicles = null, fuelEntries = null; } /// 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. class DriveSyncService { final DriveAuthService authService; final StorageService storageService; String? _appFolderId; String? _receiptsFolderId; String? _dataFileId; DriveSyncService({required this.authService, required this.storageService}); bool get isConfigured => _appFolderId != null; /// Call once a Drive app folder has been chosen (or restored at launch). void configure(String appFolderId) { _appFolderId = appFolderId; _receiptsFolderId = null; _dataFileId = null; } void clearConfiguration() { _appFolderId = null; _receiptsFolderId = null; _dataFileId = null; } /// Finds-or-creates the `MO-Fuel-Tax-Back` folder under [parentId] (a /// folder the user picked in the Drive folder browser) and configures /// this service to use it. Returns the resulting folder ID. Future selectAppFolder(String parentId) async { final client = authService.authenticatedHttpClient(); try { final drive = DriveService(client); final folderId = await drive.findOrCreateAppFolder(parentId); configure(folderId); return folderId; } finally { client.close(); } } Future syncNow({ required List localVehicles, required List localFuelEntries, }) async { final appFolderId = _appFolderId; if (!authService.isSignedIn || appFolderId == null) { return SyncResult.skipped(); } http.Client? client; DriveService? drive; String? lockFileId; try { client = authService.authenticatedHttpClient(); drive = DriveService(client); lockFileId = await _acquireLock( drive, appFolderId: appFolderId, username: authService.currentAccountEmail!, ); _receiptsFolderId ??= await drive.findOrCreateReceiptsFolder(appFolderId); _dataFileId ??= await drive.findDataFileId(appFolderId); var remoteVehicles = []; var remoteFuelEntries = []; 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 mergedVehicles = mergeById( remoteVehicles, localVehicles, (v) => v.id, (v) => v.updatedAt, ); final mergedFuelEntries = await _uploadPendingReceiptsAndMerge( drive: drive, remoteFuelEntries: remoteFuelEntries, localFuelEntries: localFuelEntries, ); final jsonContent = storageService.encodeData( vehicles: mergedVehicles, entries: mergedFuelEntries, ); _dataFileId = await drive.uploadDataFile( appFolderId: appFolderId, existingFileId: _dataFileId, jsonContent: jsonContent, ); return SyncResult.success(vehicles: mergedVehicles, fuelEntries: mergedFuelEntries); } catch (e) { return SyncResult.failure(e); } finally { if (lockFileId != null && drive != null) { try { await drive.deleteFile(lockFileId); } catch (_) { // Best-effort: if this fails, the 10-minute staleness reap on // other devices' next sync attempt will clean it up. } } client?.close(); } } Future> _uploadPendingReceiptsAndMerge({ required DriveService drive, required List remoteFuelEntries, required List localFuelEntries, }) async { final merged = mergeById( remoteFuelEntries, localFuelEntries, (e) => e.id, (e) => e.updatedAt, ); final result = []; for (final entry in merged) { if (!entry.hasPendingLocalReceipt) { result.add(entry); continue; } final localPath = entry.receiptImagePath!; final localFile = File(localPath); if (!await localFile.exists()) { result.add(entry); 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)); } return result; } Future _acquireLock( DriveService drive, { required String appFolderId, required String username, }) { return lock.acquireLock( username: username, createLock: (name) => drive.createLockFile(appFolderId: appFolderId, name: name), listLocks: () => drive.listLockFiles(appFolderId), deleteLock: drive.deleteFile, ); } }