import 'dart:convert'; 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_data.json'; const _folderMimeType = 'application/vnd.google-apps.folder'; class DriveFolder { final String id; final String name; DriveFolder({required this.id, required this.name}); } 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!; } Future findDataFileId(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)', spaces: 'drive', ); return (result.files ?? []).firstOrNullWithId(); } Future downloadTextFile(String fileId) async { final media = await _api.files.get( fileId, downloadOptions: drive.DownloadOptions.fullMedia, ) as drive.Media; final bytes = await _collectBytes(media.stream); return utf8.decode(bytes); } 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. Returns the (possibly new) file ID. Future uploadDataFile({ required String appFolderId, required String? existingFileId, required String jsonContent, }) async { final bytes = utf8.encode(jsonContent); final media = drive.Media( Stream.value(bytes), bytes.length, contentType: 'application/json', ); if (existingFileId != null) { final updated = await _api.files.update( drive.File(), existingFileId, uploadMedia: media, ); return updated.id ?? existingFileId; } final created = await _api.files.create( drive.File() ..name = dataFileName ..parents = [appFolderId], uploadMedia: media, ); return created.id!; } 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; }