import 'dart:async'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/foundation.dart'; import '../core/app_config.dart'; import '../services/token_store.dart'; class UploadRecord { const UploadRecord({ required this.taskId, required this.name, required this.filename, required this.createdAt, this.progress = 0, this.status = TaskStatus.enqueued, this.sizeBytes = -1, this.errorMessage, }); final String taskId; final String name; final String filename; final DateTime createdAt; final double progress; final TaskStatus status; final int sizeBytes; final String? errorMessage; bool get isFinished => status.isFinalState; bool get canRetry => status == TaskStatus.failed; UploadRecord copyWith({ double? progress, TaskStatus? status, int? sizeBytes, String? errorMessage, }) => UploadRecord( taskId: taskId, name: name, filename: filename, createdAt: createdAt, progress: progress ?? this.progress, status: status ?? this.status, sizeBytes: sizeBytes ?? this.sizeBytes, errorMessage: errorMessage ?? this.errorMessage, ); } class UploadController extends ChangeNotifier { UploadController(this._tokens) { _taskSubscription = FileDownloader().updates.listen(_onUpdate); _databaseSubscription = FileDownloader().database.updates.listen( _onDatabaseUpdate, ); unawaited(_restoreHistory()); } final TokenStore _tokens; late final StreamSubscription _taskSubscription; late final StreamSubscription _databaseSubscription; final Map _uploads = {}; final Map _tasks = {}; bool _notificationPermissionChecked = false; bool _disposed = false; List get uploads { final result = _uploads.values.toList() ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); return result; } int completedVersion = 0; Future _restoreHistory() async { // start() initializes tracking asynchronously during application startup. // A short retry also covers slower devices on their first launch. for (var attempt = 0; attempt < 3; attempt++) { try { final records = await FileDownloader().database.allRecords( group: 'uploads', ); for (final record in records) { _storeDatabaseRecord(record); } if (!_disposed) notifyListeners(); return; } catch (_) { await Future.delayed(Duration(milliseconds: 400 * (attempt + 1))); } } } Future refreshHistory() => _restoreHistory(); Future enqueue({ required String filename, required String displayName, String? folderId, }) async { await _ensureNotificationPermission(); final token = await _tokens.read(); if (token == null) throw StateError('Your session has expired'); final task = UploadTask( url: '${AppConfig.apiBaseUrl}/files/upload', filename: filename, directory: 'upload_queue', baseDirectory: BaseDirectory.applicationSupport, fileField: 'file', fields: folderId == null ? const {} : {'folderId': folderId}, headers: {'Authorization': 'Bearer $token'}, group: 'uploads', updates: Updates.statusAndProgress, retries: 2, displayName: displayName, metaData: displayName, ); _tasks[task.taskId] = task; _uploads[task.taskId] = UploadRecord( taskId: task.taskId, name: displayName, filename: filename, createdAt: task.creationTime, ); notifyListeners(); final accepted = await FileDownloader().enqueue(task); if (!accepted) { _uploads[task.taskId] = _uploads[task.taskId]!.copyWith( status: TaskStatus.failed, errorMessage: 'The background upload queue rejected this file.', ); notifyListeners(); throw StateError('The background upload queue rejected this file'); } } Future _ensureNotificationPermission() async { if (_notificationPermissionChecked) return; _notificationPermissionChecked = true; final status = await FileDownloader().permissions.status( PermissionType.notifications, ); if (status != PermissionStatus.granted) { await FileDownloader().permissions.request(PermissionType.notifications); } } void _onUpdate(TaskUpdate update) { _tasks[update.task.taskId] = update.task; final current = _uploads[update.task.taskId] ?? _fromTask(update.task); switch (update) { case TaskProgressUpdate(): _uploads[update.task.taskId] = current.copyWith( progress: update.progress.clamp(0, 1), ); case TaskStatusUpdate(): _uploads[update.task.taskId] = current.copyWith( status: update.status, progress: update.status == TaskStatus.complete ? 1 : current.progress, errorMessage: update.exception?.description, ); if (update.status == TaskStatus.complete) completedVersion++; if (update.status == TaskStatus.complete || update.status == TaskStatus.canceled || update.status == TaskStatus.notFound) { unawaited(_deleteStagedFile(update.task)); } } notifyListeners(); } void _onDatabaseUpdate(TaskRecord record) { if (record.group != 'uploads') return; _storeDatabaseRecord(record); if (!_disposed) notifyListeners(); } void _storeDatabaseRecord(TaskRecord record) { _tasks[record.taskId] = record.task; _uploads[record.taskId] = UploadRecord( taskId: record.taskId, name: record.task.displayName, filename: record.task.filename, createdAt: record.task.creationTime, progress: record.progress.clamp(0, 1), status: record.status, sizeBytes: record.expectedFileSize, errorMessage: record.exception?.description, ); } UploadRecord _fromTask(Task task) => UploadRecord( taskId: task.taskId, name: task.displayName, filename: task.filename, createdAt: task.creationTime, ); Future _deleteStagedFile(Task task) async { final file = File(await task.filePath()); if (await file.exists()) await file.delete(); } Future cancel(String taskId) => FileDownloader().cancelTaskWithId(taskId); Future retry(String taskId) async { final original = _tasks[taskId]; if (original is! UploadTask) { throw StateError('The original upload information is unavailable.'); } final file = File(await original.filePath()); if (!await file.exists()) { throw StateError( 'The staged file is no longer available. Select it again.', ); } final token = await _tokens.read(); if (token == null) throw StateError('Your session has expired'); final retried = original.copyWith( taskId: DateTime.now().microsecondsSinceEpoch.toString(), headers: {'Authorization': 'Bearer $token'}, creationTime: DateTime.now(), retriesRemaining: original.retries, ); _tasks[retried.taskId] = retried; _uploads[retried.taskId] = _fromTask(retried); notifyListeners(); if (!await FileDownloader().enqueue(retried)) { _uploads[retried.taskId] = _uploads[retried.taskId]!.copyWith( status: TaskStatus.failed, errorMessage: 'The background upload queue rejected this retry.', ); notifyListeners(); throw StateError('The background upload queue rejected this retry.'); } } Future removeHistory(String taskId) async { final task = _tasks.remove(taskId); _uploads.remove(taskId); await FileDownloader().database.deleteRecordWithId(taskId); if (task != null && task.group == 'uploads') { await _deleteStagedFile(task); } notifyListeners(); } Future clearFinished() async { final finished = _uploads.values.where((item) => item.isFinished).toList(); for (final item in finished) { await removeHistory(item.taskId); } } @override void dispose() { _disposed = true; _taskSubscription.cancel(); _databaseSubscription.cancel(); super.dispose(); } }