MO-Fuel-Tax-Back/lib/services/cloud/cloud_storage_provider.dart

153 lines
6 KiB
Dart

import 'dart:io';
/// Shared naming convention across all providers: whichever cloud storage
/// backend is active, the app looks for (or creates) a folder with this
/// exact name wherever the user points it, so two devices pointed at the
/// same shared parent location converge on the same data regardless of
/// which provider they're using.
const appFolderName = 'Show Me The Fuel Refund';
const receiptsFolderName = 'receipts';
const dataFileName = 'fuel_tax_tracker.db';
enum CloudProviderId { googleDrive, dropbox, oneDrive, webdav }
class CloudFolder {
final String id;
final String name;
CloudFolder({required this.id, required this.name});
}
class CloudFileInfo {
final String id;
/// Opaque change-detection signal — Drive's md5Checksum, Dropbox's
/// content_hash, OneDrive's cTag all satisfy "did this change since I
/// last looked", which is the only thing callers need from it.
final String? versionTag;
CloudFileInfo({required this.id, required this.versionTag});
}
class CloudLockFile {
final String id;
final String username;
final DateTime createdAtUtc;
CloudLockFile({required this.id, required this.username, required this.createdAtUtc});
}
/// Thrown when an operation needs authorization that isn't currently
/// available without prompting the user, e.g. during a background sync
/// with an expired/revoked token.
class CloudNotAuthorizedException implements Exception {
final String providerName;
CloudNotAuthorizedException(this.providerName);
@override
String toString() => '$providerName access is not currently authorized.';
}
/// One connected cloud storage backend (Google Drive, Dropbox, OneDrive).
/// Owns account-level identity/auth; per-sync operations go through a
/// [CloudStorageSession] obtained via [beginSession].
abstract class CloudStorageProvider {
CloudProviderId get id;
String get displayName;
bool get isSignedIn;
String? get accountLabel;
/// Attempts to restore a previous sign-in without any UI. Returns true
/// if signed in and authorized.
Future<bool> attemptSilentSignIn();
/// Interactive sign-in. Must be called from a user-initiated action
/// (e.g. a button press). Returns a label to display (email/username).
Future<String> signIn();
Future<void> signOut();
/// Starts one session's worth of operations (roughly: one sync round,
/// or one folder-browsing screen visit). The caller owns its lifecycle —
/// call [CloudStorageSession.close] when done with it.
CloudStorageSession beginSession();
}
/// Raw operations against one cloud storage backend, scoped to a single
/// authenticated session (e.g. one HTTP client). `folderId`/`fileId` are
/// opaque per-provider — for most providers a real ID, but for a
/// path-addressed API (Dropbox) a session may internally treat the path
/// itself as the "id". Callers never need to know which.
abstract class CloudStorageSession {
/// Lists folders under [parentId], or (if [sharedWithMe] is true and the
/// provider supports it) top-level folders shared with the signed-in
/// account regardless of parent. Providers that don't have a meaningful
/// separate "shared with me" concept may just ignore [sharedWithMe] and
/// always list under [parentId].
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false});
/// True if this provider has a distinct "Shared with me" browsing mode
/// worth showing as a separate tab in the folder picker UI.
bool get supportsSharedWithMe;
/// Finds a folder named [name] directly under [parentId], or creates one
/// if none exists. If duplicates exist, the earliest-created one wins.
Future<String> findOrCreateFolder({required String parentId, required String name});
/// Looks up a file's ID + versionTag by name within [folderId] without
/// downloading its content, or null if no such file exists yet.
Future<CloudFileInfo?> findFile({required String folderId, required String name});
Future<List<int>> downloadFileBytes(String fileId);
/// Creates the file if [existingFileId] is null, otherwise overwrites
/// its content. Returns the (possibly new) file ID and fresh versionTag.
Future<CloudFileInfo> uploadFile({
required String folderId,
required String name,
String? existingFileId,
required File localFile,
required String contentType,
});
Future<void> deleteFile(String fileId);
Future<String> createLockFile({required String folderId, required String name});
Future<List<CloudLockFile>> listLockFiles(String folderId);
/// Releases any resources (e.g. closes an underlying HTTP client).
void close();
}
/// Implemented by providers that need the user to type in connection
/// details (server URL, username, password) instead of completing an
/// OAuth browser flow — namely a self-hosted WebDAV server, which has no
/// central authorization server to redirect to. The Settings screen checks
/// `provider is ManualCredentialCloudStorageProvider` to decide whether
/// "Connect" opens a small credentials form instead of calling
/// [CloudStorageProvider.signIn] directly.
abstract class ManualCredentialCloudStorageProvider {
Future<String> signInWithCredentials({
required String serverUrl,
required String username,
required String password,
});
}
/// Parses a lock file named `{username}-{utcEpochMillis}.lock` — shared by
/// every provider's [CloudStorageSession.listLockFiles] implementation
/// rather than duplicated, since the lock file naming convention itself
/// (owned by `lock_coordinator.dart`) is provider-agnostic.
(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));
}