From 8e51852ae3d87af3c2c876f47fdfd573a0d13b43 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 22 Oct 2026 11:29:19 -0400 Subject: [PATCH] add general room settings --- lib/controllers/room_settings_sections.dart | 178 ++++++++++++++ lib/models/info/image.dart | 4 +- lib/widgets/message_image.dart | 4 +- lib/widgets/pages/settings.dart | 250 +------------------ lib/widgets/pages/settings_category.dart | 10 +- lib/widgets/room_menu_button.dart | 17 ++ lib/widgets/settings/settings_layout.dart | 255 ++++++++++++++++++++ lib/widgets/settings/text_setting_tile.dart | 62 +++++ 8 files changed, 535 insertions(+), 245 deletions(-) create mode 100644 lib/controllers/room_settings_sections.dart create mode 100644 lib/widgets/settings/settings_layout.dart create mode 100644 lib/widgets/settings/text_setting_tile.dart diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart new file mode 100644 index 0000000..b4a41f4 --- /dev/null +++ b/lib/controllers/room_settings_sections.dart @@ -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>> { + @override + Future>> 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 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 + >(RoomSettingsSectionsController.new); +} diff --git a/lib/models/info/image.dart b/lib/models/info/image.dart index 266fb01..cc09d1c 100644 --- a/lib/models/info/image.dart +++ b/lib/models/info/image.dart @@ -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, diff --git a/lib/widgets/message_image.dart b/lib/widgets/message_image.dart index 42c08d6..969dcd3 100644 --- a/lib/widgets/message_image.dart +++ b/lib/widgets/message_image.dart @@ -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(), diff --git a/lib/widgets/pages/settings.dart b/lib/widgets/pages/settings.dart index 6bc298c..c205d3e 100644 --- a/lib/widgets/pages/settings.dart +++ b/lib/widgets/pages/settings.dart @@ -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), - 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, - ), - ); - }, - ), + Widget build(BuildContext context, WidgetRef ref) => SettingsLayout( + title: "Settings", + sectionsProvider: SettingsSectionsController.provider, + actions: [ + IconButton( + onPressed: () => context.showAboutSheet(ref), + icon: Icon(Icons.info_outline), + ), + ], ); } diff --git a/lib/widgets/pages/settings_category.dart b/lib/widgets/pages/settings_category.dart index 2398f7f..db22367 100644 --- a/lib/widgets/pages/settings_category.dart +++ b/lib/widgets/pages/settings_category.dart @@ -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>> + > + 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( diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart index 38ce25d..38ad847 100644 --- a/lib/widgets/room_menu_button.dart +++ b/lib/widgets/room_menu_button.dart @@ -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, diff --git a/lib/widgets/settings/settings_layout.dart b/lib/widgets/settings/settings_layout.dart new file mode 100644 index 0000000..ca770d6 --- /dev/null +++ b/lib/widgets/settings/settings_layout.dart @@ -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>> + > + sectionsProvider, + final List 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, + ), + ); + }, + ), + ); +} diff --git a/lib/widgets/settings/text_setting_tile.dart b/lib/widgets/settings/text_setting_tile.dart new file mode 100644 index 0000000..d172bb3 --- /dev/null +++ b/lib/widgets/settings/text_setting_tile.dart @@ -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), + ), + ], + ); + }, + ), + ), + ); +}