added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry
This commit is contained in:
@@ -1,151 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../api/marianumcloud/webdav/webdav_api.dart';
|
||||
import '../model/account_data.dart';
|
||||
import 'file_downloader.dart';
|
||||
|
||||
/// Snapshot of a single download's lifecycle. UI widgets rebuild whenever the
|
||||
/// owning [DownloadJob.status] notifier emits a new instance.
|
||||
sealed class DownloadStatus {
|
||||
const DownloadStatus();
|
||||
}
|
||||
|
||||
class DownloadInProgress extends DownloadStatus {
|
||||
const DownloadInProgress(this.percent);
|
||||
final double percent;
|
||||
}
|
||||
|
||||
class DownloadDone extends DownloadStatus {
|
||||
const DownloadDone(this.localPath);
|
||||
final String localPath;
|
||||
}
|
||||
|
||||
class DownloadCancelled extends DownloadStatus {
|
||||
const DownloadCancelled();
|
||||
}
|
||||
|
||||
class DownloadFailed extends DownloadStatus {
|
||||
const DownloadFailed(this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// Tracks a single in-flight or finished download. Survives widget dispose so
|
||||
/// that re-entering the screen reattaches to the same job.
|
||||
class DownloadJob {
|
||||
DownloadJob({
|
||||
required this.remotePath,
|
||||
required this.name,
|
||||
required this.localPath,
|
||||
required FileDownloader downloader,
|
||||
}) : _downloader = downloader;
|
||||
|
||||
final String remotePath;
|
||||
final String name;
|
||||
final String localPath;
|
||||
final FileDownloader _downloader;
|
||||
|
||||
final ValueNotifier<DownloadStatus> status = ValueNotifier(
|
||||
const DownloadInProgress(0),
|
||||
);
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isFinished =>
|
||||
status.value is DownloadDone ||
|
||||
status.value is DownloadFailed ||
|
||||
status.value is DownloadCancelled;
|
||||
|
||||
void cancel() {
|
||||
if (isFinished) return;
|
||||
_downloader.cancel();
|
||||
status.value = const DownloadCancelled();
|
||||
}
|
||||
|
||||
void _dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
status.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Central in-memory registry for downloads. Keyed by remote path so a file
|
||||
/// triggered from multiple screens (Files + Chat) reuses one job.
|
||||
///
|
||||
/// Not persistent across app restarts — restarting abandons in-flight
|
||||
/// downloads and partial files are cleaned on next start attempt.
|
||||
class DownloadManager {
|
||||
DownloadManager._();
|
||||
static final DownloadManager instance = DownloadManager._();
|
||||
|
||||
final Map<String, DownloadJob> _jobs = {};
|
||||
|
||||
/// Active or recently finished job for [remotePath], or null if none.
|
||||
DownloadJob? jobFor(String remotePath) => _jobs[remotePath];
|
||||
|
||||
/// Returns the existing job if a download is in progress for [remotePath],
|
||||
/// otherwise starts a new one. Caller listens on [DownloadJob.status].
|
||||
Future<DownloadJob> start({
|
||||
required String remotePath,
|
||||
required String name,
|
||||
}) async {
|
||||
final existing = _jobs[remotePath];
|
||||
if (existing != null && !existing.isFinished) return existing;
|
||||
if (existing != null) {
|
||||
_jobs.remove(remotePath);
|
||||
scheduleMicrotask(existing._dispose);
|
||||
}
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/');
|
||||
final localPath = '${tempDir.path}${Platform.pathSeparator}$name';
|
||||
|
||||
final downloader = FileDownloader();
|
||||
final job = DownloadJob(
|
||||
remotePath: remotePath,
|
||||
name: name,
|
||||
localPath: localPath,
|
||||
downloader: downloader,
|
||||
);
|
||||
_jobs[remotePath] = job;
|
||||
|
||||
downloader.run(
|
||||
client: Dio(BaseOptions(headers: AccountData().authHeaders())),
|
||||
url: '${WebdavApi.buildWebdavUrl()}$encodedPath',
|
||||
savePath: localPath,
|
||||
onProgress: (percent) {
|
||||
if (job.isFinished) return;
|
||||
job.status.value = DownloadInProgress(percent);
|
||||
},
|
||||
onDone: () {
|
||||
if (job.isFinished) return;
|
||||
job.status.value = DownloadDone(localPath);
|
||||
},
|
||||
onError: (error) {
|
||||
if (job.isFinished) return;
|
||||
try {
|
||||
File(localPath).deleteSync();
|
||||
} on FileSystemException {
|
||||
// partial file may not exist — ignore
|
||||
}
|
||||
job.status.value = DownloadFailed(error.toString());
|
||||
},
|
||||
);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/// Removes a finished job from the registry. Safe to call from a status
|
||||
/// listener: actual disposal of the underlying notifier is deferred to the
|
||||
/// next microtask so the in-flight `notifyListeners` cycle can finish before
|
||||
/// the notifier is destroyed. Active (unfinished) jobs are left untouched.
|
||||
void clear(String remotePath) {
|
||||
final job = _jobs[remotePath];
|
||||
if (job == null || !job.isFinished) return;
|
||||
_jobs.remove(remotePath);
|
||||
scheduleMicrotask(job._dispose);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../share_intent/remote_file_ref.dart';
|
||||
|
||||
/// Snapshot of a single download's lifecycle. UI widgets rebuild whenever the
|
||||
/// owning [DownloadJob.status] notifier emits a new instance.
|
||||
sealed class DownloadStatus {
|
||||
const DownloadStatus();
|
||||
}
|
||||
|
||||
class DownloadInProgress extends DownloadStatus {
|
||||
const DownloadInProgress(this.percent);
|
||||
|
||||
/// 0–100, or a value `<= 0` while the total size is still unknown
|
||||
/// (indeterminate progress).
|
||||
final double percent;
|
||||
}
|
||||
|
||||
class DownloadDone extends DownloadStatus {
|
||||
const DownloadDone(this.localPath);
|
||||
final String localPath;
|
||||
}
|
||||
|
||||
class DownloadCancelled extends DownloadStatus {
|
||||
const DownloadCancelled();
|
||||
}
|
||||
|
||||
class DownloadFailed extends DownloadStatus {
|
||||
const DownloadFailed(this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// Tracks a single in-flight or finished download. Survives widget dispose so
|
||||
/// that re-entering a screen (or opening the downloads overview) reattaches to
|
||||
/// the same job. Kept engine-agnostic on purpose — the native
|
||||
/// `background_downloader` task lives entirely inside [DownloadManager].
|
||||
class DownloadJob {
|
||||
DownloadJob({
|
||||
required this.remotePath,
|
||||
required this.name,
|
||||
this.remoteFile,
|
||||
});
|
||||
|
||||
final String remotePath;
|
||||
final String name;
|
||||
|
||||
/// Server-side reference so the viewer can offer "An Chat senden" /
|
||||
/// "In Cloud speichern" for downloads triggered from Files or Talk.
|
||||
final RemoteFileRef? remoteFile;
|
||||
|
||||
/// Native task id, set by [DownloadManager] once the task is enqueued.
|
||||
String? taskId;
|
||||
|
||||
/// True once the finished file has been opened (auto-open or from the
|
||||
/// overview). Opened jobs drop out of the downloads tray.
|
||||
bool opened = false;
|
||||
|
||||
final ValueNotifier<DownloadStatus> status = ValueNotifier(
|
||||
const DownloadInProgress(0),
|
||||
);
|
||||
|
||||
bool get isFinished =>
|
||||
status.value is DownloadDone ||
|
||||
status.value is DownloadFailed ||
|
||||
status.value is DownloadCancelled;
|
||||
|
||||
bool get isDone => status.value is DownloadDone;
|
||||
bool get isFailed => status.value is DownloadFailed;
|
||||
bool get isCancelled => status.value is DownloadCancelled;
|
||||
|
||||
/// Local file path once finished, else null.
|
||||
String? get localPath {
|
||||
final value = status.value;
|
||||
return value is DownloadDone ? value.localPath : null;
|
||||
}
|
||||
|
||||
void dispose() => status.dispose();
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart' as bd;
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../api/marianumcloud/webdav/webdav_api.dart';
|
||||
import '../../model/account_data.dart';
|
||||
import '../../notification/notification_service.dart';
|
||||
import '../../share_intent/remote_file_ref.dart';
|
||||
import 'download_job.dart';
|
||||
|
||||
/// Central registry for file downloads, shared between the Files list and Talk
|
||||
/// chat bubbles. Backed by `background_downloader` so downloads run in a native
|
||||
/// foreground service (Android) / URLSession background session (iOS) and
|
||||
/// therefore keep running — with a system progress notification — while the app
|
||||
/// is backgrounded or the user works elsewhere, even for large files.
|
||||
///
|
||||
/// Jobs are keyed by remote (WebDAV) path so a file triggered from multiple
|
||||
/// screens reuses one job. The in-memory registry is not rebuilt across a full
|
||||
/// app restart; a still-running native download keeps going and its completion
|
||||
/// notification stays tappable (rebuilt on demand in [_onNotificationTap]).
|
||||
class DownloadManager {
|
||||
DownloadManager._();
|
||||
static final DownloadManager instance = DownloadManager._();
|
||||
|
||||
/// Callback group for our tasks — scopes the notification-tap callback so it
|
||||
/// only fires for downloads we started.
|
||||
static const _group = 'mm_downloads';
|
||||
|
||||
/// Files land in the app's private temp area under this subdirectory, mirror-
|
||||
/// ing the previous behaviour. Export to the device happens via the viewer's
|
||||
/// "Teilen"/"Speichern" actions.
|
||||
static const _directory = 'downloads';
|
||||
|
||||
final Map<String, DownloadJob> _jobs = {}; // keyed by remotePath
|
||||
final Map<String, DownloadJob> _byTaskId = {};
|
||||
final Map<String, bd.DownloadTask> _taskById = {};
|
||||
|
||||
/// All jobs the user should currently see in the downloads tray/overview:
|
||||
/// everything that is in progress or finished-but-not-yet-opened (failed
|
||||
/// downloads linger so the user can retry; cancelled ones drop out).
|
||||
final ValueNotifier<List<DownloadJob>> visibleJobs = ValueNotifier(const []);
|
||||
|
||||
/// Emits whenever a job reaches [DownloadDone]. The downloads tray listens to
|
||||
/// decide between auto-opening (single download) and parking it in the
|
||||
/// overview (parallel downloads).
|
||||
final StreamController<DownloadJob> _completions =
|
||||
StreamController<DownloadJob>.broadcast();
|
||||
Stream<DownloadJob> get completions => _completions.stream;
|
||||
|
||||
/// Set when the user taps a completion notification; consumed by the
|
||||
/// downloads tray to open the file (also covers cold-start taps).
|
||||
final ValueNotifier<DownloadJob?> pendingOpen = ValueNotifier(null);
|
||||
|
||||
bool _initialized = false;
|
||||
StreamSubscription<bd.TaskUpdate>? _updatesSub;
|
||||
|
||||
/// Wires up the native downloader once. Safe to call repeatedly.
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
|
||||
_updatesSub = bd.FileDownloader().updates.listen(_onUpdate);
|
||||
bd.FileDownloader().registerCallbacks(
|
||||
group: _group,
|
||||
taskNotificationTapCallback: _onNotificationTap,
|
||||
);
|
||||
// Run larger downloads in a native foreground service: this lifts the
|
||||
// default 9-minute WorkManager timeout and greatly improves the odds of a
|
||||
// big file finishing while the app sits in the background. Smaller files
|
||||
// stay on the lighter WorkManager path. Requires the running notification
|
||||
// configured below + the manifest FOREGROUND_SERVICE(_DATA_SYNC) entries.
|
||||
await bd.FileDownloader().configure(
|
||||
androidConfig: (bd.Config.runInForegroundIfFileLargerThan, 10),
|
||||
);
|
||||
|
||||
bd.FileDownloader().configureNotification(
|
||||
running: const bd.TaskNotification(
|
||||
'{filename}',
|
||||
'Wird heruntergeladen … {progress}',
|
||||
),
|
||||
complete: const bd.TaskNotification(
|
||||
'{filename}',
|
||||
'Fertig – tippen zum Öffnen',
|
||||
),
|
||||
error: const bd.TaskNotification('{filename}', 'Download fehlgeschlagen'),
|
||||
progressBar: true,
|
||||
// We route taps into the in-app viewer ourselves.
|
||||
tapOpensFile: false,
|
||||
);
|
||||
|
||||
// Notification permission is normally already granted via the FCM flow at
|
||||
// login; request best-effort so downloads on a fresh install still notify.
|
||||
try {
|
||||
await bd.FileDownloader().permissions.request(
|
||||
bd.PermissionType.notifications,
|
||||
);
|
||||
} on Object catch (e) {
|
||||
debugPrint('DownloadManager: notification permission request failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Active or recently finished job for [remotePath], or null if none.
|
||||
DownloadJob? jobFor(String remotePath) => _jobs[remotePath];
|
||||
|
||||
/// Returns the existing in-flight job for [remotePath], otherwise enqueues a
|
||||
/// new one. Caller listens on [DownloadJob.status] for inline UI.
|
||||
Future<DownloadJob> start({
|
||||
required String remotePath,
|
||||
required String name,
|
||||
RemoteFileRef? remoteFile,
|
||||
}) async {
|
||||
await initialize();
|
||||
|
||||
final existing = _jobs[remotePath];
|
||||
if (existing != null && !existing.isFinished) return existing;
|
||||
if (existing != null) _remove(existing);
|
||||
|
||||
final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/');
|
||||
final task = bd.DownloadTask(
|
||||
url: '${WebdavApi.buildWebdavUrl()}$encodedPath',
|
||||
headers: AccountData().authHeaders(),
|
||||
filename: name,
|
||||
baseDirectory: bd.BaseDirectory.temporary,
|
||||
directory: _directory,
|
||||
group: _group,
|
||||
updates: bd.Updates.statusAndProgress,
|
||||
allowPause: true,
|
||||
metaData: _encodeMeta(remotePath, name, remoteFile),
|
||||
);
|
||||
|
||||
final job = DownloadJob(
|
||||
remotePath: remotePath,
|
||||
name: name,
|
||||
remoteFile: remoteFile,
|
||||
)..taskId = task.taskId;
|
||||
_jobs[remotePath] = job;
|
||||
_byTaskId[task.taskId] = job;
|
||||
_taskById[task.taskId] = task;
|
||||
_refreshVisible();
|
||||
|
||||
final ok = await bd.FileDownloader().enqueue(task);
|
||||
if (!ok && !job.isFinished) {
|
||||
_fail(job, 'Download konnte nicht gestartet werden.');
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
/// Cancels an in-flight download. The native task is cancelled and the job
|
||||
/// transitions to [DownloadCancelled] (removed from the tray).
|
||||
Future<void> cancel(DownloadJob job) async {
|
||||
final taskId = job.taskId;
|
||||
if (taskId != null) {
|
||||
await bd.FileDownloader().cancelTaskWithId(taskId);
|
||||
}
|
||||
if (!job.isFinished) job.status.value = const DownloadCancelled();
|
||||
_remove(job);
|
||||
}
|
||||
|
||||
/// Re-enqueues a failed job under the same remote path.
|
||||
Future<DownloadJob> retry(DownloadJob job) {
|
||||
_remove(job);
|
||||
return start(
|
||||
remotePath: job.remotePath,
|
||||
name: job.name,
|
||||
remoteFile: job.remoteFile,
|
||||
);
|
||||
}
|
||||
|
||||
/// Marks a finished job as opened and drops it from the tray. Also dismisses
|
||||
/// the lingering "download complete" system notification for that file.
|
||||
void markOpened(DownloadJob job) {
|
||||
job.opened = true;
|
||||
_dismissNotification(job);
|
||||
_remove(job);
|
||||
}
|
||||
|
||||
/// Cancels the task's completion notification. background_downloader derives
|
||||
/// its Android notification id from the Java `String.hashCode()` of the task
|
||||
/// id, which we reproduce to target the exact notification. Android-only —
|
||||
/// iOS uses a different identifier scheme.
|
||||
void _dismissNotification(DownloadJob job) {
|
||||
final taskId = job.taskId;
|
||||
if (taskId == null || !Platform.isAndroid) return;
|
||||
final id = _androidNotificationId(taskId);
|
||||
unawaited(
|
||||
NotificationService().flutterLocalNotificationsPlugin
|
||||
.cancel(id: id)
|
||||
.catchError((Object _) {}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Java/Kotlin `String.hashCode()` (32-bit signed) — matches the id
|
||||
/// background_downloader posts its notification under.
|
||||
int _androidNotificationId(String taskId) {
|
||||
var hash = 0;
|
||||
for (final unit in taskId.codeUnits) {
|
||||
hash = (31 * hash + unit) & 0xFFFFFFFF;
|
||||
}
|
||||
if (hash >= 0x80000000) hash -= 0x100000000;
|
||||
return hash;
|
||||
}
|
||||
|
||||
/// Removes a finished job from the tray without opening (swipe-to-dismiss).
|
||||
void dismiss(DownloadJob job) => _remove(job);
|
||||
|
||||
/// Clears the whole tray at once (swipe the chip away): cancels anything still
|
||||
/// running and drops every job. The registry empties synchronously so the UI
|
||||
/// updates immediately; native cancellations fire in the background.
|
||||
void clearAll() {
|
||||
for (final job in _jobs.values.toList()) {
|
||||
final taskId = job.taskId;
|
||||
if (!job.isFinished) {
|
||||
if (taskId != null) {
|
||||
unawaited(bd.FileDownloader().cancelTaskWithId(taskId));
|
||||
}
|
||||
// Notify inline listeners (list/bubble) so their progress bars clear
|
||||
// instead of freezing at the last percent.
|
||||
job.status.value = const DownloadCancelled();
|
||||
}
|
||||
_jobs.remove(job.remotePath);
|
||||
if (taskId != null) {
|
||||
_byTaskId.remove(taskId);
|
||||
_taskById.remove(taskId);
|
||||
}
|
||||
scheduleMicrotask(job.dispose);
|
||||
}
|
||||
_refreshVisible();
|
||||
}
|
||||
|
||||
// --- native update handling ------------------------------------------------
|
||||
|
||||
void _onUpdate(bd.TaskUpdate update) {
|
||||
final job = _byTaskId[update.task.taskId];
|
||||
if (job == null || job.isFinished) return;
|
||||
|
||||
switch (update) {
|
||||
case bd.TaskStatusUpdate():
|
||||
switch (update.status) {
|
||||
case bd.TaskStatus.complete:
|
||||
unawaited(_complete(job, update.task));
|
||||
case bd.TaskStatus.failed:
|
||||
case bd.TaskStatus.notFound:
|
||||
_fail(
|
||||
job,
|
||||
update.exception?.description ??
|
||||
(update.status == bd.TaskStatus.notFound
|
||||
? 'Datei auf dem Server nicht gefunden.'
|
||||
: 'Download fehlgeschlagen.'),
|
||||
);
|
||||
case bd.TaskStatus.canceled:
|
||||
job.status.value = const DownloadCancelled();
|
||||
_remove(job);
|
||||
case bd.TaskStatus.enqueued:
|
||||
case bd.TaskStatus.running:
|
||||
case bd.TaskStatus.waitingToRetry:
|
||||
case bd.TaskStatus.paused:
|
||||
break; // progress is delivered separately
|
||||
}
|
||||
case bd.TaskProgressUpdate():
|
||||
// Negative values are sentinels (failed/canceled/…) handled via status.
|
||||
final progress = update.progress;
|
||||
job.status.value = DownloadInProgress(
|
||||
progress >= 0 && progress <= 1 ? progress * 100 : 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _complete(DownloadJob job, bd.Task task) async {
|
||||
if (job.isFinished) return;
|
||||
final path = await task.filePath();
|
||||
if (job.isFinished) return;
|
||||
job.status.value = DownloadDone(path);
|
||||
_refreshVisible();
|
||||
_completions.add(job);
|
||||
}
|
||||
|
||||
void _fail(DownloadJob job, String message) {
|
||||
if (job.isFinished) return;
|
||||
job.status.value = DownloadFailed(message);
|
||||
_refreshVisible();
|
||||
}
|
||||
|
||||
Future<void> _onNotificationTap(
|
||||
bd.Task task,
|
||||
bd.NotificationType notificationType,
|
||||
) async {
|
||||
if (notificationType != bd.NotificationType.complete) return;
|
||||
final job = _byTaskId[task.taskId] ?? await _rebuildJob(task);
|
||||
if (job == null) return;
|
||||
pendingOpen.value = job;
|
||||
}
|
||||
|
||||
/// Rebuilds a finished [DownloadJob] straight from a native task — used when a
|
||||
/// completion notification is tapped after the app (and thus the in-memory
|
||||
/// registry) was gone.
|
||||
Future<DownloadJob?> _rebuildJob(bd.Task task) async {
|
||||
try {
|
||||
final meta = _decodeMeta(task.metaData);
|
||||
final path = await task.filePath();
|
||||
final job = DownloadJob(
|
||||
remotePath: meta?.remotePath ?? '',
|
||||
name: task.filename,
|
||||
remoteFile: meta?.remoteFile,
|
||||
)..taskId = task.taskId;
|
||||
job.status.value = DownloadDone(path);
|
||||
return job;
|
||||
} on Object catch (e) {
|
||||
debugPrint('DownloadManager: could not rebuild tapped task: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- bookkeeping -----------------------------------------------------------
|
||||
|
||||
void _remove(DownloadJob job) {
|
||||
_jobs.remove(job.remotePath);
|
||||
final taskId = job.taskId;
|
||||
if (taskId != null) {
|
||||
_byTaskId.remove(taskId);
|
||||
_taskById.remove(taskId);
|
||||
}
|
||||
_refreshVisible();
|
||||
scheduleMicrotask(job.dispose);
|
||||
}
|
||||
|
||||
void _refreshVisible() {
|
||||
visibleJobs.value = _jobs.values
|
||||
.where((j) => !j.opened && !j.isCancelled)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
// --- metadata (remote ref) serialisation ----------------------------------
|
||||
|
||||
String _encodeMeta(String remotePath, String name, RemoteFileRef? ref) =>
|
||||
jsonEncode({
|
||||
'remotePath': remotePath,
|
||||
'name': name,
|
||||
if (ref != null)
|
||||
'ref': {
|
||||
'path': ref.path,
|
||||
'name': ref.name,
|
||||
'fileId': ref.fileId,
|
||||
'hasPreview': ref.hasPreview,
|
||||
},
|
||||
});
|
||||
|
||||
({String remotePath, RemoteFileRef? remoteFile})? _decodeMeta(String meta) {
|
||||
if (meta.isEmpty) return null;
|
||||
final map = jsonDecode(meta) as Map<String, dynamic>;
|
||||
final ref = map['ref'] as Map<String, dynamic>?;
|
||||
return (
|
||||
remotePath: (map['remotePath'] as String?) ?? '',
|
||||
remoteFile: ref == null
|
||||
? null
|
||||
: RemoteFileRef(
|
||||
path: ref['path'] as String,
|
||||
name: ref['name'] as String,
|
||||
fileId: ref['fileId'] as int?,
|
||||
hasPreview: ref['hasPreview'] as bool?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugDispose() {
|
||||
_updatesSub?.cancel();
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Lightweight cancel handle around a single `Dio.download` call. The download
|
||||
/// itself is started by [run]; the handle returns synchronously so callers can
|
||||
/// install it into shared state before the first progress event can fire.
|
||||
class FileDownloader {
|
||||
FileDownloader();
|
||||
|
||||
final CancelToken _cancelToken = CancelToken();
|
||||
bool _cancelled = false;
|
||||
|
||||
bool get isCancelled => _cancelled;
|
||||
|
||||
void cancel() {
|
||||
if (_cancelled) return;
|
||||
_cancelled = true;
|
||||
_cancelToken.cancel('user cancelled');
|
||||
}
|
||||
|
||||
/// Kicks off the download. Returns immediately; the download progresses in
|
||||
/// the background and events are delivered via callbacks. Callbacks are not
|
||||
/// invoked once [cancel] has been called.
|
||||
void run({
|
||||
required Dio client,
|
||||
required String url,
|
||||
required String savePath,
|
||||
required void Function(double percent) onProgress,
|
||||
required void Function() onDone,
|
||||
required void Function(Object error) onError,
|
||||
}) {
|
||||
client
|
||||
.download(
|
||||
url,
|
||||
savePath,
|
||||
cancelToken: _cancelToken,
|
||||
onReceiveProgress: (received, total) {
|
||||
if (_cancelled || total <= 0) return;
|
||||
onProgress((received / total) * 100);
|
||||
},
|
||||
)
|
||||
.then((_) {
|
||||
if (_cancelled) return;
|
||||
onDone();
|
||||
})
|
||||
.catchError((Object error) {
|
||||
if (_cancelled) return;
|
||||
onError(error);
|
||||
})
|
||||
.ignore();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user