204 lines
5.8 KiB
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,
|
|
);
|
|
}
|
|
}
|