refactored broad range of the application, split files, modularized calendar and file views, centralized bottom sheets and clipboard handling, and implemented unit test coverage
This commit is contained in:
@@ -1,26 +1,22 @@
|
||||
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/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/async_action_button.dart';
|
||||
import '../../../../widget/loading_spinner.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';
|
||||
import 'poll_options_list.dart';
|
||||
|
||||
class ChatBubble extends StatefulWidget {
|
||||
final BuildContext context;
|
||||
@@ -54,8 +50,8 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
||||
late ChatMessage message;
|
||||
DownloadJob? _job;
|
||||
|
||||
late Offset _position = const Offset(0, 0);
|
||||
late Offset _dragStartPosition = Offset.zero;
|
||||
Offset _position = Offset.zero;
|
||||
Offset _dragStartPosition = Offset.zero;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -99,7 +95,7 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
||||
DownloadManager.instance.clear(job.remotePath);
|
||||
_detachJob();
|
||||
setState(() {});
|
||||
showDialog<void>(context: context, builder: (context) => AlertDialog(content: Text(message)));
|
||||
InfoDialog.show(context, message, title: 'Download fehlgeschlagen');
|
||||
} else if (status is DownloadCancelled) {
|
||||
DownloadManager.instance.clear(job.remotePath);
|
||||
_detachJob();
|
||||
@@ -122,66 +118,69 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
||||
}
|
||||
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
ConfirmDialog(
|
||||
title: 'Download abbrechen?',
|
||||
content: 'Möchtest du den Download abbrechen?',
|
||||
confirmButton: 'Ja, Abbrechen',
|
||||
cancelButton: 'Nein',
|
||||
onConfirm: () => _job?.cancel(),
|
||||
).asDialog(context);
|
||||
}
|
||||
|
||||
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 {
|
||||
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 _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);
|
||||
var showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
|
||||
var showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system
|
||||
&& widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment;
|
||||
final showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment
|
||||
&& widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
|
||||
final showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system
|
||||
&& widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment;
|
||||
|
||||
var parent = widget.bubbleData.parent;
|
||||
var actorText = Text(
|
||||
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),
|
||||
);
|
||||
|
||||
var timeText = Text(
|
||||
Jiffy.parseFromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).format(pattern: 'HH:mm'),
|
||||
final timeText = Text(
|
||||
DateTime.fromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).formatHm(),
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(color: widget.timeIconColor, fontSize: widget.timeIconSize),
|
||||
);
|
||||
@@ -191,191 +190,161 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
textDirection: TextDirection.ltr,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
|
||||
children: [
|
||||
GestureDetector(
|
||||
onHorizontalDragStart: (details) {
|
||||
_dragStartPosition = _position;
|
||||
},
|
||||
onHorizontalDragStart: (_) => _dragStartPosition = _position,
|
||||
onHorizontalDragUpdate: (details) {
|
||||
if(!widget.bubbleData.isReplyable) return;
|
||||
var dx = details.delta.dx - _dragStartPosition.dx;
|
||||
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);
|
||||
_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) {
|
||||
onHorizontalDragEnd: (_) {
|
||||
final isAction = _position.dx.abs() > 50;
|
||||
setState(() => _position = Offset.zero);
|
||||
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();
|
||||
}
|
||||
},
|
||||
onLongPress: _showOptionsDialog,
|
||||
onDoubleTap: _showOptionsDialog,
|
||||
onTap: _onTap,
|
||||
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;
|
||||
}(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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() ?? [],
|
||||
),
|
||||
),
|
||||
),
|
||||
ChatBubbleReactions(
|
||||
bubbleData: widget.bubbleData,
|
||||
chatData: widget.chatData,
|
||||
isSender: widget.isSender,
|
||||
onChanged: widget.refetch,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stack inside the bubble: actor name (top-left, optional), message body
|
||||
/// (centre), timestamp + read marker (bottom-right, optional), and a
|
||||
/// download progress bar overlaid at the bottom while a job is running.
|
||||
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;
|
||||
}(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user