1
0
Fork 0
forked from Nexus/nexus

Compare commits

...

11 commits

Author SHA1 Message Date
ddc8db8326
some keyboard navigation fixes
This still isnt perfect, it sometimes skips over nodes, needs investigation.
2026-06-11 13:41:06 -04:00
bc307cbcda
up size of spaces in sidebar 2026-06-11 12:50:47 -04:00
e40de37b9d
move message button above kick/ban 2026-06-10 10:41:25 -04:00
7757825e27
add placeholder while profile loading 2026-06-10 10:40:21 -04:00
795e9b6e9b
add l10n todo comment 2026-06-09 21:15:15 -04:00
abba60c28b
onTapUp -> onTap when possible 2026-06-09 20:40:31 -04:00
8cea4b6cf3
fix user sheet on small screens 2026-06-09 20:37:21 -04:00
d46646d781
redesign user popover 2026-06-09 20:34:56 -04:00
e15d947fac
rename FlashWrapper to HighlightWrapper 2026-06-08 11:03:34 -04:00
562ce5d4ff
Add link to settings issue 2026-06-07 17:12:54 -04:00
f60189bfb8
fix mark all as read behavior with subspaces 2026-06-07 16:57:57 -04:00
14 changed files with 380 additions and 300 deletions

View file

@ -113,7 +113,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35)) - [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35))
- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) - [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
- [ ] Invites - [ ] Invites
- [ ] Settings - [ ] Settings ([#37](https://git.federated.nexus/Nexus/nexus/issues/37))
- [ ] Matrix: URIs vs Matrix.to links - [ ] Matrix: URIs vs Matrix.to links
- [ ] Light/Dark mode - [ ] Light/Dark mode
- [ ] Remote Gomuks instance - [ ] Remote Gomuks instance

View file

@ -1,25 +1,18 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/widgets/user_popover.dart"; import "package:nexus/widgets/user_bottom_sheet.dart";
extension ShowUserPopover on BuildContext { extension ShowUserPopover on BuildContext {
void showUserPopover( void showUserPopover(
MembershipContent member, MembershipContent member,
String userId, { String userId, {
String? roomId, String? roomId,
required Offset globalPosition, }) => showModalBottomSheet(
}) => showContextMenu( constraints: BoxConstraints.loose(
globalPosition: globalPosition, Size(500, View.of(this).physicalSize.height - 80),
children: [ ),
PopupMenuItem( isScrollControlled: true,
enabled: false, context: this,
padding: .symmetric(horizontal: 16, vertical: 8), builder: (context) => UserBottomSheet(member, userId, roomId: roomId),
child: IconTheme(
data: .new(),
child: UserPopover(member, userId, roomId: roomId),
),
),
],
); );
} }

View file

@ -24,7 +24,7 @@ class AvatarOrHash extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final box = ColoredBox( final box = ColoredBox(
color: ColorHash(title).color, color: ColorHash(title).color,
child: Center(child: Text(title.isEmpty ? "" : title[0])), child: Center(child: Icon(Icons.person, size: height / 2)),
); );
final parsedAvatar = avatar?.mxcToHttps( final parsedAvatar = avatar?.mxcToHttps(

View file

@ -1,16 +1,16 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
class FlashWrapper extends StatelessWidget { class HighlightWrapper extends StatelessWidget {
final Widget child; final Widget child;
final bool isFlashing; final bool isHighlighted;
const FlashWrapper(this.child, {this.isFlashing = false, super.key}); const HighlightWrapper(this.child, {this.isHighlighted = false, super.key});
@override @override
Widget build(BuildContext context) => ClipRRect( Widget build(BuildContext context) => ClipRRect(
borderRadius: .all(.circular(12)), borderRadius: .all(.circular(12)),
child: AnimatedContainer( child: AnimatedContainer(
padding: isFlashing ? .all(8) : .all(0), padding: isHighlighted ? .all(8) : .all(0),
color: isFlashing color: isHighlighted
? Theme.of(context).colorScheme.onSurface.withAlpha(50) ? Theme.of(context).colorScheme.onSurface.withAlpha(50)
: Colors.transparent, : Colors.transparent,
duration: .new(milliseconds: 250), duration: .new(milliseconds: 250),

View file

@ -23,14 +23,9 @@ class MentionChip extends ConsumerWidget {
return mention == null return mention == null
? SizedBox.shrink() ? SizedBox.shrink()
: InkWell( : InkWell(
onTapUp: (details) { onTap: () {
if (membership != null) { if (membership != null) {
context.showUserPopover( context.showUserPopover(membership, mention, roomId: roomId);
membership,
mention,
roomId: roomId,
globalPosition: details.globalPosition,
);
} }
}, },
child: IgnorePointer( child: IgnorePointer(

View file

@ -12,21 +12,18 @@ class MessageAvatar extends ConsumerWidget {
const MessageAvatar(this.event, {this.height = 24, super.key}); const MessageAvatar(this.event, {this.height = 24, super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) => Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch(
switch (ref.watch(AuthorController.provider(event))) { AuthorController.provider(event),
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( )) {
onTapUp: (details) => context.showUserPopover( AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
value, onTap: () =>
event.sender, context.showUserPopover(value, event.sender, roomId: event.roomId),
roomId: event.roomId, child: AvatarOrHash(
globalPosition: details.globalPosition, value.avatarUrl,
), value.displayName ?? event.sender.localpart,
child: AvatarOrHash( height: height,
value.avatarUrl, ),
value.displayName ?? event.sender.localpart, ),
height: height, _ => AvatarOrHash(null, event.sender.localpart, height: height),
), };
),
_ => AvatarOrHash(null, event.sender.localpart, height: height),
};
} }

View file

@ -22,12 +22,11 @@ class MessageDisplayname extends ConsumerWidget {
AuthorController.provider(event), AuthorController.provider(event),
)) { )) {
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
onTapUp: clickable onTap: clickable
? (details) => context.showUserPopover( ? () => context.showUserPopover(
value, value,
event.sender, event.sender,
roomId: event.roomId, roomId: event.roomId,
globalPosition: details.globalPosition,
) )
: null, : null,
child: Wrap( child: Wrap(

View file

@ -14,6 +14,7 @@ import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/user_bottom_sheet.dart";
class MemberList extends HookConsumerWidget { class MemberList extends HookConsumerWidget {
final String roomId; final String roomId;
@ -138,8 +139,11 @@ class MemberList extends HookConsumerWidget {
), ),
leading: AvatarOrHash( leading: AvatarOrHash(
avatarUrl, avatarUrl,
height: 36,
displayName ?? displayName ??
members[index].sender.localpart, members[index]
.stateKey!
.localpart,
), ),
), ),
_ => throw Exception( _ => throw Exception(
@ -147,12 +151,25 @@ class MemberList extends HookConsumerWidget {
), ),
}, },
onTap: (index) { onTap: (index) {
// context.showUserPopover( final member = members[index];
// member.content as MembershipContent, if (member.content
// member.stateKey!, case MembershipContent content) {
// roomId: roomId, showModalBottomSheet(
// globalPosition: details.globalPosition, constraints: BoxConstraints.loose(
// ), Size(
500,
(context.size?.height ?? 1000) - 80,
),
),
isScrollControlled: true,
context: context,
builder: (context) => UserBottomSheet(
content,
member.stateKey!,
roomId: roomId,
),
);
}
}, },
), ),
], ],

View file

@ -1,6 +1,7 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart";
import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/canonical_alias.dart";
@ -24,7 +25,7 @@ import "package:nexus/widgets/reaction_row.dart";
import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/membership.dart";
import "package:nexus/widgets/renderers/generic_event.dart"; import "package:nexus/widgets/renderers/generic_event.dart";
class EventRenderer extends ConsumerWidget { class EventRenderer extends HookConsumerWidget {
final Event event; final Event event;
final bool textOnly; final bool textOnly;
final bool isGrouped; final bool isGrouped;
@ -46,6 +47,8 @@ class EventRenderer extends ConsumerWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final errorStyle = TextStyle(color: colorScheme.error); final errorStyle = TextStyle(color: colorScheme.error);
final focusNode = useFocusNode();
useListenable(focusNode);
final child = event.redactedBy != null || event.relationType == "m.replace" final child = event.redactedBy != null || event.relationType == "m.replace"
? null ? null
@ -168,12 +171,44 @@ class EventRenderer extends ConsumerWidget {
if (textOnly) if (textOnly)
child child
else ...[ else ...[
GestureDetector( Builder(
onSecondaryTapUp: contextMenuCallback, builder: (context) => FocusableActionDetector(
onLongPressStart: contextMenuCallback, focusNode: focusNode,
child: Padding( actions: contextMenuCallback == null
padding: isGrouped ? .zero : .only(top: 8), ? null
child: child, : {
ActivateIntent: CallbackAction(
onInvoke: (_) {
final renderBox =
context.findRenderObject() as RenderBox;
final topLeft = renderBox.localToGlobal(
Offset.zero,
);
context.showContextMenu(
globalPosition: topLeft,
children: getEventOptions!(event).toList(),
);
return null;
},
),
},
child: Container(
decoration: BoxDecoration(
color: focusNode.hasPrimaryFocus
? theme.colorScheme.surfaceContainerHighest
: null,
),
child: GestureDetector(
onSecondaryTapUp: contextMenuCallback,
onLongPressStart: contextMenuCallback,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 8,
).copyWith(top: isGrouped ? 0 : 8),
child: child,
),
),
),
), ),
), ),

View file

@ -21,11 +21,10 @@ class MembershipRenderer extends StatelessWidget {
return switch (event.content) { return switch (event.content) {
MembershipContent content => GenericEventRenderer(Icons.people, [ MembershipContent content => GenericEventRenderer(Icons.people, [
InkWell( InkWell(
onTapUp: (details) => context.showUserPopover( onTap: () => context.showUserPopover(
content, content,
event.stateKey!, event.stateKey!,
roomId: event.roomId, roomId: event.roomId,
globalPosition: details.globalPosition,
), ),
child: Text( child: Text(
overflow: .ellipsis, overflow: .ellipsis,

View file

@ -20,7 +20,7 @@ import "package:nexus/widgets/emoji_picker_button.dart";
import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/renderers/event.dart";
import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart"; import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/widgets/flash_wrapper.dart"; import "package:nexus/widgets/highlight_wrapper.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
@ -41,7 +41,7 @@ class RoomChat extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final relatedEvent = useState<Event?>(null); final relatedEvent = useState<Event?>(null);
final relationType = useState(RelationType.reply); final relationType = useState(RelationType.reply);
final flashingEvent = useState<String?>(null); final highlightedEvent = useState<String?>(null);
final composerSize = useState<double>(64); final composerSize = useState<double>(64);
@ -400,7 +400,7 @@ class RoomChat extends HookConsumerWidget {
children: [ children: [
Positioned.fill( Positioned.fill(
child: Padding( child: Padding(
padding: .symmetric(horizontal: 12), padding: .symmetric(horizontal: 4),
child: switch (controllerData) { child: switch (controllerData) {
AsyncData(:final value?) || AsyncData(:final value?) ||
AsyncLoading(:final value?) => CustomScrollView( AsyncLoading(:final value?) => CustomScrollView(
@ -426,7 +426,7 @@ class RoomChat extends HookConsumerWidget {
itemBuilder: (_, index) { itemBuilder: (_, index) {
final event = value[index]; final event = value[index];
final previousEvent = value.getOrNull(index - 1); final previousEvent = value.getOrNull(index - 1);
return FlashWrapper( return HighlightWrapper(
EventRenderer( EventRenderer(
event, event,
onTapReply: () async { onTapReply: () async {
@ -440,10 +440,10 @@ class RoomChat extends HookConsumerWidget {
duration: (_) => .new(milliseconds: 700), duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut, curve: (_) => Curves.easeInOut,
); );
flashingEvent.value = replyId; highlightedEvent.value = replyId;
await Future.delayed(.new(seconds: 1), () { await Future.delayed(.new(seconds: 1), () {
if (flashingEvent.value == replyId) { if (highlightedEvent.value == replyId) {
flashingEvent.value = null; highlightedEvent.value = null;
} }
}); });
}, },
@ -457,8 +457,8 @@ class RoomChat extends HookConsumerWidget {
"${event.sender}${event.pmp?.id}" == "${event.sender}${event.pmp?.id}" ==
"${previousEvent?.sender}${previousEvent?.pmp?.id}", "${previousEvent?.sender}${previousEvent?.pmp?.id}",
), ),
isFlashing: isHighlighted:
flashingEvent.value == event.eventId, highlightedEvent.value == event.eventId,
); );
}, },
), ),

View file

@ -132,6 +132,7 @@ class Sidebar extends HookConsumerWidget {
}, },
short: true, short: true,
icon: AvatarOrHash( icon: AvatarOrHash(
height: 28,
space.room?.metadata?.avatar, space.room?.metadata?.avatar,
fallback: space.icon == null fallback: space.icon == null
? null ? null
@ -209,7 +210,11 @@ class Sidebar extends HookConsumerWidget {
actions: [ actions: [
RoomMenu( RoomMenu(
selectedSpace.room, selectedSpace.room,
children: selectedSpace.children, children: selectedSpace.children.addAll(
selectedSpace.subSpaces
.map((element) => element.children)
.flattened,
),
), ),
], ],
), ),

View file

@ -0,0 +1,264 @@
import "package:collection/collection.dart";
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:intl/intl.dart";
import "package:m3e_buttons/m3e_buttons.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/power_level_controller.dart";
import "package:nexus/controllers/profile_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/requests/membership_action.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/expandable_image.dart";
class UserBottomSheet extends ConsumerWidget {
final MembershipContent member;
final String userId;
final String? roomId;
const UserBottomSheet(this.member, this.userId, {this.roomId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final client = ref.watch(ClientController.provider.notifier);
void showMembershipDialog(MembershipAction action) => showDialog(
context: context,
builder: (context) => HookBuilder(
builder: (context) {
final actionReasonController = useTextEditingController();
return AlertDialog(
title: Text("${toBeginningOfSentenceCase(action.name)} $userId"),
content: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text("Are you sure you want to ${action.name} $userId?"),
SizedBox(height: 12),
TextField(
textCapitalization: .sentences,
controller: actionReasonController,
decoration: .new(
labelText: "Reason for ${action.name} (optional)",
),
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
client
.setMembership(
.new(
userId: userId,
roomId: roomId!,
action: action,
reason: actionReasonController.text,
),
)
.onError(showError);
},
child: Text(toBeginningOfSentenceCase(action.name)),
),
],
);
},
),
);
return Padding(
padding: .all(42),
child: Column(
spacing: 4,
mainAxisSize: .min,
crossAxisAlignment: .center,
children: [
Row(
mainAxisAlignment: .end,
children: [
M3EButton(
onPressed: Navigator.of(context).pop,
child: Icon(Icons.close),
),
],
),
SizedBox(height: 18),
ExpandableImage(
member.avatarUrl
?.mxcToHttps(
ref.watch(
ClientStateController.provider.select(
(value) => value!.homeserverUrl!,
),
),
)
.toString(),
child: AvatarOrHash(
member.avatarUrl,
member.displayName ?? userId.localpart,
height: 200,
),
),
SizedBox(height: 8),
SelectableText(
member.displayName ?? userId.localpart,
style: textTheme.headlineLarge,
textAlign: .center,
),
SelectableText(
userId,
textAlign: .center,
style: textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
ref
.watch(ProfileController.provider(userId))
.betterWhen(
loading: () => Text(""),
data: (profile) => Column(
children: [
if (profile.timezone == null && profile.pronouns.isEmpty)
Text(""),
Wrap(
crossAxisAlignment: .center,
alignment: .center,
spacing: 4,
runSpacing: 4,
children: [
...profile.pronouns
.where(
// TODO: Check system language (l10n)
(pronoun) => pronoun.language == "en",
)
.mapIndexed(
(index, pronoun) => [
if (index != 0)
Icon(
Icons.circle,
size: 4,
color: theme.colorScheme.onSurfaceVariant,
),
Text(
pronoun.summary,
textAlign: .center,
style: textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
)
.flattened,
if (profile.timezone != null) ...[
if (profile.pronouns.isNotEmpty)
SizedBox(
height: 16,
child: VerticalDivider(
thickness: 1.5,
width: 4,
color: theme.colorScheme.onSurfaceVariant,
),
),
Text(
profile.timezone!,
textAlign: .center,
style: textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
],
),
),
SizedBox(height: 8),
if (userId != ref.watch(ClientStateController.provider)?.userId &&
roomId != null) ...[
Row(
children: [
Expanded(
child: M3EButton.icon(
onPressed: null,
shape: .square,
style: .tonal,
icon: Icon(Icons.message),
label: Text("Message"),
),
),
],
),
if (ref.watch(
PowerLevelController.provider(
.membershipAction(
action: .kick,
roomId: roomId!,
targetUser: userId,
),
),
) &&
member.status == .join ||
member.status == .invite)
Padding(
padding: .only(top: 4),
child: Row(
mainAxisSize: MainAxisSize.max,
spacing: 8,
children: [
M3EButton.icon(
onPressed: () => showMembershipDialog(.kick),
shape: .square,
icon: Icon(Icons.sports_martial_arts),
label: Text("Kick"),
decoration: .new(
backgroundColor: WidgetStatePropertyAll(
theme.colorScheme.error,
),
foregroundColor: WidgetStatePropertyAll(
theme.colorScheme.onError,
),
),
),
M3EButton.icon(
onPressed: () => showMembershipDialog(.ban),
shape: .square,
icon: Icon(Icons.gavel),
label: Text("Ban"),
decoration: .new(
backgroundColor: WidgetStatePropertyAll(
theme.colorScheme.errorContainer,
),
foregroundColor: WidgetStatePropertyAll(
theme.colorScheme.onErrorContainer,
),
),
),
].map((e) => Expanded(child: e)).toList(),
),
),
],
],
),
);
}
}

View file

@ -1,224 +0,0 @@
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:intl/intl.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/power_level_controller.dart";
import "package:nexus/controllers/profile_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/requests/membership_action.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/expandable_image.dart";
class UserPopover extends ConsumerWidget {
final MembershipContent member;
final String userId;
final String? roomId;
const UserPopover(this.member, this.userId, {this.roomId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final client = ref.watch(ClientController.provider.notifier);
void showMembershipDialog(MembershipAction action) => showDialog(
context: context,
builder: (context) => HookBuilder(
builder: (context) {
final actionReasonController = useTextEditingController();
return AlertDialog(
title: Text("${toBeginningOfSentenceCase(action.name)} $userId"),
content: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text("Are you sure you want to ${action.name} $userId?"),
SizedBox(height: 12),
TextField(
textCapitalization: .sentences,
controller: actionReasonController,
decoration: .new(
labelText: "Reason for ${action.name} (optional)",
),
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
client
.setMembership(
.new(
userId: userId,
roomId: roomId!,
action: action,
reason: actionReasonController.text,
),
)
.onError(showError);
},
child: Text(toBeginningOfSentenceCase(action.name)),
),
],
);
},
),
);
final actionButton = ButtonStyle(
padding: WidgetStatePropertyAll(.symmetric(horizontal: 24, vertical: 18)),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: .circular(8)),
),
);
return Column(
spacing: 16,
crossAxisAlignment: .stretch,
children: [
Wrap(
alignment: .center,
spacing: 16,
runSpacing: 8,
children: [
ExpandableImage(
member.avatarUrl
?.mxcToHttps(
ref.watch(
ClientStateController.provider.select(
(value) => value!.homeserverUrl!,
),
),
)
.toString(),
child: AvatarOrHash(
member.avatarUrl,
member.displayName ?? userId.localpart,
height: 80,
),
),
Column(
children: [
SelectableText(
member.displayName ?? userId.localpart,
style: textTheme.headlineSmall,
),
SelectableText(userId, style: textTheme.titleSmall),
SizedBox(height: 4),
ref
.watch(ProfileController.provider(userId))
.betterWhen(
loading: SizedBox.shrink,
data: (profile) => Wrap(
alignment: .center,
spacing: 4,
runSpacing: 4,
children: [
for (final pronoun in profile.pronouns.where(
(pronoun) => pronoun.language == "en",
))
Chip(
label: Text(pronoun.summary),
labelStyle: .new(
color: theme.colorScheme.onPrimary,
),
color: WidgetStatePropertyAll(
theme.colorScheme.primary,
),
),
if (profile.timezone != null)
Chip(
label: Text(profile.timezone!),
labelStyle: .new(
color: theme.colorScheme.onPrimary,
),
color: WidgetStatePropertyAll(
theme.colorScheme.primary,
),
),
],
),
),
],
),
],
),
if (userId != ref.watch(ClientStateController.provider)?.userId &&
roomId != null)
Wrap(
alignment: .center,
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: null,
label: Text("Message"),
icon: Icon(Icons.message),
style: actionButton,
),
if (ref.watch(
PowerLevelController.provider(
.membershipAction(
action: .kick,
roomId: roomId!,
targetUser: userId,
),
),
) &&
member.status == .join ||
member.status == .invite)
FilledButton.icon(
onPressed: () => showMembershipDialog(.kick),
label: Text("Kick"),
style: actionButton.copyWith(
backgroundColor: WidgetStatePropertyAll(
theme.colorScheme.error,
),
foregroundColor: WidgetStatePropertyAll(
theme.colorScheme.onError,
),
),
icon: Icon(Icons.sports_martial_arts),
),
if (ref.watch(
PowerLevelController.provider(
.membershipAction(
roomId: roomId!,
action: .ban,
targetUser: userId,
),
),
))
ElevatedButton.icon(
onPressed: () => showMembershipDialog(
member.status == .ban ? .unban : .ban,
),
icon: Icon(Icons.gavel),
label: Text(member.status == .ban ? "Unban" : "Ban"),
style: actionButton.copyWith(
backgroundColor: WidgetStatePropertyAll(
theme.colorScheme.errorContainer,
),
foregroundColor: WidgetStatePropertyAll(
theme.colorScheme.onErrorContainer,
),
),
),
],
),
],
);
}
}