Files
Client/lib/widget/downloads/download_tray.dart
T

369 lines
13 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 '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<double> 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<int> epoch = ValueNotifier(0);
static void _bump(Route<dynamic>? route) {
if (route is PageRoute) epoch.value++;
}
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) =>
_bump(route);
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) =>
_bump(route);
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) =>
_bump(newRoute);
@override
void didRemove(Route<dynamic> route, Route<dynamic>? 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<DownloadTrayHost> createState() => _DownloadTrayHostState();
}
class _DownloadTrayHostState extends State<DownloadTrayHost>
with WidgetsBindingObserver {
DownloadManager get _manager => DownloadManager.instance;
StreamSubscription<DownloadJob>? _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<DownloadJob, int> _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<DownloadJob> jobs, int epoch) =>
shouldShowDownloadChip(
sheetOpen: _sheetOpen,
jobCount: jobs.length,
anySurfaced: jobs.any(
(j) => j.isDone || j.isFailed || (_originEpoch[j] ?? epoch) != epoch,
),
);
Future<void> _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<List<DownloadJob>>(
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<DownloadJob> 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<double>(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,
),
],
),
),
),
),
),
);
},
);
}