Compare commits

...

4 Commits

6 changed files with 274 additions and 239 deletions

View File

@ -103,7 +103,8 @@
"retractBody": "Are you sure you want to retract the message? Keep in mind that this is only a request that the client does not have to honour.",
"forward": "Forward",
"edit": "Edit",
"quote": "Quote"
"quote": "Quote",
"copy": "Copy content"
},
"addcontact": {
"title": "Add new contact",

View File

@ -103,7 +103,8 @@
"retractBody": "Bist du dir sicher, dass du die Nachricht löschen willst? Bedenke, dass dies nur eine Bitte ist, die dein gegenüber nicht beachten muss.",
"forward": "Weiterleiten",
"edit": "Bearbeiten",
"quote": "Zitieren"
"quote": "Zitieren",
"copy": "Inhalt kopieren"
},
"addcontact": {
"title": "Neuen Kontakt hinzufügen",

View File

@ -242,7 +242,11 @@ Future<void> performGetMessagesForJid(GetMessagesForJidCommand command, { dynami
Future<void> performSetOpenConversation(SetOpenConversationCommand command, { dynamic extra }) async {
await GetIt.I.get<XmppService>().setCurrentlyOpenedChatJid(command.jid ?? '');
await GetIt.I.get<NotificationsService>().dismissNotificationsByJid(command.jid!);
// Null just means that the chat has been closed
if (command.jid != null) {
await GetIt.I.get<NotificationsService>().dismissNotificationsByJid(command.jid!);
}
}
Future<void> performSendMessage(SendMessageCommand command, { dynamic extra }) async {

View File

@ -154,4 +154,7 @@ class Message with _$Message {
mediaType!.startsWith('image/') ||
mediaType!.startsWith('video/')
);
/// Returns true if the message can be copied to the clipboard.
bool get isCopyable => !isMedia && body.isNotEmpty;
}

View File

@ -1,8 +1,14 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_vibrate/flutter_vibrate.dart';
import 'package:get_it/get_it.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';
@ -10,6 +16,7 @@ import 'package:moxxyv2/ui/pages/conversation/bottom.dart';
import 'package:moxxyv2/ui/pages/conversation/helpers.dart';
import 'package:moxxyv2/ui/pages/conversation/topbar.dart';
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
import 'package:moxxyv2/ui/widgets/overview_menu.dart';
class ConversationPage extends StatefulWidget {
const ConversationPage({ super.key });
@ -35,7 +42,9 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
final TextEditingController _controller;
final ValueNotifier<bool> _isSpeedDialOpen;
final ScrollController _scrollController;
late final AnimationController _animationController;
late final AnimationController _animationController;
late final AnimationController _overviewAnimationController;
late Animation<double> _overviewMsgAnimation;
late final Animation<double> _scrollToBottom;
bool _scrolledToBottomState;
@ -44,10 +53,15 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
super.initState();
_scrollController.addListener(_onScroll);
_overviewAnimationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
// Values taken from here: https://stackoverflow.com/questions/45539395/flutter-float-action-button-hiding-the-visibility-of-items#45598028
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 180),
vsync: this,
);
_scrollToBottom = CurvedAnimation(
parent: _animationController,
@ -62,10 +76,33 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
..removeListener(_onScroll)
..dispose();
_animationController.dispose();
_overviewAnimationController.dispose();
super.dispose();
}
void _quoteMessage(BuildContext context, Message message) {
context.read<ConversationBloc>().add(MessageQuotedEvent(message));
}
Future<void> _retractMessage(BuildContext context, String originId) 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(originId),
);
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
}
}
Widget _renderBubble(ConversationState state, BuildContext context, int _index, double maxWidth, String jid) {
// TODO(Unknown): Since we reverse the list: Fix start, end and between
final index = state.messages.length - 1 - _index;
@ -78,17 +115,145 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
isSent(state.messages[index + 1], jid) != isSent(item, jid);
final between = !start && !end;
final lastMessageTimestamp = index > 0 ? state.messages[index - 1].timestamp : null;
final sentBySelf = isSent(item, jid);
final bubble = RawChatBubble(
item,
maxWidth,
sentBySelf,
state.conversation!.encrypted,
start,
between,
end,
);
return ChatBubble(
bubble: bubble,
message: item,
sentBySelf: isSent(item, jid),
chatEncrypted: state.conversation!.encrypted,
start: start,
end: end,
between: between,
sentBySelf: sentBySelf,
maxWidth: maxWidth,
lastMessageTimestamp: lastMessageTimestamp,
onSwipedCallback: (_) => context.read<ConversationBloc>().add(MessageQuotedEvent(item)),
onSwipedCallback: (_) => _quoteMessage(context, item),
onLongPressed: (event) async {
if (!item.isLongpressable) {
return;
}
Vibrate.feedback(FeedbackType.medium);
_overviewMsgAnimation = Tween<double>(
begin: event.globalPosition.dy - 20,
end: 200,
).animate(
CurvedAnimation(
parent: _overviewAnimationController,
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 _overviewAnimationController.forward();
await showDialog<void>(
context: context,
builder: (context) => OverviewMenu(
_overviewMsgAnimation,
rightBorder: sentBySelf,
left: sentBySelf ? null : 8,
right: sentBySelf ? 8 : null,
highlightMaterialBorder: RawChatBubble.getBorderRadius(
sentBySelf,
start,
between,
end,
),
highlight: bubble,
children: [
...item.canRetract(sentBySelf) ? [
OverviewMenuItem(
icon: Icons.delete,
text: t.pages.conversation.retract,
onPressed: () => _retractMessage(context, item.originId!),
),
] : [],
...item.canEdit(sentBySelf) ? [
OverviewMenuItem(
icon: Icons.edit,
text: t.pages.conversation.edit,
onPressed: () {
showNotImplementedDialog(
'editing',
context,
);
},
),
] : [],
...item.errorMenuVisible ? [
OverviewMenuItem(
icon: Icons.info_outline,
text: 'Show Error',
onPressed: () {
showInfoDialog(
'Error',
errorToTranslatableString(item.errorType!),
context,
);
},
),
] : [],
...item.hasWarning ? [
OverviewMenuItem(
icon: Icons.warning,
text: 'Show warning',
onPressed: () {
showInfoDialog(
'Warning',
warningToTranslatableString(item.warningType!),
context,
);
},
),
] : [],
...item.isCopyable ? [
OverviewMenuItem(
icon: Icons.content_copy,
text: t.pages.conversation.copy,
onPressed: () {
// TODO(Unknown): Show a toast saying the message has been copied
Clipboard.setData(ClipboardData(text: item.body));
Navigator.of(context).pop();
},
),
] : [],
...item.isQuotable ? [
OverviewMenuItem(
icon: Icons.forward,
text: t.pages.conversation.forward,
onPressed: () {
showNotImplementedDialog(
'sharing',
context,
);
},
),
] : [],
OverviewMenuItem(
icon: Icons.reply,
text: t.pages.conversation.quote,
onPressed: () {
_quoteMessage(context, item);
Navigator.of(context).pop();
},
),
],
),
);
await _overviewAnimationController.reverse();
},
);
}

View File

@ -1,104 +1,49 @@
// 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:moxxyv2/ui/widgets/overview_menu.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 RawChatBubble extends StatelessWidget {
const RawChatBubble(
this.message,
this.maxWidth,
this.sentBySelf,
this.chatEncrypted,
this.start,
this.between,
this.end,
{
super.key,
}
);
}
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 double maxWidth;
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;
final bool start;
@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() {
static BorderRadius getBorderRadius(bool sentBySelf, bool start, bool between, bool end) {
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,
topLeft: !sentBySelf && (between || end) && !(start && end) ? radiusSmall : radiusLarge,
topRight: sentBySelf && (between || end) && !(start && end) ? radiusSmall : radiusLarge,
bottomLeft: !sentBySelf && (between || start) && !(start && end) ? radiusSmall : radiusLarge,
bottomRight: sentBySelf && (between || start) && !(start && 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/');
if (message.mediaType != null) {
return message.mediaType!.startsWith('image/');
}
return false;
@ -106,31 +51,91 @@ class ChatBubbleState extends State<ChatBubble>
/// Specified when the message bubble should not have color
bool _shouldNotColorBubble() {
return widget.message.isMedia && widget.message.mediaUrl != null && _isInlinedWidget();
return message.isMedia && 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) {
if (chatEncrypted && !message.encrypted) {
return bubbleColorUnencrypted;
}
if (widget.message.isRetracted) {
if (widget.sentBySelf) {
if (message.isRetracted) {
if (sentBySelf) {
return const Color(0xff614d91);
} else {
return const Color(0xff585858);
}
}
if (widget.sentBySelf) {
if (sentBySelf) {
return bubbleColorSent;
} else {
return bubbleColorReceived;
}
}
@override
Widget build(BuildContext context) {
final borderRadius = getBorderRadius(sentBySelf, start, between, end);
return Container(
constraints: BoxConstraints(
maxWidth: maxWidth,
),
decoration: BoxDecoration(
color: _getBubbleColor(context),
borderRadius: borderRadius,
),
child: Padding(
// NOTE: Images don't work well with padding here
padding: message.isMedia || message.quotes != null ?
EdgeInsets.zero :
const EdgeInsets.all(8),
child: buildMessageWidget(
message,
maxWidth,
borderRadius,
sentBySelf,
),
),
);
}
}
class ChatBubble extends StatefulWidget {
const ChatBubble({
required this.message,
required this.sentBySelf,
required this.maxWidth,
required this.lastMessageTimestamp,
required this.onSwipedCallback,
required this.bubble,
this.onLongPressed,
super.key,
});
final Message message;
final bool sentBySelf;
// For rendering the corners
final double maxWidth;
// For rendering the date bubble
final int? lastMessageTimestamp;
// For acting on swiping
final void Function(Message) onSwipedCallback;
// For acting on long-pressing the message
final GestureLongPressStartCallback? onLongPressed;
// THe actual message bubble
final RawChatBubble bubble;
@override
ChatBubbleState createState() => ChatBubbleState();
}
class ChatBubbleState extends State<ChatBubble>
with AutomaticKeepAliveClientMixin<ChatBubble> {
@override
bool get wantKeepAlive => true;
SwipeDirection _getSwipeDirection() {
// Should the message be quotable?
@ -141,48 +146,7 @@ class ChatBubbleState extends State<ChatBubble>
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) {
final message = 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,
),
),
);
return SwipeableTile.swipeToTrigger(
direction: _getSwipeDirection(),
swipeThreshold: 0.2,
@ -250,111 +214,8 @@ class ChatBubbleState extends State<ChatBubble>
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) => OverviewMenu(
_msgY,
rightBorder: widget.sentBySelf,
left: widget.sentBySelf ? null : 8,
right: widget.sentBySelf ? 8 : null,
highlightMaterialBorder: _getBorderRadius(),
highlight: message,
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,
);
},
),
] : [],
...widget.message.isQuotable ? [
_buildMessageOption(
Icons.forward,
t.pages.conversation.forward,
() {
showNotImplementedDialog(
'sharing',
context,
);
},
),
] : [],
_buildMessageOption(
Icons.reply,
t.pages.conversation.quote,
() {
widget.onSwipedCallback(widget.message);
Navigator.of(context).pop();
},
),
],
),
);
await _controller.reverse();
},
child: message,
onLongPressStart: widget.onLongPressed,
child: widget.bubble,
),
],
),