lots of stuff

This commit is contained in:
Henry Hiles 2025-11-14 15:50:38 -05:00
commit ba9e99a951
No known key found for this signature in database
19 changed files with 608 additions and 360 deletions

View file

@ -0,0 +1,38 @@
import "package:color_hash/color_hash.dart";
import "package:flutter/widgets.dart";
class AvatarOrHash extends StatelessWidget {
final Uri? avatar;
final String title;
final Widget? fallback;
final Map<String, String> headers;
const AvatarOrHash(
this.avatar,
this.title, {
this.fallback,
required this.headers,
super.key,
});
@override
Widget build(BuildContext context) {
final box = ColoredBox(
color: ColorHash(title).color,
child: Center(child: Text(title[0])),
);
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(4)),
child: SizedBox(
width: 24,
height: 24,
child: avatar == null
? fallback ?? box
: Image.network(
avatar.toString(),
headers: headers,
errorBuilder: (_, _, _) => box,
),
),
);
}
}

64
lib/widgets/chat_box.dart Normal file
View file

@ -0,0 +1,64 @@
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_ui/flutter_chat_ui.dart";
class ChatBox extends StatelessWidget {
final Message? replyToMessage;
final VoidCallback onDismiss;
final Map<String, String> headers;
const ChatBox({
required this.replyToMessage,
required this.onDismiss,
required this.headers,
super.key,
});
@override
Widget build(BuildContext context) => Composer(
sigmaX: 0,
sigmaY: 0,
sendIconColor: Theme.of(context).colorScheme.primary,
sendOnEnter: true,
topWidget: replyToMessage == null
? null
: ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
spacing: 8,
children: [
Avatar(
userId: replyToMessage!.authorId,
headers: headers,
size: 16,
),
Text(
replyToMessage!.metadata?["displayName"] ??
replyToMessage!.authorId,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Expanded(
child: (replyToMessage is TextMessage)
? Text(
(replyToMessage as TextMessage).text,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium,
maxLines: 1,
)
: SizedBox(),
),
IconButton(
onPressed: onDismiss,
icon: Icon(Icons.close),
iconSize: 20,
),
],
),
),
),
autofocus: true,
);
}

View file

@ -0,0 +1,53 @@
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:matrix/matrix.dart";
import "package:nexus/controllers/avatar_controller.dart";
import "package:nexus/controllers/members_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class MemberList extends ConsumerWidget {
final Room room;
const MemberList(this.room, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerLow,
child: SizedBox(
width: 240,
child: ref
.watch(MembersController.provider(room))
.betterWhen(
data: (members) => ListView(
children: [
...members
.where(
(membership) =>
membership.content["membership"] ==
Membership.join.name,
)
.map(
(member) => ListTile(
leading: AvatarOrHash(
ref
.watch(
AvatarController.provider(
member.content["avatar_url"].toString(),
),
)
.whenOrNull(data: (data) => data),
member.content["displayname"].toString(),
headers: room.client.headers,
),
title: Text(
member.content["displayname"].toString(),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
),
);
}

View file

@ -0,0 +1,66 @@
import "dart:io";
import "package:flutter/material.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/models/full_room.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class RoomAppbar extends StatelessWidget implements PreferredSizeWidget {
final bool isDesktop;
final FullRoom room;
final VoidCallback onOpenMemberList;
final VoidCallback onOpenDrawer;
const RoomAppbar(
this.room, {
required this.isDesktop,
required this.onOpenMemberList,
required this.onOpenDrawer,
super.key,
});
@override
Size get preferredSize => Size.fromHeight(AppBar().preferredSize.height + 16);
@override
AppBar build(BuildContext context) => AppBar(
bottom: PreferredSize(
preferredSize: Size.zero, // Does this even matter??
child: Row(
children: [
Expanded(
child: Padding(
padding: EdgeInsets.all(8).copyWith(top: 0),
child: Text(
room.roomData.topic,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
],
),
),
leading: isDesktop ? null : DrawerButton(onPressed: onOpenDrawer),
actionsPadding: EdgeInsets.symmetric(horizontal: 8),
title: Row(
children: [
AvatarOrHash(
room.avatar,
room.title,
fallback: Icon(Icons.numbers),
headers: room.roomData.client.headers,
),
SizedBox(width: 12),
Expanded(child: Text(room.title, overflow: TextOverflow.ellipsis)),
],
),
actions: [
IconButton(onPressed: onOpenMemberList, icon: Icon(Icons.people)),
if (!(Platform.isAndroid || Platform.isIOS))
IconButton(onPressed: () => exit(0), icon: Icon(Icons.close)),
],
);
}

View file

@ -1,25 +0,0 @@
import "package:color_hash/color_hash.dart";
import "package:flutter/widgets.dart";
class RoomAvatar extends StatelessWidget {
final Widget? avatar;
final String title;
final Widget? fallback;
const RoomAvatar(this.avatar, this.title, {this.fallback, super.key});
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(4)),
child: SizedBox(
width: 24,
height: 24,
child:
avatar ??
fallback ??
ColoredBox(
color: ColorHash(title).color,
child: Center(child: Text(title[0])),
),
),
);
}

View file

@ -1,5 +1,3 @@
import "dart:io";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart";
@ -15,8 +13,10 @@ import "package:nexus/controllers/current_room_controller.dart";
import "package:nexus/controllers/room_chat_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/widgets/chat_box.dart";
import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/widgets/top_widget.dart";
import "package:nexus/widgets/room_avatar.dart";
class RoomChat extends HookConsumerWidget {
final bool isDesktop;
@ -33,12 +33,18 @@ class RoomChat extends HookConsumerWidget {
Offset.zero & (context.findRenderObject() as RenderBox).size,
),
color: Theme.of(context).colorScheme.surfaceContainerHighest,
items: [PopupMenuItem(onTap: onTap, child: Text("Reply"))],
items: [
PopupMenuItem(
onTap: onTap,
child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
),
],
);
@override
Widget build(BuildContext context, WidgetRef ref) {
final replyToMessage = useState<Message?>(null);
final memberListOpened = useState<bool>(isDesktop);
final urlRegex = RegExp(r"https?://[^\s\]\(\)]+");
final theme = Theme.of(context);
return ref
@ -48,250 +54,200 @@ class RoomChat extends HookConsumerWidget {
final controllerProvider = RoomChatController.provider(
room.roomData,
);
final headers = {
"authorization": "Bearer ${room.roomData.client.accessToken}",
};
return Scaffold(
appBar: AppBar(
leading: isDesktop
? null
: DrawerButton(onPressed: Scaffold.of(context).openDrawer),
actionsPadding: EdgeInsets.symmetric(horizontal: 8),
title: Row(
children: [
RoomAvatar(
room.avatar,
room.title,
fallback: Icon(Icons.numbers),
),
SizedBox(width: 12),
Expanded(
child: Text(room.title, overflow: TextOverflow.ellipsis),
),
],
),
actions: [
if (!(Platform.isAndroid || Platform.isIOS))
IconButton(
onPressed: () => exit(0),
icon: Icon(Icons.close),
),
],
appBar: RoomAppbar(
room,
isDesktop: isDesktop,
onOpenDrawer: Scaffold.of(context).openDrawer,
onOpenMemberList: () =>
memberListOpened.value = !memberListOpened.value,
),
body: ref
.watch(controllerProvider)
.betterWhen(
data: (controller) => Chat(
currentUserId: room.roomData.client.userID!,
theme: ChatTheme.fromThemeData(theme).copyWith(
colors: ChatColors.fromThemeData(theme).copyWith(
primary: theme.colorScheme.primaryContainer,
onPrimary: theme.colorScheme.onPrimaryContainer,
),
),
onMessageSecondaryTap:
(
context,
message, {
required details,
required index,
}) => showContextMenu(
context: context,
globalPosition: details.globalPosition,
onTap: () => replyToMessage.value = message,
),
onMessageLongPress:
(
context,
message, {
required details,
required index,
}) => showContextMenu(
context: context,
globalPosition: details.globalPosition,
onTap: () => replyToMessage.value = message,
),
builders: Builders(
composerBuilder: (_) => Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (replyToMessage.value != null)
ColoredBox(
color: theme.colorScheme.surfaceContainer,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: Row(
spacing: 8,
children: [
Avatar(
userId: replyToMessage.value!.authorId,
headers: headers,
size: 16,
),
Text(
replyToMessage
.value!
.metadata?["displayName"] ??
replyToMessage.value!.authorId,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
Expanded(
child: (replyToMessage.value as dynamic)
? Text(
(replyToMessage.value
as TextMessage)
.text,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.labelMedium,
maxLines: 1,
)
: SizedBox(),
),
IconButton(
onPressed: () =>
replyToMessage.value = null,
icon: Icon(Icons.close),
iconSize: 20,
),
],
),
),
body: Row(
children: [
Expanded(
child: ref
.watch(controllerProvider)
.betterWhen(
data: (controller) => Chat(
currentUserId: room.roomData.client.userID!,
theme: ChatTheme.fromThemeData(theme).copyWith(
colors: ChatColors.fromThemeData(theme).copyWith(
primary: theme.colorScheme.primaryContainer,
onPrimary: theme.colorScheme.onPrimaryContainer,
),
Composer(
sigmaX: 0,
sigmaY: 0,
sendIconColor: theme.colorScheme.primary,
sendOnEnter: true,
autofocus: true,
),
],
),
unsupportedMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => kDebugMode
? Text(
"${message.authorId} sent ${message.metadata?["eventType"]}",
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.grey,
onMessageSecondaryTap:
(
context,
message, {
required details,
required index,
}) => showContextMenu(
context: context,
globalPosition: details.globalPosition,
onTap: () => replyToMessage.value = message,
),
)
: SizedBox.shrink(),
textMessageBuilder:
(
context,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatTextMessage(
topWidget: TopWidget(message, headers: headers),
message: message.copyWith(
text: message.text.replaceAllMapped(
urlRegex,
(match) =>
"[${match.group(0)}](${match.group(0)})",
onMessageLongPress:
(
context,
message, {
required details,
required index,
}) => showContextMenu(
context: context,
globalPosition: details.globalPosition,
onTap: () => replyToMessage.value = message,
),
builders: Builders(
chatAnimatedListBuilder: (context, itemBuilder) {
return ChatAnimatedList(
itemBuilder: itemBuilder,
onEndReached: ref
.watch(controllerProvider.notifier)
.loadOlder,
);
},
composerBuilder: (_) => ChatBox(
replyToMessage: replyToMessage.value,
onDismiss: () => replyToMessage.value = null,
headers: room.roomData.client.headers,
),
showTime: true,
index: index,
onLinkTap: (url, _) => ref
.watch(LaunchHelper.provider)
.launchUrl(Uri.parse(url)),
linksDecoration: TextDecoration.underline,
sentLinksColor: Colors.blue,
receivedLinksColor: Colors.blue,
),
linkPreviewBuilder: (_, message, isSentByMe) =>
LinkPreview(
text:
urlRegex.firstMatch(message.text)?.group(0) ??
"",
backgroundColor: isSentByMe
? theme.colorScheme.inversePrimary
: theme.colorScheme.surfaceContainerLow,
insidePadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
linkPreviewData: message.linkPreviewData,
onLinkPreviewDataFetched: (linkPreviewData) => ref
.watch(controllerProvider.notifier)
.updateMessage(
textMessageBuilder:
(
context,
message,
message.copyWith(
linkPreviewData: linkPreviewData,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatTextMessage(
topWidget: TopWidget(
message,
headers: room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message.copyWith(
text: message.text.replaceAllMapped(
urlRegex,
(match) =>
"[${match.group(0)}](${match.group(0)})",
),
),
showTime: true,
index: index,
onLinkTap: (url, _) => ref
.watch(LaunchHelper.provider)
.launchUrl(Uri.parse(url)),
linksDecoration: TextDecoration.underline,
sentLinksColor: Colors.blue,
receivedLinksColor: Colors.blue,
),
linkPreviewBuilder: (_, message, isSentByMe) =>
LinkPreview(
text:
urlRegex
.firstMatch(message.text)
?.group(0) ??
"",
backgroundColor: isSentByMe
? theme.colorScheme.inversePrimary
: theme.colorScheme.surfaceContainerLow,
insidePadding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 16,
),
linkPreviewData: message.linkPreviewData,
onLinkPreviewDataFetched:
(linkPreviewData) => ref
.watch(controllerProvider.notifier)
.updateMessage(
message,
message.copyWith(
linkPreviewData:
linkPreviewData,
),
),
),
imageMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatImageMessage(
topWidget: TopWidget(
message,
headers: room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
index: index,
headers: room.roomData.client.headers,
),
fileMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => InkWell(
onTap: () => showAboutDialog(
context: context,
), // TODO: Download
child: FlyerChatFileMessage(
topWidget: TopWidget(
message,
headers: room.roomData.client.headers,
groupStatus: groupStatus,
),
message: message,
index: index,
),
),
systemMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatSystemMessage(
message: message,
index: index,
),
unsupportedMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => kDebugMode
? Text(
"${message.authorId} sent ${message.metadata?["eventType"]}",
style: theme.textTheme.labelSmall
?.copyWith(color: Colors.grey),
)
: SizedBox.shrink(),
),
imageMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatImageMessage(
topWidget: TopWidget(message, headers: headers),
message: message,
index: index,
headers: headers,
),
fileMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => InkWell(
onTap: () => showAboutDialog(
context: context,
), // TODO: Download
child: FlyerChatFileMessage(
topWidget: TopWidget(message, headers: headers),
message: message,
index: index,
),
),
systemMessageBuilder:
(
_,
message,
index, {
required bool isSentByMe,
MessageGroupStatus? groupStatus,
}) => FlyerChatSystemMessage(
message: message,
index: index,
),
),
onMessageSend: (message) {
ref
.watch(controllerProvider.notifier)
.send(message, replyTo: replyToMessage.value);
replyToMessage.value = null;
},
resolveUser: ref
.watch(controllerProvider.notifier)
.resolveUser,
chatController: controller,
),
onMessageSend: (message) {
ref
.watch(controllerProvider.notifier)
.send(message, replyTo: replyToMessage.value);
replyToMessage.value = null;
},
resolveUser: ref
.watch(controllerProvider.notifier)
.resolveUser,
chatController: controller,
),
),
),
if (memberListOpened.value == true) MemberList(room.roomData),
],
),
);
},
);

View file

@ -4,7 +4,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/current_room_controller.dart";
import "package:nexus/controllers/spaces_controller.dart";
import "package:nexus/helpers/extension_helper.dart";
import "package:nexus/widgets/room_avatar.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class Sidebar extends HookConsumerWidget {
const Sidebar({super.key});
@ -38,7 +38,12 @@ class Sidebar extends HookConsumerWidget {
destinations: spaces
.map(
(space) => NavigationRailDestination(
icon: RoomAvatar(space.avatar, space.title),
icon: AvatarOrHash(
space.avatar,
fallback: space.icon,
space.title,
headers: space.client.headers,
),
label: Text(space.title),
padding: EdgeInsets.only(top: 4),
),
@ -58,7 +63,12 @@ class Sidebar extends HookConsumerWidget {
appBar: AppBar(
title: Row(
children: [
RoomAvatar(space.avatar, space.title),
AvatarOrHash(
space.avatar,
fallback: space.icon,
space.title,
headers: space.client.headers,
),
SizedBox(width: 12),
Expanded(
child: Text(
@ -80,12 +90,13 @@ class Sidebar extends HookConsumerWidget {
destinations: space.children
.map(
(room) => NavigationRailDestination(
icon: RoomAvatar(
icon: AvatarOrHash(
room.avatar,
room.title,
fallback: selectedSpace.value == 1
? null
: Icon(Icons.numbers),
headers: space.client.headers,
),
label: Text(room.title),
),

View file

@ -10,7 +10,13 @@ import "package:nexus/helpers/extension_helper.dart";
class TopWidget extends ConsumerWidget {
final Message message;
final Map<String, String> headers;
const TopWidget(this.message, {required this.headers, super.key});
final MessageGroupStatus? groupStatus;
const TopWidget(
this.message, {
required this.headers,
required this.groupStatus,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) => Column(
@ -32,16 +38,14 @@ class TopWidget extends ConsumerWidget {
(message as TextMessage).text.length - 20,
replyMessage.text.length,
),
40,
5,
),
replyMessage.text.length,
),
)
: replyMessage.text;
return InkWell(
onTap: () => showAboutDialog(
context: context,
), // TODO: Scroll to message
onTap: () => showAboutDialog(context: context),
child: Container(
decoration: BoxDecoration(
border: Border(
@ -62,11 +66,14 @@ class TopWidget extends ConsumerWidget {
headers: headers,
size: 16,
),
Text(
replyMessage.metadata?["displayName"] ??
replyMessage.authorId,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(fontWeight: FontWeight.bold),
Flexible(
child: Text(
replyMessage.metadata?["displayName"] ??
replyMessage.authorId,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
Flexible(
child: Text(
@ -85,23 +92,27 @@ class TopWidget extends ConsumerWidget {
),
SizedBox(height: 12),
],
InkWell(
onTap: () =>
showAboutDialog(context: context), // TODO: Show user profile
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Avatar(userId: message.authorId, headers: headers),
Text(
message.metadata?["displayName"] ?? message.authorId,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
],
if (groupStatus?.isFirst != false)
InkWell(
onTap: () =>
showAboutDialog(context: context), // TODO: Show user profile
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Avatar(userId: message.authorId, headers: headers),
Flexible(
child: Text(
message.metadata?["displayName"] ?? message.authorId,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
SizedBox(height: 4),
],
);