added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry

This commit is contained in:
2026-07-06 15:43:17 +02:00
parent 614ab159af
commit 124b1a5177
34 changed files with 1866 additions and 409 deletions
@@ -19,6 +19,17 @@ IconData iconForFile(CacheableFile file) {
return Icons.insert_drive_file_outlined;
}
/// Icon for a bare file name (no [CacheableFile] / MIME available), e.g. in the
/// downloads overview. Falls back to a generic document icon.
IconData iconForFileName(String name) {
final ext = _extensionOf(name);
if (ext != null) {
final byExt = _extensionIcons[ext];
if (byExt != null) return byExt;
}
return Icons.insert_drive_file_outlined;
}
String? _extensionOf(String name) {
final dot = name.lastIndexOf('.');
if (dot <= 0 || dot == name.length - 1) return null;
+17 -83
View File
@@ -10,12 +10,13 @@ import '../../../../model/endpoint_data.dart';
import '../../../../routing/app_routes.dart';
import '../../../../share_intent/remote_file_ref.dart';
import '../../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import '../../../../utils/download_manager.dart';
import '../../../../utils/downloads/download_job.dart';
import '../../../../utils/file_clipboard.dart';
import '../../../../utils/haptics.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/downloads/download_trigger.dart';
import '../../../../widget/info_dialog.dart';
import '../../talk/widgets/highlighted_linkify.dart';
import '../sharing/share_sheet.dart';
@@ -43,101 +44,32 @@ class FileElement extends StatefulWidget {
State<FileElement> createState() => _FileElementState();
}
class _FileElementState extends State<FileElement> {
DownloadJob? _job;
class _FileElementState extends State<FileElement>
with DownloadTrigger<FileElement> {
@override
String? get downloadRemotePath =>
widget.file.isDirectory ? null : widget.file.path;
@override
void initState() {
super.initState();
_attachJob(DownloadManager.instance.jobFor(widget.file.path));
initDownloadTrigger();
}
@override
void didUpdateWidget(covariant FileElement oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.file.path != widget.file.path) {
_detachJob();
_attachJob(DownloadManager.instance.jobFor(widget.file.path));
}
if (oldWidget.file.path != widget.file.path) refreshDownloadTrigger();
}
@override
void dispose() {
_detachJob();
disposeDownloadTrigger();
super.dispose();
}
void _attachJob(DownloadJob? job) {
_job = job;
if (job == null) return;
job.status.addListener(_onStatusChange);
if (job.isFinished) {
WidgetsBinding.instance.addPostFrameCallback((_) => _onStatusChange());
}
}
void _detachJob() {
_job?.status.removeListener(_onStatusChange);
_job = null;
}
void _onStatusChange() {
if (!mounted) return;
final job = _job;
if (job == null) return;
final status = job.status.value;
if (status is DownloadDone) {
Haptics.success();
DownloadManager.instance.clear(widget.file.path);
_detachJob();
AppRoutes.openFileViewer(
context,
status.localPath,
remoteFile: RemoteFileRef.fromCacheable(widget.file),
);
setState(() {});
} else if (status is DownloadFailed) {
final message = status.message;
DownloadManager.instance.clear(widget.file.path);
_detachJob();
setState(() {});
InfoDialog.show(context, message, title: 'Download', copyable: true);
} else if (status is DownloadCancelled) {
DownloadManager.instance.clear(widget.file.path);
_detachJob();
setState(() {});
} else {
setState(() {});
}
}
Future<void> _startDownload() async {
final job = await DownloadManager.instance.start(
remotePath: widget.file.path,
name: widget.file.name,
);
if (!mounted) return;
if (_job == job) return;
_detachJob();
_attachJob(job);
setState(() {});
}
void _confirmCancel() {
showDialog<void>(
context: context,
builder: (dialogContext) => ConfirmDialog(
title: 'Download abbrechen?',
content: 'Möchtest du den Download abbrechen?',
cancelButton: 'Nein',
confirmButton: 'Ja, Abbrechen',
onConfirm: () => _job?.cancel(),
),
);
}
Widget? _subtitle() {
final status = _job?.status.value;
final status = downloadJob?.status.value;
if (status is DownloadInProgress) {
return Row(
children: [
@@ -180,12 +112,14 @@ class _FileElementState extends State<FileElement> {
);
return;
}
final status = _job?.status.value;
if (status is DownloadInProgress) {
_confirmCancel();
if (isDownloading) {
confirmCancelDownload();
return;
}
_startDownload();
startDownload(
name: widget.file.name,
remoteFile: RemoteFileRef.fromCacheable(widget.file),
);
}
// All paths here are relative to the WebDAV root (matching CacheableFile.path).
+22 -81
View File
@@ -5,13 +5,11 @@ import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../extensions/date_time.dart';
import '../../../../extensions/text.dart';
import '../../../../routing/app_routes.dart';
import '../../../../share_intent/remote_file_ref.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/download_manager.dart';
import '../../../../utils/downloads/download_job.dart';
import '../../../../utils/haptics.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/info_dialog.dart';
import '../../../../widget/downloads/download_trigger.dart';
import '../data/chat_bubble_styles.dart';
import '../data/chat_message.dart';
import 'answer_reference.dart';
@@ -58,97 +56,40 @@ class ChatBubble extends StatefulWidget {
}
class _ChatBubbleState extends State<ChatBubble>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin, DownloadTrigger<ChatBubble> {
late ChatMessage message;
DownloadJob? _job;
Offset _position = Offset.zero;
Offset _dragStartPosition = Offset.zero;
bool _swipeActionArmed = false;
@override
String? get downloadRemotePath =>
widget.bubbleData.messageParameters?['file']?.path;
@override
void initState() {
super.initState();
final filePath = widget.bubbleData.messageParameters?['file']?.path;
if (filePath != null) _attachJob(DownloadManager.instance.jobFor(filePath));
initDownloadTrigger();
}
@override
void didUpdateWidget(covariant ChatBubble oldWidget) {
super.didUpdateWidget(oldWidget);
final oldPath = oldWidget.bubbleData.messageParameters?['file']?.path;
if (oldPath != downloadRemotePath) refreshDownloadTrigger();
}
@override
void dispose() {
_detachJob();
disposeDownloadTrigger();
super.dispose();
}
void _attachJob(DownloadJob? job) {
_job = job;
if (job == null) return;
job.status.addListener(_onStatusChange);
if (job.isFinished) {
WidgetsBinding.instance.addPostFrameCallback((_) => _onStatusChange());
}
}
void _detachJob() {
_job?.status.removeListener(_onStatusChange);
_job = null;
}
void _onStatusChange() {
if (!mounted) return;
final job = _job;
if (job == null) return;
final status = job.status.value;
if (status is DownloadDone) {
Haptics.success();
DownloadManager.instance.clear(job.remotePath);
_detachJob();
final talkFile = message.file;
AppRoutes.openFileViewer(
context,
status.localPath,
remoteFile: talkFile != null
? RemoteFileRef.fromTalk(talkFile)
: null,
);
setState(() {});
} else if (status is DownloadFailed) {
final message = status.message;
DownloadManager.instance.clear(job.remotePath);
_detachJob();
setState(() {});
InfoDialog.show(context, message, title: 'Download fehlgeschlagen');
} else if (status is DownloadCancelled) {
DownloadManager.instance.clear(job.remotePath);
_detachJob();
setState(() {});
} else {
setState(() {});
}
}
Future<void> _startFileDownload() async {
void _startFileDownload() {
final file = message.file;
final filePath = file?.path;
if (file == null || filePath == null) return;
final job = await DownloadManager.instance.start(
remotePath: filePath,
name: file.name,
);
if (!mounted) return;
if (_job == job) return;
_detachJob();
_attachJob(job);
setState(() {});
}
void _confirmCancel() {
ConfirmDialog(
title: 'Download abbrechen?',
content: 'Möchtest du den Download abbrechen?',
confirmButton: 'Ja, Abbrechen',
cancelButton: 'Nein',
onConfirm: () => _job?.cancel(),
).asDialog(context);
if (file == null) return;
startDownload(name: file.name, remoteFile: RemoteFileRef.fromTalk(file));
}
bool get _rendersAsCommentBubble =>
@@ -236,8 +177,8 @@ class _ChatBubbleState extends State<ChatBubble>
return;
}
if (message.file == null) return;
if (_job?.status.value is DownloadInProgress) {
_confirmCancel();
if (isDownloading) {
confirmCancelDownload();
} else {
_startFileDownload();
}
@@ -349,7 +290,7 @@ class _ChatBubbleState extends State<ChatBubble>
timeIconColor: widget.timeIconColor,
showActorDisplayName: showActorDisplayName,
showBubbleTime: showBubbleTime,
downloadJob: _job,
downloadJob: downloadJob,
),
),
),
@@ -13,6 +13,7 @@ import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/downloads/download_tray.dart';
import '../../../../widget/emoji_picker_dialog.dart';
import '../../../../widget/file_pick.dart';
import '../../../../widget/focus_behaviour.dart';
@@ -34,8 +35,17 @@ class _ChatTextfieldState extends State<ChatTextfield> {
final TextEditingController _textBoxController = TextEditingController();
final AsyncActionController _sendController = AsyncActionController();
final FocusNode _focusNode = FocusNode();
final GlobalKey _sizeKey = GlobalKey();
String? _sendError;
/// Publishes the input bar's measured height so the downloads chip floats
/// above it instead of covering it.
void _publishHeight() {
if (!mounted) return;
final height = _sizeKey.currentContext?.size?.height;
if (height != null) downloadChipBottomObstruction.value = height;
}
void share(List<String> uploadedRemotePaths) {
shareFilesToChat(
token: widget.sendToToken,
@@ -104,6 +114,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
@override
void dispose() {
downloadChipBottomObstruction.value = 0;
_sendController.dispose();
_focusNode.dispose();
super.dispose();
@@ -236,7 +247,9 @@ class _ChatTextfieldState extends State<ChatTextfield> {
}
}
WidgetsBinding.instance.addPostFrameCallback((_) => _publishHeight());
return Stack(
key: _sizeKey,
children: <Widget>[
Align(
alignment: Alignment.bottomLeft,