import 'dart:async'; import 'package:flutter/material.dart'; 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/send_message/send_message.dart'; import '../../../api/marianumcloud/talk/send_message/send_message_params.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'; import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/info_dialog.dart'; import '../../../widget/placeholder_view.dart'; import '../files/files_upload_dialog.dart'; import '../talk/search_chat.dart'; import '../talk/widgets/chat_tile.dart'; typedef _ChatPickedCallback = Future Function(BuildContext context, GetRoomResponseObject room); 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), ); /// Forward an existing Talk message (text and/or already-uploaded file /// attachment) into another chat. The attachment is re-shared via the same /// FileSharingApi path used for [forInternalShare]; plain text is posted /// with [SendMessage]. factory ShareChatPicker.forMessageForward({ String? text, RemoteFileRef? file, }) { assert( text != null || file != null, 'forMessageForward requires either text or file', ); return ShareChatPicker._( onPicked: (ctx, room) => _forwardMessageFlow(ctx, room, text, file), ); } @override Widget build(BuildContext context) { final talkSettings = context.watch().val().talkSettings; return Scaffold( appBar: AppBar( title: const Text('Talk-Chat auswählen'), actions: [ Builder( builder: (ctx) => IconButton( icon: const Icon(Icons.search), onPressed: () { final rooms = ctx.read().state.data?.rooms; if (rooms == null) return; showSearch( context: ctx, delegate: SearchChat( rooms.data.where((r) => r.readOnly == 0).toList(), onTapOverride: (room) { Navigator.of(ctx).pop(); _onPicked(ctx, room); }, ), ); }, ), ), ], ), body: LoadableStateConsumer( child: (state, _) { final rooms = state.rooms; if (rooms == null) return const SizedBox.shrink(); final sorted = rooms .sortBy( lastActivity: true, favoritesToTop: talkSettings.sortFavoritesToTop, unreadToTop: talkSettings.sortUnreadToTop, ) // Hide chats the user can't write to (announcement channels, // archived rooms, …) — uploading there would only fail at the // share-API call with 403. .where((r) => r.readOnly == 0) .toList(); if (sorted.isEmpty) { return const PlaceholderView( icon: Icons.chat_bubble_outline, text: 'Keine schreibbaren Chats verfügbar', ); } return ListView.builder( padding: EdgeInsets.zero, itemCount: sorted.length, itemBuilder: (context, i) => ChatTile( data: sorted[i], disableContextActions: true, onTapOverride: (room) => _onPicked(context, room), ), ); }, ), ); } } Future _externalShareFlow( BuildContext context, GetRoomResponseObject room, PendingShare share, ) 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) => _afterExternalFilesUploaded(context, room, uploaded, share), ), ); return; } 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); } Future _forwardMessageFlow( BuildContext context, GetRoomResponseObject room, String? text, RemoteFileRef? file, ) async { unawaited(_showBlockingSpinner(context)); try { if (file != null) { await shareFilesToChat( token: room.token, remoteFilePaths: [file.path], ); } if (text != null && text.isNotEmpty) { await SendMessage(room.token, SendMessageParams(text)).run(); } } 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()), ), );