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