added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
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';
|
||||
|
||||
/// 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;
|
||||
_manager.markOpened(job);
|
||||
final ctx = AppRoutes.overlayContext;
|
||||
if (path == null || ctx == null) return;
|
||||
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 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user