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
20 changed files with 1115 additions and 119 deletions

View file

@ -1,8 +1,10 @@
import "dart:async";
import "dart:convert"; import "dart:convert";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
class AccountDataController extends Notifier<AccountData> { class AccountDataController extends Notifier<AccountData> {
@ -22,6 +24,44 @@ class AccountDataController extends Notifier<AccountData> {
} }
}); });
Future<void> setEnabledImagePacks(ImagePackRooms packs) async {
ISet<(String, String)> keysOf(ImagePackRooms rooms) => rooms.rooms.entries
.expand((entry) => entry.value.keys.map((key) => (entry.key, key)))
.toISet();
final keys = keysOf(packs);
bool arrived(AccountData data) {
final current = keysOf(data.enabledImagePacks);
return current.length == keys.length && current.containsAll(keys);
}
final completer = Completer<void>();
final removeListener = listenSelf((_, data) {
if (arrived(data) && !completer.isCompleted) completer.complete();
});
try {
final client = ref.read(ClientController.provider.notifier);
await Future.wait([
if (state.imagePackRooms != null)
client.setAccountData(
.new(type: AccountData.imagePackRoomsKey, content: packs),
),
client.setAccountData(
.new(type: AccountData.legacyImagePackRoomsKey, content: packs),
),
]);
if (!arrived(state)) {
await completer.future.timeout(
const .new(seconds: 10),
onTimeout: () {},
);
}
} finally {
removeListener();
}
}
static final provider = NotifierProvider<AccountDataController, AccountData>( static final provider = NotifierProvider<AccountDataController, AccountData>(
AccountDataController.new, AccountDataController.new,
); );

View file

@ -0,0 +1,69 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/requests/get_specific_room_state.dart";
/// The image packs the user has enabled everywhere, as room IDs to their
/// packs' state keys, once the packs' state has been loaded.
class EnabledImagePacksController
extends AsyncNotifier<IMap<String, ISet<String>>> {
@override
Future<IMap<String, ISet<String>>> build() async {
final enabledPacks = ref.watch(
AccountDataController.provider.select(
(data) => data.enabledImagePacks.rooms.map(
(roomId, packs) => .new(roomId, packs.keys.toISet()),
),
),
);
const packTypes = <EventType>[.imagePack, .legacyImagePack];
// Rooms we aren't in can't be fetched, and fetched rooms have every pack.
final missingKeys = ref.watch(
RoomsController.provider.select(
(rooms) => enabledPacks.entries
.where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true))
.expand(
(entry) => entry.value
.where(
(stateKey) => packTypes.every(
(type) =>
rooms[entry.key]?.state[type.type]?[stateKey] == null,
),
)
.expand(
(stateKey) => packTypes.map(
(type) => RoomStateKey(
roomId: entry.key,
type: type.type,
stateKey: stateKey,
),
),
),
)
.toIList(),
),
);
if (missingKeys.isNotEmpty) {
ref
.read(RoomsController.provider.notifier)
.addSpecificState(
await ref
.read(ClientController.provider.notifier)
.getSpecificRoomState(.new(keys: missingKeys)),
);
}
return enabledPacks;
}
static final provider =
AsyncNotifierProvider.autoDispose<
EnabledImagePacksController,
IMap<String, ISet<String>>
>(EnabledImagePacksController.new);
}

View file

@ -1,11 +1,9 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/enabled_image_packs.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart"; import "package:nexus/controllers/space_edges.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/requests/get_specific_room_state.dart";
typedef ImagePackSource = ({String roomId, String? stateKey}); typedef ImagePackSource = ({String roomId, String? stateKey});
@ -13,14 +11,8 @@ class ImagePackSourcesController(final String roomId)
extends AsyncNotifier<IList<ImagePackSource>> { extends AsyncNotifier<IList<ImagePackSource>> {
@override @override
Future<IList<ImagePackSource>> build() async { Future<IList<ImagePackSource>> build() async {
final enabledPacks = ref.watch( final enabledPacksFuture = ref.watch(
AccountDataController.provider.select( EnabledImagePacksController.provider.future,
(data) =>
(data.imagePackRooms ?? data.legacyImagePackRooms)?.rooms.map(
(roomId, packs) => .new(roomId, packs.keys.toISet()),
) ??
const .empty(),
),
); );
final spaceEdges = ref.watch(SpaceEdgesController.provider); final spaceEdges = ref.watch(SpaceEdgesController.provider);
final directParents = ref.watch( final directParents = ref.watch(
@ -63,36 +55,7 @@ class ImagePackSourcesController(final String roomId)
), ),
), ),
); );
final enabledPacks = await enabledPacksFuture;
const packTypes = <EventType>[.imagePack, .legacyImagePack];
// Rooms we aren't in can't be fetched, and fetched rooms have every pack
final missingKeys = enabledPacks.entries
.where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true))
.expand(
(entry) => entry.value
.where(
(stateKey) => packTypes.every(
(type) =>
rooms[entry.key]?.state[type.type]?[stateKey] == null,
),
)
.expand(
(stateKey) => packTypes.map(
(type) => RoomStateKey(
roomId: entry.key,
type: type.type,
stateKey: stateKey,
),
),
),
)
.toIList();
if (missingKeys.isNotEmpty) {
roomsNotifier.addSpecificState(
await client.getSpecificRoomState(.new(keys: missingKeys)),
);
}
return .new([ return .new([
(roomId: roomId, stateKey: null), (roomId: roomId, stateKey: null),

View file

@ -2,9 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/image_pack_sources.dart"; import "package:nexus/controllers/image_pack_sources.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart"; import "package:nexus/helpers/extensions/room_image_packs.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/image_pack.dart"; import "package:nexus/models/image_pack.dart";
class ImagePacksController(final String roomId) class ImagePacksController(final String roomId)
@ -18,40 +16,13 @@ class ImagePacksController(final String roomId)
final packs = ref.watch( final packs = ref.watch(
RoomsController.provider.select( RoomsController.provider.select(
(rooms) => sources.expand((source) { (rooms) => sources.expand((source) {
final room = rooms[source.roomId]; final packs =
if (room == null) return const <ImagePack>[]; rooms[source.roomId]?.imagePacks(source.roomId) ?? const .empty();
final newPacks = room.state[EventType.imagePack.type];
final legacyPacks = room.state[EventType.legacyImagePack.type];
final roomName = room.metadata?.name ?? source.roomId;
return switch (source.stateKey) { return switch (source.stateKey) {
final stateKey? => ISet([stateKey]), final stateKey? => [?packs[stateKey]],
null => ISet( null => packs.values,
newPacks?.keys ?? const <String>[], };
).addAll(legacyPacks?.keys ?? const []),
}
.map(
(stateKey) =>
switch (newPacks?[stateKey] ?? legacyPacks?[stateKey]) {
final rowId? => switch (room.events[rowId]) {
Event(
redactedBy: null,
content: final ImagePackContent content,
) =>
ImagePack.fromContent(
content,
id: "${source.roomId}/$stateKey",
fallbackName: stateKey.isEmpty
? roomName
: "$roomName - $stateKey",
),
_ => null,
},
null => null,
},
)
.nonNulls;
}).toIList(), }).toIList(),
), ),
); );

View file

@ -0,0 +1,37 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/room_image_packs.dart";
import "package:nexus/models/image_pack.dart";
/// The image packs in [roomId]'s own state by state key, loading its state if
/// it hasn't been yet.
class RoomImagePacksController(final String roomId)
extends AsyncNotifier<IMap<String, ImagePack>> {
@override
Future<IMap<String, ImagePack>> build() async {
if (ref.read(RoomsController.provider)[roomId] case final room?
when !room.hasFetchedState) {
ref
.read(RoomsController.provider.notifier)
.addState(
roomId,
await ref
.read(ClientController.provider.notifier)
.getRoomState(.new(roomId: roomId)),
);
}
return ref.watch(
RoomsController.provider.select(
(rooms) => rooms[roomId]?.imagePacks(roomId) ?? const .empty(),
),
);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<RoomImagePacksController, IMap<String, ImagePack>, String>(
RoomImagePacksController.new,
);
}

View file

@ -7,6 +7,7 @@ import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/local_aliases.dart"; import "package:nexus/controllers/local_aliases.dart";
import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/power_levels_saving.dart"; import "package:nexus/controllers/power_levels_saving.dart";
import "package:nexus/controllers/room_image_packs.dart";
import "package:nexus/controllers/room_power_levels.dart"; import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart"; import "package:nexus/controllers/space_edges.dart";
@ -21,6 +22,7 @@ import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/encryption.dart"; import "package:nexus/models/content/encryption.dart";
import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
@ -33,10 +35,12 @@ import "package:nexus/models/setting.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/image_pack_avatar.dart";
import "package:nexus/widgets/settings/add_users_power_level_sheet.dart"; import "package:nexus/widgets/settings/add_users_power_level_sheet.dart";
import "package:nexus/widgets/settings/aliases_sheet.dart"; import "package:nexus/widgets/settings/aliases_sheet.dart";
import "package:nexus/widgets/settings/allowed_rooms_sheet.dart"; import "package:nexus/widgets/settings/allowed_rooms_sheet.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/settings/image_pack_sheet.dart";
import "package:nexus/widgets/settings/local_aliases_sheet.dart"; import "package:nexus/widgets/settings/local_aliases_sheet.dart";
import "package:nexus/widgets/settings/power_level_entry_sheet.dart"; import "package:nexus/widgets/settings/power_level_entry_sheet.dart";
import "package:nexus/widgets/settings/power_level_tile.dart"; import "package:nexus/widgets/settings/power_level_tile.dart";
@ -91,6 +95,7 @@ class RoomSettingsSectionsController(final String roomId)
final canSetJoinRules = canSet(.joinRules); final canSetJoinRules = canSet(.joinRules);
final canSetHistoryVisibility = canSet(.historyVisibility); final canSetHistoryVisibility = canSet(.historyVisibility);
final canSetAliases = canSet(.canonicalAlias); final canSetAliases = canSet(.canonicalAlias);
final canCreatePacks = canSet(.imagePack);
final canSetPowerLevels = final canSetPowerLevels =
canSet(.powerLevels) && canSet(.powerLevels) &&
!ref.watch(PowerLevelsSavingController.provider(roomId)); !ref.watch(PowerLevelsSavingController.provider(roomId));
@ -161,15 +166,78 @@ class RoomSettingsSectionsController(final String roomId)
final encrypted = stateContent(.encryption) is EncryptionContent; final encrypted = stateContent(.encryption) is EncryptionContent;
final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted); final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted);
Future<void> setState(EventType type, Content content) async { Future<void> setState(
EventType type,
Content content, {
String stateKey = "",
}) async {
final eventId = await client.setState( final eventId = await client.setState(
.new(roomId: roomId, type: type.type, stateKey: "", content: content), .new(
roomId: roomId,
type: type.type,
stateKey: stateKey,
content: content,
),
); );
if (eventId case final eventId?) { if (eventId case final eventId?) {
await roomsController.awaitState(roomId, type.type, eventId); await roomsController.awaitState(
roomId,
type.type,
eventId,
stateKey: stateKey,
);
} }
} }
final imagePacks =
ref.watch(RoomImagePacksController.provider(roomId)).value ??
const .empty();
// Pack sheets can stay open while the state changes, so these read it when
// called, rather than using the state from building
IMap<String, IMap<String, int>> currentState() =>
ref.read(RoomsController.provider)[roomId]?.state ?? const .empty();
// Whether the legacy type still has a pack at [stateKey], to clear it
bool hasLegacyPack(String stateKey) =>
switch (currentState()[EventType.legacyImagePack.type]?[stateKey]) {
final rowId? => switch (contentOf(rowId)) {
final ImagePackContent content => content.hasContent,
_ => false,
},
null => false,
};
final canSetLegacyPacks = canSet(.legacyImagePack);
/// Saves the pack at [stateKey] as the stable type, migrating it by
/// clearing its legacy event if it has one.
Future<void> setPack(String stateKey, Content content) async {
await setState(.imagePack, content, stateKey: stateKey);
if (hasLegacyPack(stateKey)) {
await setState(.legacyImagePack, const .new(), stateKey: stateKey);
}
}
/// A state key for a new pack named [name]. It's one no pack has ever
/// had, even deleted ones, as users may still have those enabled.
String newPackKey(String name) {
final state = currentState();
final usedKeys = [
EventType.imagePack,
EventType.legacyImagePack,
].expand((type) => state[type.type]?.keys ?? []).toISet();
final slug = name
.toLowerCase()
.replaceAll(RegExp(r"[^\p{L}\p{N}_-]+", unicode: true), "_")
.replaceAll(RegExp(r"^_+|_+$"), "");
final base = slug.isEmpty ? "pack" : slug;
return Iterable.generate(
usedKeys.length + 1,
(index) => index == 0 ? base : "${base}_${index + 1}",
).firstWhere((key) => !usedKeys.contains(key));
}
Future<void> setAliases( Future<void> setAliases(
String? canonical, String? canonical,
ISet<String> alternatives, ISet<String> alternatives,
@ -817,6 +885,90 @@ class RoomSettingsSectionsController(final String roomId)
), ),
]), ]),
), ),
.new(
title: "Image Packs",
icon: Icons.emoji_emotions,
settings: .new([
...imagePacks.entries.map((entry) {
final stateKey = entry.key;
final pack = entry.value;
final canEdit =
canCreatePacks &&
(canSetLegacyPacks || !hasLegacyPack(stateKey));
return Setting(
title: pack.name,
description: pack.imageCountLabel,
icon: Icons.emoji_emotions,
builder: (title, description, _) => HookBuilder(
key: ValueKey(stateKey),
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: !saving.active,
leading: ImagePackAvatar(pack.icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet(
(_) => ImagePackSheet(
title: canEdit ? "Edit Image Pack" : title,
content: pack.content,
onSave: canEdit
? (content) => saving.run(
() => setPack(
stateKey,
content,
).onError(showError),
)
: null,
onDeleted: canEdit
? () => saving.run(
() => setPack(
stateKey,
const .new(),
).onError(showError),
)
: null,
),
),
);
},
),
);
}),
.new(
title: "Create Pack",
description: "Add a pack of emoticons and stickers to the room.",
icon: Icons.add,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: canCreatePacks && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet(
(_) => ImagePackSheet(
title: "Create Pack",
content: const .new(),
onSave: (content) => saving.run(
() => setState(
.imagePack,
content,
stateKey: newPackKey(content.pack!.displayName!),
).onError(showError),
),
),
),
);
},
),
),
]),
),
]), ]),
}); });
} }

View file

@ -9,23 +9,31 @@ import "package:m3e_buttons/m3e_buttons.dart";
import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/enabled_image_packs.dart";
import "package:nexus/controllers/init_complete.dart"; import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/unified_push.dart";
import "package:nexus/controllers/spec_versions.dart"; import "package:nexus/controllers/spec_versions.dart";
import "package:nexus/controllers/unified_push_allowed.dart"; import "package:nexus/controllers/unified_push_allowed.dart";
import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/controllers/unified_push_distributors.dart";
import "package:nexus/helpers/extensions/distributor_name.dart"; import "package:nexus/helpers/extensions/distributor_name.dart";
import "package:nexus/helpers/extensions/room_image_packs.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/models/account_data.dart"; import "package:nexus/models/account_data.dart";
import "package:nexus/models/client_state.dart"; import "package:nexus/models/client_state.dart";
import "package:nexus/models/image_pack.dart";
import "package:nexus/models/name_color.dart"; import "package:nexus/models/name_color.dart";
import "package:nexus/models/room_order.dart"; import "package:nexus/models/room_order.dart";
import "package:nexus/models/setting.dart";
import "package:nexus/models/settings.dart"; import "package:nexus/models/settings.dart";
import "package:nexus/models/settings_category.dart"; import "package:nexus/models/settings_category.dart";
import "package:nexus/models/show_and_send.dart"; import "package:nexus/models/show_and_send.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/image_pack_avatar.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/settings/enable_image_packs_sheet.dart";
import "package:nexus/widgets/user_bottom_sheet.dart"; import "package:nexus/widgets/user_bottom_sheet.dart";
import "package:nexus/widgets/reset_encryption_sheet.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
@ -36,6 +44,33 @@ class SettingsSectionsController
Future<IMap<String, IList<SettingsCategory>>> build() async { Future<IMap<String, IList<SettingsCategory>>> build() async {
final settings = await ref.watch(SettingsController.provider.future); final settings = await ref.watch(SettingsController.provider.future);
// Loads the enabled packs' state, so they can be shown
ref.watch(EnabledImagePacksController.provider);
final enabledPacks = ref.watch(
AccountDataController.provider.select((data) => data.enabledImagePacks),
);
final enabledPackInfo = ref.watch(
RoomsController.provider.select(
(rooms) => enabledPacks.rooms.entries.expand((entry) {
final room = rooms[entry.key];
final packs = room?.imagePacks(entry.key) ?? const .empty();
return entry.value.keys.map(
(stateKey) => (
roomId: entry.key,
stateKey: stateKey,
roomName: room?.metadata?.name ?? entry.key,
pack: packs[stateKey],
),
);
}).toIList(),
),
);
Future<void> setEnabledPacks(ImagePackRooms packs) => ref
.read(AccountDataController.provider.notifier)
.setEnabledImagePacks(packs);
/// A tile for a [ShowAndSend] setting, saving the settings from [update]. /// A tile for a [ShowAndSend] setting, saving the settings from [update].
Widget showAndSendTile( Widget showAndSendTile(
String title, String title,
@ -300,6 +335,78 @@ class SettingsSectionsController
), ),
]), ]),
), ),
.new(
title: "Image Packs",
icon: Icons.emoji_emotions,
settings: .new([
...enabledPackInfo.map(
(info) => Setting(
title:
info.pack?.name ??
ImagePack.fallbackName(info.roomName, info.stateKey),
description: info.roomName,
icon: Icons.emoji_emotions,
builder: (title, description, _) => HookBuilder(
key: ValueKey((info.roomId, info.stateKey)),
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: !saving.active,
leading: ImagePackAvatar(info.pack?.icon),
title: Text(title),
subtitle: Text(description),
trailing: IconButton(
tooltip: "Remove pack",
icon: Icon(Icons.delete),
onPressed: saving.active
? null
: () => saving.run(
() => setEnabledPacks(
ref
.read(AccountDataController.provider)
.enabledImagePacks
.withPack(
info.roomId,
info.stateKey,
enabled: false,
),
).onError(showError),
),
),
);
},
),
),
),
.new(
title: "Add Packs",
description:
"Use image packs from any of your rooms in every room.",
icon: Icons.add,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet(
(_) => EnableImagePacksSheet(
enabled: enabledPacks,
onSave: (packs) => saving.run(
() => setEnabledPacks(packs).onError(showError),
),
),
),
);
},
),
),
]),
),
.new( .new(
title: "Notifications", title: "Notifications",
icon: Icons.notifications, icon: Icons.notifications,

View file

@ -144,7 +144,7 @@ extension BuildEventOptions on Event {
(context) => EmojiPicker( (context) => EmojiPicker(
roomId: roomId, roomId: roomId,
allowFreeText: true, allowFreeText: true,
onSelection: (emoji, {shortcode}) { onSelection: (emoji, {shortcode, description}) {
Navigator.of(context).pop(); Navigator.of(context).pop();
ref ref
.read(notifier) .read(notifier)

View file

@ -0,0 +1,34 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/image_pack.dart";
import "package:nexus/models/room.dart";
extension RoomImagePacks on Room {
/// The room's image packs by state key, preferring the stable type when a
/// pack has both, and leaving out deleted ones. [roomId] names those without
/// a name when the room has none either.
IMap<String, ImagePack> imagePacks(String roomId) => .fromEntries(
const [EventType.legacyImagePack, EventType.imagePack].expand(
(type) => (state[type.type] ?? .new()).entries
.map(
(entry) => switch (events[entry.value]) {
Event(redactedBy: null, content: final ImagePackContent content)
when content.hasContent =>
MapEntry(
entry.key,
ImagePack.fromContent(
content,
roomId: roomId,
roomName: metadata?.name ?? roomId,
stateKey: entry.key,
),
),
_ => null,
},
)
.nonNulls,
),
);
}

View file

@ -29,6 +29,9 @@ class const AccountData({
static const imagePackRoomsKey = "m.image_pack.rooms"; static const imagePackRoomsKey = "m.image_pack.rooms";
static const legacyImagePackRoomsKey = "im.ponies.emote_rooms"; static const legacyImagePackRoomsKey = "im.ponies.emote_rooms";
ImagePackRooms get enabledImagePacks =>
imagePackRooms ?? legacyImagePackRooms ?? const .new();
Map<String, Object?> toJson() => _$AccountDataToJson(this); Map<String, Object?> toJson() => _$AccountDataToJson(this);
factory AccountData.fromJson(Map<String, Object?> json) => factory AccountData.fromJson(Map<String, Object?> json) =>
@ -53,6 +56,29 @@ class const ImagePackRooms({
/// empty) object /// empty) object
final IMap<String, IMap<String, Map<String, dynamic>>> rooms = const .empty(), final IMap<String, IMap<String, Map<String, dynamic>>> rooms = const .empty(),
}) with _$ImagePackRooms { }) with _$ImagePackRooms {
bool contains(String roomId, String stateKey) =>
rooms[roomId]?.containsKey(stateKey) ?? false;
ImagePackRooms withPack(
String roomId,
String stateKey, {
required bool enabled,
}) {
final packs =
rooms[roomId] ?? const IMap<String, Map<String, dynamic>>.empty();
final updated = switch ((enabled, packs.containsKey(stateKey))) {
(true, false) => packs.add(stateKey, const {}),
(false, true) => packs.remove(stateKey),
_ => packs,
};
return copyWith(
rooms: updated.isEmpty
? rooms.remove(roomId)
: rooms.add(roomId, updated),
);
}
Map<String, Object?> toJson() => _$ImagePackRoomsToJson(this); Map<String, Object?> toJson() => _$ImagePackRoomsToJson(this);
factory ImagePackRooms.fromJson(Map<String, Object?> json) => factory ImagePackRooms.fromJson(Map<String, Object?> json) =>

View file

@ -7,11 +7,14 @@ part "image_pack.g.dart";
/// An image pack (MSC2545), from a room's state. /// An image pack (MSC2545), from a room's state.
@Freezed(toJson: false, fromJson: false) @Freezed(toJson: false, fromJson: false)
@JsonSerializable() @JsonSerializable(includeIfNull: false)
class const ImagePackContent({ class const ImagePackContent({
final IMap<String, PackImage> images = const .empty(), final IMap<String, PackImage> images = const .empty(),
final PackInfo? pack, final PackInfo? pack,
}) extends Content with _$ImagePackContent { }) extends Content with _$ImagePackContent {
/// Whether the pack has images or a name, as deleted packs are left empty.
bool get hasContent => images.isNotEmpty || pack?.displayName != null;
IMap<String, PackImage> imagesFor(ImageUsage usage) => images.where( IMap<String, PackImage> imagesFor(ImageUsage usage) => images.where(
(_, image) => switch ((image.usage, pack?.usage ?? const .empty())) { (_, image) => switch ((image.usage, pack?.usage ?? const .empty())) {
(final usages, _) when usages.isNotEmpty => usages.contains(usage), (final usages, _) when usages.isNotEmpty => usages.contains(usage),
@ -30,12 +33,12 @@ class const ImagePackContent({
} }
@Freezed(toJson: false, fromJson: false) @Freezed(toJson: false, fromJson: false)
@JsonSerializable() @JsonSerializable(includeIfNull: false)
class const PackImage({ class const PackImage({
required final Uri url, required final Uri url,
final String? body, final String? body,
final ImageInfo? info, final ImageInfo? info,
@JsonKey(fromJson: ImageUsage.setFromJson) @JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson)
final ISet<ImageUsage> usage = const .empty(), final ISet<ImageUsage> usage = const .empty(),
}) with _$PackImage { }) with _$PackImage {
Map<String, Object?> toJson() => _$PackImageToJson(this); Map<String, Object?> toJson() => _$PackImageToJson(this);
@ -45,12 +48,12 @@ class const PackImage({
} }
@Freezed(toJson: false, fromJson: false) @Freezed(toJson: false, fromJson: false)
@JsonSerializable() @JsonSerializable(includeIfNull: false)
class const PackInfo({ class const PackInfo({
final String? displayName, final String? displayName,
final Uri? avatarUrl, final Uri? avatarUrl,
final String? attribution, final String? attribution,
@JsonKey(fromJson: ImageUsage.setFromJson) @JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson)
final ISet<ImageUsage> usage = const .empty(), final ISet<ImageUsage> usage = const .empty(),
}) with _$PackInfo { }) with _$PackInfo {
Map<String, Object?> toJson() => _$PackInfoToJson(this); Map<String, Object?> toJson() => _$PackInfoToJson(this);
@ -66,4 +69,8 @@ enum ImageUsage {
/// Ignores usages we don't know about. /// Ignores usages we don't know about.
static ISet<ImageUsage> setFromJson(List<dynamic>? json) => static ISet<ImageUsage> setFromJson(List<dynamic>? json) =>
ISet(values.where((value) => json?.contains(value.name) ?? false)); ISet(values.where((value) => json?.contains(value.name) ?? false));
/// Omits an empty set, which means every usage.
static List<String>? setToJson(ISet<ImageUsage> usages) =>
usages.isEmpty ? null : usages.map((usage) => usage.name).toList();
} }

View file

@ -13,21 +13,42 @@ class const ImagePack({
/// Map of shortcode to images /// Map of shortcode to images
required final IMap<String, PackImage> emoticons, required final IMap<String, PackImage> emoticons,
required final IMap<String, PackImage> stickers, required final IMap<String, PackImage> stickers,
/// The state event the pack is from, for editing it.
required final ImagePackContent content,
}) with _$ImagePack { }) with _$ImagePack {
static String emojiMarkdown(String shortcode, String url) { /// The pack's avatar, or its first image if it has none.
final title = ":$shortcode:".replaceAll(r"\", r"\\").replaceAll('"', r'\"'); Uri? get icon => avatar ?? content.images.values.firstOrNull?.url;
return '![:$shortcode:]($url "Emoji: $title")';
} String get imageCountLabel => content.images.length == 1
? "1 image"
: "${content.images.length} images";
/// The name of a pack without one, from its room's name and state key.
static String fallbackName(String roomName, String stateKey) =>
stateKey.isEmpty ? roomName : "$roomName - $stateKey";
static String emojiMarkdown(
String shortcode,
String url, {
String? description,
}) =>
'![:$shortcode:]($url "Emoji: ${switch (description) {
final description? when description.isNotEmpty => description,
_ => ":$shortcode:",
}.replaceAll(r"\", r"\\").replaceAll('"', r'\"')}")';
factory ImagePack.fromContent( factory ImagePack.fromContent(
ImagePackContent content, { ImagePackContent content, {
required String id, required String roomId,
required String fallbackName, required String roomName,
required String stateKey,
}) => .new( }) => .new(
id: id, id: "$roomId/$stateKey",
name: content.pack?.displayName ?? fallbackName, name: content.pack?.displayName ?? fallbackName(roomName, stateKey),
avatar: content.pack?.avatarUrl, avatar: content.pack?.avatarUrl,
emoticons: content.imagesFor(.emoticon), emoticons: content.imagesFor(.emoticon),
stickers: content.imagesFor(.sticker), stickers: content.imagesFor(.sticker),
content: content,
); );
} }

View file

@ -271,28 +271,30 @@ class const Composer(
onPressed: () => context.showAdaptiveSheet( onPressed: () => context.showAdaptiveSheet(
(context) => EmojiPicker( (context) => EmojiPicker(
roomId: roomId, roomId: roomId,
onSelection: (value, {shortcode}) { onSelection:
Navigator.of(context).pop(); (value, {shortcode, description}) {
if (shortcode == null) { Navigator.of(context).pop();
controller.text += value; if (shortcode == null) {
} else { controller.text += value;
final text = "${controller.text}:"; } else {
controller.value = .new( final text = "${controller.text}:";
text: text, controller.value = .new(
selection: .collapsed( text: text,
offset: text.length, selection: .collapsed(
), offset: text.length,
); ),
controller.addTag( );
id: ImagePack.emojiMarkdown( controller.addTag(
shortcode, id: ImagePack.emojiMarkdown(
value, shortcode,
), value,
name: shortcode, description: description,
); ),
} name: shortcode,
node?.requestFocus(); );
}, }
node?.requestFocus();
},
), ),
), ),
icon: Icon(Icons.emoji_emotions), 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:material_ui/material_ui.dart";
import "package:nexus/controllers/all_emoji.dart"; import "package:nexus/controllers/all_emoji.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/image_pack.dart"; import "package:nexus/models/image_pack.dart";
class const EmojiOverlay( class const EmojiOverlay(
@ -39,6 +40,10 @@ class const EmojiOverlay(
? ImagePack.emojiMarkdown( ? ImagePack.emojiMarkdown(
emoji.aliases.first, emoji.aliases.first,
emoji.value, emoji.value,
description: switch (emoji.data) {
PackImage(:final body) => body,
_ => null,
},
) )
: emoji.value, : emoji.value,
name: emoji.aliases.first, name: emoji.aliases.first,

View file

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