Files
Client/lib/view/pages/talk/widgets/chat_bubble.dart
T

382 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jiffy/jiffy.dart';
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message.dart';
import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart';
import '../../../../api/marianumcloud/talk/get_poll/get_poll_state.dart';
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 '../../../../extensions/text.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/download_manager.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/loading_spinner.dart';
import '../data/chat_bubble_styles.dart';
import '../data/chat_message.dart';
import 'answer_reference.dart';
import 'bubble.dart';
import 'chat_message_options_dialog.dart';
import 'poll_options_list.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<ChatBubble> createState() => _ChatBubbleState();
}
class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateMixin {
late ChatMessage message;
DownloadJob? _job;
late Offset _position = const Offset(0, 0);
late 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(() {});
showDialog<void>(context: context, builder: (context) => AlertDialog(content: Text(message)));
} else if (status is DownloadCancelled) {
DownloadManager.instance.clear(job.remotePath);
_detachJob();
setState(() {});
} else {
setState(() {});
}
}
Future<void> _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() {
showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Download abbrechen?'),
content: const Text('Möchtest du den Download abbrechen?'),
actions: [
TextButton(onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Nein')),
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
_job?.cancel();
},
child: const Text('Ja, Abbrechen'),
),
],
),
);
}
BubbleStyle getStyle() {
var styles = ChatBubbleStyles(context);
if(widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) {
if(widget.isSender) {
return styles.getSelfStyle(false);
} else {
return styles.getRemoteStyle(false);
}
} else {
return styles.getSystemStyle();
}
}
void showOptionsDialog() {
showChatMessageOptionsDialog(
context,
chatData: widget.chatData,
bubbleData: widget.bubbleData,
isSender: widget.isSender,
onRefetch: widget.refetch,
);
}
@override
Widget build(BuildContext context) {
message = ChatMessage(originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters);
var showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
var showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system
&& widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment;
var parent = widget.bubbleData.parent;
var actorText = Text(
widget.bubbleData.actorDisplayName,
textAlign: TextAlign.start,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
);
var timeText = Text(
Jiffy.parseFromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).format(pattern: 'HH:mm'),
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: (details) {
_dragStartPosition = _position;
},
onHorizontalDragUpdate: (details) {
if(!widget.bubbleData.isReplyable) return;
var dx = details.delta.dx - _dragStartPosition.dx;
setState(() {
_position = (_position.dx + dx).abs() > 60 ? Offset(_position.dx, 0) : Offset(_position.dx + dx, 0);
});
},
onHorizontalDragEnd: (DragEndDetails details) {
var isAction = _position.dx.abs() > 50;
setState(() {
_position = const Offset(0, 0);
});
if(widget.bubbleData.isReplyable && isAction) {
context.read<ChatBloc>().setReferenceMessageId(widget.bubbleData.id);
}
},
onLongPress: showOptionsDialog,
onDoubleTap: showOptionsDialog,
onTap: () {
if(message.originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
var pollId = int.parse(message.originalData!['object']!.id);
var pollState = GetPollState(token: widget.bubbleData.token, pollId: pollId).run();
showDialog(context: context, builder: (context) => AlertDialog(
title: Text(message.originalData!['object']!.name, overflow: TextOverflow.ellipsis),
content: FutureBuilder(
future: pollState,
builder: (context, snapshot) {
if(snapshot.connectionState == ConnectionState.waiting) return const Column(mainAxisSize: MainAxisSize.min, children: [LoadingSpinner()]);
var pollData = snapshot.data!.data;
return SingleChildScrollView(
child: PollOptionsList(
pollData: pollData,
chatToken: widget.chatData.token,
),
);
}
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zurück')
),
],
));
}
if (message.file == null) return;
if (_job?.status.value is DownloadInProgress) {
_confirmCancel();
} else {
_startFileDownload();
}
},
child: Transform.translate(
offset: _position,
child: Bubble(
style: getStyle(),
child: Column(
children: [
Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
minWidth: showActorDisplayName
? actorText.size.width
: timeText.size.width + (widget.isSender ? widget.spacing + widget.timeIconSize : 0) + 3,
),
child: Stack(
children: [
Visibility(
visible: showActorDisplayName,
child: 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 && widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[
AnswerReference(
context: context,
referenceMessage: parent,
selfId: widget.selfId,
),
const SizedBox(height: 5),
],
message.getWidget(),
],
),
),
Visibility(
visible: showBubbleTime,
child: Positioned(
bottom: 0,
right: 0,
child: Row(
children: [
timeText,
if(widget.isSender) ...[
SizedBox(width: widget.spacing),
Icon(
widget.isRead ? Icons.done_all_outlined: Icons.done_outlined,
size: widget.timeIconSize,
color: widget.timeIconColor
)
]
],
)
),
),
if (_job?.status.value is DownloadInProgress)
Positioned(
bottom: 0,
right: 0,
left: 0,
child: LinearProgressIndicator(
value: () {
final s = _job!.status.value as DownloadInProgress;
return s.percent <= 0 ? null : s.percent / 100;
}(),
),
),
],
),
),
],
),
),
),
),
Visibility(
visible: widget.bubbleData.reactions != null,
child: Transform.translate(
offset: const Offset(0, -10),
child: Container(
width: MediaQuery.of(context).size.width,
margin: const EdgeInsets.only(left: 15, right: 15),
child: Wrap(
alignment: widget.isSender ? WrapAlignment.end : WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
children: widget.bubbleData.reactions?.entries.map<Widget>((e) {
var hasSelfReacted = widget.bubbleData.reactionsSelf?.contains(e.key) ?? false;
return Container(
margin: const EdgeInsets.only(right: 2.5, left: 2.5),
child: ActionChip(
label: Text('${e.key} ${e.value}'),
visualDensity: const VisualDensity(vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity),
padding: EdgeInsets.zero,
backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null,
onPressed: () {
runWithErrorDialog(context, () async {
if (hasSelfReacted) {
await DeleteReactMessage(
chatToken: widget.chatData.token,
messageId: widget.bubbleData.id,
params: DeleteReactMessageParams(e.key),
).run();
} else {
await ReactMessage(
chatToken: widget.chatData.token,
messageId: widget.bubbleData.id,
params: ReactMessageParams(e.key),
).run();
}
widget.refetch(renew: true);
});
},
),
);
}).toList() ?? [],
),
),
),
),
],
);
}
}