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 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? _sheetRoute; @override void didChangeDependencies() { super.didChangeDependencies(); _sheetRoute = ModalRoute.of(context); } @override Widget build(BuildContext context) => ValueListenableBuilder>( 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, ); } }