add general room settings

This commit is contained in:
Henry Hiles 2026-10-22 11:29:19 -04:00
commit 8e51852ae3
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
8 changed files with 535 additions and 245 deletions

View file

@ -0,0 +1,178 @@
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:nexus/controllers/client.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
import "package:nexus/helpers/file_dialog_helper.dart";
import "package:nexus/main.dart";
import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/name.dart";
import "package:nexus/models/content/topic.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/settings/text_setting_tile.dart";
class RoomSettingsSectionsController(final String roomId)
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
@override
Future<IMap<String, IList<SettingsCategory>>> build() async {
final room = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
final client = ref.read(ClientController.provider.notifier);
// The room's own state, rather than metadata like a name made up from
// its members when it has none
Content? stateContent(EventType type) =>
switch (room?.state[type.type]?[""]) {
final rowId? => room?.events[rowId]?.content,
null => null,
};
final name = switch (stateContent(.name)) {
NameContent(:final name) => name,
_ => null,
};
final topic = switch (stateContent(.topic)) {
TopicContent(:final topic) => topic,
_ => room?.metadata?.topic,
};
final avatar = switch (stateContent(.avatar)) {
AvatarContent(:final url) => url,
_ => room?.metadata?.avatar,
};
bool canSet(EventType type) => ref.watch(
PowerLevelController.provider(.state(eventType: type, roomId: roomId)),
);
final canSetAvatar = canSet(.avatar);
final canSetName = canSet(.name);
final canSetTopic = canSet(.topic);
Future<void> setState(EventType type, Content content) => client.setState(
.new(roomId: roomId, type: type.type, stateKey: "", content: content),
);
return .new({
"Room": .new([
.new(
title: "General",
icon: Icons.tune,
settings: .new([
.new(
title: "Avatar",
description: "The image shown for the room.",
icon: Icons.image,
builder: (title, description, icon) => Builder(
builder: (context) => ListTile(
enabled: canSetAvatar,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
trailing: Row(
mainAxisSize: .min,
spacing: 8,
children: [
AvatarOrHash(
avatar,
name ?? "Unnamed Room",
dimension: 40,
fallback: Icon(Icons.numbers),
),
if (avatar != null && canSetAvatar)
IconButton(
tooltip: "Remove avatar",
icon: Icon(Icons.delete),
onPressed: () => setState(
.avatar,
const .new(),
).onError(showError),
),
],
),
onTap: () async {
final file = await ProviderScope.containerOf(context)
.read(FileDialogHelper.provider)
.pickFile(images: true);
if (file == null || !context.mounted) return;
await context.withProgressSnackbar(
"Changing room avatar...",
() async {
final content = await client.uploadMedia(
.new(path: file.path, encrypt: false),
);
if (content case ImageMessageContent(
:final url,
:final info,
)) {
await setState(
.avatar,
AvatarContent(url: url, info: info),
);
} else {
throw Exception("The file isn't an image");
}
},
);
},
),
),
),
.new(
title: "Room Name",
description: "The name shown for the room.",
icon: Icons.title,
builder: (title, description, icon) => TextSettingTile(
icon: Icon(icon),
title: title,
description: description,
value: name,
onChanged: canSetName
? (value) => setState(
.name,
NameContent(name: value),
).onError(showError)
: null,
),
),
.new(
title: "Topic",
description: "A short description of the room.",
icon: Icons.info,
builder: (title, description, icon) => TextSettingTile(
icon: Icon(icon),
title: title,
description: description,
value: topic,
multiline: true,
onChanged: canSetTopic
? (value) => setState(
.topic,
TopicContent(
topic: value,
content: .new(
representations: .new([.new(body: value)]),
),
),
).onError(showError)
: null,
),
),
]),
),
]),
});
}
static final provider = AsyncNotifierProvider.family
.autoDispose<
RoomSettingsSectionsController,
IMap<String, IList<SettingsCategory>>,
String
>(RoomSettingsSectionsController.new);
}

View file

@ -6,8 +6,8 @@ part "image.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const ImageInfo({
@JsonKey(name: "h") final double? height,
@JsonKey(name: "w") final double? width,
@JsonKey(name: "h") final int? height,
@JsonKey(name: "w") final int? width,
@JsonKey(name: "mimetype") final String? mimeType,
@JsonKey(name: "xyz.amorgan.blurhash") final String? blurHash,
final int? size,

View file

@ -26,7 +26,7 @@ final class const MessageImage(
aspectRatio: (info?.width ?? 1) / (info?.height ?? 1),
child: Image(
image: MxcImage(ref, request),
width: info?.width,
width: info?.width?.toDouble(),
fit: BoxFit.fitWidth,
loadingBuilder: (_, child, loadingProgress) =>
loadingProgress == null
@ -40,7 +40,7 @@ final class const MessageImage(
child: BlurHash(hash: blurHash),
)
: SizedBox(
width: info!.width,
width: info!.width!.toDouble(),
child: BlurHash(hash: blurHash),
),
_ => Loading(),

View file

@ -1,247 +1,19 @@
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:hooks_riverpod/hooks_riverpod.dart";
import "package:m3e_card_list/m3e_card_list.dart";
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/settings_sections.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/show_about_sheet.dart";
import "package:nexus/widgets/pages/settings_category.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:super_sliver_list/super_sliver_list.dart";
import "package:nexus/widgets/settings/settings_layout.dart";
class const SettingsPage({super.key}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
builder: (_, constraints) => HookBuilder(
builder: (context) {
final categoriesArePages = constraints.maxWidth < 550;
final selected = useState(0);
final highlightedMatch = useState<(int, int)?>(null);
final listController = useRef(ListController());
final scrollController = useScrollController();
final searchBar = SearchAnchor.bar(
barHintText: "Search...",
isFullScreen: categoriesArePages,
suggestionsBuilder: (suggestionsContext, controller) async {
final categories = await ref.read(
SettingsSectionsController.provider.future,
);
final query = controller.text.toLowerCase();
final matches = categories.values.flattenedToList
.asMap()
.entries
.expand(
(categoryEntry) => categoryEntry.value.settings
.asMap()
.entries
.where(
(settingEntry) =>
settingEntry.value.title.toLowerCase().contains(
query,
) ||
settingEntry.value.description
.toLowerCase()
.contains(query),
)
.map(
(settingEntry) => (
(categoryEntry.key, settingEntry.key),
settingEntry.value,
),
),
)
.toIList();
return matches.map(
(match) => ListTile(
onTap: () async {
if (context.mounted) Navigator.of(suggestionsContext).pop();
controller.text = "";
if (categoriesArePages) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => SettingsCategoryPage(
match.$1.$1,
initialHighlight: match.$1.$2,
),
),
);
} else {
selected.value = match.$1.$1;
listController.value.animateToItem(
index: match.$1.$2,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => Durations.extralong1,
curve: (_) => Curves.easeInOut,
);
highlightedMatch.value = match.$1;
await Future.delayed(Durations.extralong4, () {
if (highlightedMatch.value == match.$1) {
highlightedMatch.value = null;
}
});
}
},
leading: Icon(match.$2.icon),
title: Text(match.$2.title),
subtitle: Text(match.$2.description),
),
);
},
);
final settingsContent = Scaffold(
appBar: AppBar(
title: Text("Settings"),
actionsPadding: .symmetric(horizontal: 12),
Widget build(BuildContext context, WidgetRef ref) => SettingsLayout(
title: "Settings",
sectionsProvider: SettingsSectionsController.provider,
actions: [
IconButton(
onPressed: () => context.showAboutSheet(ref),
icon: Icon(Icons.info_outline),
),
],
),
body: ref
.watch(SettingsSectionsController.provider)
.betterWhen(
data: (sections) => categoriesArePages
? CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(12).copyWith(bottom: 8),
child: searchBar,
),
),
...sections
.mapTo(
(section, categories) => [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 4),
child: DividerText(section),
),
),
SliverM3ECardList(
padding: .symmetric(
horizontal: 4,
vertical: 8,
),
margin: .symmetric(horizontal: 12),
color: Theme.of(context)
.colorScheme
.primaryContainer,
itemCount: categories.length,
onTap: (index) => Navigator.of(context)
.push(
MaterialPageRoute(
builder: (context) =>
SettingsCategoryPage(
sections
.values
.flattenedToList
.indexOf(
categories[index],
),
),
),
),
itemBuilder: (context, index) => ListTile(
leading: Icon(categories[index].icon),
title: Text(categories[index].title),
),
),
],
)
.flattened,
],
)
: Row(
children: [
MaterialUiCompatibilityBridge(
child: NavigationRailM3E(
type: .alwaysExpand,
trailing: searchBar,
scrollable: true,
sections: sections
.mapTo(
(
categoryGroup,
categories,
) => NavigationRailM3ESection(
header: DividerText(categoryGroup),
destinations: categories
.map(
(category) =>
NavigationRailM3EDestination(
icon: Icon(category.icon),
label: category.title,
),
)
.toList(),
),
)
.toList(),
selectedIndex: selected.value,
onDestinationSelected: (value) =>
selected.value = value,
),
),
VerticalDivider(),
Expanded(
child: SuperListView(
listController: listController.value,
controller: scrollController,
padding: .symmetric(vertical: 12),
children: sections
.values
.flattenedToList[selected.value]
.settings
.mapIndexed(
(index, setting) => Padding(
padding: .only(bottom: 4),
child: HighlightWrapper(
setting.builder(
setting.title,
setting.description,
setting.icon,
),
isHighlighted:
highlightedMatch.value ==
(selected.value, index),
),
),
)
.toList(),
),
),
],
),
),
);
return constraints.maxWidth < 650
? settingsContent
: Dialog(
constraints: .loose(Size(900, 600)),
child: ClipRRect(
borderRadius: BorderRadiusGeometry.circular(12),
child: settingsContent,
),
);
},
),
);
}

View file

@ -4,13 +4,19 @@ import "package:collection/collection.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/settings_sections.dart";
import "package:hooks_riverpod/misc.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class const SettingsCategoryPage(
final int index, {
required final ProviderListenable<
AsyncValue<IMap<String, IList<SettingsCategory>>>
>
sectionsProvider,
final int? initialHighlight,
super.key,
}) extends HookConsumerWidget {
@ -45,7 +51,7 @@ class const SettingsCategoryPage(
}, []);
return ref
.watch(SettingsSectionsController.provider)
.watch(sectionsProvider)
.betterWhen(
data: (sections) => Scaffold(
appBar: AppBar(

View file

@ -7,7 +7,9 @@ import "package:nexus/widgets/sheet_layout.dart";
import "package:flutter/services.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/room_settings_sections.dart";
import "package:nexus/widgets/adaptive_menu_button.dart";
import "package:nexus/widgets/settings/settings_layout.dart";
import "package:nexus/controllers/via.dart";
import "package:nexus/models/room.dart";
import "package:flutter_hooks/flutter_hooks.dart";
@ -122,6 +124,21 @@ final class const RoomMenuButton(
title: Text("Invite"),
),
),
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (_) => SettingsLayout(
title: "Room Settings",
sectionsProvider: RoomSettingsSectionsController.provider(
room!.metadata!.id,
),
),
),
child: ListTile(
leading: Icon(Icons.settings),
title: Text("Settings"),
),
),
PopupMenuItem(
onTap: () => context.showLeaveRoomSheet(
room!.metadata!.id,

View file

@ -0,0 +1,255 @@
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:hooks_riverpod/hooks_riverpod.dart";
import "package:hooks_riverpod/misc.dart";
import "package:m3e_card_list/m3e_card_list.dart";
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/pages/settings_category.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:super_sliver_list/super_sliver_list.dart";
/// A settings screen split into categories: a list of them that opens each
/// as its own page on narrow screens, or a rail beside the selected one.
class const SettingsLayout({
required final String title,
required final ProviderListenable<
AsyncValue<IMap<String, IList<SettingsCategory>>>
>
sectionsProvider,
final List<Widget> actions = const [],
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
builder: (_, constraints) => HookBuilder(
builder: (context) {
final categoriesArePages = constraints.maxWidth < 550;
final selected = useState(0);
final highlightedMatch = useState<(int, int)?>(null);
final listController = useRef(ListController());
final scrollController = useScrollController();
final searchBar = SearchAnchor.bar(
barHintText: "Search...",
isFullScreen: categoriesArePages,
suggestionsBuilder: (suggestionsContext, controller) async {
// Only searched once shown, by which point these have loaded
final categories =
ref.read(sectionsProvider).value ?? const IMap.empty();
final query = controller.text.toLowerCase();
final matches = categories.values.flattenedToList
.asMap()
.entries
.expand(
(categoryEntry) => categoryEntry.value.settings
.asMap()
.entries
.where(
(settingEntry) =>
settingEntry.value.title.toLowerCase().contains(
query,
) ||
settingEntry.value.description
.toLowerCase()
.contains(query),
)
.map(
(settingEntry) => (
(categoryEntry.key, settingEntry.key),
settingEntry.value,
),
),
)
.toIList();
return matches.map(
(match) => ListTile(
onTap: () async {
if (context.mounted) Navigator.of(suggestionsContext).pop();
controller.text = "";
if (categoriesArePages) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => SettingsCategoryPage(
match.$1.$1,
sectionsProvider: sectionsProvider,
initialHighlight: match.$1.$2,
),
),
);
} else {
selected.value = match.$1.$1;
listController.value.animateToItem(
index: match.$1.$2,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => Durations.extralong1,
curve: (_) => Curves.easeInOut,
);
highlightedMatch.value = match.$1;
await Future.delayed(Durations.extralong4, () {
if (highlightedMatch.value == match.$1) {
highlightedMatch.value = null;
}
});
}
},
leading: Icon(match.$2.icon),
title: Text(match.$2.title),
subtitle: Text(match.$2.description),
),
);
},
);
final settingsContent = Scaffold(
appBar: AppBar(
title: Text(title),
actionsPadding: .symmetric(horizontal: 12),
actions: actions,
),
body: ref
.watch(sectionsProvider)
.betterWhen(
data: (sections) => categoriesArePages
? CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(12).copyWith(bottom: 8),
child: searchBar,
),
),
...sections
.mapTo(
(section, categories) => [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 4),
child: DividerText(section),
),
),
SliverM3ECardList(
padding: .symmetric(
horizontal: 4,
vertical: 8,
),
margin: .symmetric(horizontal: 12),
color: Theme.of(context)
.colorScheme
.primaryContainer,
itemCount: categories.length,
onTap: (index) => Navigator.of(context)
.push(
MaterialPageRoute(
builder: (context) =>
SettingsCategoryPage(
sections
.values
.flattenedToList
.indexOf(
categories[index],
),
sectionsProvider:
sectionsProvider,
),
),
),
itemBuilder: (context, index) => ListTile(
leading: Icon(categories[index].icon),
title: Text(categories[index].title),
),
),
],
)
.flattened,
],
)
: Row(
children: [
MaterialUiCompatibilityBridge(
child: NavigationRailM3E(
type: .alwaysExpand,
trailing: searchBar,
scrollable: true,
sections: sections
.mapTo(
(
categoryGroup,
categories,
) => NavigationRailM3ESection(
header: DividerText(categoryGroup),
destinations: categories
.map(
(category) =>
NavigationRailM3EDestination(
icon: Icon(category.icon),
label: category.title,
),
)
.toList(),
),
)
.toList(),
selectedIndex: selected.value,
onDestinationSelected: (value) =>
selected.value = value,
),
),
VerticalDivider(),
Expanded(
child: SuperListView(
listController: listController.value,
controller: scrollController,
padding: .symmetric(vertical: 12),
children: sections
.values
.flattenedToList[selected.value]
.settings
.mapIndexed(
(index, setting) => Padding(
padding: .only(bottom: 4),
child: HighlightWrapper(
setting.builder(
setting.title,
setting.description,
setting.icon,
),
isHighlighted:
highlightedMatch.value ==
(selected.value, index),
),
),
)
.toList(),
),
),
],
),
),
);
return constraints.maxWidth < 650
? settingsContent
: Dialog(
constraints: .loose(Size(900, 600)),
child: ClipRRect(
borderRadius: BorderRadiusGeometry.circular(12),
child: settingsContent,
),
);
},
),
);
}

View file

@ -0,0 +1,62 @@
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/widgets/sheet_layout.dart";
class const TextSettingTile({
required final Icon icon,
required final String title,
required final String description,
required final String? value,
/// Disables the tile if null
required final void Function(String value)? onChanged,
final bool multiline = false,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => ListTile(
enabled: onChanged != null,
leading: icon,
title: Text(title),
subtitle: Text(
value?.isNotEmpty == true ? value! : description,
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => context.showAdaptiveSheet(
(context) => HookBuilder(
builder: (context) {
final controller = useTextEditingController(text: value);
void save() {
Navigator.of(context).pop();
onChanged!(controller.text.trim());
}
return SheetLayout(
title: Text(title),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(onPressed: save, child: Text("Save")),
],
children: [
TextField(
controller: controller,
autofocus: true,
textCapitalization: .sentences,
minLines: 1,
maxLines: multiline ? 6 : 1,
onSubmitted: multiline ? null : (_) => save(),
decoration: .new(labelText: title),
),
],
);
},
),
),
);
}