add general room settings
This commit is contained in:
parent
48c04d9b3e
commit
8e51852ae3
8 changed files with 535 additions and 245 deletions
178
lib/controllers/room_settings_sections.dart
Normal file
178
lib/controllers/room_settings_sections.dart
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,8 @@ part "image.g.dart";
|
||||||
@Freezed(toJson: false, fromJson: false)
|
@Freezed(toJson: false, fromJson: false)
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class const ImageInfo({
|
class const ImageInfo({
|
||||||
@JsonKey(name: "h") final double? height,
|
@JsonKey(name: "h") final int? height,
|
||||||
@JsonKey(name: "w") final double? width,
|
@JsonKey(name: "w") final int? width,
|
||||||
@JsonKey(name: "mimetype") final String? mimeType,
|
@JsonKey(name: "mimetype") final String? mimeType,
|
||||||
@JsonKey(name: "xyz.amorgan.blurhash") final String? blurHash,
|
@JsonKey(name: "xyz.amorgan.blurhash") final String? blurHash,
|
||||||
final int? size,
|
final int? size,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ final class const MessageImage(
|
||||||
aspectRatio: (info?.width ?? 1) / (info?.height ?? 1),
|
aspectRatio: (info?.width ?? 1) / (info?.height ?? 1),
|
||||||
child: Image(
|
child: Image(
|
||||||
image: MxcImage(ref, request),
|
image: MxcImage(ref, request),
|
||||||
width: info?.width,
|
width: info?.width?.toDouble(),
|
||||||
fit: BoxFit.fitWidth,
|
fit: BoxFit.fitWidth,
|
||||||
loadingBuilder: (_, child, loadingProgress) =>
|
loadingBuilder: (_, child, loadingProgress) =>
|
||||||
loadingProgress == null
|
loadingProgress == null
|
||||||
|
|
@ -40,7 +40,7 @@ final class const MessageImage(
|
||||||
child: BlurHash(hash: blurHash),
|
child: BlurHash(hash: blurHash),
|
||||||
)
|
)
|
||||||
: SizedBox(
|
: SizedBox(
|
||||||
width: info!.width,
|
width: info!.width!.toDouble(),
|
||||||
child: BlurHash(hash: blurHash),
|
child: BlurHash(hash: blurHash),
|
||||||
),
|
),
|
||||||
_ => Loading(),
|
_ => Loading(),
|
||||||
|
|
|
||||||
|
|
@ -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:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:m3e_card_list/m3e_card_list.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
|
|
||||||
import "package:nexus/controllers/settings_sections.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/helpers/extensions/show_about_sheet.dart";
|
||||||
import "package:nexus/widgets/pages/settings_category.dart";
|
import "package:nexus/widgets/settings/settings_layout.dart";
|
||||||
import "package:nexus/widgets/divider_text.dart";
|
|
||||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
|
||||||
import "package:super_sliver_list/super_sliver_list.dart";
|
|
||||||
|
|
||||||
class const SettingsPage({super.key}) extends ConsumerWidget {
|
class const SettingsPage({super.key}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
|
Widget build(BuildContext context, WidgetRef ref) => SettingsLayout(
|
||||||
builder: (_, constraints) => HookBuilder(
|
title: "Settings",
|
||||||
builder: (context) {
|
sectionsProvider: SettingsSectionsController.provider,
|
||||||
final categoriesArePages = constraints.maxWidth < 550;
|
actions: [
|
||||||
|
IconButton(
|
||||||
final selected = useState(0);
|
onPressed: () => context.showAboutSheet(ref),
|
||||||
|
icon: Icon(Icons.info_outline),
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,19 @@ import "package:collection/collection.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.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/helpers/extensions/better_when.dart";
|
||||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||||
import "package:super_sliver_list/super_sliver_list.dart";
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
|
||||||
class const SettingsCategoryPage(
|
class const SettingsCategoryPage(
|
||||||
final int index, {
|
final int index, {
|
||||||
|
required final ProviderListenable<
|
||||||
|
AsyncValue<IMap<String, IList<SettingsCategory>>>
|
||||||
|
>
|
||||||
|
sectionsProvider,
|
||||||
final int? initialHighlight,
|
final int? initialHighlight,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends HookConsumerWidget {
|
}) extends HookConsumerWidget {
|
||||||
|
|
@ -45,7 +51,7 @@ class const SettingsCategoryPage(
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return ref
|
return ref
|
||||||
.watch(SettingsSectionsController.provider)
|
.watch(sectionsProvider)
|
||||||
.betterWhen(
|
.betterWhen(
|
||||||
data: (sections) => Scaffold(
|
data: (sections) => Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ import "package:nexus/widgets/sheet_layout.dart";
|
||||||
import "package:flutter/services.dart";
|
import "package:flutter/services.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client.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/adaptive_menu_button.dart";
|
||||||
|
import "package:nexus/widgets/settings/settings_layout.dart";
|
||||||
import "package:nexus/controllers/via.dart";
|
import "package:nexus/controllers/via.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
|
@ -122,6 +124,21 @@ final class const RoomMenuButton(
|
||||||
title: Text("Invite"),
|
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(
|
PopupMenuItem(
|
||||||
onTap: () => context.showLeaveRoomSheet(
|
onTap: () => context.showLeaveRoomSheet(
|
||||||
room!.metadata!.id,
|
room!.metadata!.id,
|
||||||
|
|
|
||||||
255
lib/widgets/settings/settings_layout.dart
Normal file
255
lib/widgets/settings/settings_layout.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
62
lib/widgets/settings/text_setting_tile.dart
Normal file
62
lib/widgets/settings/text_setting_tile.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue