Merge pull request #144 from GetStream/feature/animations

Feature/animations
This commit is contained in:
Salvatore Giordano
2020-11-23 12:20:07 +01:00
committed by GitHub
7 changed files with 447 additions and 293 deletions
+73 -50
View File
@@ -95,59 +95,82 @@ class MessageActionsModal extends StatelessWidget {
messageTheme: messageTheme, messageTheme: messageTheme,
), ),
), ),
IgnorePointer( TweenAnimationBuilder<double>(
child: MessageWidget( tween: Tween(begin: 0.0, end: 1.0),
key: Key('MessageWidget'), duration: Duration(milliseconds: 300),
reverse: reverse, builder: (context, val, snapshot) {
message: message.copyWith( return Transform.scale(
text: message.text.length > 200 scale: val,
? '${message.text.substring(0, 200)}...' child: IgnorePointer(
: message.text, child: MessageWidget(
), key: Key('MessageWidget'),
messageTheme: messageTheme, reverse: reverse,
showReactions: false, message: message.copyWith(
showUsername: false, text: message.text.length > 200
showReplyIndicator: false, ? '${message.text.substring(0, 200)}...'
showUserAvatar: showUserAvatar, : message.text,
showTimestamp: false, ),
translateUserAvatar: false, messageTheme: messageTheme,
showReactionPickerIndicator: true, showReactions: false,
showSendingIndicator: DisplayWidget.gone, showUsername: false,
shape: messageShape, showReplyIndicator: false,
), showUserAvatar: showUserAvatar,
), showTimestamp: false,
translateUserAvatar: false,
showReactionPickerIndicator: true,
showSendingIndicator: DisplayWidget.gone,
shape: messageShape,
),
),
);
}),
SizedBox( SizedBox(
height: 8, height: 8,
), ),
Padding( TweenAnimationBuilder<double>(
padding: const EdgeInsets.symmetric( tween: Tween(begin: 0.0, end: 1.0),
horizontal: 48.0, duration: Duration(milliseconds: 300),
), curve: Curves.easeInOut,
child: Material( builder: (context, val, wid) {
clipBehavior: Clip.hardEdge, return Transform(
shape: RoundedRectangleBorder( transform: Matrix4.identity()
borderRadius: BorderRadius.circular(16), ..scale(val)
), ..rotateZ(-1.0 + val),
child: Column( alignment: Alignment.topRight,
crossAxisAlignment: CrossAxisAlignment.stretch, child: Padding(
children: ListTile.divideTiles( padding: const EdgeInsets.symmetric(
context: context, horizontal: 48.0,
tiles: [ ),
if (showReply && child: Material(
(message.status == clipBehavior: Clip.hardEdge,
MessageSendingStatus.SENT || shape: RoundedRectangleBorder(
message.status == null) && borderRadius: BorderRadius.circular(16),
message.parentId == null) ),
_buildReplyButton(context), child: Column(
if (showEditMessage) _buildEditMessage(context), crossAxisAlignment:
if (showDeleteMessage) CrossAxisAlignment.stretch,
_buildDeleteButton(context), children: ListTile.divideTiles(
if (showCopyMessage) _buildCopyButton(context), context: context,
], tiles: [
).toList(), if (showReply &&
), (message.status ==
), MessageSendingStatus.SENT ||
) message.status == null) &&
message.parentId == null)
_buildReplyButton(context),
if (showEditMessage)
_buildEditMessage(context),
if (showDeleteMessage)
_buildDeleteButton(context),
if (showCopyMessage)
_buildCopyButton(context),
],
).toList(),
),
),
),
);
})
], ],
), ),
), ),
+165 -137
View File
@@ -540,86 +540,101 @@ class MessageInputState extends State<MessageInput> {
bottom: size.height + MediaQuery.of(context).viewInsets.bottom, bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0, left: 0,
right: 0, right: 0,
child: Padding( child: TweenAnimationBuilder<double>(
padding: const EdgeInsets.all(8.0), tween: Tween(begin: 0.0, end: 1.0),
child: Card( duration: Duration(milliseconds: 300),
elevation: 2.0, curve: Curves.easeInOutExpo,
shape: RoundedRectangleBorder( builder: (context, val, wid) {
borderRadius: BorderRadius.circular(8.0), return Transform.scale(
), alignment: Alignment.center,
color: StreamChatTheme.of(context).primaryColor, scale: val,
clipBehavior: Clip.antiAlias, child: Padding(
child: Container( padding: const EdgeInsets.all(8.0),
constraints: BoxConstraints.loose(Size.fromHeight(400)), child: Card(
decoration: BoxDecoration( elevation: 2.0,
color: StreamChatTheme.of(context).primaryColor, shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0)), borderRadius: BorderRadius.circular(8.0),
child: ListView( ),
padding: const EdgeInsets.all(0), color: StreamChatTheme.of(context).primaryColor,
shrinkWrap: true, clipBehavior: Clip.antiAlias,
children: [ child: Container(
if (commands.isNotEmpty) constraints: BoxConstraints.loose(Size.fromHeight(400)),
Padding( decoration: BoxDecoration(
padding: const EdgeInsets.only(left: 8.0, top: 8.0), color: StreamChatTheme.of(context).primaryColor,
child: Row( borderRadius: BorderRadius.circular(8.0)),
child: ListView(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
children: [ children: [
Padding( if (commands.isNotEmpty)
padding: Padding(
const EdgeInsets.symmetric(horizontal: 8.0), padding:
child: Icon( const EdgeInsets.only(left: 8.0, top: 8.0),
StreamIcons.lightning, child: Row(
color: StreamChatTheme.of(context).accentColor, children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0),
child: Icon(
StreamIcons.lightning,
color: StreamChatTheme.of(context)
.accentColor,
),
),
Text(
'Instant Commands',
style: TextStyle(
color: Colors.black.withOpacity(.5),
),
)
],
),
), ),
), ...commands
Text( .map(
'Instant Commands', (c) => ListTile(
style: TextStyle( leading: c.name == 'giphy'
color: Colors.black.withOpacity(.5), ? _buildGiphyIcon()
), : null,
) title: Text.rich(
TextSpan(
text: '${c.name.capitalize()}',
style: TextStyle(
fontWeight: FontWeight.bold),
children: [
TextSpan(
text: ' /${c.name} ${c.args}',
style: TextStyle(
fontWeight: FontWeight.w300,
),
),
],
),
),
trailing: CircleAvatar(
backgroundColor:
StreamChatTheme.of(context).accentColor,
child: Icon(
StreamIcons.lightning,
color: Colors.white,
size: 12.5,
),
maxRadius: 12,
),
//subtitle: Text(c.description),
onTap: () {
_setCommand(c);
},
),
)
.toList(),
], ],
), ),
), ),
...commands ),
.map( ),
(c) => ListTile( );
leading: c.name == 'giphy' ? _buildGiphyIcon() : null, }),
title: Text.rich(
TextSpan(
text: '${c.name.capitalize()}',
style: TextStyle(fontWeight: FontWeight.bold),
children: [
TextSpan(
text: ' /${c.name} ${c.args}',
style: TextStyle(
fontWeight: FontWeight.w300,
),
),
],
),
),
trailing: CircleAvatar(
backgroundColor:
StreamChatTheme.of(context).accentColor,
child: Icon(
StreamIcons.lightning,
color: Colors.white,
size: 12.5,
),
maxRadius: 12,
),
//subtitle: Text(c.description),
onTap: () {
_setCommand(c);
},
),
)
.toList(),
],
),
),
),
),
); );
}); });
} }
@@ -929,71 +944,84 @@ class MessageInputState extends State<MessageInput> {
bottom: size.height + MediaQuery.of(context).viewInsets.bottom, bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0, left: 0,
right: 0, right: 0,
child: Card( child: TweenAnimationBuilder<double>(
margin: EdgeInsets.all(8.0), tween: Tween(begin: 0.0, end: 1.0),
elevation: 2.0, duration: Duration(milliseconds: 300),
color: StreamChatTheme.of(context).primaryColor, curve: Curves.easeInOutExpo,
shape: RoundedRectangleBorder( builder: (context, val, wid) {
borderRadius: BorderRadius.circular(8.0), return Transform.scale(
), scale: val,
clipBehavior: Clip.antiAlias, child: Card(
child: Container( margin: EdgeInsets.all(8.0),
constraints: BoxConstraints.loose(Size.fromHeight(400)), elevation: 2.0,
decoration: BoxDecoration( color: StreamChatTheme.of(context).primaryColor,
color: StreamChatTheme.of(context).primaryColor, shape: RoundedRectangleBorder(
), borderRadius: BorderRadius.circular(8.0),
child: FutureBuilder<List<Member>>( ),
future: queryMembers ?? Future.value(members), clipBehavior: Clip.antiAlias,
initialData: members, child: Container(
builder: (context, snapshot) { constraints: BoxConstraints.loose(Size.fromHeight(400)),
return ListView( decoration: BoxDecoration(
padding: const EdgeInsets.all(0), color: StreamChatTheme.of(context).primaryColor,
shrinkWrap: true, ),
children: snapshot.data child: FutureBuilder<List<Member>>(
.map((m) => ListTile( future: queryMembers ?? Future.value(members),
leading: UserAvatar( initialData: members,
constraints: BoxConstraints.tight( builder: (context, snapshot) {
Size( return ListView(
40, padding: const EdgeInsets.all(0),
40, shrinkWrap: true,
), children: snapshot.data
), .map((m) => ListTile(
user: m.user, leading: UserAvatar(
), constraints: BoxConstraints.tight(
title: Text( Size(
'${m.user.name}', 40,
style: TextStyle(fontWeight: FontWeight.bold), 40,
), ),
subtitle: Text('@${m.userId}'), ),
trailing: Icon( user: m.user,
StreamIcons.at_mention, ),
color: StreamChatTheme.of(context).accentColor, title: Text(
), '${m.user.name}',
onTap: () { style: TextStyle(
_mentionedUsers.add(m.user); fontWeight: FontWeight.bold),
),
subtitle: Text('@${m.userId}'),
trailing: Icon(
StreamIcons.at_mention,
color: StreamChatTheme.of(context)
.accentColor,
),
onTap: () {
_mentionedUsers.add(m.user);
splits[splits.length - 1] = m.user.name; splits[splits.length - 1] = m.user.name;
final rejoin = splits.join('@'); final rejoin = splits.join('@');
textEditingController.value = TextEditingValue( textEditingController.value =
text: rejoin + TextEditingValue(
textEditingController.text.substring( text: rejoin +
textEditingController textEditingController.text
.selection.start), .substring(
selection: TextSelection.collapsed( textEditingController
offset: rejoin.length, .selection.start),
), selection: TextSelection.collapsed(
); offset: rejoin.length,
),
);
_mentionsOverlay?.remove(); _mentionsOverlay?.remove();
_mentionsOverlay = null; _mentionsOverlay = null;
}, },
)) ))
.toList(), .toList(),
); );
}), }),
), ),
), ),
);
}),
); );
}); });
} }
+82 -64
View File
@@ -86,27 +86,35 @@ class MessageReactionsModal extends StatelessWidget {
messageTheme: messageTheme, messageTheme: messageTheme,
), ),
), ),
IgnorePointer( TweenAnimationBuilder<double>(
child: MessageWidget( tween: Tween(begin: 0.0, end: 1.0),
key: Key('MessageWidget'), duration: Duration(milliseconds: 300),
reverse: reverse, builder: (context, val, snapshot) {
message: message.copyWith( return Transform.scale(
text: message.text.length > 200 scale: val,
? '${message.text.substring(0, 200)}...' child: IgnorePointer(
: message.text, child: MessageWidget(
), key: Key('MessageWidget'),
messageTheme: messageTheme, reverse: reverse,
showReactions: false, message: message.copyWith(
showUsername: false, text: message.text.length > 200
showUserAvatar: showUserAvatar, ? '${message.text.substring(0, 200)}...'
showReplyIndicator: false, : message.text,
showTimestamp: false, ),
translateUserAvatar: false, messageTheme: messageTheme,
showSendingIndicator: DisplayWidget.gone, showReactions: false,
shape: messageShape, showUsername: false,
showReactionPickerIndicator: true, showUserAvatar: showUserAvatar,
), showReplyIndicator: false,
), showTimestamp: false,
translateUserAvatar: false,
showSendingIndicator: DisplayWidget.gone,
shape: messageShape,
showReactionPickerIndicator: true,
),
),
);
}),
SizedBox( SizedBox(
height: 16, height: 16,
), ),
@@ -178,50 +186,60 @@ class MessageReactionsModal extends StatelessWidget {
BuildContext context, BuildContext context,
) { ) {
final isCurrentUser = reaction.user.id == currentUser.id; final isCurrentUser = reaction.user.id == currentUser.id;
return ConstrainedBox( return TweenAnimationBuilder<double>(
constraints: BoxConstraints.loose(Size( tween: Tween(begin: 0.0, end: 1.0),
64, duration: Duration(milliseconds: 300),
98, curve: Curves.easeInOut,
)), builder: (context, val, snapshot) {
child: Column( return Transform.scale(
mainAxisSize: MainAxisSize.min, scale: val,
mainAxisAlignment: MainAxisAlignment.start, child: ConstrainedBox(
crossAxisAlignment: CrossAxisAlignment.center, constraints: BoxConstraints.loose(Size(
children: [ 64,
Stack( 98,
children: [ )),
UserAvatar( child: Column(
onTap: onUserAvatarTap, mainAxisSize: MainAxisSize.min,
user: reaction.user, mainAxisAlignment: MainAxisAlignment.start,
constraints: BoxConstraints.tightFor( crossAxisAlignment: CrossAxisAlignment.center,
height: 64, children: [
width: 64, Stack(
), children: [
borderRadius: BorderRadius.circular(32), UserAvatar(
), onTap: onUserAvatarTap,
Positioned( user: reaction.user,
child: Align( constraints: BoxConstraints.tightFor(
alignment: Alignment.centerLeft, height: 64,
child: ReactionBubble( width: 64,
reactions: [reaction], ),
borderColor: messageTheme.reactionsBorderColor, borderRadius: BorderRadius.circular(32),
backgroundColor: messageTheme.reactionsBackgroundColor, ),
highlightOwnReactions: false, Positioned(
child: Align(
alignment: Alignment.centerLeft,
child: ReactionBubble(
reactions: [reaction],
borderColor: messageTheme.reactionsBorderColor,
backgroundColor:
messageTheme.reactionsBackgroundColor,
highlightOwnReactions: false,
),
),
bottom: 4,
left: isCurrentUser ? 0 : null,
right: isCurrentUser ? 0 : null,
),
],
), ),
), Text(
bottom: 4, reaction.user.name,
left: isCurrentUser ? 0 : null, style: Theme.of(context).textTheme.subtitle2,
right: isCurrentUser ? 0 : null, textAlign: TextAlign.center,
),
],
), ),
], ),
), );
Text( });
reaction.user.name,
style: Theme.of(context).textTheme.subtitle2,
textAlign: TextAlign.center,
),
],
),
);
} }
} }
+121 -42
View File
@@ -1,4 +1,6 @@
import 'package:ezanimation/ezanimation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import '../stream_chat_flutter.dart'; import '../stream_chat_flutter.dart';
@@ -8,7 +10,8 @@ import '../stream_chat_flutter.dart';
/// It shows a reaction picker /// It shows a reaction picker
/// ///
/// Usually you don't use this widget as it's one of the default widgets used by [MessageWidget.onMessageActions]. /// Usually you don't use this widget as it's one of the default widgets used by [MessageWidget.onMessageActions].
class ReactionPicker extends StatelessWidget {
class ReactionPicker extends StatefulWidget {
const ReactionPicker({ const ReactionPicker({
Key key, Key key,
@required this.message, @required this.message,
@@ -18,59 +21,135 @@ class ReactionPicker extends StatelessWidget {
final Message message; final Message message;
final MessageTheme messageTheme; final MessageTheme messageTheme;
@override
_ReactionPickerState createState() => _ReactionPickerState();
}
class _ReactionPickerState extends State<ReactionPicker>
with TickerProviderStateMixin {
List<EzAnimation> animations = [];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final reactionIcons = StreamChatTheme.of(context).reactionIcons; final reactionIcons = StreamChatTheme.of(context).reactionIcons;
return Material(
color: messageTheme.reactionsBackgroundColor, if (animations.isEmpty && reactionIcons.isNotEmpty) {
clipBehavior: Clip.hardEdge, reactionIcons.forEach((element) {
shape: RoundedRectangleBorder( animations.add(
borderRadius: BorderRadius.circular(24), EzAnimation.sequence(
), [
child: Row( SequenceItem(0.0, 1.4),
crossAxisAlignment: CrossAxisAlignment.start, SequenceItem(1.4, 1.0),
mainAxisAlignment: MainAxisAlignment.end, ],
mainAxisSize: MainAxisSize.min, Duration(milliseconds: 500),
children: reactionIcons.map((reactionIcon) { vsync: this,
final ownReactionIndex = message.ownReactions?.indexWhere( ),
(reaction) => reaction.type == reactionIcon.type) ?? );
-1; });
return IconButton(
iconSize: 24, triggerAnimations();
icon: Icon( }
reactionIcon.iconData,
color: ownReactionIndex != -1 return TweenAnimationBuilder<double>(
? StreamChatTheme.of(context).accentColor tween: Tween(begin: 0.0, end: 1.0),
: Theme.of(context).iconTheme.color.withOpacity(.5), curve: Curves.easeInOutExpo,
duration: Duration(milliseconds: 500),
builder: (context, val, wid) {
return Transform.scale(
scale: val,
child: Material(
color: widget.messageTheme.reactionsBackgroundColor,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: reactionIcons.map((reactionIcon) {
final ownReactionIndex = widget.message.ownReactions
?.indexWhere((reaction) =>
reaction.type == reactionIcon.type) ??
-1;
var index = reactionIcons.indexOf(reactionIcon);
return IconButton(
iconSize: 24,
icon: AnimatedBuilder(
animation: animations[index],
builder: (context, val) {
return Transform(
transform: Matrix4.identity()
..scale(animations[index].value,
animations[index].value)
..rotateZ(1.0 - animations[index].value),
child: Icon(
reactionIcon.iconData,
size: animations[index].value * 24.0,
color: ownReactionIndex != -1
? StreamChatTheme.of(context).accentColor
: Theme.of(context)
.iconTheme
.color
.withOpacity(.5),
),
);
}),
onPressed: () {
if (ownReactionIndex != -1) {
removeReaction(
context,
widget.message.ownReactions[ownReactionIndex],
);
} else {
sendReaction(
context,
reactionIcon.type,
);
}
},
);
}).toList(),
),
), ),
onPressed: () {
if (ownReactionIndex != -1) {
removeReaction(
context,
message.ownReactions[ownReactionIndex],
);
} else {
sendReaction(
context,
reactionIcon.type,
);
}
},
); );
}).toList(), });
), }
);
void triggerAnimations() async {
for (var a in animations) {
a.start();
await Future.delayed(Duration(milliseconds: 100));
}
}
void pop() async {
for (var a in animations) {
a.stop();
}
Navigator.of(context).pop();
} }
/// Add a reaction to the message /// Add a reaction to the message
void sendReaction(BuildContext context, String reactionType) { void sendReaction(BuildContext context, String reactionType) {
StreamChannel.of(context).channel.sendReaction(message, reactionType); StreamChannel.of(context)
Navigator.of(context).pop(); .channel
.sendReaction(widget.message, reactionType);
pop();
} }
/// Remove a reaction from the message /// Remove a reaction from the message
void removeReaction(BuildContext context, Reaction reaction) { void removeReaction(BuildContext context, Reaction reaction) {
StreamChannel.of(context).channel.deleteReaction(message, reaction); StreamChannel.of(context).channel.deleteReaction(widget.message, reaction);
Navigator.of(context).pop(); pop();
}
@override
void dispose() {
for (var a in animations) {
a?.dispose();
}
super.dispose();
} }
} }
+1
View File
@@ -40,6 +40,7 @@ dependencies:
git: https://github.com/imtoori/media_gallery.git git: https://github.com/imtoori/media_gallery.git
permission_handler: ^5.0.1+1 permission_handler: ^5.0.1+1
transparent_image: ^1.0.0 transparent_image: ^1.0.0
ezanimation: ^0.4.1
flutter: flutter:
assets: assets:
+2
View File
@@ -40,6 +40,7 @@ void main() {
); );
await tester.pump(); await tester.pump();
await tester.pump(Duration(milliseconds: 1000));
expect(find.byKey(Key('MessageWidget')), findsOneWidget); expect(find.byKey(Key('MessageWidget')), findsOneWidget);
expect(find.byIcon(StreamIcons.sorting_up), findsOneWidget); expect(find.byIcon(StreamIcons.sorting_up), findsOneWidget);
expect(find.byIcon(StreamIcons.edit), findsOneWidget); expect(find.byIcon(StreamIcons.edit), findsOneWidget);
@@ -84,6 +85,7 @@ void main() {
); );
await tester.pump(); await tester.pump();
await tester.pump(Duration(milliseconds: 1000));
expect(find.byKey(Key('MessageWidget')), findsOneWidget); expect(find.byKey(Key('MessageWidget')), findsOneWidget);
expect(find.byIcon(StreamIcons.sorting_up), findsNothing); expect(find.byIcon(StreamIcons.sorting_up), findsNothing);
expect(find.byIcon(StreamIcons.edit), findsNothing); expect(find.byIcon(StreamIcons.edit), findsNothing);
@@ -39,6 +39,8 @@ void main() {
), ),
); );
await tester.pump(Duration(milliseconds: 1000));
expect(find.byKey(Key('MessageWidget')), findsOneWidget); expect(find.byKey(Key('MessageWidget')), findsOneWidget);
expect(find.byIcon(StreamIcons.thumbs_up_reaction), findsOneWidget); expect(find.byIcon(StreamIcons.thumbs_up_reaction), findsOneWidget);
}, },
@@ -87,6 +89,7 @@ void main() {
); );
await tester.pump(); await tester.pump();
await tester.pump(Duration(milliseconds: 1000));
expect(find.byKey(Key('MessageWidget')), findsOneWidget); expect(find.byKey(Key('MessageWidget')), findsOneWidget);
expect(find.byIcon(StreamIcons.thumbs_up_reaction), findsNWidgets(2)); expect(find.byIcon(StreamIcons.thumbs_up_reaction), findsNWidgets(2));
expect(find.byIcon(StreamIcons.love_reaction), findsNWidgets(2)); expect(find.byIcon(StreamIcons.love_reaction), findsNWidgets(2));