added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry

This commit is contained in:
2026-07-06 15:43:17 +02:00
parent 614ab159af
commit 124b1a5177
34 changed files with 1866 additions and 409 deletions
+182
View File
@@ -0,0 +1,182 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../routing/app_routes.dart';
import '../../utils/downloads/download_job.dart';
import '../../utils/downloads/download_manager.dart';
import '../../utils/haptics.dart';
import '../../view/pages/files/data/file_type_icon.dart';
import '../centered_leading.dart';
import '../details_bottom_sheet.dart';
import '../info_dialog.dart';
/// Overview of all active and finished-but-unopened downloads. Lets the user
/// open/switch between finished files, cancel running ones and retry failures.
/// Completes when the sheet is dismissed.
Future<void> showDownloadsSheet(BuildContext context) {
final rootContext = context;
return showDetailsBottomSheet(
context,
header: const Padding(
padding: EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Text(
'Downloads',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
children: (sheetContext) => [
_DownloadsList(rootContext: rootContext, sheetContext: sheetContext),
],
);
}
class _DownloadsList extends StatefulWidget {
const _DownloadsList({required this.rootContext, required this.sheetContext});
/// Context under the navigator, used to push the viewer after the sheet closes.
final BuildContext rootContext;
final BuildContext sheetContext;
@override
State<_DownloadsList> createState() => _DownloadsListState();
}
class _DownloadsListState extends State<_DownloadsList> {
ModalRoute<dynamic>? _sheetRoute;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_sheetRoute = ModalRoute.of(context);
}
@override
Widget build(BuildContext context) =>
ValueListenableBuilder<List<DownloadJob>>(
valueListenable: DownloadManager.instance.visibleJobs,
builder: (context, jobs, _) {
if (jobs.isEmpty) {
// Nothing left to manage — close the sheet, but only if it's still
// the top route. When _open drained the tray it already popped the
// sheet and pushed the viewer; without this guard we'd pop that
// viewer straight back off.
WidgetsBinding.instance.addPostFrameCallback((_) {
final route = _sheetRoute;
if (route != null && route.isCurrent) {
Navigator.of(widget.sheetContext).pop();
}
});
return const SizedBox.shrink();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final job in jobs)
_DownloadRow(
key: ValueKey(job.remotePath),
job: job,
onOpen: () => _open(job),
),
],
);
},
);
void _open(DownloadJob job) {
final path = job.localPath;
if (path == null) return;
Haptics.success();
Navigator.of(widget.sheetContext).pop();
DownloadManager.instance.markOpened(job);
AppRoutes.openFileViewer(
widget.rootContext,
path,
remoteFile: job.remoteFile,
);
}
}
class _DownloadRow extends StatelessWidget {
const _DownloadRow({required this.job, required this.onOpen, super.key});
final DownloadJob job;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: job.status,
builder: (context, _) {
final status = job.status.value;
final theme = Theme.of(context);
final tile = ListTile(
leading: CenteredLeading(Icon(iconForFileName(job.name))),
title: Text(job.name, maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: switch (status) {
DownloadInProgress(:final percent) => Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: percent <= 0 ? null : percent / 100,
),
),
const SizedBox(width: 10),
Text('${percent.round()}%'),
],
),
DownloadDone() => const Text('Fertig tippen zum Öffnen'),
DownloadFailed() => Text(
'Fehlgeschlagen',
style: TextStyle(color: theme.colorScheme.error),
),
DownloadCancelled() => const Text('Abgebrochen'),
},
trailing: switch (status) {
DownloadInProgress() => IconButton(
icon: const Icon(Icons.close),
tooltip: 'Abbrechen',
onPressed: () =>
unawaited(DownloadManager.instance.cancel(job)),
),
DownloadFailed() => IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Erneut versuchen',
onPressed: () => unawaited(DownloadManager.instance.retry(job)),
),
DownloadDone() => const Icon(Icons.open_in_new),
DownloadCancelled() => null,
},
onTap: switch (status) {
DownloadDone() => onOpen,
DownloadFailed() => () => _showError(context, status),
_ => null,
},
);
// Finished rows can be swiped away; running ones stay put.
if (status is DownloadInProgress) return tile;
return Dismissible(
key: ValueKey('dismiss-${job.remotePath}'),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 24),
color: theme.colorScheme.errorContainer,
child: Icon(Icons.delete_outline, color: theme.colorScheme.onErrorContainer),
),
onDismissed: (_) => DownloadManager.instance.dismiss(job),
child: tile,
);
},
);
void _showError(BuildContext context, DownloadFailed status) {
InfoDialog.show(
context,
status.message,
title: 'Download fehlgeschlagen',
copyable: true,
);
}
}