From 76118d0cc51084aec6b010e46ebddcddf4b9b52c Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Mon, 7 Mar 2022 20:32:29 +0100 Subject: [PATCH] ui: Allow quoting messages --- lib/ui/pages/conversation/conversation.dart | 117 +++++++++++++++++--- lib/ui/redux/conversation/actions.dart | 7 ++ lib/ui/redux/conversation/reducers.dart | 8 +- lib/ui/redux/conversation/state.dart | 16 ++- lib/ui/widgets/quotedmessage.dart | 62 +++++++++++ lib/ui/widgets/textfield.dart | 48 ++++---- pubspec.lock | 16 +++ pubspec.yaml | 5 + 8 files changed, 237 insertions(+), 42 deletions(-) create mode 100644 lib/ui/widgets/quotedmessage.dart diff --git a/lib/ui/pages/conversation/conversation.dart b/lib/ui/pages/conversation/conversation.dart index 2d5c52ab..dc3652f5 100644 --- a/lib/ui/pages/conversation/conversation.dart +++ b/lib/ui/pages/conversation/conversation.dart @@ -4,6 +4,7 @@ import "package:moxxyv2/ui/widgets/topbar.dart"; import "package:moxxyv2/ui/widgets/chatbubble.dart"; import "package:moxxyv2/ui/widgets/avatar.dart"; import "package:moxxyv2/ui/widgets/textfield.dart"; +import "package:moxxyv2/ui/widgets/quotedmessage.dart"; import "package:moxxyv2/ui/pages/profile/profile.dart"; import "package:moxxyv2/ui/pages/conversation/arguments.dart"; import "package:moxxyv2/ui/constants.dart"; @@ -16,6 +17,8 @@ import "package:moxxyv2/ui/redux/conversation/actions.dart"; import "package:flutter/material.dart"; import "package:flutter_speed_dial/flutter_speed_dial.dart"; import "package:flutter_redux/flutter_redux.dart"; +import "package:swipeable_tile/swipeable_tile.dart"; +import "package:flutter_vibrate/flutter_vibrate.dart"; typedef SendMessageFunction = void Function(String body); @@ -58,8 +61,23 @@ class _MessageListViewModel { final void Function() closeChat; final void Function() resetCurrentConversation; final String backgroundPath; + final Message? quotedMessage; + final void Function(Message?) setQuotedMessage; - _MessageListViewModel({ required this.conversation, required this.showSendButton, required this.sendMessage, required this.setShowSendButton, required this.showScrollToEndButton, required this.setShowScrollToEndButton, required this.closeChat, required this.messages, required this.resetCurrentConversation, required this.backgroundPath }); + _MessageListViewModel({ + required this.conversation, + required this.showSendButton, + required this.sendMessage, + required this.setShowSendButton, + required this.showScrollToEndButton, + required this.setShowScrollToEndButton, + required this.closeChat, + required this.messages, + required this.resetCurrentConversation, + required this.backgroundPath, + required this.setQuotedMessage, + required this.quotedMessage + }); } class ConversationPage extends StatefulWidget { @@ -100,23 +118,82 @@ class _ConversationPageState extends State { } } - Widget _renderBubble(List messages, int _index, double maxWidth) { + Widget _renderBubble(_MessageListViewModel viewModel, int _index, double maxWidth) { // TODO: Since we reverse the list: Fix start, end and between - final index = messages.length - 1 - _index; - Message item = messages[index]; - bool start = index - 1 < 0 ? true : messages[index - 1].sent != item.sent; - bool end = index + 1 >= messages.length ? true : messages[index + 1].sent != item.sent; + final index = viewModel.messages.length - 1 - _index; + Message item = viewModel.messages[index]; + bool start = index - 1 < 0 ? true : viewModel.messages[index - 1].sent != item.sent; + bool end = index + 1 >= viewModel.messages.length ? true : viewModel.messages[index + 1].sent != item.sent; bool between = !start && !end; - return ChatBubble( - message: item, - sentBySelf: item.sent, - start: start, - end: end, - between: between, - closerTogether: !end, - maxWidth: maxWidth, - key: ValueKey("message;" + item.toString()) + return SwipeableTile.swipeToTrigger( + direction: SwipeDirection.horizontal, + swipeThreshold: 0.2, + onSwiped: (_) => viewModel.setQuotedMessage(item), + backgroundBuilder: (_, direction, progress) { + // NOTE: Taken from https://github.com/watery-desert/swipeable_tile/blob/main/example/lib/main.dart#L240 + // and modified. + bool 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( + begin: 0.0, + end: 1.2, + ) + .animate( + CurvedAnimation( + parent: progress, + curve: const Interval(0.5, 1.0, + curve: Curves.linear), + ), + ) + .value, + child: Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.3), + shape: BoxShape.circle + ), + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Icon( + Icons.reply, + color: Colors.white, + ) + ) + ), + ), + ), + ); + }, + ); + }, + isEelevated: false, + key: ValueKey("message;" + item.toString()), + child: ChatBubble( + message: item, + sentBySelf: item.sent, + start: start, + end: end, + between: between, + closerTogether: !end, + maxWidth: maxWidth, + ) ); } @@ -148,7 +225,9 @@ class _ConversationPageState extends State { ) ), resetCurrentConversation: () => store.dispatch(SetOpenConversationAction(jid: null)), - backgroundPath: store.state.preferencesState.backgroundPath + backgroundPath: store.state.preferencesState.backgroundPath, + setQuotedMessage: (msg) => store.dispatch(QuoteMessageUIAction(msg)), + quotedMessage: store.state.conversationPageState.quotedMessage ); }, builder: (context, viewModel) { @@ -224,7 +303,7 @@ class _ConversationPageState extends State { child: ListView.builder( reverse: true, itemCount: viewModel.messages.length, - itemBuilder: (context, index) => _renderBubble(viewModel.messages, index, maxWidth) + itemBuilder: (context, index) => _renderBubble(viewModel, index, maxWidth) ) ), Positioned( @@ -261,6 +340,10 @@ class _ConversationPageState extends State { onChanged: (value) => _onMessageTextChanged(value, viewModel), contentPadding: textfieldPaddingConversation, cornerRadius: textfieldRadiusConversation, + topWidget: viewModel.quotedMessage != null ? QuotedMessageWidget( + message: viewModel.quotedMessage!, + resetQuotedMessage: () => viewModel.setQuotedMessage(null) + ) : null ) ), Padding( diff --git a/lib/ui/redux/conversation/actions.dart b/lib/ui/redux/conversation/actions.dart index 3cd75710..71a7f4e2 100644 --- a/lib/ui/redux/conversation/actions.dart +++ b/lib/ui/redux/conversation/actions.dart @@ -106,3 +106,10 @@ class UpdateConversationAction { UpdateConversationAction({ required this.conversation }); } + +/// Triggered when a message is to be quoted +class QuoteMessageUIAction { + final Message? message; + + QuoteMessageUIAction(this.message); +} diff --git a/lib/ui/redux/conversation/reducers.dart b/lib/ui/redux/conversation/reducers.dart index 971ca900..2ab403ff 100644 --- a/lib/ui/redux/conversation/reducers.dart +++ b/lib/ui/redux/conversation/reducers.dart @@ -51,9 +51,13 @@ HashMap> messageReducer(HashMap> sta ConversationPageState conversationPageReducer(ConversationPageState state, dynamic action) { if (action is SetShowSendButtonAction) { - return state.copyWith(showSendButton: action.show); + return state.copyWith(state.quotedMessage, showSendButton: action.show); } else if (action is SetShowScrollToEndButtonAction) { - return state.copyWith(showScrollToEndButton: action.show); + return state.copyWith(state.quotedMessage, showScrollToEndButton: action.show); + } else if (action is QuoteMessageUIAction) { + return state.copyWith(action.message); + } else if (action is SendMessageAction) { + return state.copyWith(null); } return state; diff --git a/lib/ui/redux/conversation/state.dart b/lib/ui/redux/conversation/state.dart index 772cd885..330864ad 100644 --- a/lib/ui/redux/conversation/state.dart +++ b/lib/ui/redux/conversation/state.dart @@ -1,14 +1,22 @@ +import "package:moxxyv2/shared/models/message.dart"; + class ConversationPageState { final bool showSendButton; final bool showScrollToEndButton; + final Message? quotedMessage; - ConversationPageState({ required this.showSendButton, required this.showScrollToEndButton }); - ConversationPageState.initialState() : showSendButton = false, showScrollToEndButton = false; + ConversationPageState({ + required this.showSendButton, + required this.showScrollToEndButton, + this.quotedMessage + }); + ConversationPageState.initialState() : showSendButton = false, showScrollToEndButton = false, quotedMessage = null; - ConversationPageState copyWith({ bool? showSendButton, bool? showScrollToEndButton }) { + ConversationPageState copyWith(Message? quotedMessage, { bool? showSendButton, bool? showScrollToEndButton }) { return ConversationPageState( showSendButton: showSendButton ?? this.showSendButton, - showScrollToEndButton: showScrollToEndButton ?? this.showScrollToEndButton + showScrollToEndButton: showScrollToEndButton ?? this.showScrollToEndButton, + quotedMessage: quotedMessage ); } } diff --git a/lib/ui/widgets/quotedmessage.dart b/lib/ui/widgets/quotedmessage.dart new file mode 100644 index 00000000..ed0acb0e --- /dev/null +++ b/lib/ui/widgets/quotedmessage.dart @@ -0,0 +1,62 @@ +import "package:moxxyv2/shared/models/message.dart"; +import "package:moxxyv2/ui/constants.dart"; + +import "package:flutter/material.dart"; + +/// This Widget is used to show that a message has been quoted. +class QuotedMessageWidget extends StatelessWidget { + final Message message; + final void Function() resetQuotedMessage; + + /// [message]: The message used to quote + /// [resetQuotedMessage]: Function to reset the quoted message + const QuotedMessageWidget({ + required this.message, + required this.resetQuotedMessage, + Key? key + }) : super(key: key); + + @override + Widget build(BuildContext context) { + const quoteLeftBorderWidth = 7.0; + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + decoration: const BoxDecoration( + color: bubbleColorReceived, + borderRadius: BorderRadius.all(radiusLarge) + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + Positioned( + left: 0.0, + top: 0.0, + bottom: 0.0, + child: Container( + color: Colors.white, + width: quoteLeftBorderWidth, + ) + ), + Positioned( + right: 3.0, + top: 3.0, + child: InkWell( + onTap: resetQuotedMessage, + child: Icon( + Icons.close, + size: 24.0 + ) + ) + ), + Padding( + padding: const EdgeInsets.all(8.0).add(const EdgeInsets.only(left: quoteLeftBorderWidth, right: 26.0)), + child: Text(message.body) + ), + ] + ) + ) + ); + } +} diff --git a/lib/ui/widgets/textfield.dart b/lib/ui/widgets/textfield.dart index 1f2a7719..466592c0 100644 --- a/lib/ui/widgets/textfield.dart +++ b/lib/ui/widgets/textfield.dart @@ -10,6 +10,7 @@ class CustomTextField extends StatelessWidget { final Widget? suffix; final String? suffixText; final Widget? suffixIcon; + final Widget? topWidget; final EdgeInsetsGeometry contentPadding; final bool enabled; final bool obscureText; @@ -28,6 +29,7 @@ class CustomTextField extends StatelessWidget { this.suffix, this.suffixText, this.suffixIcon, + this.topWidget, this.enabled = true, this.obscureText = false, this.maxLines = 1, @@ -42,6 +44,8 @@ class CustomTextField extends StatelessWidget { @override Widget build(BuildContext context) { + const quoteLeftBorderWidth = 7.0; + return Column( children: [ Container( @@ -52,25 +56,31 @@ class CustomTextField extends StatelessWidget { color: Colors.purple ) ), - child: TextField( - maxLines: maxLines, - minLines: minLines, - obscureText: obscureText, - enabled: enabled, - controller: controller, - onChanged: onChanged, - enableSuggestions: enableIMEFeatures, - autocorrect: enableIMEFeatures, - decoration: InputDecoration( - labelText: labelText, - hintText: hintText, - border: InputBorder.none, - contentPadding: contentPadding, - suffixIcon: suffixIcon, - suffix: suffix, - suffixText: suffixText, - isDense: isDense - ) + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...(topWidget != null ? [ topWidget! ] : []), + TextField( + maxLines: maxLines, + minLines: minLines, + obscureText: obscureText, + enabled: enabled, + controller: controller, + onChanged: onChanged, + enableSuggestions: enableIMEFeatures, + autocorrect: enableIMEFeatures, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + border: InputBorder.none, + contentPadding: contentPadding, + suffixIcon: suffixIcon, + suffix: suffix, + suffixText: suffixText, + isDense: isDense + ) + ) + ] ) ), Visibility( diff --git a/pubspec.lock b/pubspec.lock index bc193eb2..ed6d30a6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -456,6 +456,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "5.0.0+1" + flutter_vibrate: + dependency: "direct main" + description: + name: flutter_vibrate + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" flutter_web_plugins: dependency: transitive description: flutter @@ -958,6 +965,15 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + swipeable_tile: + dependency: "direct main" + description: + path: "." + ref: bfab5e28f1f1ea624232002f0d05481cb2bd9997 + resolved-ref: bfab5e28f1f1ea624232002f0d05481cb2bd9997 + url: "https://github.com/PapaTutuWawa/swipeable_tile.git" + source: git + version: "1.0.0+6" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b25d7163..ff310d8a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,11 @@ dependencies: open_file: ^3.2.1 hex: ^0.2.0 drop_down_list: ^0.0.2 + swipeable_tile: + git: + url: https://github.com/PapaTutuWawa/swipeable_tile.git + ref: bfab5e28f1f1ea624232002f0d05481cb2bd9997 + flutter_vibrate: ^1.3.0 dev_dependencies: #flutter_test: