moxxy/lib/ui/widgets/chat/chatbubble.dart

442 lines
16 KiB
Dart

// TODO(Unknown): The timestamp may be too light
// TODO(Unknown): The timestamp is too small
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_vibrate/flutter_vibrate.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/warning_types.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/chat/datebubble.dart';
import 'package:moxxyv2/ui/widgets/chat/media/media.dart';
import 'package:swipeable_tile/swipeable_tile.dart';
Widget _buildMessageOption(IconData icon, String text, void Function() callback) {
return InkResponse(
onTap: callback,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(
top: 8,
right: 8,
bottom: 8,
),
child: Icon(icon),
),
Text(text),
],
),
);
}
class ChatBubble extends StatefulWidget {
const ChatBubble({
required this.message,
required this.sentBySelf,
required this.chatEncrypted,
required this.between,
required this.start,
required this.end,
required this.maxWidth,
required this.lastMessageTimestamp,
required this.onSwipedCallback,
super.key,
});
final Message message;
final bool sentBySelf;
final bool chatEncrypted;
// For rendering the corners
final bool between;
final bool start;
final bool end;
final double maxWidth;
// For rendering the date bubble
final int? lastMessageTimestamp;
// For acting on swiping
final void Function(Message) onSwipedCallback;
@override
ChatBubbleState createState() => ChatBubbleState();
}
class ChatBubbleState extends State<ChatBubble>
with AutomaticKeepAliveClientMixin<ChatBubble>, TickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
late Animation<double> _msgY;
//late Animation<double> _msgX;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
bool get wantKeepAlive => true;
BorderRadius _getBorderRadius() {
return BorderRadius.only(
topLeft: !widget.sentBySelf && (widget.between || widget.end) && !(widget.start && widget.end) ? radiusSmall : radiusLarge,
topRight: widget.sentBySelf && (widget.between || widget.end) && !(widget.start && widget.end) ? radiusSmall : radiusLarge,
bottomLeft: !widget.sentBySelf && (widget.between || widget.start) && !(widget.start && widget.end) ? radiusSmall : radiusLarge,
bottomRight: widget.sentBySelf && (widget.between || widget.start) && !(widget.start && widget.end) ? radiusSmall : radiusLarge,
);
}
/// Returns true if the mime type has a special widget which replaces the bubble.
/// False otherwise.
bool _isInlinedWidget() {
if (widget.message.mediaType != null) {
return widget.message.mediaType!.startsWith('image/');
}
return false;
}
/// Specified when the message bubble should not have color
bool _shouldNotColorBubble() {
return widget.message.isMedia && widget.message.mediaUrl != null && _isInlinedWidget();
}
Color? _getBubbleColor(BuildContext context) {
if (_shouldNotColorBubble()) return null;
// Color the bubble red if it should be encrypted but is not.
if (widget.chatEncrypted && !widget.message.encrypted) {
return bubbleColorUnencrypted;
}
if (widget.message.isRetracted) {
if (widget.sentBySelf) {
return const Color(0xff614d91);
} else {
return const Color(0xff585858);
}
}
if (widget.sentBySelf) {
return bubbleColorSent;
} else {
return bubbleColorReceived;
}
}
SwipeDirection _getSwipeDirection() {
// Should the message be quotable?
if (!widget.message.isQuotable) {
return SwipeDirection.none;
}
return widget.sentBySelf ? SwipeDirection.endToStart : SwipeDirection.startToEnd;
}
/// Called when the user wants to retract the message
Future<void> _retractMessage(BuildContext context) async {
final result = await showConfirmationDialog(
t.pages.conversation.retract,
t.pages.conversation.retractBody,
context,
);
if (result) {
// ignore: use_build_context_synchronously
context.read<ConversationBloc>().add(
MessageRetractedEvent(widget.message.originId!),
);
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
}
}
Widget _buildBubble(BuildContext context) {
return SwipeableTile.swipeToTrigger(
direction: _getSwipeDirection(),
swipeThreshold: 0.2,
onSwiped: (_) => widget.onSwipedCallback(widget.message),
backgroundBuilder: (_, direction, progress) {
// NOTE: Taken from https://github.com/watery-desert/swipeable_tile/blob/main/example/lib/main.dart#L240
// and modified.
var vibrated = false;
return AnimatedBuilder(
animation: progress,
builder: (_, __) {
if (progress.value > 0.9999 && !vibrated) {
Vibrate.feedback(FeedbackType.light);
vibrated = true;
} else if (progress.value < 0.9999) {
vibrated = false;
}
return Container(
alignment: direction == SwipeDirection.endToStart ? Alignment.centerRight : Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(
right: direction == SwipeDirection.endToStart ? 24.0 : 0.0,
left: direction == SwipeDirection.startToEnd ? 24.0 : 0.0,
),
child: Transform.scale(
scale: Tween<double>(
begin: 0,
end: 1.2,
)
.animate(
CurvedAnimation(
parent: progress,
curve: const Interval(0.5, 1,),
),
)
.value,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
shape: BoxShape.circle,
),
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(
Icons.reply,
color: Colors.white,
),
),
),
),
),
);
},
);
},
isEelevated: false,
key: ValueKey('message;${widget.message}'),
child: Padding(
padding: EdgeInsets.only(
left: !widget.sentBySelf ? 8.0 : 0.0,
right: widget.sentBySelf ? 8.0 : 0.0,
),
child: Row(
mainAxisAlignment: widget.sentBySelf ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
GestureDetector(
onLongPressStart: (event) async {
if (!widget.message.isLongpressable) {
return;
}
Vibrate.feedback(FeedbackType.medium);
_msgY = Tween<double>(
begin: event.globalPosition.dy - 20,
end: 200,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutCubic,
),
);
// TODO(PapaTutuWawa): Animate the message to the center?
/*_msgX = Tween<double>(
begin: 8,
end: (MediaQuery.of(context).size.width - obj.paintBounds.width) / 2,
).animate(_controller);*/
await _controller.forward();
await showDialog<void>(
context: context,
builder: (context) {
return Stack(
children: [
AnimatedBuilder(
animation: _msgY,
builder: (context, child) {
return Positioned(
top: _msgY.value,
// TODO(PapaTutuWawa): See above
//right: widget.sentBySelf ? _msgX.value : null,
//left: widget.sentBySelf ? null : _msgX.value,
right: widget.sentBySelf ? 8 : null,
left: widget.sentBySelf ? null : 8,
child: Material(
borderRadius: _getBorderRadius(),
child: Container(
constraints: BoxConstraints(
maxWidth: widget.maxWidth,
),
decoration: BoxDecoration(
color: _getBubbleColor(context),
borderRadius: _getBorderRadius(),
),
child: Padding(
// NOTE: Images don't work well with padding here
padding: widget.message.isMedia || widget.message.quotes != null ? EdgeInsets.zero : const EdgeInsets.all(8),
child: buildMessageWidget(
widget.message,
widget.maxWidth,
_getBorderRadius(),
widget.sentBySelf,
),
),
),
),
);
},
),
Positioned(
bottom: 50,
right: widget.sentBySelf ? 8 : null,
left: widget.sentBySelf ? null : 8,
child: Row(
children: [
Material(
borderRadius: const BorderRadius.all(radiusLarge),
child: Padding(
padding: const EdgeInsets.all(12),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...widget.message.canRetract(widget.sentBySelf) ? [
_buildMessageOption(
Icons.delete,
t.pages.conversation.retract,
() => _retractMessage(context),
),
] : [],
...widget.message.canEdit(widget.sentBySelf) ? [
_buildMessageOption(
Icons.edit,
t.pages.conversation.edit,
() {
showNotImplementedDialog(
'editing',
context,
);
},
),
] : [],
...widget.message.errorMenuVisible ? [
_buildMessageOption(
Icons.info_outline,
'Show Error',
() {
showInfoDialog(
'Error',
errorToTranslatableString(widget.message.errorType!),
context,
);
},
),
] : [],
...widget.message.hasWarning ? [
_buildMessageOption(
Icons.warning,
'Show warning',
() {
showInfoDialog(
'Warning',
warningToTranslatableString(widget.message.warningType!),
context,
);
},
),
] : [],
_buildMessageOption(
Icons.forward,
t.pages.conversation.forward,
() {
showNotImplementedDialog(
'sharing',
context,
);
},
),
],
),
),
),
),
],
),
),
],
);
},
);
await _controller.reverse();
},
child: Container(
constraints: BoxConstraints(
maxWidth: widget.maxWidth,
),
decoration: BoxDecoration(
color: _getBubbleColor(context),
borderRadius: _getBorderRadius(),
),
child: Padding(
// NOTE: Images don't work well with padding here
padding: widget.message.isMedia || widget.message.quotes != null ?
EdgeInsets.zero :
const EdgeInsets.all(8),
child: buildMessageWidget(
widget.message,
widget.maxWidth,
_getBorderRadius(),
widget.sentBySelf,
),
),
),
),
],
),
),
);
}
Widget _buildWithDateBubble(Widget widget, String dateString) {
return IntrinsicHeight(
child: Column(
children: [
DateBubble(dateString),
widget,
],
),
);
}
@override
Widget build(BuildContext context) {
super.build(context);
// lastMessageTimestamp == null means that there is no previous message
final thisMessageDateTime = DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp);
if (widget.lastMessageTimestamp == null) {
return _buildWithDateBubble(
_buildBubble(context),
formatDateBubble(thisMessageDateTime, DateTime.now()),
);
}
final lastMessageDateTime = DateTime.fromMillisecondsSinceEpoch(widget.lastMessageTimestamp!);
if (lastMessageDateTime.day != thisMessageDateTime.day ||
lastMessageDateTime.month != thisMessageDateTime.month ||
lastMessageDateTime.year != thisMessageDateTime.year) {
return _buildWithDateBubble(
_buildBubble(context),
formatDateBubble(thisMessageDateTime, DateTime.now()),
);
}
return _buildBubble(context);
}
}