design: Remake UX of selecting messages and chats

This commit is contained in:
krille-chan 2024-04-17 11:09:11 +02:00
commit 384e57fbd2
No known key found for this signature in database
2 changed files with 274 additions and 295 deletions

View file

@ -9,7 +9,6 @@ import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/date_time_extension.dart';
import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/utils/string_color.dart';
import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/matrix.dart';
import '../../../config/app_config.dart'; import '../../../config/app_config.dart';
import 'message_content.dart'; import 'message_content.dart';
@ -149,211 +148,247 @@ class Message extends StatelessWidget {
offset: Offset(0, animateIn ? 1 : 0), offset: Offset(0, animateIn ? 1 : 0),
duration: FluffyThemes.animationDuration, duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve, curve: FluffyThemes.animationCurve,
child: Row( child: Stack(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: rowMainAxisAlignment,
children: [ children: [
if (longPressSelect) Positioned(
SizedBox( top: 0,
height: 32, bottom: 0,
width: Avatar.defaultSize, left: 0,
child: Checkbox.adaptive( right: 0,
value: selected, child: InkWell(
shape: const CircleBorder(), onTap: () => onSelect(event),
onChanged: (_) => onSelect(event), borderRadius:
BorderRadius.circular(AppConfig.borderRadius / 2),
child: Material(
borderRadius:
BorderRadius.circular(AppConfig.borderRadius / 2),
color: selected
? Theme.of(context)
.colorScheme
.secondaryContainer
.withAlpha(100)
: highlightMarker
? Theme.of(context)
.colorScheme
.tertiaryContainer
.withAlpha(100)
: Colors.transparent,
), ),
)
else if (nextEventSameSender || ownMessage)
SizedBox(
width: Avatar.defaultSize,
child: Center(
child: SizedBox(
width: 16,
height: 16,
child: event.status == EventStatus.error
? const Icon(Icons.error, color: Colors.red)
: event.fileSendingStatus != null
? const CircularProgressIndicator.adaptive(
strokeWidth: 1,
)
: null,
),
),
)
else
FutureBuilder<User?>(
future: event.fetchSenderUser(),
builder: (context, snapshot) {
final user =
snapshot.data ?? event.senderFromMemoryOrFallback;
return Avatar(
mxContent: user.avatarUrl,
name: user.calcDisplayname(),
presenceUserId: user.stateKey,
presenceBackgroundColor: avatarPresenceBackgroundColor,
onTap: () => onAvatarTab(event),
);
},
), ),
Expanded( ),
child: Column( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisAlignment: rowMainAxisAlignment,
children: [ children: [
if (!nextEventSameSender) if (longPressSelect)
Padding( SizedBox(
padding: const EdgeInsets.only(left: 8.0, bottom: 4), height: 32,
child: ownMessage || event.room.isDirectChat width: Avatar.defaultSize,
? const SizedBox(height: 12) child: Checkbox.adaptive(
: FutureBuilder<User?>( value: selected,
future: event.fetchSenderUser(), shape: const CircleBorder(),
builder: (context, snapshot) { onChanged: (_) => onSelect(event),
final displayname =
snapshot.data?.calcDisplayname() ??
event.senderFromMemoryOrFallback
.calcDisplayname();
return Text(
displayname,
style: TextStyle(
fontSize: 12,
color: (Theme.of(context).brightness ==
Brightness.light
? displayname.color
: displayname.lightColorText),
),
);
},
),
), ),
Container( )
alignment: alignment, else if (nextEventSameSender || ownMessage)
padding: const EdgeInsets.only(left: 8), SizedBox(
child: GestureDetector( width: Avatar.defaultSize,
onLongPress: longPressSelect child: Center(
? null child: SizedBox(
: () { width: 16,
HapticFeedback.heavyImpact(); height: 16,
onSelect(event); child: event.status == EventStatus.error
}, ? const Icon(Icons.error, color: Colors.red)
child: AnimatedOpacity( : event.fileSendingStatus != null
opacity: animateIn ? const CircularProgressIndicator.adaptive(
? 0 strokeWidth: 1,
: event.redacted || )
event.messageType == : null,
MessageTypes.BadEncrypted || ),
event.status.isSending ),
? 0.5 )
: 1, else
duration: FluffyThemes.animationDuration, FutureBuilder<User?>(
curve: FluffyThemes.animationCurve, future: event.fetchSenderUser(),
child: Material( builder: (context, snapshot) {
color: noBubble ? Colors.transparent : color, final user =
clipBehavior: Clip.antiAlias, snapshot.data ?? event.senderFromMemoryOrFallback;
shape: RoundedRectangleBorder( return Avatar(
borderRadius: borderRadius, mxContent: user.avatarUrl,
), name: user.calcDisplayname(),
child: Container( presenceUserId: user.stateKey,
decoration: BoxDecoration( presenceBackgroundColor:
borderRadius: BorderRadius.circular( avatarPresenceBackgroundColor,
AppConfig.borderRadius, onTap: () => onAvatarTab(event),
), );
), },
padding: noBubble || noPadding ),
? EdgeInsets.zero Expanded(
: const EdgeInsets.symmetric( child: Column(
horizontal: 16, crossAxisAlignment: CrossAxisAlignment.start,
vertical: 8, mainAxisSize: MainAxisSize.min,
), children: [
constraints: const BoxConstraints( if (!nextEventSameSender)
maxWidth: FluffyThemes.columnWidth * 1.5, Padding(
), padding:
child: Column( const EdgeInsets.only(left: 8.0, bottom: 4),
mainAxisSize: MainAxisSize.min, child: ownMessage || event.room.isDirectChat
crossAxisAlignment: CrossAxisAlignment.start, ? const SizedBox(height: 12)
children: <Widget>[ : FutureBuilder<User?>(
if (event.relationshipType == future: event.fetchSenderUser(),
RelationshipTypes.reply) builder: (context, snapshot) {
FutureBuilder<Event?>( final displayname =
future: event.getReplyEvent(timeline), snapshot.data?.calcDisplayname() ??
builder: event.senderFromMemoryOrFallback
(BuildContext context, snapshot) { .calcDisplayname();
final replyEvent = snapshot.hasData return Text(
? snapshot.data! displayname,
: Event( style: TextStyle(
eventId: fontSize: 12,
event.relationshipEventId!, color:
content: { (Theme.of(context).brightness ==
'msgtype': 'm.text', Brightness.light
'body': '...', ? displayname.color
}, : displayname.lightColorText),
senderId: event.senderId, ),
type: 'm.room.message', );
room: event.room, },
status: EventStatus.sent,
originServerTs: DateTime.now(),
);
return Padding(
padding: const EdgeInsets.only(
bottom: 4.0,
),
child: InkWell(
borderRadius:
ReplyContent.borderRadius,
onTap: () => scrollToEventId(
replyEvent.eventId,
),
child: AbsorbPointer(
child: ReplyContent(
replyEvent,
ownMessage: ownMessage,
timeline: timeline,
),
),
),
);
},
),
MessageContent(
displayEvent,
textColor: textColor,
onInfoTab: onInfoTab,
borderRadius: borderRadius,
), ),
if (event.hasAggregatedEvents( ),
timeline, Container(
RelationshipTypes.edit, alignment: alignment,
)) padding: const EdgeInsets.only(left: 8),
Padding( child: GestureDetector(
padding: const EdgeInsets.only( onLongPress: longPressSelect
top: 4.0, ? null
), : () {
child: Row( HapticFeedback.heavyImpact();
mainAxisSize: MainAxisSize.min, onSelect(event);
children: [ },
Icon( child: AnimatedOpacity(
Icons.edit_outlined, opacity: animateIn
color: textColor.withAlpha(164), ? 0
size: 14, : event.redacted ||
), event.messageType ==
Text( MessageTypes.BadEncrypted ||
' - ${displayEvent.originServerTs.localizedTimeShort(context)}', event.status.isSending
style: TextStyle( ? 0.5
color: textColor.withAlpha(164), : 1,
fontSize: 12, duration: FluffyThemes.animationDuration,
), curve: FluffyThemes.animationCurve,
), child: Material(
], color: noBubble ? Colors.transparent : color,
), clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
), ),
], ),
padding: noBubble || noPadding
? EdgeInsets.zero
: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 1.5,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
if (event.relationshipType ==
RelationshipTypes.reply)
FutureBuilder<Event?>(
future: event.getReplyEvent(timeline),
builder:
(BuildContext context, snapshot) {
final replyEvent = snapshot.hasData
? snapshot.data!
: Event(
eventId: event
.relationshipEventId!,
content: {
'msgtype': 'm.text',
'body': '...',
},
senderId: event.senderId,
type: 'm.room.message',
room: event.room,
status: EventStatus.sent,
originServerTs:
DateTime.now(),
);
return Padding(
padding: const EdgeInsets.only(
bottom: 4.0,
),
child: InkWell(
borderRadius:
ReplyContent.borderRadius,
onTap: () => scrollToEventId(
replyEvent.eventId,
),
child: AbsorbPointer(
child: ReplyContent(
replyEvent,
ownMessage: ownMessage,
timeline: timeline,
),
),
),
);
},
),
MessageContent(
displayEvent,
textColor: textColor,
onInfoTab: onInfoTab,
borderRadius: borderRadius,
),
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
))
Padding(
padding: const EdgeInsets.only(
top: 4.0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.edit_outlined,
color: textColor.withAlpha(164),
size: 14,
),
Text(
' - ${displayEvent.originServerTs.localizedTimeShort(context)}',
style: TextStyle(
color:
textColor.withAlpha(164),
fontSize: 12,
),
),
],
),
),
],
),
),
), ),
), ),
), ),
), ),
), ],
), ),
], ),
), ],
), ),
], ],
), ),
@ -449,13 +484,7 @@ class Message extends StatelessWidget {
container = row; container = row;
} }
return Container( return Center(
alignment: Alignment.center,
color: selected
? Theme.of(context).colorScheme.secondaryContainer.withAlpha(100)
: highlightMarker
? Theme.of(context).colorScheme.tertiaryContainer.withAlpha(100)
: Colors.transparent,
child: Swipeable( child: Swipeable(
key: ValueKey(event.eventId), key: ValueKey(event.eventId),
background: const Padding( background: const Padding(
@ -466,87 +495,17 @@ class Message extends StatelessWidget {
), ),
direction: SwipeDirection.endToStart, direction: SwipeDirection.endToStart,
onSwipe: (_) => onSwipe(), onSwipe: (_) => onSwipe(),
child: HoverBuilder( child: Container(
builder: (context, hovered) => Stack( constraints: const BoxConstraints(
children: [ maxWidth: FluffyThemes.columnWidth * 2.5,
Container(
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth * 2.5,
),
padding: EdgeInsets.only(
left: 8.0,
right: 8.0,
top: nextEventSameSender ? 1.0 : 4.0,
bottom: previousEventSameSender ? 1.0 : 4.0,
),
child: container,
),
Positioned(
left: ownMessage ? null : 48,
right: ownMessage ? 4 : null,
top: displayTime ? 38 : 0,
child: AnimatedScale(
duration: Duration(
milliseconds:
(FluffyThemes.animationDuration.inMilliseconds / 2)
.floor(),
),
curve: FluffyThemes.animationCurve,
scale: !longPressSelect && hovered ? 1 : 0,
alignment: Alignment.center,
child: Material(
color: Theme.of(context)
.colorScheme
.secondaryContainer
.withOpacity(0.9),
elevation:
Theme.of(context).appBarTheme.scrolledUnderElevation ??
4,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
shadowColor: Theme.of(context).appBarTheme.shadowColor,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (event.room.canSendDefaultMessages)
SizedBox(
width: 32,
height: 32,
child: IconButton(
icon: Icon(
Icons.reply_outlined,
size: 16,
color: Theme.of(context)
.colorScheme
.onTertiaryContainer,
),
tooltip: L10n.of(context)!.reply,
onPressed: event.room.canSendDefaultMessages
? () => onSwipe()
: null,
),
),
SizedBox(
width: 32,
height: 32,
child: IconButton(
icon: Icon(
Icons.more_vert,
size: 16,
color: Theme.of(context)
.colorScheme
.onTertiaryContainer,
),
tooltip: L10n.of(context)!.select,
onPressed: () => onSelect(event),
),
),
],
),
),
),
),
],
), ),
padding: EdgeInsets.only(
left: 8.0,
right: 8.0,
top: nextEventSameSender ? 1.0 : 4.0,
bottom: previousEventSameSender ? 1.0 : 4.0,
),
child: container,
), ),
), ),
); );

View file

@ -97,11 +97,43 @@ class ChatListItem extends StatelessWidget {
visualDensity: const VisualDensity(vertical: -0.5), visualDensity: const VisualDensity(vertical: -0.5),
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 8),
onLongPress: onLongPress, onLongPress: onLongPress,
leading: Avatar( leading: Stack(
mxContent: room.avatar, clipBehavior: Clip.none,
name: displayname, children: [
presenceUserId: room.directChatMatrixID, HoverBuilder(
presenceBackgroundColor: backgroundColor, builder: (context, hovered) => AnimatedScale(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
scale: hovered ? 1.1 : 1.0,
child: Avatar(
mxContent: room.avatar,
name: displayname,
presenceUserId: room.directChatMatrixID,
presenceBackgroundColor: backgroundColor,
onTap: onLongPress,
),
),
),
Positioned(
bottom: -2,
right: -2,
child: AnimatedScale(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
scale: (hovered || selected) ? 1.0 : 0.0,
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(16),
child: Icon(
selected
? Icons.check_circle
: Icons.check_circle_outlined,
size: 18,
),
),
),
),
],
), ),
title: Row( title: Row(
children: <Widget>[ children: <Widget>[
@ -281,19 +313,7 @@ class ChatListItem extends StatelessWidget {
), ),
onTap: onTap, onTap: onTap,
trailing: onForget == null trailing: onForget == null
? hovered || selected ? null
? IconButton(
color: selected
? Theme.of(context).colorScheme.primary
: null,
icon: Icon(
selected
? Icons.check_circle
: Icons.check_circle_outlined,
),
onPressed: onLongPress,
)
: null
: IconButton( : IconButton(
icon: const Icon(Icons.delete_outlined), icon: const Icon(Icons.delete_outlined),
onPressed: onForget, onPressed: onForget,