import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; 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 '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../utils/download_manager.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/info_dialog.dart'; import '../data/chat_bubble_styles.dart'; import '../data/chat_message.dart'; import 'answer_reference.dart'; import 'bubble.dart'; import 'chat_bubble_poll.dart'; import 'chat_bubble_reactions.dart'; import 'chat_message_options_dialog.dart'; class ChatBubble extends StatefulWidget { final BuildContext context; final bool isSender; final GetChatResponseObject bubbleData; final GetRoomResponseObject chatData; final bool isRead; final String? selfId; final double spacing = 3; final double timeIconSize = 11; final Color timeIconColor = Colors.grey; final void Function({bool renew}) refetch; const ChatBubble({ required this.context, required this.isSender, required this.bubbleData, required this.chatData, required this.refetch, this.isRead = false, this.selfId, super.key}); @override State createState() => _ChatBubbleState(); } class _ChatBubbleState extends State with SingleTickerProviderStateMixin { late ChatMessage message; DownloadJob? _job; Offset _position = Offset.zero; Offset _dragStartPosition = Offset.zero; @override void initState() { super.initState(); final filePath = widget.bubbleData.messageParameters?['file']?.path; if (filePath != null) _attachJob(DownloadManager.instance.jobFor(filePath)); } @override void dispose() { _detachJob(); 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) { DownloadManager.instance.clear(job.remotePath); _detachJob(); AppRoutes.openFileViewer(context, status.localPath); 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 _startFileDownload() async { 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); } BubbleStyle _getStyle() { final styles = ChatBubbleStyles(context); if (widget.bubbleData.messageType != GetRoomResponseObjectMessageType.comment) { return styles.getSystemStyle(); } return widget.isSender ? styles.getSelfStyle(false) : styles.getRemoteStyle(false); } void _showOptionsDialog() => showChatMessageOptionsDialog( context, chatData: widget.chatData, bubbleData: widget.bubbleData, isSender: widget.isSender, onRefetch: widget.refetch, ); void _onTap() { final obj = message.originalData?['object']; if (obj?.type == RichObjectStringObjectType.talkPoll) { showChatBubblePollDialog( context, chatToken: widget.chatData.token, messageToken: widget.bubbleData.token, pollId: int.parse(obj!.id), pollName: obj.name, ); return; } if (message.file == null) return; if (_job?.status.value is DownloadInProgress) { _confirmCancel(); } else { _startFileDownload(); } } @override Widget build(BuildContext context) { message = ChatMessage(originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters); final showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; final showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system && widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment; final parent = widget.bubbleData.parent; final actorText = Text( widget.bubbleData.actorDisplayName, textAlign: TextAlign.start, overflow: TextOverflow.ellipsis, style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold), ); final timeText = Text( DateTime.fromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).formatHm(), textAlign: TextAlign.end, style: TextStyle(color: widget.timeIconColor, fontSize: widget.timeIconSize), ); return Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, textDirection: TextDirection.ltr, crossAxisAlignment: CrossAxisAlignment.end, children: [ GestureDetector( onHorizontalDragStart: (_) => _dragStartPosition = _position, onHorizontalDragUpdate: (details) { if (!widget.bubbleData.isReplyable) return; final dx = details.delta.dx - _dragStartPosition.dx; setState(() { _position = (_position.dx + dx).abs() > 60 ? Offset(_position.dx, 0) : Offset(_position.dx + dx, 0); }); }, onHorizontalDragEnd: (_) { final isAction = _position.dx.abs() > 50; setState(() => _position = Offset.zero); if (widget.bubbleData.isReplyable && isAction) { context.read().setReferenceMessageId(widget.bubbleData.id); } }, onLongPress: _showOptionsDialog, onDoubleTap: _showOptionsDialog, onTap: _onTap, child: Transform.translate( offset: _position, child: Bubble( style: _getStyle(), child: _BubbleContent( actorText: actorText, timeText: timeText, messageWidget: message.getWidget(), parent: parent, bubbleData: widget.bubbleData, isSender: widget.isSender, isRead: widget.isRead, selfId: widget.selfId, spacing: widget.spacing, timeIconSize: widget.timeIconSize, timeIconColor: widget.timeIconColor, showActorDisplayName: showActorDisplayName, showBubbleTime: showBubbleTime, downloadJob: _job, ), ), ), ), ChatBubbleReactions( bubbleData: widget.bubbleData, chatData: widget.chatData, isSender: widget.isSender, onChanged: widget.refetch, ), ], ); } } class _BubbleContent extends StatelessWidget { final Text actorText; final Text timeText; final Widget messageWidget; final GetChatResponseObject? parent; final GetChatResponseObject bubbleData; final bool isSender; final bool isRead; final String? selfId; final double spacing; final double timeIconSize; final Color timeIconColor; final bool showActorDisplayName; final bool showBubbleTime; final DownloadJob? downloadJob; const _BubbleContent({ required this.actorText, required this.timeText, required this.messageWidget, required this.parent, required this.bubbleData, required this.isSender, required this.isRead, required this.selfId, required this.spacing, required this.timeIconSize, required this.timeIconColor, required this.showActorDisplayName, required this.showBubbleTime, required this.downloadJob, }); @override Widget build(BuildContext context) => Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.9, minWidth: showActorDisplayName ? actorText.size.width : timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, ), child: Stack( children: [ if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorText), Padding( padding: EdgeInsets.only( bottom: showBubbleTime ? 18 : 0, top: showActorDisplayName ? 18 : 0, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (parent != null && bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[ AnswerReference( context: context, referenceMessage: parent!, selfId: selfId, ), const SizedBox(height: 5), ], messageWidget, ], ), ), if (showBubbleTime) Positioned( bottom: 0, right: 0, child: Row( children: [ timeText, if (isSender) ...[ SizedBox(width: spacing), Icon( isRead ? Icons.done_all_outlined : Icons.done_outlined, size: timeIconSize, color: timeIconColor, ), ], ], ), ), if (downloadJob?.status.value is DownloadInProgress) Positioned( bottom: 0, right: 0, left: 0, child: LinearProgressIndicator( value: () { final s = downloadJob!.status.value as DownloadInProgress; return s.percent <= 0 ? null : s.percent / 100; }(), ), ), ], ), ); }