diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 3f46286..ac9042a 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -7,6 +7,7 @@ import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../main.dart'; import '../model/account_data.dart'; import '../share_intent/pending_share.dart'; +import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/app_modules.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; @@ -46,11 +47,16 @@ class AppRoutes { BuildContext context, String localPath, { bool openExternal = false, + RemoteFileRef? remoteFile, }) { pushScreen( context, withNavBar: false, - screen: FileViewer(path: localPath, openExternal: openExternal), + screen: FileViewer( + path: localPath, + openExternal: openExternal, + remoteFile: remoteFile, + ), ); } @@ -106,7 +112,7 @@ class AppRoutes { pushScreen( context, withNavBar: false, - screen: ShareChatPicker(share: share), + screen: ShareChatPicker.forExternalShare(share: share), ); } @@ -114,7 +120,29 @@ class AppRoutes { pushScreen( context, withNavBar: false, - screen: ShareFolderPicker(share: share), + screen: ShareFolderPicker.forExternalShare(share: share), + ); + } + + static void openInternalShareToChat( + BuildContext context, + RemoteFileRef file, + ) { + pushScreen( + context, + withNavBar: false, + screen: ShareChatPicker.forInternalShare(file: file), + ); + } + + static void openInternalSaveToFolder( + BuildContext context, + RemoteFileRef file, + ) { + pushScreen( + context, + withNavBar: false, + screen: ShareFolderPicker.forInternalSave(file: file), ); } diff --git a/lib/share_intent/internal_share_actions.dart b/lib/share_intent/internal_share_actions.dart new file mode 100644 index 0000000..f9a5a47 --- /dev/null +++ b/lib/share_intent/internal_share_actions.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:nextcloud/nextcloud.dart'; + +import '../api/marianumcloud/webdav/webdav_api.dart'; +import '../widget/confirm_dialog.dart'; +import 'remote_file_ref.dart'; + +/// Server-side WebDAV copy of [source] into [targetFolderPath]. On a 412 +/// conflict the user is asked whether to overwrite; on confirmation the call +/// is retried with `overwrite: true`. Returns true when the file ended up at +/// the target, false when the user cancelled. +Future copyRemoteFileTo({ + required BuildContext context, + required RemoteFileRef source, + required String targetFolderPath, +}) async { + final webdav = await WebdavApi.webdav; + final dst = targetFolderPath.isEmpty + ? source.name + : '${targetFolderPath.replaceAll(RegExp(r'/+$'), '')}/${source.name}'; + final src = PathUri.parse(source.path); + final dstUri = PathUri.parse(dst); + + try { + await webdav.copy(src, dstUri); + return true; + } on DynamiteApiException catch (e) { + if (e.statusCode != 412) rethrow; + if (!context.mounted) return false; + final overwrite = await showDialog( + context: context, + builder: (ctx) => ConfirmDialog( + title: 'Datei existiert bereits', + content: + '"${source.name}" existiert in /$targetFolderPath. Überschreiben?', + confirmButton: 'Überschreiben', + cancelButton: 'Abbrechen', + onConfirm: () => Navigator.of(ctx).pop(true), + ), + ); + if (overwrite != true) return false; + await webdav.copy(src, dstUri, overwrite: true); + return true; + } +} diff --git a/lib/share_intent/remote_file_ref.dart b/lib/share_intent/remote_file_ref.dart new file mode 100644 index 0000000..909c410 --- /dev/null +++ b/lib/share_intent/remote_file_ref.dart @@ -0,0 +1,20 @@ +import '../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; + +/// References a file that already lives on the Nextcloud server. Used by the +/// in-app share/save flows that operate on remote paths instead of local +/// cache files (no upload needed). +class RemoteFileRef { + final String path; + final String name; + + const RemoteFileRef({required this.path, required this.name}); + + /// Caller must verify `file.path != null` first — Talk message parameters + /// without a path (system events, mentions, polls) are not file refs. + factory RemoteFileRef.fromTalk(RichObjectString file) => + RemoteFileRef(path: file.path!, name: file.name); + + factory RemoteFileRef.fromCacheable(CacheableFile file) => + RemoteFileRef(path: file.path, name: file.name); +} diff --git a/lib/view/login/widgets/login_branding.dart b/lib/view/login/widgets/login_branding.dart index 04649c5..b3aad88 100644 --- a/lib/view/login/widgets/login_branding.dart +++ b/lib/view/login/widgets/login_branding.dart @@ -45,7 +45,7 @@ class LoginDisclaimer extends StatelessWidget { Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( - 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', + 'Inoffizieller Marianum-Cloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.75), diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index 0e64864..c573ae8 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -7,6 +7,7 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../extensions/date_time.dart'; import '../../../../model/endpoint_data.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../share_intent/remote_file_ref.dart'; import '../../../../utils/download_manager.dart'; import '../../../../utils/file_clipboard.dart'; import '../../../../widget/centered_leading.dart'; @@ -71,7 +72,11 @@ class _FileElementState extends State { if (status is DownloadDone) { DownloadManager.instance.clear(widget.file.path); _detachJob(); - AppRoutes.openFileViewer(context, status.localPath); + AppRoutes.openFileViewer( + context, + status.localPath, + remoteFile: RemoteFileRef.fromCacheable(widget.file), + ); setState(() {}); } else if (status is DownloadFailed) { final message = status.message; @@ -299,6 +304,18 @@ class _FileElementState extends State { _putOnClipboard(copy: true); }, ), + if (!widget.file.isDirectory) + ListTile( + leading: const CenteredLeading(Icon(Icons.chat_bubble_outline)), + title: const Text('Im Talk-Chat teilen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + AppRoutes.openInternalShareToChat( + context, + RemoteFileRef.fromCacheable(widget.file), + ); + }, + ), ListTile( leading: const CenteredLeading(Icon(Icons.delete_outline)), title: const Text('Löschen'), diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart index eeda08b..b2fa812 100644 --- a/lib/view/pages/settings/sections/about_section.dart +++ b/lib/view/pages/settings/sections/about_section.dart @@ -69,7 +69,7 @@ class AboutSection extends StatelessWidget { applicationVersion: '${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', applicationLegalese: - 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' + 'Dies ist ein Inoffizieller Marianum-Cloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' 'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n' "${kReleaseMode ? "Production" : "Development"} build\n" 'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller', @@ -82,7 +82,7 @@ class AboutSection extends StatelessWidget { ListTile( leading: const CenteredLeading(Icon(Icons.school_outlined)), title: const Text('Infos zum Marianum Fulda'), - subtitle: const Text('Für Talk-Chats und Dateien'), + subtitle: const Text('Für Talk-Chats und Cloud-Dateien'), trailing: const Icon(Icons.arrow_right), onTap: () => PrivacyInfo( providerText: 'Marianum', diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index 575f4cc..2aae4fe 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -60,7 +60,7 @@ class TalkSection extends StatelessWidget { context, "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' - 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' + 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!', title: 'Info über Push', diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart index b9fb76d..8233554 100644 --- a/lib/view/pages/share_intent/share_chat_picker.dart +++ b/lib/view/pages/share_intent/share_chat_picker.dart @@ -5,11 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import '../../../api/errors/error_mapper.dart'; import '../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../api/marianumcloud/talk/share_files_to_chat.dart'; import '../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../routing/app_routes.dart'; import '../../../share_intent/pending_share.dart'; +import '../../../share_intent/remote_file_ref.dart'; import '../../../share_intent/share_intent_listener.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; @@ -21,17 +23,36 @@ import '../files/files_upload_dialog.dart'; import '../talk/search_chat.dart'; import '../talk/widgets/chat_tile.dart'; -class ShareChatPicker extends StatelessWidget { - final PendingShare share; +typedef _ChatPickedCallback = + Future Function(BuildContext context, GetRoomResponseObject room); - const ShareChatPicker({super.key, required this.share}); +class ShareChatPicker extends StatelessWidget { + final _ChatPickedCallback _onPicked; + + const ShareChatPicker._({required _ChatPickedCallback onPicked}) + : _onPicked = onPicked; + + /// External share-intent flow: uploads local files into the Talk share + /// folder, then shares them in the chosen chat. Falls back to a draft-only + /// flow when the pending share contains no files. + factory ShareChatPicker.forExternalShare({required PendingShare share}) => + ShareChatPicker._( + onPicked: (ctx, room) => _externalShareFlow(ctx, room, share), + ); + + /// In-app share flow: links an already-uploaded server file into the chosen + /// chat via FileSharingApi (no upload needed). + factory ShareChatPicker.forInternalShare({required RemoteFileRef file}) => + ShareChatPicker._( + onPicked: (ctx, room) => _internalShareFlow(ctx, room, file), + ); @override Widget build(BuildContext context) { final talkSettings = context.watch().val().talkSettings; return Scaffold( appBar: AppBar( - title: const Text('Chat auswählen'), + title: const Text('Talk-Chat auswählen'), actions: [ Builder( builder: (ctx) => IconButton( @@ -45,7 +66,7 @@ class ShareChatPicker extends StatelessWidget { rooms.data.where((r) => r.readOnly == 0).toList(), onTapOverride: (room) { Navigator.of(ctx).pop(); - _onChatPicked(ctx, room); + _onPicked(ctx, room); }, ), ); @@ -81,92 +102,128 @@ class ShareChatPicker extends StatelessWidget { itemBuilder: (context, i) => ChatTile( data: sorted[i], disableContextActions: true, - onTapOverride: (room) => _onChatPicked(context, room), + onTapOverride: (room) => _onPicked(context, room), ), ); }, ), ); } +} - Future _onChatPicked( - BuildContext context, - GetRoomResponseObject room, - ) async { - if (share.hasFiles) { - try { - final webdav = await WebdavApi.webdav; - await webdav.mkcol(PathUri.parse('/$talkShareFolder')); - } catch (_) { - // mkcol throws when the folder already exists; ignore. - } - if (!context.mounted) return; - await pushScreen( - context, - withNavBar: false, - screen: FilesUploadDialog( - filePaths: share.filePaths, - remotePath: talkShareFolder, - uniqueNames: true, - onUploadFinished: (uploaded) => - _afterFilesUploaded(context, room, uploaded), - ), - ); - return; - } - if (share.hasText) { - _setDraftAndOpenChat(context, room); - } - } - - Future _afterFilesUploaded( - BuildContext context, - GetRoomResponseObject room, - List uploadedRemotePaths, - ) async { - // Block the picker UI while the share-API roundtrips run, otherwise the - // user can re-tap a chat and double-share the same files. - unawaited( - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => const PopScope( - canPop: false, - child: Center(child: CircularProgressIndicator()), - ), - ), - ); - +Future _externalShareFlow( + BuildContext context, + GetRoomResponseObject room, + PendingShare share, +) async { + if (share.hasFiles) { try { - await shareFilesToChat( - token: room.token, - remoteFilePaths: uploadedRemotePaths, - ); - } catch (e) { - if (context.mounted) Navigator.of(context).pop(); - if (context.mounted) { - InfoDialog.show( - context, - 'Datei konnte nicht im Chat geteilt werden: $e', - title: 'Fehler', - copyable: true, - ); - } - return; + final webdav = await WebdavApi.webdav; + await webdav.mkcol(PathUri.parse('/$talkShareFolder')); + } catch (_) { + // mkcol throws when the folder already exists; ignore. } if (!context.mounted) return; - // The blocking dialog is popped together with the picker by - // _setDraftAndOpenChat's popUntil(isFirst) below. - _setDraftAndOpenChat(context, room); + await pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: share.filePaths, + remotePath: talkShareFolder, + uniqueNames: true, + onUploadFinished: (uploaded) => + _afterExternalFilesUploaded(context, room, uploaded, share), + ), + ); + return; } - - void _setDraftAndOpenChat(BuildContext context, GetRoomResponseObject room) { - if (share.hasText) { - final settings = context.read(); - settings.val(write: true).talkSettings.drafts[room.token] = share.text!; - } - ShareIntentListener.instance.clear(); - Navigator.of(context).popUntil((route) => route.isFirst); - AppRoutes.openChatByToken(context, room.token); + if (share.hasText) { + _setExternalDraftAndOpenChat(context, room, share); } } + +Future _afterExternalFilesUploaded( + BuildContext context, + GetRoomResponseObject room, + List uploadedRemotePaths, + PendingShare share, +) async { + unawaited(_showBlockingSpinner(context)); + try { + await shareFilesToChat( + token: room.token, + remoteFilePaths: uploadedRemotePaths, + ); + } catch (e) { + if (context.mounted) Navigator.of(context).pop(); + if (context.mounted) { + InfoDialog.show( + context, + errorToUserMessage(e), + title: 'Fehler', + copyable: true, + ); + } + return; + } + if (!context.mounted) return; + _setExternalDraftAndOpenChat(context, room, share); +} + +void _setExternalDraftAndOpenChat( + BuildContext context, + GetRoomResponseObject room, + PendingShare share, +) { + if (share.hasText) { + final settings = context.read(); + settings.val(write: true).talkSettings.drafts[room.token] = share.text!; + } + ShareIntentListener.instance.clear(); + _finishWithChat(context, room); +} + +/// Closes any picker/spinner pages stacked on top of the current tab and +/// jumps to the chosen chat. Shared by external + internal share flows. +void _finishWithChat(BuildContext context, GetRoomResponseObject room) { + Navigator.of(context).popUntil((route) => route.isFirst); + AppRoutes.openChatByToken(context, room.token); +} + +Future _internalShareFlow( + BuildContext context, + GetRoomResponseObject room, + RemoteFileRef file, +) async { + unawaited(_showBlockingSpinner(context)); + try { + await shareFilesToChat( + token: room.token, + remoteFilePaths: [file.path], + ); + } catch (e) { + if (context.mounted) Navigator.of(context).pop(); + if (context.mounted) { + InfoDialog.show( + context, + errorToUserMessage(e), + title: 'Fehler', + copyable: true, + ); + } + return; + } + if (!context.mounted) return; + _finishWithChat(context, room); +} + +/// Modal progress overlay shown during share-API roundtrips. The dialog is +/// popped together with the picker by the subsequent popUntil(isFirst). +Future _showBlockingSpinner(BuildContext context) => showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const PopScope( + canPop: false, + child: Center(child: CircularProgressIndicator()), + ), +); diff --git a/lib/view/pages/share_intent/share_folder_picker.dart b/lib/view/pages/share_intent/share_folder_picker.dart index 2d2a9dc..914f156 100644 --- a/lib/view/pages/share_intent/share_folder_picker.dart +++ b/lib/view/pages/share_intent/share_folder_picker.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import '../../../api/errors/error_mapper.dart'; import '../../../routing/app_routes.dart'; +import '../../../share_intent/internal_share_actions.dart'; import '../../../share_intent/pending_share.dart'; +import '../../../share_intent/remote_file_ref.dart'; import '../../../share_intent/share_intent_listener.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; @@ -11,28 +14,57 @@ import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/modules/files/bloc/files_bloc.dart'; import '../../../state/app/modules/files/bloc/files_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../widget/info_dialog.dart'; import '../../../widget/placeholder_view.dart'; import '../files/data/sort_options.dart'; import '../files/files_upload_dialog.dart'; import '../files/widgets/add_file_menu.dart'; import '../files/widgets/files_sort_actions.dart'; -class ShareFolderPicker extends StatelessWidget { - final PendingShare share; +typedef _FolderConfirmedCallback = + Future Function(BuildContext context, List targetPath); - const ShareFolderPicker({super.key, required this.share}); +class ShareFolderPicker extends StatelessWidget { + final String _fabLabel; + final _FolderConfirmedCallback _onConfirm; + + const ShareFolderPicker._({ + required String fabLabel, + required _FolderConfirmedCallback onConfirm, + }) : _fabLabel = fabLabel, + _onConfirm = onConfirm; + + /// External share-intent flow: upload local files into the chosen folder. + factory ShareFolderPicker.forExternalShare({required PendingShare share}) => + ShareFolderPicker._( + fabLabel: 'Hier hochladen', + onConfirm: (ctx, target) => _externalUploadFlow(ctx, target, share), + ); + + /// In-app save flow: server-to-server WebDAV-copy of an existing file into + /// the chosen folder. + factory ShareFolderPicker.forInternalSave({required RemoteFileRef file}) => + ShareFolderPicker._( + fabLabel: 'Hierhin kopieren', + onConfirm: (ctx, target) => _internalCopyFlow(ctx, target, file), + ); @override Widget build(BuildContext context) => BlocModule>( create: (_) => FilesBloc(), - child: (context, _, _) => _ShareFolderPickerView(share: share), + child: (context, _, _) => + _ShareFolderPickerView(fabLabel: _fabLabel, onConfirm: _onConfirm), ); } class _ShareFolderPickerView extends StatefulWidget { - final PendingShare share; - const _ShareFolderPickerView({required this.share}); + final String fabLabel; + final _FolderConfirmedCallback onConfirm; + const _ShareFolderPickerView({ + required this.fabLabel, + required this.onConfirm, + }); @override State<_ShareFolderPickerView> createState() => _ShareFolderPickerViewState(); @@ -60,25 +92,6 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { bloc.setPath(currentPath.sublist(0, currentPath.length - 1)); } - Future _uploadHere(List currentPath) async { - await pushScreen( - context, - withNavBar: false, - screen: FilesUploadDialog( - filePaths: widget.share.filePaths, - remotePath: currentPath.join('/'), - onUploadFinished: (_) => _afterUploaded(currentPath), - ), - ); - } - - void _afterUploaded(List targetPath) { - ShareIntentListener.instance.clear(); - if (!mounted) return; - Navigator.of(context).popUntil((route) => route.isFirst); - AppRoutes.openFolder(context, targetPath); - } - @override Widget build(BuildContext context) { final bloc = context.read(); @@ -138,9 +151,9 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { ), floatingActionButton: FloatingActionButton.extended( heroTag: 'shareUploadHere', - onPressed: () => _uploadHere(currentPath), + onPressed: () => widget.onConfirm(context, currentPath), icon: const Icon(Icons.upload), - label: const Text('Hier hochladen'), + label: Text(widget.fabLabel), ), body: LoadableStateConsumer( isReady: (state) => state.listing != null, @@ -185,3 +198,59 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { ), ); } + +Future _externalUploadFlow( + BuildContext context, + List targetPath, + PendingShare share, +) async { + await pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: share.filePaths, + remotePath: targetPath.join('/'), + onUploadFinished: (_) => _afterExternalUploaded(context, targetPath), + ), + ); +} + +void _afterExternalUploaded(BuildContext context, List targetPath) { + ShareIntentListener.instance.clear(); + if (!context.mounted) return; + _finishWithFolder(context, targetPath); +} + +/// Closes any picker pages stacked on top of the current tab and jumps to +/// the chosen folder. Shared by external upload + internal copy flows. +void _finishWithFolder(BuildContext context, List targetPath) { + Navigator.of(context).popUntil((route) => route.isFirst); + AppRoutes.openFolder(context, targetPath); +} + +Future _internalCopyFlow( + BuildContext context, + List targetPath, + RemoteFileRef file, +) async { + final bool ok; + try { + ok = await copyRemoteFileTo( + context: context, + source: file, + targetFolderPath: targetPath.join('/'), + ); + } catch (e) { + if (context.mounted) { + InfoDialog.show( + context, + errorToUserMessage(e), + title: 'Kopieren fehlgeschlagen', + copyable: true, + ); + } + return; + } + if (!ok || !context.mounted) return; + _finishWithFolder(context, targetPath); +} diff --git a/lib/view/pages/share_intent/share_target_page.dart b/lib/view/pages/share_intent/share_target_page.dart index 57227d3..17257a7 100644 --- a/lib/view/pages/share_intent/share_target_page.dart +++ b/lib/view/pages/share_intent/share_target_page.dart @@ -88,7 +88,7 @@ class ShareTargetPage extends StatelessWidget { ), ListTile( leading: const Icon(Icons.chat_bubble_outline), - title: const Text('An Chat senden'), + title: const Text('An Talk-Chat senden'), subtitle: const Text( 'Datei oder Text in einem Talk-Chat teilen', ), @@ -98,11 +98,11 @@ class ShareTargetPage extends StatelessWidget { ), ListTile( enabled: share.hasFiles, - leading: const Icon(Icons.folder_outlined), - title: const Text('In Dateien speichern'), + leading: const Icon(Icons.cloud_outlined), + title: const Text('In Cloud speichern'), subtitle: Text( share.hasFiles - ? 'In einen Nextcloud-Ordner hochladen' + ? 'In einen Cloud-Ordner hochladen' : 'Nur für Dateien verfügbar', ), trailing: const Icon(Icons.chevron_right), diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 8939266..0eb72c8 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -153,10 +153,10 @@ class _ChatListViewState extends State<_ChatListView> { ) { if (username == null || !context.mounted) return; ConfirmDialog( - title: 'Chat starten', + title: 'Talk-Chat starten', content: - "Möchtest du einen Chat mit Nutzer '$username' starten?", - confirmButton: 'Chat starten', + "Möchtest du einen Talk-Chat mit Nutzer '$username' starten?", + confirmButton: 'Talk-Chat starten', onConfirmAsync: () => bloc.createDirectChat(username), ).asDialog(context); }); diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 7e50a9e..9e5079f 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -6,6 +6,7 @@ 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 '../../../../widget/confirm_dialog.dart'; @@ -90,7 +91,14 @@ class _ChatBubbleState extends State if (status is DownloadDone) { DownloadManager.instance.clear(job.remotePath); _detachJob(); - AppRoutes.openFileViewer(context, status.localPath); + 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; diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index 227490b..d291d15 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -9,6 +9,7 @@ import '../../../../api/marianumcloud/talk/react_message/react_message.dart'; import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../share_intent/remote_file_ref.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../utils/clipboard_helper.dart'; import '../../../../widget/app_progress_indicator.dart'; @@ -18,6 +19,16 @@ import '../../../../widget/details_bottom_sheet.dart'; const _commonReactions = ['👍', '👎', '😆', '❤️', '👀']; +RichObjectString? _attachedFile(GetChatResponseObject bubbleData) { + final file = bubbleData.messageParameters?['file']; + if (file == null || + file.path == null || + file.type != RichObjectStringObjectType.file) { + return null; + } + return file; +} + /// Long-press / double-tap options dialog for a single chat message bubble. /// The hosting [ChatBubble] keeps responsibility for rendering the bubble; /// this file owns the modal interactions (react, reply, copy, delete, ...). @@ -36,6 +47,7 @@ void showChatMessageOptionsDialog( DateTime.fromMillisecondsSinceEpoch( bubbleData.timestamp * 1000, ).add(const Duration(hours: 6)).isAfter(DateTime.now()); + final attachedFile = _attachedFile(bubbleData); showDetailsBottomSheet( context, @@ -79,6 +91,19 @@ void showChatMessageOptionsDialog( Navigator.of(sheetCtx).pop(); }, ), + if (attachedFile != null) + ListTile( + leading: const Icon(Icons.cloud_outlined), + title: const Text('In Cloud speichern'), + onTap: () { + Navigator.of(sheetCtx).pop(); + if (!parentContext.mounted) return; + AppRoutes.openInternalSaveToFolder( + parentContext, + RemoteFileRef.fromTalk(attachedFile), + ); + }, + ), if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne) diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index 4c2c998..bea3098 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -207,11 +207,11 @@ class _ChatTileState extends State { ), ListTile( leading: const Icon(Icons.delete_outline), - title: const Text('Konversation verlassen'), + title: const Text('Talk-Chat verlassen'), onTap: () { Navigator.of(sheetCtx).pop(); ConfirmDialog( - title: 'Chat verlassen', + title: 'Talk-Chat verlassen', content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', confirmButton: 'Verlassen', diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 6440bad..9d3ab72 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -11,6 +11,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import '../routing/app_routes.dart'; +import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; import 'info_dialog.dart'; import 'placeholder_view.dart'; @@ -19,13 +20,24 @@ import 'share_position_origin.dart'; class FileViewer extends StatefulWidget { final String path; final bool openExternal; - const FileViewer({super.key, required this.path, this.openExternal = false}); + + /// When set, enables the in-app actions "An Chat senden" and "In Dateien + /// speichern" — these need a server-side reference, not the local cache + /// path. Aufrufer reichen die Referenz durch (siehe AppRoutes.openFileViewer). + final RemoteFileRef? remoteFile; + + const FileViewer({ + super.key, + required this.path, + this.openExternal = false, + this.remoteFile, + }); @override State createState() => _FileViewerState(); } -enum FileViewingActions { openExternal, share, save } +enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud } /// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal /// LayoutBuilder calls `localToGlobal` during build, which asserts when an @@ -110,6 +122,16 @@ class _FileViewerState extends State { context, widget.path, openExternal: true, + remoteFile: widget.remoteFile, + ); + break; + case FileViewingActions.sendToChat: + AppRoutes.openInternalShareToChat(context, widget.remoteFile!); + break; + case FileViewingActions.saveToCloud: + AppRoutes.openInternalSaveToFolder( + context, + widget.remoteFile!, ); break; case FileViewingActions.share: @@ -154,6 +176,24 @@ class _FileViewerState extends State { dense: true, ), ), + if (widget.remoteFile != null) ...[ + const PopupMenuItem( + value: FileViewingActions.sendToChat, + child: ListTile( + leading: Icon(Icons.chat_bubble_outline), + title: Text('An Talk-Chat senden'), + dense: true, + ), + ), + const PopupMenuItem( + value: FileViewingActions.saveToCloud, + child: ListTile( + leading: Icon(Icons.cloud_outlined), + title: Text('In Cloud speichern'), + dense: true, + ), + ), + ], const PopupMenuItem( value: FileViewingActions.share, child: ListTile(