import 'dart:io'; import 'package:googleapis/drive/v3.dart' as drive; import 'package:http/http.dart' as http; const appFolderName = 'MO-Fuel-Tax-Back'; const receiptsFolderName = 'receipts'; const dataFileName = 'fuel_tax_tracker.db'; const _folderMimeType = 'application/vnd.google-apps.folder'; class DriveFolder { final String id; final String name; DriveFolder({required this.id, required this.name}); } class DriveDataFileInfo { final String id; /// Cheap change-detection signal: compare against the last value seen to /// decide whether a pull is actually needed, without downloading content. final String? md5Checksum; DriveDataFileInfo({required this.id, required this.md5Checksum}); } class DriveLockFile { final String id; final String username; final DateTime createdAtUtc; DriveLockFile({required this.id, required this.username, required this.createdAtUtc}); } /// Raw Google Drive API operations, built on top of an already-authenticated /// [http.Client] (see [DriveAuthService.authenticatedHttpClient]). Callers /// own the client's lifecycle (create it, use a [DriveService] instance for /// the duration of one sync, then close it). class DriveService { final drive.DriveApi _api; DriveService(http.Client authenticatedClient) : _api = drive.DriveApi(authenticatedClient); /// Lists folders under [parentId], or (if [sharedWithMe] is true) the /// top-level folders that other users have shared with the signed-in /// account, regardless of parent. Future> listFolders({String? parentId, bool sharedWithMe = false}) async { final query = sharedWithMe ? "sharedWithMe=true and mimeType='$_folderMimeType' and trashed=false" : "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false"; final result = await _api.files.list( q: query, orderBy: 'name', $fields: 'files(id,name)', spaces: 'drive', ); return (result.files ?? []) .where((f) => f.id != null && f.name != null) .map((f) => DriveFolder(id: f.id!, name: f.name!)) .toList(); } /// Finds a folder named [appFolderName] under [parentId], or creates one /// if none exists. Multiple devices pointed at the same shared parent /// converge on the same folder this way. If duplicates exist (Drive /// allows same-named folders), the earliest-created one wins. Future findOrCreateAppFolder(String parentId) { return _findOrCreateFolder(name: appFolderName, parentId: parentId); } Future findOrCreateReceiptsFolder(String appFolderId) { return _findOrCreateFolder(name: receiptsFolderName, parentId: appFolderId); } Future _findOrCreateFolder({required String name, required String parentId}) async { final existing = await _api.files.list( q: "'$parentId' in parents and mimeType='$_folderMimeType' and trashed=false and name='$name'", orderBy: 'createdTime', $fields: 'files(id,name)', spaces: 'drive', ); final firstMatch = (existing.files ?? []).firstOrNullWithId(); if (firstMatch != null) return firstMatch; final created = await _api.files.create( drive.File() ..name = name ..mimeType = _folderMimeType ..parents = [parentId], ); return created.id!; } /// Looks up the shared data file's ID and md5 checksum without /// downloading its content, so sync can cheaply decide whether a pull is /// actually needed. Future findDataFile(String appFolderId) async { final result = await _api.files.list( q: "'$appFolderId' in parents and trashed=false and name='$dataFileName'", orderBy: 'modifiedTime desc', $fields: 'files(id,name,md5Checksum)', spaces: 'drive', ); final files = result.files ?? []; if (files.isEmpty) return null; final first = files.first; if (first.id == null) return null; return DriveDataFileInfo(id: first.id!, md5Checksum: first.md5Checksum); } Future> downloadFileBytes(String fileId) async { final media = await _api.files.get( fileId, downloadOptions: drive.DownloadOptions.fullMedia, ) as drive.Media; return _collectBytes(media.stream); } /// Creates the data file if [existingFileId] is null, otherwise /// overwrites its content with the bytes of the local sqlite database /// file (see [DriveSyncService]). Returns the (possibly new) file ID and /// its fresh md5 checksum, so the caller can remember it for /// change-detection on the next sync without an extra round-trip. Future uploadDataFile({ required String appFolderId, required String? existingFileId, required File localSnapshotFile, }) async { final length = await localSnapshotFile.length(); final media = drive.Media( localSnapshotFile.openRead(), length, contentType: 'application/x-sqlite3', ); if (existingFileId != null) { final updated = await _api.files.update( drive.File(), existingFileId, uploadMedia: media, $fields: 'id,md5Checksum', ); return DriveDataFileInfo(id: updated.id ?? existingFileId, md5Checksum: updated.md5Checksum); } final created = await _api.files.create( drive.File() ..name = dataFileName ..parents = [appFolderId], uploadMedia: media, $fields: 'id,md5Checksum', ); return DriveDataFileInfo(id: created.id!, md5Checksum: created.md5Checksum); } Future uploadReceiptImage({ required String receiptsFolderId, required String fileName, required File imageFile, }) async { final length = await imageFile.length(); final media = drive.Media( imageFile.openRead(), length, contentType: 'image/jpeg', ); final created = await _api.files.create( drive.File() ..name = fileName ..parents = [receiptsFolderId], uploadMedia: media, ); return created.id!; } Future deleteFile(String fileId) async { try { await _api.files.delete(fileId); } on drive.DetailedApiRequestError catch (e) { // Already gone (e.g. deleted by another device) — not an error for // our purposes. if (e.status != 404) rethrow; } } Future createLockFile({required String appFolderId, required String name}) async { final created = await _api.files.create( drive.File() ..name = name ..parents = [appFolderId], uploadMedia: drive.Media(Stream.value(const []), 0, contentType: 'text/plain'), ); return created.id!; } Future> listLockFiles(String appFolderId) async { final result = await _api.files.list( q: "'$appFolderId' in parents and trashed=false and name contains '.lock'", $fields: 'files(id,name)', spaces: 'drive', ); final locks = []; for (final f in result.files ?? []) { final parsed = _parseLockFileName(f.name); if (f.id != null && parsed != null) { locks.add(DriveLockFile(id: f.id!, username: parsed.$1, createdAtUtc: parsed.$2)); } } return locks; } static (String, DateTime)? _parseLockFileName(String? name) { if (name == null || !name.endsWith('.lock')) return null; final withoutExt = name.substring(0, name.length - '.lock'.length); final lastDash = withoutExt.lastIndexOf('-'); if (lastDash == -1) return null; final username = withoutExt.substring(0, lastDash); final epochStr = withoutExt.substring(lastDash + 1); final epoch = int.tryParse(epochStr); if (epoch == null) return null; return (username, DateTime.fromMillisecondsSinceEpoch(epoch, isUtc: true)); } Future> _collectBytes(Stream> stream) async { final bytes = []; await for (final chunk in stream) { bytes.addAll(chunk); } return bytes; } } extension _FirstMatchExtension on List { String? firstOrNullWithId() => isEmpty ? null : first.id; }