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/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 '../../../../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/emoji_picker_dialog.dart'; import '../../../../widget/file_pick.dart'; import '../../../../widget/focus_behaviour.dart'; import '../../files/files_upload_dialog.dart'; import 'answer_reference.dart'; class ChatTextfield extends StatefulWidget { final String sendToToken; final String? selfId; const ChatTextfield(this.sendToToken, {this.selfId, super.key}); @override State createState() => _ChatTextfieldState(); } class _ChatTextfieldState extends State { late SettingsCubit settings; final TextEditingController _textBoxController = TextEditingController(); final AsyncActionController _sendController = AsyncActionController(); final FocusNode _focusNode = FocusNode(); String? _sendError; void share(List uploadedRemotePaths) { shareFilesToChat( token: widget.sendToToken, remoteFilePaths: uploadedRemotePaths, ).then((_) { if (mounted) context.read().refresh(); }); } Future mediaUpload(List? paths) async { if (paths == null) return; unawaited( WebdavApi.webdav.then( (webdav) => webdav.mkcol(PathUri.parse('/$talkShareFolder')), ), ); if (!mounted) return; unawaited( pushScreen( context, withNavBar: false, screen: FilesUploadDialog( filePaths: paths, remotePath: talkShareFolder, onUploadFinished: share, uniqueNames: true, ), ), ); } void _setDraft(String text) { final talkSettings = settings.val(write: true).talkSettings; if (text.isNotEmpty) { talkSettings.drafts[widget.sendToToken] = text; } else { talkSettings.drafts.removeWhere((key, _) => key == widget.sendToToken); } } void _setDraftReply(int? messageId) { final talkSettings = settings.val(write: true).talkSettings; if (messageId != null) { talkSettings.draftReplies[widget.sendToToken] = messageId; } else { talkSettings.draftReplies.removeWhere( (key, _) => key == widget.sendToToken, ); } } @override void initState() { super.initState(); settings = context.read(); final draftReply = settings .val() .talkSettings .draftReplies[widget.sendToToken]; if (draftReply != null) { context.read().setReferenceMessageId(draftReply); } } @override void dispose() { _sendController.dispose(); _focusNode.dispose(); super.dispose(); } void _showAttachmentSheet() { showDetailsBottomSheet( context, children: (sheetCtx) => [ ListTile( leading: const Icon(Icons.file_open), title: const Text('Aus Dateien auswählen'), onTap: () { FilePick.documentPick().then(mediaUpload); Navigator.of(sheetCtx).pop(); }, ), ListTile( leading: const Icon(Icons.image), title: const Text('Aus Galerie auswählen'), onTap: () { FilePick.multipleGalleryPick().then((value) { if (value != null) { mediaUpload(value.map((e) => e.path).toList()); } }); Navigator.of(sheetCtx).pop(); }, ), ListTile( leading: const Icon(Icons.camera_alt_outlined), title: const Text('Foto aufnehmen'), onTap: () { FilePick.cameraPick().then((image) { if (image != null) mediaUpload([image.path]); }); Navigator.of(sheetCtx).pop(); }, ), ], ); } Future _pickEmoji() async { final emoji = await showEmojiPicker(context); if (emoji == null || !mounted) return; _insertEmoji(emoji); // Keep the field focused so the user can keep typing after inserting. _focusNode.requestFocus(); } void _insertEmoji(String emoji) { final selection = _textBoxController.selection; final text = _textBoxController.text; // Selection is invalid (-1) until the field was focused once — append then. final start = selection.start < 0 ? text.length : selection.start; final end = selection.end < 0 ? text.length : selection.end; final newText = text.replaceRange(start, end, emoji); _textBoxController.value = TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: start + emoji.length), ); // Programmatic edits skip the TextField's onChanged, so persist manually. _setDraft(newText); } Future _sendMessage(ChatBloc chatBloc) async { if (_textBoxController.text.isEmpty) return; final text = _textBoxController.text; final replyTo = chatBloc.state.data?.referenceMessageId?.toString(); final ownToken = widget.sendToToken; setState(() => _sendError = null); await SendMessage( ownToken, SendMessageParams(text, replyTo: replyTo), ).run(); // Reached only on success — SendMessage.run() throws on failure and // skips this block, leaving the persisted draft as a recovery aid. // Drafts live on the global SettingsCubit keyed by token, so clear // them even when the user navigated away mid-send — otherwise the // sent message lingers as a phantom draft on the chat list. _setDraft(''); _setDraftReply(null); // The global ChatBloc may already point at a different chat (user // switched mid-send); only touch it while it still references us. if (chatBloc.state.data?.currentToken == ownToken) { chatBloc.setReferenceMessageId(null); chatBloc.refresh(); } if (!mounted) return; _textBoxController.text = ''; } @override Widget build(BuildContext context) { _textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? ''; final chatBloc = context.watch(); final chatState = chatBloc.state.data; Widget replyBanner = const SizedBox.shrink(); if (chatState != null && chatState.referenceMessageId != null && chatState.chatResponse != null) { try { final referenceMessage = chatState.chatResponse! .sortByTimestamp() .firstWhere((e) => e.id == chatState.referenceMessageId); replyBanner = Row( children: [ Expanded( child: AnswerReference( context: context, referenceMessage: referenceMessage, selfId: widget.selfId, ), ), IconButton( onPressed: () { chatBloc.setReferenceMessageId(null); _setDraftReply(null); }, icon: const Icon(Icons.close_outlined), padding: const EdgeInsets.only(left: 0), ), ], ); } catch (_) { /* reference no longer in current chat data */ } } return Stack( children: [ Align( alignment: Alignment.bottomLeft, child: Container( padding: const EdgeInsets.only( left: 10, bottom: 3, top: 3, right: 10, ), width: double.infinity, child: Column( children: [ replyBanner, if (_sendError != null) Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( _sendError!, style: TextStyle( color: Theme.of(context).colorScheme.error, fontSize: 12, ), ), ), Row( // Outer row centers the pill against the (taller) send FAB. // The inner row keeps end-alignment so the icon buttons drop // to the bottom once the text wraps to multiple lines. crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: DecoratedBox( decoration: BoxDecoration( color: Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(24), ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ IconButton( visualDensity: VisualDensity.compact, padding: const EdgeInsets.all(4), constraints: const BoxConstraints(), tooltip: 'Emoji einfügen', icon: Icon( Icons.emoji_emotions_outlined, color: Theme.of(context).hintColor, ), onPressed: _pickEmoji, ), Expanded( child: Padding( // 7px keeps a single line as tall as the // 32px icon buttons, so end-alignment reads as // centered for one line but drops the buttons // to the bottom once the text wraps. padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 7, ), child: TextField( autocorrect: true, textCapitalization: TextCapitalization.sentences, controller: _textBoxController, focusNode: _focusNode, maxLines: 7, minLines: 1, decoration: const InputDecoration( hintText: 'Nachricht schreiben...', border: InputBorder.none, isCollapsed: true, ), onChanged: (text) { if (text.trim().toLowerCase() == 'marbot marbot marbot') { const newText = 'Roboter sind cool und so, aber Marbots sind besser!'; _textBoxController.text = newText; text = newText; } _setDraft(text); }, onTapOutside: (_) => FocusBehaviour.textFieldTapOutside( context, ), ), ), ), IconButton( visualDensity: VisualDensity.compact, padding: const EdgeInsets.all(4), constraints: const BoxConstraints(), tooltip: 'Anhang', icon: Icon( Icons.attach_file_outlined, color: Theme.of(context).hintColor, ), onPressed: _showAttachmentSheet, ), ], ), ), ), const SizedBox(width: 8), ValueListenableBuilder( valueListenable: _textBoxController, builder: (context, value, _) => AsyncFab( mini: true, heroTag: 'chatSend_${widget.sendToToken}', icon: Icons.send, backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, controller: _sendController, onPressed: value.text.trim().isEmpty ? null : () => _sendMessage(chatBloc), onError: (message) => setState(() => _sendError = message), onSuccess: () => setState(() => _sendError = null), ), ), ], ), ], ), ), ), ], ); } }