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
6 changed files with 535 additions and 245 deletions

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),
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),
),
],
);
}

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),
),
],
);
},
),
),
);
}