ui: Allow quoting messages
This commit is contained in:
parent
7f8cc962b9
commit
76118d0cc5
@ -4,6 +4,7 @@ import "package:moxxyv2/ui/widgets/topbar.dart";
|
|||||||
import "package:moxxyv2/ui/widgets/chatbubble.dart";
|
import "package:moxxyv2/ui/widgets/chatbubble.dart";
|
||||||
import "package:moxxyv2/ui/widgets/avatar.dart";
|
import "package:moxxyv2/ui/widgets/avatar.dart";
|
||||||
import "package:moxxyv2/ui/widgets/textfield.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/profile/profile.dart";
|
||||||
import "package:moxxyv2/ui/pages/conversation/arguments.dart";
|
import "package:moxxyv2/ui/pages/conversation/arguments.dart";
|
||||||
import "package:moxxyv2/ui/constants.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/material.dart";
|
||||||
import "package:flutter_speed_dial/flutter_speed_dial.dart";
|
import "package:flutter_speed_dial/flutter_speed_dial.dart";
|
||||||
import "package:flutter_redux/flutter_redux.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);
|
typedef SendMessageFunction = void Function(String body);
|
||||||
|
|
||||||
@ -58,8 +61,23 @@ class _MessageListViewModel {
|
|||||||
final void Function() closeChat;
|
final void Function() closeChat;
|
||||||
final void Function() resetCurrentConversation;
|
final void Function() resetCurrentConversation;
|
||||||
final String backgroundPath;
|
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 {
|
class ConversationPage extends StatefulWidget {
|
||||||
@ -100,23 +118,82 @@ class _ConversationPageState extends State<ConversationPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _renderBubble(List<Message> messages, int _index, double maxWidth) {
|
Widget _renderBubble(_MessageListViewModel viewModel, int _index, double maxWidth) {
|
||||||
// TODO: Since we reverse the list: Fix start, end and between
|
// TODO: Since we reverse the list: Fix start, end and between
|
||||||
final index = messages.length - 1 - _index;
|
final index = viewModel.messages.length - 1 - _index;
|
||||||
Message item = messages[index];
|
Message item = viewModel.messages[index];
|
||||||
bool start = index - 1 < 0 ? true : messages[index - 1].sent != item.sent;
|
bool start = index - 1 < 0 ? true : viewModel.messages[index - 1].sent != item.sent;
|
||||||
bool end = index + 1 >= messages.length ? true : 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;
|
bool between = !start && !end;
|
||||||
|
|
||||||
return ChatBubble(
|
return SwipeableTile.swipeToTrigger(
|
||||||
message: item,
|
direction: SwipeDirection.horizontal,
|
||||||
sentBySelf: item.sent,
|
swipeThreshold: 0.2,
|
||||||
start: start,
|
onSwiped: (_) => viewModel.setQuotedMessage(item),
|
||||||
end: end,
|
backgroundBuilder: (_, direction, progress) {
|
||||||
between: between,
|
// NOTE: Taken from https://github.com/watery-desert/swipeable_tile/blob/main/example/lib/main.dart#L240
|
||||||
closerTogether: !end,
|
// and modified.
|
||||||
maxWidth: maxWidth,
|
bool vibrated = false;
|
||||||
key: ValueKey("message;" + item.toString())
|
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.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<ConversationPage> {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
resetCurrentConversation: () => store.dispatch(SetOpenConversationAction(jid: null)),
|
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) {
|
builder: (context, viewModel) {
|
||||||
@ -224,7 +303,7 @@ class _ConversationPageState extends State<ConversationPage> {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
itemCount: viewModel.messages.length,
|
itemCount: viewModel.messages.length,
|
||||||
itemBuilder: (context, index) => _renderBubble(viewModel.messages, index, maxWidth)
|
itemBuilder: (context, index) => _renderBubble(viewModel, index, maxWidth)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@ -261,6 +340,10 @@ class _ConversationPageState extends State<ConversationPage> {
|
|||||||
onChanged: (value) => _onMessageTextChanged(value, viewModel),
|
onChanged: (value) => _onMessageTextChanged(value, viewModel),
|
||||||
contentPadding: textfieldPaddingConversation,
|
contentPadding: textfieldPaddingConversation,
|
||||||
cornerRadius: textfieldRadiusConversation,
|
cornerRadius: textfieldRadiusConversation,
|
||||||
|
topWidget: viewModel.quotedMessage != null ? QuotedMessageWidget(
|
||||||
|
message: viewModel.quotedMessage!,
|
||||||
|
resetQuotedMessage: () => viewModel.setQuotedMessage(null)
|
||||||
|
) : null
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
|
@ -106,3 +106,10 @@ class UpdateConversationAction {
|
|||||||
|
|
||||||
UpdateConversationAction({ required this.conversation });
|
UpdateConversationAction({ required this.conversation });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Triggered when a message is to be quoted
|
||||||
|
class QuoteMessageUIAction {
|
||||||
|
final Message? message;
|
||||||
|
|
||||||
|
QuoteMessageUIAction(this.message);
|
||||||
|
}
|
||||||
|
@ -51,9 +51,13 @@ HashMap<String, List<Message>> messageReducer(HashMap<String, List<Message>> sta
|
|||||||
|
|
||||||
ConversationPageState conversationPageReducer(ConversationPageState state, dynamic action) {
|
ConversationPageState conversationPageReducer(ConversationPageState state, dynamic action) {
|
||||||
if (action is SetShowSendButtonAction) {
|
if (action is SetShowSendButtonAction) {
|
||||||
return state.copyWith(showSendButton: action.show);
|
return state.copyWith(state.quotedMessage, showSendButton: action.show);
|
||||||
} else if (action is SetShowScrollToEndButtonAction) {
|
} 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;
|
return state;
|
||||||
|
@ -1,14 +1,22 @@
|
|||||||
|
import "package:moxxyv2/shared/models/message.dart";
|
||||||
|
|
||||||
class ConversationPageState {
|
class ConversationPageState {
|
||||||
final bool showSendButton;
|
final bool showSendButton;
|
||||||
final bool showScrollToEndButton;
|
final bool showScrollToEndButton;
|
||||||
|
final Message? quotedMessage;
|
||||||
|
|
||||||
ConversationPageState({ required this.showSendButton, required this.showScrollToEndButton });
|
ConversationPageState({
|
||||||
ConversationPageState.initialState() : showSendButton = false, showScrollToEndButton = false;
|
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(
|
return ConversationPageState(
|
||||||
showSendButton: showSendButton ?? this.showSendButton,
|
showSendButton: showSendButton ?? this.showSendButton,
|
||||||
showScrollToEndButton: showScrollToEndButton ?? this.showScrollToEndButton
|
showScrollToEndButton: showScrollToEndButton ?? this.showScrollToEndButton,
|
||||||
|
quotedMessage: quotedMessage
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
62
lib/ui/widgets/quotedmessage.dart
Normal file
62
lib/ui/widgets/quotedmessage.dart
Normal file
@ -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)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -10,6 +10,7 @@ class CustomTextField extends StatelessWidget {
|
|||||||
final Widget? suffix;
|
final Widget? suffix;
|
||||||
final String? suffixText;
|
final String? suffixText;
|
||||||
final Widget? suffixIcon;
|
final Widget? suffixIcon;
|
||||||
|
final Widget? topWidget;
|
||||||
final EdgeInsetsGeometry contentPadding;
|
final EdgeInsetsGeometry contentPadding;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final bool obscureText;
|
final bool obscureText;
|
||||||
@ -28,6 +29,7 @@ class CustomTextField extends StatelessWidget {
|
|||||||
this.suffix,
|
this.suffix,
|
||||||
this.suffixText,
|
this.suffixText,
|
||||||
this.suffixIcon,
|
this.suffixIcon,
|
||||||
|
this.topWidget,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
this.obscureText = false,
|
this.obscureText = false,
|
||||||
this.maxLines = 1,
|
this.maxLines = 1,
|
||||||
@ -42,6 +44,8 @@ class CustomTextField extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
const quoteLeftBorderWidth = 7.0;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@ -52,25 +56,31 @@ class CustomTextField extends StatelessWidget {
|
|||||||
color: Colors.purple
|
color: Colors.purple
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: Column(
|
||||||
maxLines: maxLines,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
minLines: minLines,
|
children: [
|
||||||
obscureText: obscureText,
|
...(topWidget != null ? [ topWidget! ] : []),
|
||||||
enabled: enabled,
|
TextField(
|
||||||
controller: controller,
|
maxLines: maxLines,
|
||||||
onChanged: onChanged,
|
minLines: minLines,
|
||||||
enableSuggestions: enableIMEFeatures,
|
obscureText: obscureText,
|
||||||
autocorrect: enableIMEFeatures,
|
enabled: enabled,
|
||||||
decoration: InputDecoration(
|
controller: controller,
|
||||||
labelText: labelText,
|
onChanged: onChanged,
|
||||||
hintText: hintText,
|
enableSuggestions: enableIMEFeatures,
|
||||||
border: InputBorder.none,
|
autocorrect: enableIMEFeatures,
|
||||||
contentPadding: contentPadding,
|
decoration: InputDecoration(
|
||||||
suffixIcon: suffixIcon,
|
labelText: labelText,
|
||||||
suffix: suffix,
|
hintText: hintText,
|
||||||
suffixText: suffixText,
|
border: InputBorder.none,
|
||||||
isDense: isDense
|
contentPadding: contentPadding,
|
||||||
)
|
suffixIcon: suffixIcon,
|
||||||
|
suffix: suffix,
|
||||||
|
suffixText: suffixText,
|
||||||
|
isDense: isDense
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
Visibility(
|
Visibility(
|
||||||
|
16
pubspec.lock
16
pubspec.lock
@ -456,6 +456,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.0+1"
|
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:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -958,6 +965,15 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
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:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -72,6 +72,11 @@ dependencies:
|
|||||||
open_file: ^3.2.1
|
open_file: ^3.2.1
|
||||||
hex: ^0.2.0
|
hex: ^0.2.0
|
||||||
drop_down_list: ^0.0.2
|
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:
|
dev_dependencies:
|
||||||
#flutter_test:
|
#flutter_test:
|
||||||
|
Loading…
Reference in New Issue
Block a user