Added Google Drive storage location

This commit is contained in:
Courtney Arnold 2026-08-08 14:59:18 -05:00
parent e630f2a3ec
commit 73a7c93e64
22 changed files with 1690 additions and 205 deletions

View file

@ -0,0 +1,229 @@
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<List<DriveFolder>> 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<String> findOrCreateAppFolder(String parentId) {
return _findOrCreateFolder(name: appFolderName, parentId: parentId);
}
Future<String> findOrCreateReceiptsFolder(String appFolderId) {
return _findOrCreateFolder(name: receiptsFolderName, parentId: appFolderId);
}
Future<String> _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<String?> 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<String> 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<List<int>> 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<String> 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<String> 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<void> 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<String> 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<List<DriveLockFile>> 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 = <DriveLockFile>[];
for (final f in result.files ?? <drive.File>[]) {
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<List<int>> _collectBytes(Stream<List<int>> stream) async {
final bytes = <int>[];
await for (final chunk in stream) {
bytes.addAll(chunk);
}
return bytes;
}
}
extension _FirstMatchExtension on List<drive.File> {
String? firstOrNullWithId() => isEmpty ? null : first.id;
}