369 lines
13 KiB
Dart
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|