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