add settings for image packs

Closes #131
This commit is contained in:
Henry Hiles 2026-10-26 12:05:45 -04:00
commit 4b329b4c6f
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
8 changed files with 1115 additions and 119 deletions

View file

@ -271,28 +271,30 @@ class const Composer(
onPressed: () => context.showAdaptiveSheet(
(context) => EmojiPicker(
roomId: roomId,
onSelection: (value, {shortcode}) {
Navigator.of(context).pop();
if (shortcode == null) {
controller.text += value;
} else {
final text = "${controller.text}:";
controller.value = .new(
text: text,
selection: .collapsed(
offset: text.length,
),
);
controller.addTag(
id: ImagePack.emojiMarkdown(
shortcode,
value,
),
name: shortcode,
);
}
node?.requestFocus();
},
onSelection:
(value, {shortcode, description}) {
Navigator.of(context).pop();
if (shortcode == null) {
controller.text += value;
} else {
final text = "${controller.text}:";
controller.value = .new(
text: text,
selection: .collapsed(
offset: text.length,
),
);
controller.addTag(
id: ImagePack.emojiMarkdown(
shortcode,
value,
description: description,
),
name: shortcode,
);
}
node?.requestFocus();
},
),
),
icon: Icon(Icons.emoji_emotions),

View file

@ -3,6 +3,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/all_emoji.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/image_pack.dart";
class const EmojiOverlay(
@ -39,6 +40,10 @@ class const EmojiOverlay(
? ImagePack.emojiMarkdown(
emoji.aliases.first,
emoji.value,
description: switch (emoji.data) {
PackImage(:final body) => body,
_ => null,
},
)
: emoji.value,
name: emoji.aliases.first,

View file

@ -16,7 +16,12 @@ import "package:nexus/models/content/image_pack.dart";
class const EmojiPicker({
required final String roomId,
required final FutureOr<void> Function(String value, {String? shortcode})
/// Has the [shortcode] and [description] of images from packs.
required final FutureOr<void> Function(
String value, {
String? shortcode,
String? description,
})
onSelection,
final bool allowFreeText = false,
super.key,
@ -41,11 +46,14 @@ class const EmojiPicker({
return upstream.EmojiPicker(
onSelection: (emoji) async {
final image = switch (emoji.data) {
final PackImage image => image,
_ => null,
};
await onSelection(
emoji.value,
shortcode: emoji.data is PackImage
? emoji.aliases.first
: null,
shortcode: image == null ? null : emoji.aliases.first,
description: image?.body,
);
await ref

View file

@ -0,0 +1,14 @@
import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/mxc_emoji.dart";
class const ImagePackAvatar(final Uri? icon, {super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => SizedBox.square(
dimension: 40,
child: switch (icon) {
final icon? => MxcEmoji(icon),
null => Icon(Icons.emoji_emotions),
},
);
}

View file

@ -0,0 +1,118 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/room_image_packs.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/account_data.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/image_pack_avatar.dart";
import "package:nexus/widgets/sheet_layout.dart";
/// Chooses a room, then which of its image packs to use in every room.
class const EnableImagePacksSheet({
required final ImagePackRooms enabled,
required final void Function(ImagePackRooms enabled) onSave,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final chosen = useState(enabled);
final roomId = useState<String?>(null);
final rooms = ref.watch(
RoomsController.provider.select(
(rooms) => rooms.values
.map((room) => room.metadata)
.nonNulls
.map(
(metadata) => (
id: metadata.id,
name: metadata.name ?? metadata.id,
avatar: metadata.avatar,
),
)
.sortedBy((room) => room.name.toLowerCase())
.toIList(),
),
);
return SheetLayout(
title: Text("Add Image Packs"),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
TextButton(
onPressed: () {
Navigator.of(context).pop();
onSave(chosen.value);
},
child: Text("Save"),
),
],
children: [
Text("Packs you add can be used in every room."),
DropdownMenu(
expandedInsets: .zero,
menuHeight: 300,
enableFilter: true,
requestFocusOnTap: true,
label: Text("Room"),
onSelected: (id) => roomId.value = id,
dropdownMenuEntries: rooms
.map(
(room) => DropdownMenuEntry(
value: room.id,
label: room.name,
leadingIcon: AvatarOrHash(
room.avatar,
room.name,
fallback: Icon(Icons.numbers),
),
),
)
.toList(),
),
AnimatedSize(
duration: Durations.medium2,
curve: Curves.easeInOut,
alignment: .topCenter,
child: switch (roomId.value) {
final roomId? =>
ref
.watch(RoomImagePacksController.provider(roomId))
.betterWhen(
data: (packs) => packs.isEmpty
? Text("This room has no image packs")
: Column(
mainAxisSize: .min,
children: packs.entries
.map(
(entry) => CheckboxListTile(
secondary: ImagePackAvatar(
entry.value.icon,
),
title: Text(entry.value.name),
subtitle: Text(entry.value.imageCountLabel),
value: chosen.value.contains(
roomId,
entry.key,
),
onChanged: (value) =>
chosen.value = chosen.value.withPack(
roomId,
entry.key,
enabled: value ?? false,
),
),
)
.toList(),
),
),
null => const SizedBox(width: .infinity),
},
),
],
);
}
}

View file

@ -0,0 +1,250 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/file_dialog_helper.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/main.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/widgets/image_pack_avatar.dart";
import "package:nexus/widgets/mxc_emoji.dart";
import "package:nexus/widgets/settings/image_usage_chips.dart";
import "package:nexus/widgets/settings/pack_image_sheet.dart";
import "package:nexus/widgets/sheet_layout.dart";
/// Edits an image pack, or only shows it if [onSave] is null.
class const ImagePackSheet({
required final String title,
required final ImagePackContent content,
required final void Function(ImagePackContent content)? onSave,
final VoidCallback? onDeleted,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final editable = onSave != null;
final nameController = useTextEditingController(
text: content.pack?.displayName,
);
final name = useValueListenable(nameController).text.trim();
final avatar = useState(content.pack?.avatarUrl);
final usage = useState(content.pack?.usage ?? const ISet.empty());
final images = useState(content.images);
final uploading = Saving.use();
final packUsage = usage.value.isEmpty
? ISet(ImageUsage.values)
: usage.value;
final valid = !uploading.active && name.isNotEmpty;
Future<void> upload(
void Function(String filename, PackImage image) onUploaded,
) async {
final file = await ref
.read(FileDialogHelper.provider)
.pickFile(images: true);
if (file == null || !context.mounted) return;
await uploading.run(() async {
final content = await ref
.read(ClientController.provider.notifier)
.uploadMedia(.new(path: file.path, encrypt: false));
if (!context.mounted) return;
if (content case ImageMessageContent(:final url, :final info)) {
onUploaded(file.name, PackImage(url: url, info: info));
} else {
throw Exception("The file isn't an image");
}
});
}
void editImage(String shortcode, PackImage image, {bool isNew = false}) =>
context.showAdaptiveSheet(
(_) => PackImageSheet(
title: isNew
? "Add Image"
: editable
? "Edit Image"
: ":$shortcode:",
shortcode: shortcode,
image: image,
taken: isNew
? images.value.keys.toISet()
: images.value.keys.toISet().remove(shortcode),
packUsage: packUsage,
onSave: editable
? (newShortcode, newImage) => images.value = isNew
? images.value.add(newShortcode, newImage)
// Replaced in place, to keep the images' order
: .fromEntries(
images.value.entries.map(
(entry) => entry.key == shortcode
? MapEntry(newShortcode, newImage)
: entry,
),
)
: null,
onRemoved: editable && !isNew
? () => images.value = images.value.remove(shortcode)
: null,
),
);
Future<void> delete() async {
final confirmed = await context.showAdaptiveSheet<bool>(
(context) => SheetLayout(
title: Text("Delete Pack"),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text("Cancel"),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: Text("Delete"),
),
],
children: [
Text(
"Delete this pack? Its images will no longer be available here, or to anyone using it in other rooms.",
),
],
),
);
if (confirmed != true || !context.mounted) return;
Navigator.of(context).pop();
onDeleted?.call();
}
void save() {
Navigator.of(context).pop();
onSave!(
content.copyWith(
images: images.value,
pack: (content.pack ?? const PackInfo()).copyWith(
displayName: name,
avatarUrl: avatar.value,
usage: usage.value,
),
),
);
}
final textTheme = Theme.of(context).textTheme;
return SheetLayout(
title: Text(title),
actions: [
if (onDeleted != null)
TextButton(
onPressed: uploading.active ? null : delete,
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: Text("Delete"),
),
TextButton(
onPressed: Navigator.of(context).pop,
child: Text(editable ? "Cancel" : "Close"),
),
if (editable)
TextButton(onPressed: valid ? save : null, child: Text("Save")),
],
children: [
TextField(
controller: nameController,
enabled: editable,
autofocus: true,
decoration: .new(labelText: "Name"),
),
ListTile(
enabled: editable && !uploading.active,
leading: Icon(Icons.image),
title: Text("Avatar"),
subtitle: Text("The pack's first image if not set."),
trailing: Row(
mainAxisSize: .min,
spacing: 8,
children: [
ImagePackAvatar(
avatar.value ?? images.value.values.firstOrNull?.url,
),
if (avatar.value != null && editable)
IconButton(
tooltip: "Remove avatar",
icon: Icon(Icons.delete),
onPressed: uploading.active
? null
: () => avatar.value = null,
),
],
),
onTap: () =>
upload((_, image) => avatar.value = image.url).onError(showError),
),
Text("Default usage", style: textTheme.titleSmall),
ImageUsageChips(
value: packUsage,
onChanged: editable
? (value) =>
usage.value = value.length == ImageUsage.values.length
? const .empty()
: value
: null,
),
Text("Images", style: textTheme.titleSmall),
if (images.value.isEmpty && !editable) Text("No images"),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
...images.value.entries.map(
(entry) => Tooltip(
message: ":${entry.key}:",
child: InkWell(
borderRadius: .circular(8),
onTap: () => editImage(entry.key, entry.value),
child: Padding(
padding: .all(4),
child: SizedBox.square(
dimension: 56,
child: MxcEmoji(entry.value.url, label: entry.key),
),
),
),
),
),
if (editable)
SizedBox.square(
dimension: 64,
child: uploading.active
? Center(child: CircularProgressIndicator())
: IconButton(
tooltip: "Add image",
icon: Icon(Icons.add_photo_alternate),
onPressed: () => upload(
(filename, image) => editImage(
filename
.split(".")
.first
.replaceAll(RegExp(r"[^\w-]"), ""),
image,
isNew: true,
),
).onError(showError),
),
),
],
),
],
);
}
}

View file

@ -0,0 +1,38 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/content/image_pack.dart";
/// Chooses what images are used as, keeping at least one usage chosen.
class const ImageUsageChips({
required final ISet<ImageUsage> value,
/// Disables the chips if null.
required final void Function(ISet<ImageUsage> value)? onChanged,
super.key,
}) extends StatelessWidget {
static String nameOf(ImageUsage usage) => switch (usage) {
.emoticon => "Emoticons",
.sticker => "Stickers",
};
@override
Widget build(BuildContext context) => Wrap(
spacing: 8,
runSpacing: 8,
children: ImageUsage.values
.map(
(usage) => FilterChip(
label: Text(nameOf(usage)),
selected: value.contains(usage),
onSelected:
onChanged == null ||
(value.length == 1 && value.contains(usage))
? null
: (selected) => onChanged!(
selected ? value.add(usage) : value.remove(usage),
),
),
)
.toList(),
);
}

View file

@ -0,0 +1,134 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/widgets/mxc_emoji.dart";
import "package:nexus/widgets/settings/image_usage_chips.dart";
import "package:nexus/widgets/sheet_layout.dart";
/// Edits an image in a pack, or only shows it if [onSave] is null.
class const PackImageSheet({
required final String title,
required final String shortcode,
required final PackImage image,
/// The other images' shortcodes, which this one can't also have.
required final ISet<String> taken,
/// The pack's default usage, which images have unless they set their own.
required final ISet<ImageUsage> packUsage,
required final void Function(String shortcode, PackImage image)? onSave,
final VoidCallback? onRemoved,
super.key,
}) extends HookWidget {
static final shortcodePattern = RegExp(r"^[\w-]+$");
@override
Widget build(BuildContext context) {
final shortcodeController = useTextEditingController(text: shortcode);
final bodyController = useTextEditingController(text: image.body);
final usage = useState(image.usage);
final newShortcode = useValueListenable(shortcodeController).text.trim();
final error = taken.contains(newShortcode)
? "Another image has this shortcode"
: newShortcode.isNotEmpty && !shortcodePattern.hasMatch(newShortcode)
? "Only letters, numbers, - and _ are allowed"
: null;
final valid = newShortcode.isNotEmpty && error == null;
void save() {
final body = bodyController.text.trim();
Navigator.of(context).pop();
onSave!(
newShortcode,
image.copyWith(body: body.isEmpty ? null : body, usage: usage.value),
);
}
return SheetLayout(
title: Text(title),
actions: [
if (onRemoved case final onRemoved?)
TextButton(
onPressed: () {
Navigator.of(context).pop();
onRemoved();
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: Text("Remove"),
),
TextButton(
onPressed: Navigator.of(context).pop,
child: Text(onSave == null ? "Close" : "Cancel"),
),
if (onSave != null)
TextButton(onPressed: valid ? save : null, child: Text("Save")),
],
children: [
Center(
child: SizedBox.square(
dimension: 128,
child: MxcEmoji(image.url, label: shortcode),
),
),
TextField(
controller: shortcodeController,
enabled: onSave != null,
onSubmitted: valid ? (_) => save() : null,
decoration: .new(
labelText: "Shortcode",
prefixText: ":",
suffixText: ":",
errorText: error,
),
),
TextField(
controller: bodyController,
enabled: onSave != null,
onSubmitted: valid ? (_) => save() : null,
decoration: .new(
labelText: "Description",
hintText: "Read by screen readers",
),
),
Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
SwitchListTile(
title: Text("Custom usage"),
subtitle: Text(
usage.value.isEmpty
? "Same as the pack's default (${packUsage.map(ImageUsageChips.nameOf).join(", ")})"
: "Used as chosen below, instead of the pack's default",
),
value: usage.value.isNotEmpty,
onChanged: onSave == null
? null
: (custom) =>
usage.value = custom ? packUsage : const .empty(),
),
AnimatedSize(
duration: Durations.medium2,
curve: Curves.easeInOut,
alignment: .topCenter,
child: usage.value.isEmpty
? const SizedBox(width: .infinity)
: Padding(
padding: .only(top: 8),
child: ImageUsageChips(
value: usage.value,
onChanged: onSave == null
? null
: (value) => usage.value = value,
),
),
),
],
),
],
);
}
}