Added Google Drive storage location
This commit is contained in:
parent
e630f2a3ec
commit
73a7c93e64
22 changed files with 1690 additions and 205 deletions
204
lib/services/drive_sync_service.dart
Normal file
204
lib/services/drive_sync_service.dart
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue