reorganize and rename widgets

This commit is contained in:
Henry Hiles 2026-11-09 17:25:02 -05:00
commit d5f29dc344
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
5 changed files with 568 additions and 547 deletions

View file

@ -0,0 +1,72 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/controllers/mutual_rooms.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/sidebar_page.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
/// The rooms the user shares with [userId].
class const MutualRoomsSheet(final String userId, {super.key})
extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => SheetLayout(
title: Text("Mutual Rooms"),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")),
],
children: [
ref
.watch(MutualRoomsController.provider(userId))
.betterWhen(
data: (roomIds) {
final rooms = ref.watch(
RoomsController.provider.select(
(allRooms) => roomIds
.map((roomId) => allRooms[roomId]?.metadata)
.nonNulls
.toIList(),
),
);
return rooms.isEmpty
? Text("No mutual rooms")
: Column(
children: rooms
.map(
(room) => ListTile(
leading: AvatarOrHash(
room.avatar,
room.name ?? "Unnamed Room",
fallback: Icon(Icons.numbers),
),
title: Text(
room.name ?? "Unnamed Room",
overflow: .ellipsis,
),
onTap: () async {
final container = ProviderScope.containerOf(
context,
);
// Closes the user popover under this too
Navigator.of(context)
.popUntil((route) => route.isFirst);
await container.focusRoom(room.id);
container
.read(
SidebarPageController.provider.notifier,
)
.set(.rooms);
},
),
)
.toList(),
);
},
),
],
);
}

View file

@ -0,0 +1,78 @@
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/create_room.dart";
/// Starts a DM, asking for a user ID unless [userId] is given. Pops with true
/// if a DM was started.
class const StartDmSheet({final String? userId, final String? name, super.key})
extends HookWidget {
@override
Widget build(BuildContext context) {
final userIdController = useTextEditingController();
final text = useValueListenable(userIdController).text.trim();
final target = userId ?? text;
final valid = target.isValidUserId;
final encrypted = useState(true);
final reasonController = useTextEditingController();
void submit() {
if (!valid) return;
context.startDirectMessage(
target,
name: name ?? target,
encrypted: encrypted.value,
inviteReason: reasonController.text.trim().isEmpty
? null
: reasonController.text.trim(),
);
Navigator.of(context).pop(true);
}
return SheetLayout(
title: const Text("Start a Direct Message"),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text("Cancel"),
),
TextButton(
onPressed: valid ? submit : null,
child: const Text("Start"),
),
],
children: [
if (userId == null)
TextField(
controller: userIdController,
autofocus: true,
keyboardType: .emailAddress,
onSubmitted: (_) => submit(),
decoration: .new(
labelText: "User ID",
hintText: "@user:example.com",
errorText: text.isEmpty || valid ? null : "Enter a valid user ID",
),
)
else
Text(
"Are you sure you want to start a direct message with ${name ?? userId}?",
),
TextField(
controller: reasonController,
textCapitalization: .sentences,
onSubmitted: (_) => submit(),
decoration: const .new(labelText: "Invite reason (optional)"),
),
SwitchListTile(
secondary: const Icon(Icons.lock),
title: const Text("Encrypted"),
subtitle: const Text("Can't be turned off later"),
value: encrypted.value,
onChanged: (value) => encrypted.value = value,
),
],
);
}
}

View file

@ -0,0 +1,103 @@
import "dart:async";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/controllers/timezones.dart";
import "package:nexus/helpers/extensions/weighted_search.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/widgets/settings/inline_text_field.dart";
import "package:timezone/timezone.dart";
class const TimezoneField({
required final String? value,
required final FutureOr<void> Function(String? value)? onChanged,
final TextStyle? style,
super.key,
}) extends HookConsumerWidget {
static String offsetOf(String timezone) {
final offset = TZDateTime.now(getLocation(timezone)).timeZoneOffset;
final hours = offset.inHours.abs().toString().padLeft(2, "0");
final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, "0");
return "UTC${offset.isNegative ? "-" : "+"}$hours:$minutes";
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final saving = Saving.use();
final timezones = ref.watch(TimezonesController.provider);
final enabled = onChanged != null && !saving.active;
final iconSize = InlineTextField.iconSizeOf(style);
void choose(SearchController controller, String? timezone) {
controller.closeView("");
if (timezone != value) saving.run(() => onChanged!(timezone));
}
return SearchAnchor(
viewHintText: "Search timezones...",
viewConstraints: context.isDesktop
? const .new(minWidth: 320, maxWidth: 400, maxHeight: 400)
: null,
builder: (context, controller) => InkWell(
hoverColor: colorScheme.surfaceContainerHighest,
onTap: enabled ? controller.openView : null,
child: Padding(
padding: const .fromLTRB(6, 8, 0, 8),
child: Row(
mainAxisSize: .min,
children: [
Text(
value?.replaceAll("_", " ") ?? "Timezone",
style: value == null
? style?.copyWith(color: colorScheme.onSurfaceVariant)
: style,
),
SizedBox(
width: iconSize + 12,
child: saving.active
? Center(
child: SizedBox.square(
dimension: iconSize - 4,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: Icon(
Icons.edit,
size: iconSize,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
suggestionsBuilder: (context, controller) {
final query = controller.text.trim().toLowerCase().replaceAll(" ", "_");
return [
if (value != null && query.isEmpty)
ListTile(
leading: Icon(Icons.close),
title: Text("Remove timezone"),
onTap: () => choose(controller, null),
),
...timezones
.search(query, (timezone) => [(timezone, 1)])
.map(
(timezone) => ListTile(
leading: Icon(
timezone == value ? Icons.check : Icons.schedule,
),
title: Text(timezone.replaceAll("_", " ")),
subtitle: Text(offsetOf(timezone)),
onTap: () => choose(controller, timezone),
),
),
];
},
);
}
}

View file

@ -0,0 +1,530 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.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:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/mutual_rooms.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/profile.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/user.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/create_room.dart";
import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/widgets/common/error_dialog.dart";
import "package:nexus/widgets/common/loading.dart";
import "package:nexus/widgets/users/mutual_rooms_sheet.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/membership_action.dart";
import "package:nexus/models/profile_response.dart";
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/avatars/editable_avatar.dart";
import "package:nexus/widgets/common/expandable_image.dart";
import "package:nexus/widgets/settings/inline_text_field.dart";
import "package:nexus/widgets/users/start_dm_sheet.dart";
import "package:nexus/widgets/users/timezone_field.dart";
final class const UserBottomSheet(
final String userId, {
final String? roomId,
/// Edits the current user's global profile, instead of showing actions.
final bool editable = false,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
final subtitleStyle = textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final client = ref.read(ClientController.provider.notifier);
final profileController = ref.read(
ProfileController.provider(userId).notifier,
);
Future<void> setProfileField(String field, Object? value) =>
profileController.setField(field, value).onError(showError);
final profile = ref
.watch(ProfileController.provider(userId))
.value
?.profile;
final englishPronouns = (profile?.pronouns ?? const IList<Pronoun>.empty())
// TODO: Check system language (l10n)
.where((pronoun) => pronoun.language == "en");
final divider = SizedBox(
height: 16,
child: VerticalDivider(
thickness: 1.5,
width: 4,
color: theme.colorScheme.onSurfaceVariant,
),
);
Widget avatarOf(MembershipContent member) => ExpandableImage(
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
downloadFilename: member.displayName ?? userId.localpart,
child: AvatarOrHash(
member.avatarUrl,
member.displayName ?? userId.localpart,
dimension: 200,
),
);
void showMembershipSheet(MembershipAction action) =>
context.showAdaptiveSheet(
(context) => HookBuilder(
builder: (context) {
final actionReasonController = useTextEditingController();
return SheetLayout(
title: Text(
"${toBeginningOfSentenceCase(action.name)} $userId",
),
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)),
),
],
children: [
Text("Are you sure you want to ${action.name} $userId?"),
TextField(
textCapitalization: .sentences,
controller: actionReasonController,
decoration: .new(
labelText: "Reason for ${action.name} (optional)",
),
),
],
);
},
),
);
return switch (ref.watch(
UserController.provider(.new(roomId: roomId, userId: userId)),
)) {
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
AsyncData(value: final member) ||
AsyncLoading(value: final member?) => SingleChildScrollView(
child: Padding(
padding: .all(editable ? 16 : 42),
child: Column(
spacing: 4,
mainAxisSize: .min,
crossAxisAlignment: .center,
children: [
if (!editable) ...[
Row(
mainAxisAlignment: .end,
spacing: 8,
children: [
if (userId !=
ref.watch(ClientStateController.provider)?.userId)
Consumer(
builder: (context, ref, _) {
final ignored = ref.watch(
AccountDataController.provider.select(
(data) => data.ignoredUserList.contains(userId),
),
);
final name = member.displayName ?? userId.localpart;
Future<void> setIgnored(
bool ignored,
) => context.withProgressSnackbar(
"${ignored ? "Blocking" : "Unblocking"} $name...",
() => ref
.read(AccountDataController.provider.notifier)
.setIgnored(userId, ignored: ignored),
);
return M3EButton(
tooltip: ignored ? "Unblock" : "Block",
onPressed: ignored
? () => setIgnored(false)
: () => context.showAdaptiveSheet(
(context) => SheetLayout(
title: Text("Block $name"),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
setIgnored(true);
},
style: TextButton.styleFrom(
foregroundColor:
theme.colorScheme.error,
),
child: Text("Block"),
),
],
children: [
Text(
"Are you sure you want to block $userId? You won't see their new messages or invites.",
),
],
),
),
style: .tonal,
child: Icon(
ignored ? Icons.lock_open : Icons.block,
),
);
},
),
M3EButton(
onPressed: Navigator.of(context).pop,
child: Icon(Icons.close),
),
],
),
SizedBox(height: 18),
],
if (editable)
EditableAvatar(
hasAvatar: member.avatarUrl != null,
onChanged: (url) => setProfileField(
Profile.avatarUrlKey,
url?.toString() ?? "",
),
child: avatarOf(member),
)
else
avatarOf(member),
SizedBox(height: 8),
if (editable)
InlineTextField(
value: profile?.displayName,
hint: userId.localpart,
style: textTheme.headlineLarge,
onChanged: profile == null
? null
: (value) =>
setProfileField(Profile.displayNameKey, value),
)
else
SelectableText(
member.displayName ?? userId.localpart,
style: textTheme.headlineLarge,
textAlign: .center,
),
SelectableText(userId, textAlign: .center, style: subtitleStyle),
ref
.watch(ProfileController.provider(userId))
.betterWhen(
// Keeps showing the profile while it's fetched again after saving
skipLoadingOnRefresh: true,
loading: () => Text(""),
data: (profileResponse) => Column(
children: [
Wrap(
crossAxisAlignment: .center,
alignment: .center,
spacing: 4,
runSpacing: 4,
children: editable
? [
InlineTextField(
value: englishPronouns
.map((pronoun) => pronoun.summary)
.join(", "),
hint: "Pronouns, e.g. they/them",
balanced: false,
style: subtitleStyle,
onChanged: (value) {
final pronouns = profileResponse
.profile
.pronouns
.where(
(pronoun) =>
// TODO: Check system language (l10n)
pronoun.language != "en",
)
.toIList()
.addAll(
value
.split(",")
.map(
(summary) => summary.trim(),
)
.where(
(summary) =>
summary.isNotEmpty,
)
.map(
(summary) => Pronoun(
language: "en",
summary: summary,
),
),
);
return setProfileField(
Profile.unstablePronounsKey,
pronouns.isEmpty
? null
: pronouns
.map(
(pronoun) =>
pronoun.toJson(),
)
.toList(),
);
},
),
divider,
TimezoneField(
value: profileResponse.profile.timezone,
style: subtitleStyle,
onChanged: (value) => setProfileField(
Profile.timezoneKey,
value,
),
),
]
: [
...englishPronouns
.mapIndexed(
(index, pronoun) => [
if (index != 0)
Icon(
Icons.circle,
size: 4,
color: theme
.colorScheme
.onSurfaceVariant,
),
Text(
pronoun.summary,
textAlign: .center,
style: subtitleStyle,
),
],
)
.flattened,
if (profileResponse.profile.timezone !=
null) ...[
if (profileResponse
.profile
.pronouns
.isNotEmpty)
divider,
Text(
profileResponse.profile.timezone!,
textAlign: .center,
style: subtitleStyle,
),
],
],
),
],
),
),
if (!editable &&
userId != ref.watch(ClientStateController.provider)?.userId)
switch (ref.watch(MutualRoomsController.provider(userId))) {
AsyncData(value: final roomIds) => switch (ref.watch(
RoomsController.provider.select(
(rooms) => roomIds.where(rooms.containsKey).length,
),
)) {
0 => SizedBox.shrink(),
final count => TextButton(
onPressed: () => context.showAdaptiveSheet(
(_) => MutualRoomsSheet(userId),
),
child: Text(
"$count mutual ${Intl.plural(count, one: "room", other: "rooms")}",
),
),
},
_ => SizedBox.shrink(),
},
SizedBox(height: 8),
if (editable ||
userId !=
ref.watch(ClientStateController.provider)?.userId) ...[
Row(
children: [
Expanded(
child: M3EButton.icon(
onPressed: editable
? null
: () async {
final name =
member.displayName ?? userId.localpart;
final hasDm = ref
.read(RoomsController.provider)
.values
.any(
(room) =>
room.metadata?.dmUserId == userId,
);
if (hasDm) {
context.startDirectMessage(
userId,
name: name,
);
} else if (await context
.showAdaptiveSheet<bool>(
(_) => StartDmSheet(
userId: userId,
name: name,
),
) !=
true) {
return;
}
if (context.mounted) {
Navigator.of(context).pop();
}
},
shape: .square,
style: .tonal,
icon: Icon(Icons.message),
label: Text("Message"),
),
),
],
),
if (roomId != null &&
member.status == .knock &&
ref.watch(
PowerLevelController.provider(
.membershipAction(
action: .invite,
roomId: roomId!,
targetUser: userId,
),
),
))
Padding(
padding: .only(top: 4),
child: Row(
children: [
Expanded(
child: M3EButton.icon(
onPressed: () => client
.setMembership(
.new(
userId: userId,
roomId: roomId!,
action: .invite,
),
)
.onError(showError),
shape: .square,
icon: Icon(Icons.check),
label: Text("Accept knock"),
decoration: .styleFrom(
backgroundColor: theme.successScheme.primary,
foregroundColor: theme.successScheme.onPrimary,
),
),
),
],
),
),
// Shown as a moderator would see it, while editing
if (editable ||
(roomId != null &&
ref.watch(
PowerLevelController.provider(
.membershipAction(
action: .kick,
roomId: roomId!,
targetUser: userId,
),
),
)))
Padding(
padding: .only(top: 4),
child: Row(
spacing: 8,
children: [
if (editable ||
switch (member.status) {
.join || .invite || .knock => true,
_ => false,
})
M3EButton.icon(
onPressed: editable
? null
: () => showMembershipSheet(.kick),
shape: .square,
icon: Icon(Icons.sports_martial_arts),
label: Text("Kick"),
decoration: .styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
),
),
M3EButton.icon(
onPressed: editable
? null
: () => showMembershipSheet(.ban),
shape: .square,
icon: Icon(Icons.gavel),
label: Text("Ban"),
decoration: .styleFrom(
backgroundColor: theme.colorScheme.errorContainer,
foregroundColor: theme.colorScheme.onErrorContainer,
),
),
].map((e) => Expanded(child: e)).toList(),
),
),
],
],
),
),
),
AsyncLoading _ => Loading(),
};
}
}

View file

@ -0,0 +1,63 @@
import "package:flutter/gestures.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/controllers/author.dart";
import "package:nexus/helpers/extensions/displayname_color.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/models/event.dart";
/// A user's [name] in text, opening their popover when tapped. Unlike a widget
/// in text, it wraps and ellipsizes with the rest of it.
class UserSpan extends TextSpan {
UserSpan(
BuildContext context,
String name, {
required String userId,
required TapGestureRecognizer recognizer,
String? roomId,
super.style,
super.children,
}) : super(
text: name,
recognizer: recognizer
..onTapUp = (details) {
if (!context.mounted) return;
context.showUserPopover(
userId,
roomId: roomId,
anchor: context.globalRectAt(details.globalPosition),
);
},
);
factory UserSpan.sender(
BuildContext context,
WidgetRef ref,
Event event, {
required TapGestureRecognizer recognizer,
}) {
final color = ref.displaynameColor(event.roomId, event.sender);
return UserSpan(
context,
ref.watch(AuthorController.provider(event)).value?.displayName ??
event.sender.localpart,
userId: event.sender,
roomId: event.roomId,
recognizer: recognizer,
style: TextStyle(color: color, fontWeight: .bold),
children: [
if (event.pmp != null)
TextSpan(
text: " (via ${event.sender})",
recognizer: recognizer,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(color: color, fontWeight: .bold),
),
],
);
}
}