💄 Optimized messages styles

This commit is contained in:
LittleSheep 2025-05-18 03:05:20 +08:00
parent 3737de6936
commit dd50cfd2e9
2 changed files with 430 additions and 332 deletions

View File

@ -17,15 +17,14 @@ import 'package:island/pods/websocket.dart';
import 'package:island/route.gr.dart'; import 'package:island/route.gr.dart';
import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/compose.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_file_collection.dart'; import 'package:island/widgets/chat/message_bubble.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'chat.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'chat.dart';
part 'room.g.dart'; part 'room.g.dart';
@ -462,20 +461,28 @@ class ChatRoomScreen extends HookConsumerWidget {
itemCount: messageList.length, itemCount: messageList.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final message = messageList[index]; final message = messageList[index];
final nextMessage =
index < messageList.length - 1
? messageList[index + 1]
: null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId;
return chatIdentity.when( return chatIdentity.when(
skipError: true, skipError: true,
data: data:
(identity) => _MessageBubble( (identity) => MessageBubble(
message: message, message: message,
isCurrentUser: isCurrentUser:
identity?.id == message.senderId, identity?.id == message.senderId,
onAction: (action) { onAction: (action) {
switch (action) { switch (action) {
case _MessageBubbleAction.delete: case MessageBubbleAction.delete:
messagesNotifier.deleteMessage( messagesNotifier.deleteMessage(
message.id, message.id,
); );
case _MessageBubbleAction.edit: case MessageBubbleAction.edit:
messageEditingTo.value = messageEditingTo.value =
message.toRemoteMessage(); message.toRemoteMessage();
messageController.text = messageController.text =
@ -494,23 +501,25 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
) )
.toList(); .toList();
case _MessageBubbleAction.forward: case MessageBubbleAction.forward:
messageForwardingTo.value = messageForwardingTo.value =
message.toRemoteMessage(); message.toRemoteMessage();
case _MessageBubbleAction.reply: case MessageBubbleAction.reply:
messageReplyingTo.value = messageReplyingTo.value =
message.toRemoteMessage(); message.toRemoteMessage();
} }
}, },
progress: progress:
attachmentProgress.value[message.id], attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
), ),
loading: loading:
() => _MessageBubble( () => MessageBubble(
message: message, message: message,
isCurrentUser: false, isCurrentUser: false,
onAction: null, onAction: null,
progress: null, progress: null,
showAvatar: false,
), ),
error: (_, __) => const SizedBox.shrink(), error: (_, __) => const SizedBox.shrink(),
); );
@ -755,326 +764,3 @@ class _ChatInput extends StatelessWidget {
); );
} }
} }
class _MessageBubbleAction {
static const String edit = "edit";
static const String delete = "delete";
static const String reply = "reply";
static const String forward = "forward";
}
class _MessageBubble extends HookConsumerWidget {
final LocalChatMessage message;
final bool isCurrentUser;
final Function(String action)? onAction;
final Map<int, double>? progress;
const _MessageBubble({
required this.message,
required this.isCurrentUser,
required this.onAction,
required this.progress,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final textColor =
isCurrentUser
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurfaceVariant;
final containerColor =
isCurrentUser
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.5)
: Theme.of(context).colorScheme.surfaceContainer;
return ContextMenuWidget(
menuProvider: (_) {
if (onAction == null) return Menu(children: []);
return Menu(
children: [
if (isCurrentUser)
MenuAction(
title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () {
onAction!.call(_MessageBubbleAction.edit);
},
),
if (isCurrentUser)
MenuAction(
title: 'delete'.tr(),
image: MenuImage.icon(Symbols.delete),
callback: () {
onAction!.call(_MessageBubbleAction.delete);
},
),
if (isCurrentUser) MenuSeparator(),
MenuAction(
title: 'reply'.tr(),
image: MenuImage.icon(Symbols.reply),
callback: () {
onAction!.call(_MessageBubbleAction.reply);
},
),
MenuAction(
title: 'forward'.tr(),
image: MenuImage.icon(Symbols.forward),
callback: () {
onAction!.call(_MessageBubbleAction.forward);
},
),
],
);
},
child: Material(
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment:
isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
if (!isCurrentUser)
ProfilePictureWidget(
fileId:
message
.toRemoteMessage()
.sender
.account
.profile
.pictureId,
radius: 18,
),
const Gap(8),
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: containerColor,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.toRemoteMessage().repliedMessageId != null)
_MessageQuoteWidget(
message: message,
textColor: textColor,
isReply: true,
),
if (message.toRemoteMessage().forwardedMessageId != null)
_MessageQuoteWidget(
message: message,
textColor: textColor,
isReply: false,
),
if (message.toRemoteMessage().content?.isNotEmpty ??
false)
Text(
message.toRemoteMessage().content!,
style: TextStyle(color: textColor),
),
if (message.toRemoteMessage().attachments.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CloudFileList(
files: message.toRemoteMessage().attachments,
maxWidth: MediaQuery.of(context).size.width * 0.8,
),
],
).padding(top: 4),
if (progress != null && progress!.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
if ((message
.toRemoteMessage()
.content
?.isNotEmpty ??
false))
const Gap(0),
for (var entry in progress!.entries)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'fileUploadingProgress'.tr(
args: [
(entry.key + 1).toString(),
entry.value.toStringAsFixed(1),
],
),
style: TextStyle(
fontSize: 12,
color: textColor.withOpacity(0.8),
),
),
const Gap(4),
LinearProgressIndicator(
value: entry.value / 100,
backgroundColor:
Theme.of(
context,
).colorScheme.surfaceVariant,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
],
),
const Gap(0),
],
),
const Gap(4),
Row(
spacing: 4,
mainAxisSize: MainAxisSize.min,
children: [
Text(
DateFormat.Hm().format(message.createdAt.toLocal()),
style: TextStyle(fontSize: 10, color: textColor),
),
if (message.toRemoteMessage().editedAt != null)
Text(
'edited'.tr().toLowerCase(),
style: TextStyle(fontSize: 10, color: textColor),
),
if (isCurrentUser)
_buildStatusIcon(
context,
message.status,
textColor,
),
],
),
],
),
),
),
const Gap(8),
if (isCurrentUser)
ProfilePictureWidget(
fileId:
message
.toRemoteMessage()
.sender
.account
.profile
.pictureId,
radius: 18,
),
],
),
),
),
);
}
Widget _buildStatusIcon(
BuildContext context,
MessageStatus status,
Color textColor,
) {
switch (status) {
case MessageStatus.pending:
return Icon(Icons.access_time, size: 12, color: textColor);
case MessageStatus.sent:
return Icon(Icons.check, size: 12, color: textColor);
case MessageStatus.failed:
return Consumer(
builder:
(context, ref, _) => GestureDetector(
onTap: () {
ref
.read(messagesNotifierProvider(message.roomId).notifier)
.retryMessage(message.id);
},
child: const Icon(
Icons.error_outline,
size: 12,
color: Colors.red,
),
),
);
}
}
}
class _MessageQuoteWidget extends HookConsumerWidget {
final LocalChatMessage message;
final Color textColor;
final bool isReply;
const _MessageQuoteWidget({
required this.message,
required this.textColor,
required this.isReply,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messagesNotifier = ref.watch(
messagesNotifierProvider(message.roomId).notifier,
);
return FutureBuilder<LocalChatMessage?>(
future: messagesNotifier.fetchMessageById(
isReply
? message.toRemoteMessage().repliedMessageId!
: message.toRemoteMessage().forwardedMessageId!,
),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 6),
color: Theme.of(
context,
).colorScheme.primaryFixedDim.withOpacity(0.4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isReply)
Row(
spacing: 4,
children: [
Icon(Symbols.reply, size: 16, color: textColor),
Text(
'Replying to ${snapshot.data!.toRemoteMessage().sender.account.nick}',
).textColor(textColor).bold(),
],
)
else
Row(
spacing: 4,
children: [
Icon(Symbols.forward, size: 16, color: textColor),
Text(
'Forwarded from ${snapshot.data!.toRemoteMessage().sender.account.nick}',
).textColor(textColor).bold(),
],
),
if (snapshot.data!.toRemoteMessage().content?.isNotEmpty ??
false)
Text(
snapshot.data!.toRemoteMessage().content!,
style: TextStyle(color: textColor),
),
],
),
),
).padding(bottom: 4);
} else {
return SizedBox.shrink();
}
},
);
}
}

View File

@ -0,0 +1,412 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/database/message.dart';
import 'package:island/models/chat.dart';
import 'package:island/widgets/content/cloud_file_collection.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart';
import '../../screens/chat/room.dart';
class MessageBubbleAction {
static const String edit = "edit";
static const String delete = "delete";
static const String reply = "reply";
static const String forward = "forward";
}
class MessageBubble extends HookConsumerWidget {
final LocalChatMessage message;
final bool isCurrentUser;
final Function(String action)? onAction;
final Map<int, double>? progress;
final bool showAvatar;
const MessageBubble({
super.key,
required this.message,
required this.isCurrentUser,
required this.onAction,
required this.progress,
required this.showAvatar,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final textColor =
isCurrentUser
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurfaceVariant;
final containerColor =
isCurrentUser
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.5)
: Theme.of(context).colorScheme.surfaceContainer;
final remoteMessage = message.toRemoteMessage();
final sender = remoteMessage.sender;
return ContextMenuWidget(
menuProvider: (_) {
if (onAction == null) return Menu(children: []);
return Menu(
children: [
if (isCurrentUser)
MenuAction(
title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () {
onAction!.call(MessageBubbleAction.edit);
},
),
if (isCurrentUser)
MenuAction(
title: 'delete'.tr(),
image: MenuImage.icon(Symbols.delete),
callback: () {
onAction!.call(MessageBubbleAction.delete);
},
),
if (isCurrentUser) MenuSeparator(),
MenuAction(
title: 'reply'.tr(),
image: MenuImage.icon(Symbols.reply),
callback: () {
onAction!.call(MessageBubbleAction.reply);
},
),
MenuAction(
title: 'forward'.tr(),
image: MenuImage.icon(Symbols.forward),
callback: () {
onAction!.call(MessageBubbleAction.forward);
},
),
],
);
},
child: Material(
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Column(
crossAxisAlignment:
isCurrentUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (showAvatar && !isCurrentUser) ...[
Row(
spacing: 8,
mainAxisSize: MainAxisSize.min,
children: [
if (!isCurrentUser)
ProfilePictureWidget(
fileId: sender.account.profile.pictureId,
radius: 18,
),
Column(
crossAxisAlignment:
isCurrentUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
spacing: 2,
children: [
Text(
DateFormat.Hm().format(message.createdAt.toLocal()),
style: TextStyle(fontSize: 10, color: textColor),
),
Row(
mainAxisSize: MainAxisSize.min,
spacing: 5,
children: [
if (isCurrentUser)
Badge(
label:
Text(
sender.role >= 100
? 'permissionOwner'
: sender.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
),
Text(
sender.account.nick,
style: Theme.of(context).textTheme.bodySmall,
),
if (!isCurrentUser)
Badge(
label:
Text(
sender.role >= 100
? 'permissionOwner'
: sender.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
),
],
),
],
),
if (isCurrentUser)
ProfilePictureWidget(
fileId: sender.account.profile.pictureId,
radius: 18,
),
],
),
const Gap(4),
],
const Gap(2),
Row(
spacing: 4,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (isCurrentUser)
_buildMessageIndicators(
context,
textColor,
remoteMessage,
message,
isCurrentUser,
),
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: containerColor,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (remoteMessage.repliedMessageId != null)
MessageQuoteWidget(
message: message,
textColor: textColor,
isReply: true,
),
if (remoteMessage.forwardedMessageId != null)
MessageQuoteWidget(
message: message,
textColor: textColor,
isReply: false,
),
if (remoteMessage.content?.isNotEmpty ?? false)
Text(
remoteMessage.content!,
style: TextStyle(color: textColor),
),
if (remoteMessage.attachments.isNotEmpty)
CloudFileList(
files: remoteMessage.attachments,
maxWidth: MediaQuery.of(context).size.width * 0.8,
).padding(top: 4),
if (progress != null && progress!.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
if ((remoteMessage.content?.isNotEmpty ??
false))
const Gap(0),
for (var entry in progress!.entries)
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'fileUploadingProgress'.tr(
args: [
(entry.key + 1).toString(),
entry.value.toStringAsFixed(1),
],
),
style: TextStyle(
fontSize: 12,
color: textColor.withOpacity(0.8),
),
),
const Gap(4),
LinearProgressIndicator(
value: entry.value / 100,
backgroundColor:
Theme.of(
context,
).colorScheme.surfaceVariant,
valueColor:
AlwaysStoppedAnimation<Color>(
Theme.of(
context,
).colorScheme.primary,
),
),
],
),
const Gap(0),
],
),
],
),
),
),
if (!isCurrentUser)
_buildMessageIndicators(
context,
textColor,
remoteMessage,
message,
isCurrentUser,
),
],
),
],
),
),
),
);
}
Widget _buildMessageIndicators(
BuildContext context,
Color textColor,
SnChatMessage remoteMessage,
LocalChatMessage message,
bool isCurrentUser,
) {
return Row(
spacing: 4,
mainAxisSize: MainAxisSize.min,
children: [
Text(
DateFormat.Hm().format(message.createdAt.toLocal()),
style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)),
),
if (remoteMessage.editedAt != null)
Text(
'edited'.tr().toLowerCase(),
style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)),
),
if (isCurrentUser)
_buildStatusIcon(
context,
message.status,
textColor.withOpacity(0.7),
).padding(bottom: 3),
],
);
}
Widget _buildStatusIcon(
BuildContext context,
MessageStatus status,
Color textColor,
) {
switch (status) {
case MessageStatus.pending:
return Icon(Icons.access_time, size: 12, color: textColor);
case MessageStatus.sent:
return Icon(Icons.check, size: 12, color: textColor);
case MessageStatus.failed:
return Consumer(
builder:
(context, ref, _) => GestureDetector(
onTap: () {
ref
.read(messagesNotifierProvider(message.roomId).notifier)
.retryMessage(message.id);
},
child: const Icon(
Icons.error_outline,
size: 12,
color: Colors.red,
),
),
);
}
}
}
class MessageQuoteWidget extends HookConsumerWidget {
final LocalChatMessage message;
final Color textColor;
final bool isReply;
const MessageQuoteWidget({
super.key,
required this.message,
required this.textColor,
required this.isReply,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messagesNotifier = ref.watch(
messagesNotifierProvider(message.roomId).notifier,
);
return FutureBuilder<LocalChatMessage?>(
future: messagesNotifier.fetchMessageById(
isReply
? message.toRemoteMessage().repliedMessageId!
: message.toRemoteMessage().forwardedMessageId!,
),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 6),
color: Theme.of(
context,
).colorScheme.primaryFixedDim.withOpacity(0.4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isReply)
Row(
spacing: 4,
children: [
Icon(Symbols.reply, size: 16, color: textColor),
Text(
'Replying to ${snapshot.data!.toRemoteMessage().sender.account.nick}',
).textColor(textColor).bold(),
],
)
else
Row(
spacing: 4,
children: [
Icon(Symbols.forward, size: 16, color: textColor),
Text(
'Forwarded from ${snapshot.data!.toRemoteMessage().sender.account.nick}',
).textColor(textColor).bold(),
],
),
if (snapshot.data!.toRemoteMessage().content?.isNotEmpty ??
false)
Text(
snapshot.data!.toRemoteMessage().content!,
style: TextStyle(color: textColor),
),
],
),
),
).padding(bottom: 4);
} else {
return SizedBox.shrink();
}
},
);
}
}