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 'downloads_sheet.dart'; import 'stale_download_guard.dart'; /// Decides whether a just-finished download should open straight in the viewer. /// /// Auto-open only the lone, foreground case; any parallelism (past or present) /// or a backgrounded app parks the download in the tray instead so it never /// steals focus. Pure so it can be unit-tested. bool shouldAutoOpenCompletion({ required bool foreground, required bool suppressAutoOpen, required bool completedIsSoleVisibleJob, required bool onOriginScreen, }) => foreground && !suppressAutoOpen && completedIsSoleVisibleJob && onOriginScreen; /// Whether the downloads chip should be visible. Hidden while the overview sheet /// is open (so it can't stack sheets) and for a single in-progress download the /// user is still watching inline on its originating screen. Shown for parallel /// downloads, or a lone one that is finished/failed or whose screen was left. /// Pure so it can be unit-tested. bool shouldShowDownloadChip({ required bool sheetOpen, required int jobCount, required bool anySurfaced, }) { if (sheetOpen || jobCount == 0) return false; if (jobCount >= 2) return true; return anySurfaced; } /// Height of a screen-specific bottom bar the chip must float above (e.g. the /// chat message input). Screens publish their bar height here while active and /// reset it to 0 on dispose; 0 means "nothing extra to clear". final ValueNotifier downloadChipBottomObstruction = ValueNotifier(0); /// Bumps [epoch] on every full-page navigation (ignoring dialogs/sheets/popups) /// so the downloads tray can tell whether the user has left the screen a /// download was started on. class DownloadRouteObserver extends NavigatorObserver { static final ValueNotifier epoch = ValueNotifier(0); static void _bump(Route? route) { if (route is PageRoute) epoch.value++; } @override void didPush(Route route, Route? previousRoute) => _bump(route); @override void didPop(Route route, Route? previousRoute) => _bump(route); @override void didReplace({Route? newRoute, Route? oldRoute}) => _bump(newRoute); @override void didRemove(Route route, Route? previousRoute) => _bump(route); } /// Wraps the app's navigator and floats the downloads tray above every route. /// /// Mounted in `MaterialApp.builder`, ABOVE the navigator, so the chip is never /// covered by a full-page push (folder views, chat, viewer). Navigation uses /// [AppRoutes.overlayContext] (a descendant of the root navigator) rather than /// this widget's own context, which sits above the navigator. /// /// Also the completion coordinator: a lone foreground download opens straight in /// the viewer; parallel downloads park in the tray so nothing steals focus. class DownloadTrayHost extends StatefulWidget { const DownloadTrayHost({required this.child, super.key}); final Widget child; @override State createState() => _DownloadTrayHostState(); } class _DownloadTrayHostState extends State with WidgetsBindingObserver { DownloadManager get _manager => DownloadManager.instance; StreamSubscription? _completionSub; /// Once any parallelism is seen, auto-open is suppressed until the tray /// drains — so a burst of downloads never surprises the user by opening one. bool _suppressAutoOpen = false; bool _isForeground = true; /// True while the downloads overview sheet is open — the chip hides so it /// can't be tapped again (which would stack sheets). bool _sheetOpen = false; /// Route epoch at which each visible job was started, so the chip only /// surfaces a lone download once the user has navigated away from it. final Map _originEpoch = {}; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _isForeground = WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed; unawaited(_manager.initialize()); _completionSub = _manager.completions.listen(_onCompleted); _manager.visibleJobs.addListener(_onVisibleChanged); _manager.pendingOpen.addListener(_onPendingOpen); _onVisibleChanged(); // A completion notification tapped during cold start may already be queued. WidgetsBinding.instance.addPostFrameCallback((_) => _onPendingOpen()); } @override void dispose() { _completionSub?.cancel(); _manager.visibleJobs.removeListener(_onVisibleChanged); _manager.pendingOpen.removeListener(_onPendingOpen); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { _isForeground = state == AppLifecycleState.resumed; } void _onVisibleChanged() { final jobs = _manager.visibleJobs.value; if (jobs.length >= 2) { _suppressAutoOpen = true; } else if (jobs.isEmpty) { _suppressAutoOpen = false; } // Stamp new jobs with the current route epoch; forget vanished ones. for (final job in jobs) { _originEpoch.putIfAbsent(job, () => DownloadRouteObserver.epoch.value); } _originEpoch.removeWhere((job, _) => !jobs.contains(job)); } /// The chip only shows when there's something the inline UI can't already /// convey: multiple downloads, a finished/failed one (no inline progress /// left), or a lone download whose originating screen the user has left. bool _shouldShowChip(List jobs, int epoch) => shouldShowDownloadChip( sheetOpen: _sheetOpen, jobCount: jobs.length, anySurfaced: jobs.any( (j) => j.isDone || j.isFailed || (_originEpoch[j] ?? epoch) != epoch, ), ); Future _openSheet() async { if (_sheetOpen) return; final ctx = AppRoutes.overlayContext; if (ctx == null) return; setState(() => _sheetOpen = true); await showDownloadsSheet(ctx); if (mounted) setState(() => _sheetOpen = false); } void _onCompleted(DownloadJob job) { final visible = _manager.visibleJobs.value; final sole = visible.length == 1 && identical(visible.first, job); final epoch = DownloadRouteObserver.epoch.value; final onOriginScreen = (_originEpoch[job] ?? epoch) == epoch; if (shouldAutoOpenCompletion( foreground: _isForeground, suppressAutoOpen: _suppressAutoOpen, completedIsSoleVisibleJob: sole, onOriginScreen: onOriginScreen, )) { _openJob(job); } // Otherwise it stays in the tray; the chip + its own notification let the // user open it whenever they like. } void _onPendingOpen() { final job = _manager.pendingOpen.value; if (job == null) return; _manager.pendingOpen.value = null; _openJob(job); } void _openJob(DownloadJob job) { final path = job.localPath; final ctx = AppRoutes.overlayContext; if (path == null || ctx == null) { _manager.markOpened(job); return; } if (!ensureDownloadStillExists(ctx, job)) return; _manager.markOpened(job); Haptics.success(); AppRoutes.openFileViewer(ctx, path, remoteFile: job.remoteFile); } /// Distance from the bottom to float the chip. Anchored bottom-LEFT (FABs are /// always bottom-right, so no collision), it clears the bottom nav bar (on /// tab-root screens) plus any screen-specific bottom bar such as the chat /// message input (via [downloadChipBottomObstruction]). double _bottomInset(BuildContext context) { final atRoot = !(AppRoutes.rootNavigatorKey.currentState?.canPop() ?? false); return MediaQuery.paddingOf(context).bottom + (atRoot ? 72 : 16) + downloadChipBottomObstruction.value; } @override Widget build(BuildContext context) => Stack( children: [ Positioned.fill(child: widget.child), // Rebuilds on navigation and when a screen's bottom bar height changes so // the height re-adapts; AnimatedPositioned glides the chip between the // heights instead of jumping. AnimatedBuilder( animation: Listenable.merge([ DownloadRouteObserver.epoch, downloadChipBottomObstruction, ]), builder: (context, _) => AnimatedPositioned( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, left: 16, right: 16, bottom: _bottomInset(context), child: Align( alignment: Alignment.bottomLeft, child: ValueListenableBuilder>( valueListenable: _manager.visibleJobs, builder: (context, jobs, _) { if (!_shouldShowChip(jobs, DownloadRouteObserver.epoch.value)) { return const SizedBox.shrink(); } // Swipe left to clear the whole tray away quickly. return Dismissible( key: const ValueKey('download-tray-chip'), direction: DismissDirection.endToStart, resizeDuration: null, onDismissed: (_) => _manager.clearAll(), child: _TrayChip(jobs: jobs, onTap: _openSheet), ); }, ), ), ), ), ], ); } /// The pill itself. Listens to every visible job's status so its aggregate /// progress updates live (the visible-list notifier only fires on add/remove). class _TrayChip extends StatelessWidget { const _TrayChip({required this.jobs, required this.onTap}); final List jobs; final VoidCallback onTap; @override Widget build(BuildContext context) => AnimatedBuilder( animation: Listenable.merge([for (final j in jobs) j.status]), builder: (context, _) { final active = jobs .where((j) => j.status.value is DownloadInProgress) .toList(growable: false); final doneCount = jobs.where((j) => j.isDone).length; final failedCount = jobs.where((j) => j.isFailed).length; final theme = Theme.of(context); final colors = theme.colorScheme; final Widget leading; final String label; if (active.isNotEmpty) { final avg = active .map((j) => (j.status.value as DownloadInProgress).percent) .fold(0, (a, b) => a + b) / active.length; final value = avg <= 0 ? null : avg / 100; leading = SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2.5, value: value), ); label = active.length == 1 ? 'Download läuft… ${avg.round()}%' : '${active.length} Downloads… ${avg.round()}%'; } else if (failedCount > 0 && doneCount == 0) { leading = Icon(Icons.error_outline, size: 20, color: colors.error); label = failedCount == 1 ? 'Download fehlgeschlagen' : '$failedCount Downloads fehlgeschlagen'; } else { leading = Icon( Icons.check_circle_outline, size: 20, color: colors.primary, ); label = doneCount == 1 ? 'Download fertig' : '$doneCount fertig'; } return Semantics( button: true, label: '$label, Downloads anzeigen', onTap: onTap, child: ExcludeSemantics( child: Material( color: colors.surfaceContainerHigh, elevation: 4, borderRadius: BorderRadius.circular(24), shadowColor: Colors.black45, child: InkWell( borderRadius: BorderRadius.circular(24), onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ leading, const SizedBox(width: 12), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 8), Icon( Icons.expand_less, size: 20, color: colors.onSurfaceVariant, ), ], ), ), ), ), ), ); }, ); }