MO-Fuel-Tax-Back/lib/services/drive_sync_service.dart

204 lines
5.8 KiB
Dart

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<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})
: 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<String> 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<SyncResult> syncNow({
required List<Vehicle> localVehicles,
required List<FuelEntry> 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 = <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 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<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,
);
final result = <FuelEntry>[];
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<String> _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,
);
}
}