add power level room settings
This commit is contained in:
parent
fcfe4eed1e
commit
b0dd452926
14 changed files with 944 additions and 179 deletions
|
|
@ -1,44 +1,27 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/members_by_status.dart";
|
||||
import "package:nexus/controllers/room_creators.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/room_power_levels.dart";
|
||||
import "package:nexus/models/configs/members_by_status.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
|
||||
class MembersGroupedController(final MembersByStatusConfig config)
|
||||
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
|
||||
@override
|
||||
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((value) => value[config.roomId]),
|
||||
final powerLevels = ref.watch(
|
||||
RoomPowerLevelsController.provider(config.roomId),
|
||||
);
|
||||
|
||||
final roomCreators = room == null
|
||||
? null
|
||||
: ref.watch((RoomCreatorsController.provider(room)));
|
||||
|
||||
final powerLevelsRowId = room?.state[EventType.powerLevels.type]?[""];
|
||||
final powerLevelsEvent = powerLevelsRowId == null
|
||||
? null
|
||||
: room?.events[powerLevelsRowId];
|
||||
|
||||
final content = switch (powerLevelsEvent?.content) {
|
||||
PowerLevelsContent content => content,
|
||||
_ => PowerLevelsContent(),
|
||||
};
|
||||
|
||||
final members = await ref.watch(
|
||||
MembersByStatusController.provider(config).future,
|
||||
);
|
||||
|
||||
return members
|
||||
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
|
||||
final groupKey = roomCreators?.contains(event.stateKey!) == true
|
||||
final groupKey = powerLevels.creators.contains(event.stateKey!)
|
||||
? null
|
||||
: content.users[event.stateKey!] ?? content.usersDefault;
|
||||
: powerLevels.levelOf(event.stateKey!).toInt();
|
||||
|
||||
return result.update(
|
||||
groupKey,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/room_creators.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/room_power_levels.dart";
|
||||
import "package:nexus/models/configs/power_level.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
|
||||
class PowerLevelController(final PowerLevelConfig config)
|
||||
extends Notifier<bool> {
|
||||
|
|
@ -17,31 +15,17 @@ class PowerLevelController(final PowerLevelConfig config)
|
|||
);
|
||||
}
|
||||
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((value) => value[config.roomId]),
|
||||
final powerLevels = ref.watch(
|
||||
RoomPowerLevelsController.provider(config.roomId),
|
||||
);
|
||||
|
||||
final roomCreators = room == null
|
||||
? null
|
||||
: ref.watch(RoomCreatorsController.provider(room));
|
||||
|
||||
final eventRowId = room?.state[EventType.powerLevels.type]?[""];
|
||||
|
||||
final event = eventRowId == null ? null : room?.events[eventRowId];
|
||||
final content = event?.content is PowerLevelsContent
|
||||
? event!.content
|
||||
: PowerLevelsContent();
|
||||
final content = powerLevels.content;
|
||||
|
||||
final user = ref.watch(
|
||||
ClientStateController.provider.select((value) => value?.userId),
|
||||
);
|
||||
if (user == null || content is! PowerLevelsContent) return false;
|
||||
if (user == null) return false;
|
||||
|
||||
double powerLevelOf(String userId) => roomCreators?.contains(userId) == true
|
||||
? double.infinity
|
||||
: (content.users[userId] ?? content.usersDefault).toDouble();
|
||||
|
||||
final userLevel = powerLevelOf(user);
|
||||
final userLevel = powerLevels.levelOf(user);
|
||||
|
||||
return switch (config) {
|
||||
EventPowerLevelConfig(:final eventType) =>
|
||||
|
|
@ -52,10 +36,12 @@ class PowerLevelController(final PowerLevelConfig config)
|
|||
.invite => userLevel >= content.invite,
|
||||
|
||||
.kick =>
|
||||
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
|
||||
userLevel >= content.kick &&
|
||||
userLevel > powerLevels.levelOf(targetUser),
|
||||
|
||||
.ban =>
|
||||
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
|
||||
userLevel >= content.ban &&
|
||||
userLevel > powerLevels.levelOf(targetUser),
|
||||
|
||||
.unban => userLevel >= content.ban,
|
||||
},
|
||||
|
|
|
|||
20
lib/controllers/power_levels_saving.dart
Normal file
20
lib/controllers/power_levels_saving.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
class PowerLevelsSavingController(final String roomId) extends Notifier<bool> {
|
||||
@override
|
||||
bool build() => false;
|
||||
|
||||
Future<void> run(Future<void> Function() action) async {
|
||||
state = true;
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
if (ref.mounted) state = false;
|
||||
}
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider.autoDispose
|
||||
.family<PowerLevelsSavingController, bool, String>(
|
||||
PowerLevelsSavingController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
|
||||
class RoomCreatorsController(final Room room) extends Notifier<IList<String>> {
|
||||
@override
|
||||
IList<String> build() {
|
||||
final createRowId = room.state[EventType.create.type]?[""];
|
||||
final createEvent = createRowId == null ? null : room.events[createRowId];
|
||||
|
||||
if (createEvent == null) return .new();
|
||||
|
||||
final createEventContent = switch (createEvent.content) {
|
||||
CreateContent content => content,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return switch (createEventContent?.additionalCreatorIds) {
|
||||
IList<String> creators => creators.add(createEvent.sender),
|
||||
_ => .new([createEvent.sender]),
|
||||
};
|
||||
}
|
||||
|
||||
static final provider =
|
||||
NotifierProvider.family<RoomCreatorsController, IList<String>, Room>(
|
||||
RoomCreatorsController.new,
|
||||
);
|
||||
}
|
||||
59
lib/controllers/room_power_levels.dart
Normal file
59
lib/controllers/room_power_levels.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/room_power_levels.dart";
|
||||
|
||||
class RoomPowerLevelsController(final String roomId)
|
||||
extends Notifier<RoomPowerLevels> {
|
||||
@override
|
||||
RoomPowerLevels build() {
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((rooms) => rooms[roomId]),
|
||||
);
|
||||
|
||||
Event? stateEvent(EventType type) => switch (room?.state[type.type]?[""]) {
|
||||
final rowId? => room?.events[rowId],
|
||||
null => null,
|
||||
};
|
||||
|
||||
final createEvent = stateEvent(.create);
|
||||
final create = switch (createEvent?.content) {
|
||||
final CreateContent content => content,
|
||||
_ => null,
|
||||
};
|
||||
// Creators only outrank everyone from room version 12, earlier ones just have a power level
|
||||
final creatorsPrivileged =
|
||||
(int.tryParse(create?.roomVersion ?? "") ?? 0) >= 12;
|
||||
|
||||
return .new(
|
||||
content: switch (stateEvent(.powerLevels)?.content) {
|
||||
final PowerLevelsContent content => content,
|
||||
// Until there are power levels, creators have 100
|
||||
_ => PowerLevelsContent(
|
||||
users: switch (createEvent) {
|
||||
final createEvent? when !creatorsPrivileged => .new({
|
||||
createEvent.sender: 100,
|
||||
}),
|
||||
_ => const .empty(),
|
||||
},
|
||||
),
|
||||
},
|
||||
creators: switch (createEvent) {
|
||||
final createEvent? when creatorsPrivileged =>
|
||||
(create?.additionalCreatorIds ?? const .empty())
|
||||
.add(createEvent.sender)
|
||||
.toISet(),
|
||||
_ => const .empty(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider.autoDispose
|
||||
.family<RoomPowerLevelsController, RoomPowerLevels, String>(
|
||||
RoomPowerLevelsController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -3,8 +3,11 @@ 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/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/local_aliases.dart";
|
||||
import "package:nexus/controllers/power_level.dart";
|
||||
import "package:nexus/controllers/power_levels_saving.dart";
|
||||
import "package:nexus/controllers/room_power_levels.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/space_edges.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
|
|
@ -15,18 +18,26 @@ import "package:nexus/models/content/avatar.dart";
|
|||
import "package:nexus/models/content/canonical_alias.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/content/encryption.dart";
|
||||
import "package:nexus/models/content/history_visibility.dart";
|
||||
import "package:nexus/models/content/join_rules.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/content/name.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/content/topic.dart";
|
||||
import "package:nexus/models/join_rule.dart";
|
||||
import "package:nexus/models/setting.dart";
|
||||
import "package:nexus/models/settings_category.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/divider_text.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/allowed_rooms_sheet.dart";
|
||||
import "package:nexus/widgets/settings/dialog_list_tile.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_tile.dart";
|
||||
import "package:nexus/widgets/settings/text_setting_tile.dart";
|
||||
|
||||
class RoomSettingsSectionsController(final String roomId)
|
||||
|
|
@ -69,6 +80,9 @@ class RoomSettingsSectionsController(final String roomId)
|
|||
final canSetJoinRules = canSet(.joinRules);
|
||||
final canSetHistoryVisibility = canSet(.historyVisibility);
|
||||
final canSetAliases = canSet(.canonicalAlias);
|
||||
final canSetPowerLevels =
|
||||
canSet(.powerLevels) &&
|
||||
!ref.watch(PowerLevelsSavingController.provider(roomId));
|
||||
|
||||
final joinRules = switch (stateContent(.joinRules)) {
|
||||
final JoinRulesContent content => content,
|
||||
|
|
@ -125,6 +139,19 @@ class RoomSettingsSectionsController(final String roomId)
|
|||
.nonNulls
|
||||
.toISet();
|
||||
|
||||
final roomPowerLevels = ref.watch(
|
||||
RoomPowerLevelsController.provider(roomId),
|
||||
);
|
||||
final powerLevels = roomPowerLevels.content;
|
||||
final userId = ref.watch(
|
||||
ClientStateController.provider.select((state) => state?.userId),
|
||||
);
|
||||
final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId);
|
||||
// Encrypted rooms send messages as m.room.encrypted, so that's what limits them
|
||||
final messageType = stateContent(.encryption) is EncryptionContent
|
||||
? EventType.encrypted
|
||||
: EventType.message;
|
||||
|
||||
Future<void> setState(EventType type, Content content) async {
|
||||
final eventId = await client.setState(
|
||||
.new(roomId: roomId, type: type.type, stateKey: "", content: content),
|
||||
|
|
@ -167,6 +194,108 @@ class RoomSettingsSectionsController(final String roomId)
|
|||
),
|
||||
);
|
||||
|
||||
Setting header(String title, String description, IconData icon) => .new(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: icon,
|
||||
builder: (title, _, _) => Padding(
|
||||
padding: .symmetric(horizontal: 16),
|
||||
child: DividerText(title),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> setPowerLevels(PowerLevelsContent content) => ref
|
||||
.read(PowerLevelsSavingController.provider(roomId).notifier)
|
||||
.run(() => setState(.powerLevels, content).onError(showError));
|
||||
|
||||
// Event types with their own level that don't have a setting of their own
|
||||
final listedEvents = [
|
||||
messageType,
|
||||
EventType.sticker,
|
||||
EventType.reaction,
|
||||
EventType.pinnedEvents,
|
||||
].map((type) => type.type).toISet();
|
||||
final otherEvents = powerLevels.events.entries
|
||||
.where((entry) => !listedEvents.contains(entry.key))
|
||||
.toIList()
|
||||
.sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
// Users with their own level, the highest first
|
||||
final users = powerLevels.users.entries.toIList().sort(
|
||||
(a, b) => switch (b.value.compareTo(a.value)) {
|
||||
0 => a.key.compareTo(b.key),
|
||||
final order => order,
|
||||
},
|
||||
);
|
||||
MembershipContent? memberOf(String userId) =>
|
||||
switch (room?.state[EventType.membership.type]?[userId]) {
|
||||
final rowId? => switch (room?.events[rowId]?.content) {
|
||||
final MembershipContent content => content,
|
||||
_ => null,
|
||||
},
|
||||
null => null,
|
||||
};
|
||||
|
||||
/// A tile opening a [sheet] to add entries, like event types or users, with their own power level.
|
||||
Setting addSetting({
|
||||
required String title,
|
||||
required String description,
|
||||
required Widget Function(void Function(PowerLevelsContent content) save)
|
||||
sheet,
|
||||
}) => .new(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: Icons.add,
|
||||
builder: (title, description, icon) => Builder(
|
||||
builder: (context) => ListTile(
|
||||
enabled: canSetPowerLevels,
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
onTap: () => context.showAdaptiveSheet((_) => sheet(setPowerLevels)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Setting powerLevelSetting({
|
||||
required String title,
|
||||
required String description,
|
||||
required IconData icon,
|
||||
required int value,
|
||||
required PowerLevelsContent Function(int level) update,
|
||||
}) => .new(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: icon,
|
||||
builder: (title, description, icon) => PowerLevelTile(
|
||||
leading: Icon(icon),
|
||||
title: title,
|
||||
description: description,
|
||||
value: value,
|
||||
ownLevel: ownLevel,
|
||||
onChanged: canSetPowerLevels
|
||||
? (level) => setPowerLevels(update(level))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
/// A setting for the level needed to send [type], which otherwise needs [fallback].
|
||||
Setting eventSetting(
|
||||
EventType type, {
|
||||
required String title,
|
||||
required String description,
|
||||
required IconData icon,
|
||||
required int fallback,
|
||||
}) => powerLevelSetting(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: icon,
|
||||
value: powerLevels.events[type.type] ?? fallback,
|
||||
update: (level) => powerLevels.copyWith(
|
||||
events: powerLevels.events.add(type.type, level),
|
||||
),
|
||||
);
|
||||
|
||||
return .new({
|
||||
"Room": .new([
|
||||
.new(
|
||||
|
|
@ -473,6 +602,208 @@ class RoomSettingsSectionsController(final String roomId)
|
|||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
title: "Permissions",
|
||||
icon: Icons.admin_panel_settings,
|
||||
settings: .new([
|
||||
powerLevelSetting(
|
||||
title: "Default Power Level",
|
||||
description: "The power level of users who aren't given one.",
|
||||
icon: Icons.person,
|
||||
value: powerLevels.usersDefault,
|
||||
update: (level) => powerLevels.copyWith(usersDefault: level),
|
||||
),
|
||||
header("Users", "Power levels of specific users.", Icons.group),
|
||||
...users.map((entry) {
|
||||
final member = memberOf(entry.key);
|
||||
// Others can only be changed if they're below you, but your own level can be lowered
|
||||
final canChange =
|
||||
canSetPowerLevels &&
|
||||
(entry.key == userId || entry.value < ownLevel);
|
||||
|
||||
return Setting(
|
||||
title: member?.displayName ?? entry.key,
|
||||
description: entry.key,
|
||||
icon: Icons.person,
|
||||
builder: (title, description, _) => PowerLevelTile(
|
||||
leading: AvatarOrHash(
|
||||
member?.avatarUrl,
|
||||
title,
|
||||
dimension: 40,
|
||||
),
|
||||
title: title,
|
||||
description: description,
|
||||
value: entry.value,
|
||||
ownLevel: ownLevel,
|
||||
onChanged: canChange
|
||||
? (level) => setPowerLevels(
|
||||
powerLevels.copyWith(
|
||||
users: powerLevels.users.add(entry.key, level),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onRemoved: canChange
|
||||
? () => setPowerLevels(
|
||||
powerLevels.copyWith(
|
||||
users: powerLevels.users.remove(entry.key),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
removeConfirmation: entry.key == userId
|
||||
? "Remove your own power level? You'll have the default of ${powerLevels.usersDefault}, and won't be able to raise it again yourself."
|
||||
: "Remove $title's power level? They'll have the default of ${powerLevels.usersDefault}.",
|
||||
),
|
||||
);
|
||||
}),
|
||||
addSetting(
|
||||
title: "Add Users",
|
||||
description: "Give users their own power level.",
|
||||
sheet: (save) => AddUsersPowerLevelSheet(
|
||||
roomId: roomId,
|
||||
existing: powerLevels.users.keys.toISet().union(
|
||||
roomPowerLevels.creators,
|
||||
),
|
||||
ownLevel: ownLevel,
|
||||
onSave: (users, level) => save(
|
||||
powerLevels.copyWith(
|
||||
users: powerLevels.users.addEntries(
|
||||
users.map((user) => MapEntry(user, level)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
header(
|
||||
"Regular Events",
|
||||
"Power levels needed to send messages and other timeline events.",
|
||||
Icons.chat,
|
||||
),
|
||||
eventSetting(
|
||||
messageType,
|
||||
title: "Send Messages",
|
||||
description: "The power level needed to send messages.",
|
||||
icon: Icons.message,
|
||||
fallback: powerLevels.eventsDefault,
|
||||
),
|
||||
eventSetting(
|
||||
.sticker,
|
||||
title: "Send Stickers",
|
||||
description: "The power level needed to send stickers.",
|
||||
icon: Icons.sticky_note_2,
|
||||
fallback: powerLevels.eventsDefault,
|
||||
),
|
||||
eventSetting(
|
||||
.reaction,
|
||||
title: "Send Reactions",
|
||||
description: "The power level needed to react to messages.",
|
||||
icon: Icons.add_reaction,
|
||||
fallback: powerLevels.eventsDefault,
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Default for Events",
|
||||
description:
|
||||
"The power level needed to send events without their own.",
|
||||
icon: Icons.more_horiz,
|
||||
value: powerLevels.eventsDefault,
|
||||
update: (level) => powerLevels.copyWith(eventsDefault: level),
|
||||
),
|
||||
header(
|
||||
"State Events",
|
||||
"Power levels needed to manage members and change the room's state.",
|
||||
Icons.settings,
|
||||
),
|
||||
eventSetting(
|
||||
.pinnedEvents,
|
||||
title: "Pin Messages",
|
||||
description: "The power level needed to pin messages.",
|
||||
icon: Icons.push_pin,
|
||||
fallback: powerLevels.stateDefault,
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Invite Users",
|
||||
description: "The power level needed to invite users.",
|
||||
icon: Icons.person_add,
|
||||
value: powerLevels.invite,
|
||||
update: (level) => powerLevels.copyWith(invite: level),
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Kick Users",
|
||||
description: "The power level needed to kick users.",
|
||||
icon: Icons.sports_martial_arts,
|
||||
value: powerLevels.kick,
|
||||
update: (level) => powerLevels.copyWith(kick: level),
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Ban Users",
|
||||
description: "The power level needed to ban and unban users.",
|
||||
icon: Icons.gavel,
|
||||
value: powerLevels.ban,
|
||||
update: (level) => powerLevels.copyWith(ban: level),
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Delete Others' Messages",
|
||||
description: "The power level needed to remove others' messages.",
|
||||
icon: Icons.delete,
|
||||
value: powerLevels.redact,
|
||||
update: (level) => powerLevels.copyWith(redact: level),
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Default for State Events",
|
||||
description: "The power level needed to send state events without their own.",
|
||||
icon: Icons.tune,
|
||||
value: powerLevels.stateDefault,
|
||||
update: (level) => powerLevels.copyWith(stateDefault: level),
|
||||
),
|
||||
header(
|
||||
"Other Events",
|
||||
"Power levels for specific event types.",
|
||||
Icons.code,
|
||||
),
|
||||
...otherEvents.map(
|
||||
(entry) => Setting(
|
||||
title: entry.key,
|
||||
description: "The power level needed to send this event.",
|
||||
icon: Icons.code,
|
||||
builder: (title, _, icon) => PowerLevelTile(
|
||||
leading: Icon(icon),
|
||||
title: title,
|
||||
description: null,
|
||||
value: entry.value,
|
||||
ownLevel: ownLevel,
|
||||
onChanged: canSetPowerLevels
|
||||
? (level) => setPowerLevels(
|
||||
powerLevels.copyWith(
|
||||
events: powerLevels.events.add(entry.key, level),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onRemoved: canSetPowerLevels
|
||||
? () => setPowerLevels(
|
||||
powerLevels.copyWith(
|
||||
events: powerLevels.events.remove(entry.key),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
addSetting(
|
||||
title: "Add Event",
|
||||
description: "Give an event type its own power level.",
|
||||
sheet: (save) => PowerLevelEntrySheet(
|
||||
title: "Add Event",
|
||||
keyLabel: "Event type",
|
||||
keyHint: "m.room.name",
|
||||
ownLevel: ownLevel,
|
||||
onSave: (type, level) => save(
|
||||
powerLevels.copyWith(
|
||||
events: powerLevels.events.add(type, level),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent {
|
|||
factory PowerLevelsContent({
|
||||
@Default(IMap.empty()) IMap<String, int> events,
|
||||
@Default(IMap.empty()) IMap<String, int> users,
|
||||
Notifications? notifications,
|
||||
@JsonKey(includeIfNull: false) Notifications? notifications,
|
||||
@Default(50) int ban,
|
||||
@Default(0) int eventsDefault,
|
||||
@Default(0) int invite,
|
||||
|
|
|
|||
11
lib/models/room_power_levels.dart
Normal file
11
lib/models/room_power_levels.dart
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
|
||||
class const RoomPowerLevels({
|
||||
required final PowerLevelsContent content,
|
||||
final ISet<String> creators = const .empty(),
|
||||
}) {
|
||||
double levelOf(String userId) => creators.contains(userId)
|
||||
? double.infinity
|
||||
: (content.users[userId] ?? content.usersDefault).toDouble();
|
||||
}
|
||||
112
lib/widgets/settings/add_users_power_level_sheet.dart
Normal file
112
lib/widgets/settings/add_users_power_level_sheet.dart
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
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/members.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/settings/power_level_field.dart";
|
||||
import "package:nexus/widgets/settings/search_chooser.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// Chooses users of [roomId] to give a power level of their own.
|
||||
class const AddUsersPowerLevelSheet({
|
||||
required final String roomId,
|
||||
|
||||
/// Users who already have a level, or can't be given one, so aren't suggested.
|
||||
required final ISet<String> existing,
|
||||
|
||||
/// The user's own power level, which they can't set levels above.
|
||||
required final double ownLevel,
|
||||
required final void Function(ISet<String> users, int level) onSave,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chosen = useState(const ISet<String>.empty());
|
||||
final error = useState<String?>(null);
|
||||
final searchController = useTextEditingController();
|
||||
final levelController = useTextEditingController(text: "0");
|
||||
final level = PowerLevelField.levelFrom(
|
||||
useValueListenable(levelController).text,
|
||||
ownLevel,
|
||||
);
|
||||
|
||||
final members = IMap.fromEntries(
|
||||
(ref.watch(MembersController.provider(roomId)).value ??
|
||||
const ISet.empty())
|
||||
.map(
|
||||
(event) => switch ((event.stateKey, event.content)) {
|
||||
(final userId?, final MembershipContent content)
|
||||
when content.status == .join || content.status == .invite =>
|
||||
MapEntry(userId, content),
|
||||
_ => null,
|
||||
},
|
||||
)
|
||||
.nonNulls,
|
||||
);
|
||||
|
||||
String nameOf(String id) => members[id]?.displayName ?? id.localpart;
|
||||
|
||||
void add(String id) {
|
||||
if (existing.contains(id)) {
|
||||
error.value = "Already has a power level";
|
||||
return;
|
||||
}
|
||||
chosen.value = chosen.value.add(id);
|
||||
searchController.clear();
|
||||
}
|
||||
|
||||
final valid = chosen.value.isNotEmpty && level != null;
|
||||
void save() {
|
||||
Navigator.of(context).pop();
|
||||
onSave(chosen.value, level!);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text("Add Users"),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||
TextButton(onPressed: valid ? save : null, child: Text("Save")),
|
||||
],
|
||||
children: [
|
||||
SearchChooser(
|
||||
controller: searchController,
|
||||
chosen: chosen.value,
|
||||
options: members.keys
|
||||
.where((id) => !existing.contains(id))
|
||||
.sortedBy((id) => nameOf(id).toLowerCase())
|
||||
.toIList(),
|
||||
nameOf: nameOf,
|
||||
avatarOf: (id) =>
|
||||
AvatarOrHash(members[id]?.avatarUrl, nameOf(id), dimension: 40),
|
||||
subtitleOf: (id) => id,
|
||||
onAdded: add,
|
||||
onRemoved: (id) => chosen.value = chosen.value.remove(id),
|
||||
onSubmitted: (text, topMatch) {
|
||||
if (RegExp(r"^@[^:]+:.+$").hasMatch(text)) {
|
||||
add(text);
|
||||
} else if (topMatch != null) {
|
||||
add(topMatch);
|
||||
} else {
|
||||
error.value = "Enter a user ID";
|
||||
}
|
||||
},
|
||||
onChanged: (_) => error.value = null,
|
||||
label: "Add user",
|
||||
hint: "Search members, or enter a user ID",
|
||||
removeTooltip: "Remove user",
|
||||
error: error.value,
|
||||
autofocus: true,
|
||||
),
|
||||
PowerLevelField(
|
||||
controller: levelController,
|
||||
ownLevel: ownLevel,
|
||||
onSubmitted: valid ? save : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import "package:nexus/controllers/client.dart";
|
|||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/settings/search_chooser.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// Chooses the rooms whose members can join [roomId] without an invite.
|
||||
|
|
@ -25,7 +26,6 @@ class const AllowedRoomsSheet({
|
|||
final error = useState<String?>(null);
|
||||
final resolving = useState(false);
|
||||
final controller = useTextEditingController();
|
||||
final query = useValueListenable(controller).text.trim().toLowerCase();
|
||||
final rooms = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => IMap.fromEntries(
|
||||
|
|
@ -43,16 +43,6 @@ class const AllowedRoomsSheet({
|
|||
);
|
||||
|
||||
String nameOf(String id) => rooms[id]?.name ?? id;
|
||||
Widget avatarOf(String id) => AvatarOrHash(
|
||||
rooms[id]?.avatar,
|
||||
nameOf(id),
|
||||
fallback: Icon(Icons.numbers),
|
||||
);
|
||||
String? subtitleOf(String id) => parentSpaces.contains(id)
|
||||
? "Parent space"
|
||||
: rooms[id]?.name == null
|
||||
? null
|
||||
: id;
|
||||
|
||||
final suggestions = parentSpaces
|
||||
.addAll(
|
||||
|
|
@ -62,15 +52,7 @@ class const AllowedRoomsSheet({
|
|||
.map((entry) => entry.key),
|
||||
)
|
||||
.where((id) => id != roomId)
|
||||
.toISet();
|
||||
final shown = suggestions
|
||||
.where(
|
||||
(id) =>
|
||||
!chosen.value.contains(id) &&
|
||||
(nameOf(id).toLowerCase().contains(query) ||
|
||||
id.toLowerCase().contains(query)),
|
||||
)
|
||||
.toISet();
|
||||
.toIList();
|
||||
|
||||
void add(String id) {
|
||||
if (id == roomId) {
|
||||
|
|
@ -116,93 +98,39 @@ class const AllowedRoomsSheet({
|
|||
],
|
||||
children: [
|
||||
Text("Members of these rooms can join without an invite."),
|
||||
if (chosen.value.isEmpty)
|
||||
Text("None, so only invited users can join")
|
||||
else
|
||||
Column(
|
||||
children: chosen.value
|
||||
.map(
|
||||
(id) => ListTile(
|
||||
leading: avatarOf(id),
|
||||
title: Text(nameOf(id), overflow: .ellipsis),
|
||||
subtitle: switch (subtitleOf(id)) {
|
||||
final subtitle? => Text(subtitle, overflow: .ellipsis),
|
||||
null => null,
|
||||
},
|
||||
trailing: IconButton(
|
||||
tooltip: "Remove room",
|
||||
icon: Icon(Icons.delete),
|
||||
onPressed: () => chosen.value = chosen.value.remove(id),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
TextField(
|
||||
SearchChooser(
|
||||
controller: controller,
|
||||
enabled: !resolving.value,
|
||||
onChanged: (_) => error.value = null,
|
||||
onSubmitted: (text) {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
|
||||
if (trimmed.startsWith(RegExp("[!#]"))) {
|
||||
addRoom(trimmed).onError(showError);
|
||||
} else if (shown.firstOrNull case final id?) {
|
||||
add(id);
|
||||
chosen: chosen.value,
|
||||
options: suggestions,
|
||||
nameOf: nameOf,
|
||||
avatarOf: (id) => AvatarOrHash(
|
||||
rooms[id]?.avatar,
|
||||
nameOf(id),
|
||||
fallback: Icon(Icons.numbers),
|
||||
),
|
||||
subtitleOf: (id) => parentSpaces.contains(id)
|
||||
? "Parent space"
|
||||
: rooms[id]?.name == null
|
||||
? null
|
||||
: id,
|
||||
onAdded: add,
|
||||
onRemoved: (id) => chosen.value = chosen.value.remove(id),
|
||||
onSubmitted: (text, topMatch) {
|
||||
if (text.startsWith(RegExp("[!#]"))) {
|
||||
addRoom(text).onError(showError);
|
||||
} else if (topMatch != null) {
|
||||
add(topMatch);
|
||||
} else {
|
||||
error.value = "Enter a room ID or alias";
|
||||
}
|
||||
},
|
||||
decoration: .new(
|
||||
labelText: "Add room",
|
||||
hintText: "Search, or enter a room ID or alias",
|
||||
errorText: error.value,
|
||||
suffixIcon: resolving.value
|
||||
? Padding(
|
||||
padding: .all(12),
|
||||
child: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: Durations.short2,
|
||||
alignment: .topCenter,
|
||||
child: shown.isEmpty
|
||||
? SizedBox(width: .infinity)
|
||||
: Material(
|
||||
elevation: 4,
|
||||
borderRadius: .circular(12),
|
||||
clipBehavior: .antiAlias,
|
||||
child: ConstrainedBox(
|
||||
constraints: const .new(maxHeight: 240),
|
||||
child: ListView.builder(
|
||||
padding: .zero,
|
||||
shrinkWrap: true,
|
||||
itemExtent: 72,
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (context, index) {
|
||||
final id = shown[index];
|
||||
return ListTile(
|
||||
leading: avatarOf(id),
|
||||
title: Text(nameOf(id), overflow: .ellipsis),
|
||||
subtitle: switch (subtitleOf(id)) {
|
||||
final subtitle? => Text(
|
||||
subtitle,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
onTap: () => add(id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
onChanged: (_) => error.value = null,
|
||||
label: "Add room",
|
||||
hint: "Search, or enter a room ID or alias",
|
||||
removeTooltip: "Remove room",
|
||||
emptyText: "None, so only invited users can join",
|
||||
error: error.value,
|
||||
loading: resolving.value,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
55
lib/widgets/settings/power_level_entry_sheet.dart
Normal file
55
lib/widgets/settings/power_level_entry_sheet.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/widgets/settings/power_level_field.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// Gives something, like an event type, its own power level.
|
||||
class const PowerLevelEntrySheet({
|
||||
required final String title,
|
||||
|
||||
/// The label of the field for what's given the power level.
|
||||
required final String keyLabel,
|
||||
required final String keyHint,
|
||||
|
||||
/// The user's own power level, which they can't set levels above.
|
||||
required final double ownLevel,
|
||||
required final void Function(String key, int level) onSave,
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keyController = useTextEditingController();
|
||||
final levelController = useTextEditingController(text: "0");
|
||||
final key = useValueListenable(keyController).text.trim();
|
||||
final level = PowerLevelField.levelFrom(
|
||||
useValueListenable(levelController).text,
|
||||
ownLevel,
|
||||
);
|
||||
final valid = key.isNotEmpty && level != null;
|
||||
|
||||
void save() {
|
||||
Navigator.of(context).pop();
|
||||
onSave(key, level!);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||
TextButton(onPressed: valid ? save : null, child: Text("Save")),
|
||||
],
|
||||
children: [
|
||||
TextField(
|
||||
controller: keyController,
|
||||
autofocus: true,
|
||||
decoration: .new(labelText: keyLabel, hintText: keyHint),
|
||||
),
|
||||
PowerLevelField(
|
||||
controller: levelController,
|
||||
ownLevel: ownLevel,
|
||||
onSubmitted: valid ? save : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/widgets/settings/power_level_field.dart
Normal file
48
lib/widgets/settings/power_level_field.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import "package:flutter/services.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
/// A field for a power level, which can't be above the user's own.
|
||||
class const PowerLevelField({
|
||||
required final TextEditingController controller,
|
||||
|
||||
/// The user's own power level, which they can't set levels above.
|
||||
required final double ownLevel,
|
||||
final VoidCallback? onSubmitted,
|
||||
final bool autofocus = false,
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
/// The furthest from zero Matrix allows integers to be.
|
||||
static const maxMagnitude = (2 ^ 53) - 1;
|
||||
|
||||
/// The level in [text], if it's one someone with [ownLevel] can set.
|
||||
static int? levelFrom(String text, double ownLevel) => switch (int.tryParse(
|
||||
text,
|
||||
)) {
|
||||
final level? when level.abs() <= maxMagnitude && level <= ownLevel => level,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = useValueListenable(controller).text;
|
||||
|
||||
return TextField(
|
||||
controller: controller,
|
||||
autofocus: autofocus,
|
||||
keyboardType: .numberWithOptions(signed: true),
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"^-?\d*"))],
|
||||
onSubmitted: onSubmitted == null ? null : (_) => onSubmitted!(),
|
||||
decoration: .new(
|
||||
labelText: "Power level",
|
||||
errorText: switch (int.tryParse(text)) {
|
||||
_ when text.isEmpty || text == "-" => null,
|
||||
final level? when level.abs() <= maxMagnitude =>
|
||||
level > ownLevel ? "Can't be above your own power level" : null,
|
||||
// Including ones too far from zero to even parse
|
||||
_ => "Must be from -$maxMagnitude to $maxMagnitude",
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
120
lib/widgets/settings/power_level_tile.dart
Normal file
120
lib/widgets/settings/power_level_tile.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
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/settings/power_level_field.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// Sets the power level needed for something.
|
||||
class const PowerLevelTile({
|
||||
required final Widget leading,
|
||||
required final String title,
|
||||
required final String? description,
|
||||
required final int value,
|
||||
|
||||
/// The user's own power level, which they can't set or change levels above.
|
||||
required final double ownLevel,
|
||||
|
||||
/// Disables the tile if null.
|
||||
required final void Function(int value)? onChanged,
|
||||
|
||||
/// Shows a button to remove the level if given, disabled along with the tile.
|
||||
final VoidCallback? onRemoved,
|
||||
|
||||
/// If given, asked before removing the level.
|
||||
final String? removeConfirmation,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = onChanged != null && value <= ownLevel;
|
||||
|
||||
Future<void> remove() async {
|
||||
if (removeConfirmation case final confirmation?) {
|
||||
final confirmed = await context.showAdaptiveSheet<bool>(
|
||||
(context) => SheetLayout(
|
||||
title: Text("Remove Power Level"),
|
||||
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("Remove"),
|
||||
),
|
||||
],
|
||||
children: [Text(confirmation)],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
onRemoved!();
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
enabled: enabled,
|
||||
leading: leading,
|
||||
title: Text(title),
|
||||
subtitle: description == null ? null : Text(description!),
|
||||
trailing: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Chip(
|
||||
label: Text(
|
||||
"$value",
|
||||
style: enabled ? null : .new(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
if (onRemoved != null)
|
||||
IconButton(
|
||||
tooltip: "Remove",
|
||||
icon: Icon(Icons.delete),
|
||||
onPressed: enabled ? remove : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(context) => HookBuilder(
|
||||
builder: (context) {
|
||||
final controller = useTextEditingController(text: "$value");
|
||||
final level = PowerLevelField.levelFrom(
|
||||
useValueListenable(controller).text,
|
||||
ownLevel,
|
||||
);
|
||||
|
||||
void save() {
|
||||
Navigator.of(context).pop();
|
||||
if (level != value) onChanged!(level!);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: level == null ? null : save,
|
||||
child: Text("Save"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
PowerLevelField(
|
||||
controller: controller,
|
||||
ownLevel: ownLevel,
|
||||
autofocus: true,
|
||||
onSubmitted: level == null ? null : save,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
142
lib/widgets/settings/search_chooser.dart
Normal file
142
lib/widgets/settings/search_chooser.dart
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
/// The [chosen] items, above a field searching [options] to choose more.
|
||||
class const SearchChooser({
|
||||
required final TextEditingController controller,
|
||||
required final ISet<String> chosen,
|
||||
|
||||
required final IList<String> options,
|
||||
|
||||
required final String Function(String id) nameOf,
|
||||
required final Widget Function(String id) avatarOf,
|
||||
required final String? Function(String id) subtitleOf,
|
||||
required final void Function(String id) onAdded,
|
||||
required final void Function(String id) onRemoved,
|
||||
|
||||
/// The submitted text, and the top suggestion matching it if there is one.
|
||||
required final void Function(String text, String? topMatch) onSubmitted,
|
||||
required final String label,
|
||||
required final String hint,
|
||||
required final String removeTooltip,
|
||||
|
||||
final String? emptyText,
|
||||
final String? error,
|
||||
final bool loading = false,
|
||||
final bool autofocus = false,
|
||||
final void Function(String text)? onChanged,
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = useValueListenable(controller).text.trim().toLowerCase();
|
||||
final shown = options
|
||||
.where(
|
||||
(id) =>
|
||||
!chosen.contains(id) &&
|
||||
(nameOf(id).toLowerCase().contains(query) ||
|
||||
id.toLowerCase().contains(query)),
|
||||
)
|
||||
.toIList();
|
||||
|
||||
ListTile tileOf(String id, {Widget? trailing, VoidCallback? onTap}) =>
|
||||
ListTile(
|
||||
leading: avatarOf(id),
|
||||
title: Text(nameOf(id), overflow: .ellipsis),
|
||||
subtitle: switch (subtitleOf(id)) {
|
||||
final subtitle? => Text(subtitle, overflow: .ellipsis),
|
||||
null => null,
|
||||
},
|
||||
trailing: trailing,
|
||||
onTap: onTap,
|
||||
);
|
||||
|
||||
AnimatedSize animated(Widget? child) => AnimatedSize(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOut,
|
||||
alignment: .topCenter,
|
||||
child: child ?? const SizedBox(width: .infinity),
|
||||
);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
animated(switch ((chosen.isEmpty, emptyText)) {
|
||||
(true, null) => null,
|
||||
(true, final emptyText?) => Padding(
|
||||
padding: .only(bottom: 12),
|
||||
child: Text(emptyText),
|
||||
),
|
||||
(false, _) => Padding(
|
||||
padding: .only(bottom: 12),
|
||||
child: Column(
|
||||
children: chosen
|
||||
.map(
|
||||
(id) => tileOf(
|
||||
id,
|
||||
trailing: IconButton(
|
||||
tooltip: removeTooltip,
|
||||
icon: Icon(Icons.delete),
|
||||
onPressed: () => onRemoved(id),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
}),
|
||||
TextField(
|
||||
controller: controller,
|
||||
enabled: !loading,
|
||||
autofocus: autofocus,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: (text) {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.isNotEmpty) onSubmitted(trimmed, shown.firstOrNull);
|
||||
},
|
||||
decoration: .new(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
errorText: error,
|
||||
suffixIcon: loading
|
||||
? Padding(
|
||||
padding: .all(12),
|
||||
child: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
animated(
|
||||
shown.isEmpty
|
||||
? null
|
||||
: Padding(
|
||||
padding: .only(top: 12, bottom: 4),
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: .circular(12),
|
||||
clipBehavior: .antiAlias,
|
||||
child: ConstrainedBox(
|
||||
constraints: const .new(maxHeight: 240),
|
||||
child: ListView.builder(
|
||||
padding: .zero,
|
||||
shrinkWrap: true,
|
||||
itemExtent: 72,
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (context, index) => tileOf(
|
||||
shown[index],
|
||||
onTap: () => onAdded(shown[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue