File-Drive-App-Frontend/lib/controllers/upload_controller.dart

265 lines
8.1 KiB
Dart

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<TaskUpdate> _taskSubscription;
late final StreamSubscription<TaskRecord> _databaseSubscription;
final Map<String, UploadRecord> _uploads = {};
final Map<String, Task> _tasks = {};
bool _notificationPermissionChecked = false;
bool _disposed = false;
List<UploadRecord> get uploads {
final result = _uploads.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return result;
}
int completedVersion = 0;
Future<void> _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<void>.delayed(Duration(milliseconds: 400 * (attempt + 1)));
}
}
}
Future<void> refreshHistory() => _restoreHistory();
Future<void> 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<void> _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<void> _deleteStagedFile(Task task) async {
final file = File(await task.filePath());
if (await file.exists()) await file.delete();
}
Future<void> cancel(String taskId) =>
FileDownloader().cancelTaskWithId(taskId);
Future<void> 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<void> 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<void> 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();
}
}