187 lines
6.0 KiB
Dart
187 lines
6.0 KiB
Dart
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';
|
||
import 'stale_download_guard.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;
|
||
// Pop the sheet first so a stale-file dialog lands on the underlying
|
||
// screen instead of an emptied sheet.
|
||
Navigator.of(widget.sheetContext).pop();
|
||
if (!ensureDownloadStillExists(widget.rootContext, job)) return;
|
||
Haptics.success();
|
||
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,
|
||
);
|
||
}
|
||
}
|