implemented internal file sharing and saving, added server-side file references, refactored share pickers for unified flows, and updated UI branding labels

This commit is contained in:
2026-05-09 20:18:52 +02:00
parent cb2c38aaa1
commit 151678f0fe
15 changed files with 437 additions and 128 deletions
+31 -3
View File
@@ -7,6 +7,7 @@ import '../api/marianumcloud/talk/room/get_room_response.dart';
import '../main.dart'; import '../main.dart';
import '../model/account_data.dart'; import '../model/account_data.dart';
import '../share_intent/pending_share.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/app_modules.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
@@ -46,11 +47,16 @@ class AppRoutes {
BuildContext context, BuildContext context,
String localPath, { String localPath, {
bool openExternal = false, bool openExternal = false,
RemoteFileRef? remoteFile,
}) { }) {
pushScreen( pushScreen(
context, context,
withNavBar: false, withNavBar: false,
screen: FileViewer(path: localPath, openExternal: openExternal), screen: FileViewer(
path: localPath,
openExternal: openExternal,
remoteFile: remoteFile,
),
); );
} }
@@ -106,7 +112,7 @@ class AppRoutes {
pushScreen( pushScreen(
context, context,
withNavBar: false, withNavBar: false,
screen: ShareChatPicker(share: share), screen: ShareChatPicker.forExternalShare(share: share),
); );
} }
@@ -114,7 +120,29 @@ class AppRoutes {
pushScreen( pushScreen(
context, context,
withNavBar: false, 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),
); );
} }
@@ -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<bool> 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<bool>(
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;
}
}
+20
View File
@@ -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);
}
+1 -1
View File
@@ -45,7 +45,7 @@ class LoginDisclaimer extends StatelessWidget {
Widget build(BuildContext context) => Padding( Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text( 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, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: Colors.white.withValues(alpha: 0.75), color: Colors.white.withValues(alpha: 0.75),
+18 -1
View File
@@ -7,6 +7,7 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../extensions/date_time.dart'; import '../../../../extensions/date_time.dart';
import '../../../../model/endpoint_data.dart'; import '../../../../model/endpoint_data.dart';
import '../../../../routing/app_routes.dart'; import '../../../../routing/app_routes.dart';
import '../../../../share_intent/remote_file_ref.dart';
import '../../../../utils/download_manager.dart'; import '../../../../utils/download_manager.dart';
import '../../../../utils/file_clipboard.dart'; import '../../../../utils/file_clipboard.dart';
import '../../../../widget/centered_leading.dart'; import '../../../../widget/centered_leading.dart';
@@ -71,7 +72,11 @@ class _FileElementState extends State<FileElement> {
if (status is DownloadDone) { if (status is DownloadDone) {
DownloadManager.instance.clear(widget.file.path); DownloadManager.instance.clear(widget.file.path);
_detachJob(); _detachJob();
AppRoutes.openFileViewer(context, status.localPath); AppRoutes.openFileViewer(
context,
status.localPath,
remoteFile: RemoteFileRef.fromCacheable(widget.file),
);
setState(() {}); setState(() {});
} else if (status is DownloadFailed) { } else if (status is DownloadFailed) {
final message = status.message; final message = status.message;
@@ -299,6 +304,18 @@ class _FileElementState extends State<FileElement> {
_putOnClipboard(copy: true); _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( ListTile(
leading: const CenteredLeading(Icon(Icons.delete_outline)), leading: const CenteredLeading(Icon(Icons.delete_outline)),
title: const Text('Löschen'), title: const Text('Löschen'),
@@ -69,7 +69,7 @@ class AboutSection extends StatelessWidget {
applicationVersion: applicationVersion:
'${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', '${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}',
applicationLegalese: 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' 'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n'
"${kReleaseMode ? "Production" : "Development"} build\n" "${kReleaseMode ? "Production" : "Development"} build\n"
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller', 'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller',
@@ -82,7 +82,7 @@ class AboutSection extends StatelessWidget {
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.school_outlined)), leading: const CenteredLeading(Icon(Icons.school_outlined)),
title: const Text('Infos zum Marianum Fulda'), 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), trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo( onTap: () => PrivacyInfo(
providerText: 'Marianum', providerText: 'Marianum',
@@ -60,7 +60,7 @@ class TalkSection extends StatelessWidget {
context, context,
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" "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' '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' '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!', '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', title: 'Info über Push',
@@ -5,11 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.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/room/get_room_response.dart';
import '../../../api/marianumcloud/talk/share_files_to_chat.dart'; import '../../../api/marianumcloud/talk/share_files_to_chat.dart';
import '../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../routing/app_routes.dart'; import '../../../routing/app_routes.dart';
import '../../../share_intent/pending_share.dart'; import '../../../share_intent/pending_share.dart';
import '../../../share_intent/remote_file_ref.dart';
import '../../../share_intent/share_intent_listener.dart'; import '../../../share_intent/share_intent_listener.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.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/search_chat.dart';
import '../talk/widgets/chat_tile.dart'; import '../talk/widgets/chat_tile.dart';
class ShareChatPicker extends StatelessWidget { typedef _ChatPickedCallback =
final PendingShare share; Future<void> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final talkSettings = context.watch<SettingsCubit>().val().talkSettings; final talkSettings = context.watch<SettingsCubit>().val().talkSettings;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Chat auswählen'), title: const Text('Talk-Chat auswählen'),
actions: [ actions: [
Builder( Builder(
builder: (ctx) => IconButton( builder: (ctx) => IconButton(
@@ -45,7 +66,7 @@ class ShareChatPicker extends StatelessWidget {
rooms.data.where((r) => r.readOnly == 0).toList(), rooms.data.where((r) => r.readOnly == 0).toList(),
onTapOverride: (room) { onTapOverride: (room) {
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
_onChatPicked(ctx, room); _onPicked(ctx, room);
}, },
), ),
); );
@@ -81,92 +102,128 @@ class ShareChatPicker extends StatelessWidget {
itemBuilder: (context, i) => ChatTile( itemBuilder: (context, i) => ChatTile(
data: sorted[i], data: sorted[i],
disableContextActions: true, disableContextActions: true,
onTapOverride: (room) => _onChatPicked(context, room), onTapOverride: (room) => _onPicked(context, room),
), ),
); );
}, },
), ),
); );
} }
}
Future<void> _onChatPicked( Future<void> _externalShareFlow(
BuildContext context, BuildContext context,
GetRoomResponseObject room, GetRoomResponseObject room,
) async { PendingShare share,
if (share.hasFiles) { ) async {
try { if (share.hasFiles) {
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<void> _afterFilesUploaded(
BuildContext context,
GetRoomResponseObject room,
List<String> 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<void>(
context: context,
barrierDismissible: false,
builder: (_) => const PopScope(
canPop: false,
child: Center(child: CircularProgressIndicator()),
),
),
);
try { try {
await shareFilesToChat( final webdav = await WebdavApi.webdav;
token: room.token, await webdav.mkcol(PathUri.parse('/$talkShareFolder'));
remoteFilePaths: uploadedRemotePaths, } catch (_) {
); // mkcol throws when the folder already exists; ignore.
} 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;
} }
if (!context.mounted) return; if (!context.mounted) return;
// The blocking dialog is popped together with the picker by await pushScreen(
// _setDraftAndOpenChat's popUntil(isFirst) below. context,
_setDraftAndOpenChat(context, room); withNavBar: false,
screen: FilesUploadDialog(
filePaths: share.filePaths,
remotePath: talkShareFolder,
uniqueNames: true,
onUploadFinished: (uploaded) =>
_afterExternalFilesUploaded(context, room, uploaded, share),
),
);
return;
} }
if (share.hasText) {
void _setDraftAndOpenChat(BuildContext context, GetRoomResponseObject room) { _setExternalDraftAndOpenChat(context, room, share);
if (share.hasText) {
final settings = context.read<SettingsCubit>();
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);
} }
} }
Future<void> _afterExternalFilesUploaded(
BuildContext context,
GetRoomResponseObject room,
List<String> 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<SettingsCubit>();
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<void> _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<void> _showBlockingSpinner(BuildContext context) => showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const PopScope(
canPop: false,
child: Center(child: CircularProgressIndicator()),
),
);
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.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 '../../../routing/app_routes.dart';
import '../../../share_intent/internal_share_actions.dart';
import '../../../share_intent/pending_share.dart'; import '../../../share_intent/pending_share.dart';
import '../../../share_intent/remote_file_ref.dart';
import '../../../share_intent/share_intent_listener.dart'; import '../../../share_intent/share_intent_listener.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.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_bloc.dart';
import '../../../state/app/modules/files/bloc/files_state.dart'; import '../../../state/app/modules/files/bloc/files_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/info_dialog.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
import '../files/data/sort_options.dart'; import '../files/data/sort_options.dart';
import '../files/files_upload_dialog.dart'; import '../files/files_upload_dialog.dart';
import '../files/widgets/add_file_menu.dart'; import '../files/widgets/add_file_menu.dart';
import '../files/widgets/files_sort_actions.dart'; import '../files/widgets/files_sort_actions.dart';
class ShareFolderPicker extends StatelessWidget { typedef _FolderConfirmedCallback =
final PendingShare share; Future<void> Function(BuildContext context, List<String> 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 @override
Widget build(BuildContext context) => Widget build(BuildContext context) =>
BlocModule<FilesBloc, LoadableState<FilesState>>( BlocModule<FilesBloc, LoadableState<FilesState>>(
create: (_) => FilesBloc(), create: (_) => FilesBloc(),
child: (context, _, _) => _ShareFolderPickerView(share: share), child: (context, _, _) =>
_ShareFolderPickerView(fabLabel: _fabLabel, onConfirm: _onConfirm),
); );
} }
class _ShareFolderPickerView extends StatefulWidget { class _ShareFolderPickerView extends StatefulWidget {
final PendingShare share; final String fabLabel;
const _ShareFolderPickerView({required this.share}); final _FolderConfirmedCallback onConfirm;
const _ShareFolderPickerView({
required this.fabLabel,
required this.onConfirm,
});
@override @override
State<_ShareFolderPickerView> createState() => _ShareFolderPickerViewState(); State<_ShareFolderPickerView> createState() => _ShareFolderPickerViewState();
@@ -60,25 +92,6 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
bloc.setPath(currentPath.sublist(0, currentPath.length - 1)); bloc.setPath(currentPath.sublist(0, currentPath.length - 1));
} }
Future<void> _uploadHere(List<String> currentPath) async {
await pushScreen(
context,
withNavBar: false,
screen: FilesUploadDialog(
filePaths: widget.share.filePaths,
remotePath: currentPath.join('/'),
onUploadFinished: (_) => _afterUploaded(currentPath),
),
);
}
void _afterUploaded(List<String> targetPath) {
ShareIntentListener.instance.clear();
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
AppRoutes.openFolder(context, targetPath);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bloc = context.read<FilesBloc>(); final bloc = context.read<FilesBloc>();
@@ -138,9 +151,9 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
heroTag: 'shareUploadHere', heroTag: 'shareUploadHere',
onPressed: () => _uploadHere(currentPath), onPressed: () => widget.onConfirm(context, currentPath),
icon: const Icon(Icons.upload), icon: const Icon(Icons.upload),
label: const Text('Hier hochladen'), label: Text(widget.fabLabel),
), ),
body: LoadableStateConsumer<FilesBloc, FilesState>( body: LoadableStateConsumer<FilesBloc, FilesState>(
isReady: (state) => state.listing != null, isReady: (state) => state.listing != null,
@@ -185,3 +198,59 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
), ),
); );
} }
Future<void> _externalUploadFlow(
BuildContext context,
List<String> 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<String> 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<String> targetPath) {
Navigator.of(context).popUntil((route) => route.isFirst);
AppRoutes.openFolder(context, targetPath);
}
Future<void> _internalCopyFlow(
BuildContext context,
List<String> 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);
}
@@ -88,7 +88,7 @@ class ShareTargetPage extends StatelessWidget {
), ),
ListTile( ListTile(
leading: const Icon(Icons.chat_bubble_outline), leading: const Icon(Icons.chat_bubble_outline),
title: const Text('An Chat senden'), title: const Text('An Talk-Chat senden'),
subtitle: const Text( subtitle: const Text(
'Datei oder Text in einem Talk-Chat teilen', 'Datei oder Text in einem Talk-Chat teilen',
), ),
@@ -98,11 +98,11 @@ class ShareTargetPage extends StatelessWidget {
), ),
ListTile( ListTile(
enabled: share.hasFiles, enabled: share.hasFiles,
leading: const Icon(Icons.folder_outlined), leading: const Icon(Icons.cloud_outlined),
title: const Text('In Dateien speichern'), title: const Text('In Cloud speichern'),
subtitle: Text( subtitle: Text(
share.hasFiles share.hasFiles
? 'In einen Nextcloud-Ordner hochladen' ? 'In einen Cloud-Ordner hochladen'
: 'Nur für Dateien verfügbar', : 'Nur für Dateien verfügbar',
), ),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
+3 -3
View File
@@ -153,10 +153,10 @@ class _ChatListViewState extends State<_ChatListView> {
) { ) {
if (username == null || !context.mounted) return; if (username == null || !context.mounted) return;
ConfirmDialog( ConfirmDialog(
title: 'Chat starten', title: 'Talk-Chat starten',
content: content:
"Möchtest du einen Chat mit Nutzer '$username' starten?", "Möchtest du einen Talk-Chat mit Nutzer '$username' starten?",
confirmButton: 'Chat starten', confirmButton: 'Talk-Chat starten',
onConfirmAsync: () => bloc.createDirectChat(username), onConfirmAsync: () => bloc.createDirectChat(username),
).asDialog(context); ).asDialog(context);
}); });
+9 -1
View File
@@ -6,6 +6,7 @@ import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../extensions/date_time.dart'; import '../../../../extensions/date_time.dart';
import '../../../../extensions/text.dart'; import '../../../../extensions/text.dart';
import '../../../../routing/app_routes.dart'; import '../../../../routing/app_routes.dart';
import '../../../../share_intent/remote_file_ref.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/download_manager.dart'; import '../../../../utils/download_manager.dart';
import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/confirm_dialog.dart';
@@ -90,7 +91,14 @@ class _ChatBubbleState extends State<ChatBubble>
if (status is DownloadDone) { if (status is DownloadDone) {
DownloadManager.instance.clear(job.remotePath); DownloadManager.instance.clear(job.remotePath);
_detachJob(); _detachJob();
AppRoutes.openFileViewer(context, status.localPath); final talkFile = message.file;
AppRoutes.openFileViewer(
context,
status.localPath,
remoteFile: talkFile != null
? RemoteFileRef.fromTalk(talkFile)
: null,
);
setState(() {}); setState(() {});
} else if (status is DownloadFailed) { } else if (status is DownloadFailed) {
final message = status.message; final message = status.message;
@@ -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/react_message/react_message_params.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../routing/app_routes.dart'; import '../../../../routing/app_routes.dart';
import '../../../../share_intent/remote_file_ref.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/clipboard_helper.dart'; import '../../../../utils/clipboard_helper.dart';
import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/app_progress_indicator.dart';
@@ -18,6 +19,16 @@ import '../../../../widget/details_bottom_sheet.dart';
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀']; const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
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. /// Long-press / double-tap options dialog for a single chat message bubble.
/// The hosting [ChatBubble] keeps responsibility for rendering the bubble; /// The hosting [ChatBubble] keeps responsibility for rendering the bubble;
/// this file owns the modal interactions (react, reply, copy, delete, ...). /// this file owns the modal interactions (react, reply, copy, delete, ...).
@@ -36,6 +47,7 @@ void showChatMessageOptionsDialog(
DateTime.fromMillisecondsSinceEpoch( DateTime.fromMillisecondsSinceEpoch(
bubbleData.timestamp * 1000, bubbleData.timestamp * 1000,
).add(const Duration(hours: 6)).isAfter(DateTime.now()); ).add(const Duration(hours: 6)).isAfter(DateTime.now());
final attachedFile = _attachedFile(bubbleData);
showDetailsBottomSheet( showDetailsBottomSheet(
context, context,
@@ -79,6 +91,19 @@ void showChatMessageOptionsDialog(
Navigator.of(sheetCtx).pop(); 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 && if (!kReleaseMode &&
!isSender && !isSender &&
chatData.type != GetRoomResponseObjectConversationType.oneToOne) chatData.type != GetRoomResponseObjectConversationType.oneToOne)
+2 -2
View File
@@ -207,11 +207,11 @@ class _ChatTileState extends State<ChatTile> {
), ),
ListTile( ListTile(
leading: const Icon(Icons.delete_outline), leading: const Icon(Icons.delete_outline),
title: const Text('Konversation verlassen'), title: const Text('Talk-Chat verlassen'),
onTap: () { onTap: () {
Navigator.of(sheetCtx).pop(); Navigator.of(sheetCtx).pop();
ConfirmDialog( ConfirmDialog(
title: 'Chat verlassen', title: 'Talk-Chat verlassen',
content: content:
'Du benötigst ggf. eine Einladung um erneut beizutreten.', 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
confirmButton: 'Verlassen', confirmButton: 'Verlassen',
+42 -2
View File
@@ -11,6 +11,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart';
import 'info_dialog.dart'; import 'info_dialog.dart';
import 'placeholder_view.dart'; import 'placeholder_view.dart';
@@ -19,13 +20,24 @@ import 'share_position_origin.dart';
class FileViewer extends StatefulWidget { class FileViewer extends StatefulWidget {
final String path; final String path;
final bool openExternal; 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 @override
State<FileViewer> createState() => _FileViewerState(); State<FileViewer> createState() => _FileViewerState();
} }
enum FileViewingActions { openExternal, share, save } enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal /// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an /// LayoutBuilder calls `localToGlobal` during build, which asserts when an
@@ -110,6 +122,16 @@ class _FileViewerState extends State<FileViewer> {
context, context,
widget.path, widget.path,
openExternal: true, openExternal: true,
remoteFile: widget.remoteFile,
);
break;
case FileViewingActions.sendToChat:
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
break;
case FileViewingActions.saveToCloud:
AppRoutes.openInternalSaveToFolder(
context,
widget.remoteFile!,
); );
break; break;
case FileViewingActions.share: case FileViewingActions.share:
@@ -154,6 +176,24 @@ class _FileViewerState extends State<FileViewer> {
dense: true, 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( const PopupMenuItem(
value: FileViewingActions.share, value: FileViewingActions.share,
child: ListTile( child: ListTile(