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: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/members_by_status.dart";
|
import "package:nexus/controllers/members_by_status.dart";
|
||||||
import "package:nexus/controllers/room_creators.dart";
|
import "package:nexus/controllers/room_power_levels.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/models/configs/members_by_status.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";
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
class MembersGroupedController(final MembersByStatusConfig config)
|
class MembersGroupedController(final MembersByStatusConfig config)
|
||||||
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
|
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
|
||||||
@override
|
@override
|
||||||
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
|
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
|
||||||
final room = ref.watch(
|
final powerLevels = ref.watch(
|
||||||
RoomsController.provider.select((value) => value[config.roomId]),
|
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(
|
final members = await ref.watch(
|
||||||
MembersByStatusController.provider(config).future,
|
MembersByStatusController.provider(config).future,
|
||||||
);
|
);
|
||||||
|
|
||||||
return members
|
return members
|
||||||
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
|
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
|
||||||
final groupKey = roomCreators?.contains(event.stateKey!) == true
|
final groupKey = powerLevels.creators.contains(event.stateKey!)
|
||||||
? null
|
? null
|
||||||
: content.users[event.stateKey!] ?? content.usersDefault;
|
: powerLevels.levelOf(event.stateKey!).toInt();
|
||||||
|
|
||||||
return result.update(
|
return result.update(
|
||||||
groupKey,
|
groupKey,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/controllers/room_creators.dart";
|
import "package:nexus/controllers/room_power_levels.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/models/configs/power_level.dart";
|
import "package:nexus/models/configs/power_level.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/content/power_levels.dart";
|
|
||||||
|
|
||||||
class PowerLevelController(final PowerLevelConfig config)
|
class PowerLevelController(final PowerLevelConfig config)
|
||||||
extends Notifier<bool> {
|
extends Notifier<bool> {
|
||||||
|
|
@ -17,31 +15,17 @@ class PowerLevelController(final PowerLevelConfig config)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final room = ref.watch(
|
final powerLevels = ref.watch(
|
||||||
RoomsController.provider.select((value) => value[config.roomId]),
|
RoomPowerLevelsController.provider(config.roomId),
|
||||||
);
|
);
|
||||||
|
final content = powerLevels.content;
|
||||||
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 user = ref.watch(
|
final user = ref.watch(
|
||||||
ClientStateController.provider.select((value) => value?.userId),
|
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
|
final userLevel = powerLevels.levelOf(user);
|
||||||
? double.infinity
|
|
||||||
: (content.users[userId] ?? content.usersDefault).toDouble();
|
|
||||||
|
|
||||||
final userLevel = powerLevelOf(user);
|
|
||||||
|
|
||||||
return switch (config) {
|
return switch (config) {
|
||||||
EventPowerLevelConfig(:final eventType) =>
|
EventPowerLevelConfig(:final eventType) =>
|
||||||
|
|
@ -52,10 +36,12 @@ class PowerLevelController(final PowerLevelConfig config)
|
||||||
.invite => userLevel >= content.invite,
|
.invite => userLevel >= content.invite,
|
||||||
|
|
||||||
.kick =>
|
.kick =>
|
||||||
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
|
userLevel >= content.kick &&
|
||||||
|
userLevel > powerLevels.levelOf(targetUser),
|
||||||
|
|
||||||
.ban =>
|
.ban =>
|
||||||
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
|
userLevel >= content.ban &&
|
||||||
|
userLevel > powerLevels.levelOf(targetUser),
|
||||||
|
|
||||||
.unban => userLevel >= content.ban,
|
.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:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:nexus/controllers/client.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/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/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";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.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/canonical_alias.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
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/history_visibility.dart";
|
import "package:nexus/models/content/history_visibility.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/message.dart";
|
import "package:nexus/models/content/message.dart";
|
||||||
import "package:nexus/models/content/name.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/content/topic.dart";
|
||||||
import "package:nexus/models/join_rule.dart";
|
import "package:nexus/models/join_rule.dart";
|
||||||
|
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/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/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_tile.dart";
|
||||||
import "package:nexus/widgets/settings/text_setting_tile.dart";
|
import "package:nexus/widgets/settings/text_setting_tile.dart";
|
||||||
|
|
||||||
class RoomSettingsSectionsController(final String roomId)
|
class RoomSettingsSectionsController(final String roomId)
|
||||||
|
|
@ -69,6 +80,9 @@ 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 canSetPowerLevels =
|
||||||
|
canSet(.powerLevels) &&
|
||||||
|
!ref.watch(PowerLevelsSavingController.provider(roomId));
|
||||||
|
|
||||||
final joinRules = switch (stateContent(.joinRules)) {
|
final joinRules = switch (stateContent(.joinRules)) {
|
||||||
final JoinRulesContent content => content,
|
final JoinRulesContent content => content,
|
||||||
|
|
@ -125,6 +139,19 @@ class RoomSettingsSectionsController(final String roomId)
|
||||||
.nonNulls
|
.nonNulls
|
||||||
.toISet();
|
.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 {
|
Future<void> setState(EventType type, Content content) 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: "", 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({
|
return .new({
|
||||||
"Room": .new([
|
"Room": .new([
|
||||||
.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({
|
factory PowerLevelsContent({
|
||||||
@Default(IMap.empty()) IMap<String, int> events,
|
@Default(IMap.empty()) IMap<String, int> events,
|
||||||
@Default(IMap.empty()) IMap<String, int> users,
|
@Default(IMap.empty()) IMap<String, int> users,
|
||||||
Notifications? notifications,
|
@JsonKey(includeIfNull: false) Notifications? notifications,
|
||||||
@Default(50) int ban,
|
@Default(50) int ban,
|
||||||
@Default(0) int eventsDefault,
|
@Default(0) int eventsDefault,
|
||||||
@Default(0) int invite,
|
@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/controllers/rooms.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
|
import "package:nexus/widgets/settings/search_chooser.dart";
|
||||||
import "package:nexus/widgets/sheet_layout.dart";
|
import "package:nexus/widgets/sheet_layout.dart";
|
||||||
|
|
||||||
/// Chooses the rooms whose members can join [roomId] without an invite.
|
/// Chooses the rooms whose members can join [roomId] without an invite.
|
||||||
|
|
@ -25,7 +26,6 @@ class const AllowedRoomsSheet({
|
||||||
final error = useState<String?>(null);
|
final error = useState<String?>(null);
|
||||||
final resolving = useState(false);
|
final resolving = useState(false);
|
||||||
final controller = useTextEditingController();
|
final controller = useTextEditingController();
|
||||||
final query = useValueListenable(controller).text.trim().toLowerCase();
|
|
||||||
final rooms = ref.watch(
|
final rooms = ref.watch(
|
||||||
RoomsController.provider.select(
|
RoomsController.provider.select(
|
||||||
(rooms) => IMap.fromEntries(
|
(rooms) => IMap.fromEntries(
|
||||||
|
|
@ -43,16 +43,6 @@ class const AllowedRoomsSheet({
|
||||||
);
|
);
|
||||||
|
|
||||||
String nameOf(String id) => rooms[id]?.name ?? id;
|
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
|
final suggestions = parentSpaces
|
||||||
.addAll(
|
.addAll(
|
||||||
|
|
@ -62,15 +52,7 @@ class const AllowedRoomsSheet({
|
||||||
.map((entry) => entry.key),
|
.map((entry) => entry.key),
|
||||||
)
|
)
|
||||||
.where((id) => id != roomId)
|
.where((id) => id != roomId)
|
||||||
.toISet();
|
.toIList();
|
||||||
final shown = suggestions
|
|
||||||
.where(
|
|
||||||
(id) =>
|
|
||||||
!chosen.value.contains(id) &&
|
|
||||||
(nameOf(id).toLowerCase().contains(query) ||
|
|
||||||
id.toLowerCase().contains(query)),
|
|
||||||
)
|
|
||||||
.toISet();
|
|
||||||
|
|
||||||
void add(String id) {
|
void add(String id) {
|
||||||
if (id == roomId) {
|
if (id == roomId) {
|
||||||
|
|
@ -116,93 +98,39 @@ class const AllowedRoomsSheet({
|
||||||
],
|
],
|
||||||
children: [
|
children: [
|
||||||
Text("Members of these rooms can join without an invite."),
|
Text("Members of these rooms can join without an invite."),
|
||||||
if (chosen.value.isEmpty)
|
SearchChooser(
|
||||||
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(
|
|
||||||
controller: controller,
|
controller: controller,
|
||||||
enabled: !resolving.value,
|
chosen: chosen.value,
|
||||||
onChanged: (_) => error.value = null,
|
options: suggestions,
|
||||||
onSubmitted: (text) {
|
nameOf: nameOf,
|
||||||
final trimmed = text.trim();
|
avatarOf: (id) => AvatarOrHash(
|
||||||
if (trimmed.isEmpty) return;
|
rooms[id]?.avatar,
|
||||||
|
nameOf(id),
|
||||||
if (trimmed.startsWith(RegExp("[!#]"))) {
|
fallback: Icon(Icons.numbers),
|
||||||
addRoom(trimmed).onError(showError);
|
),
|
||||||
} else if (shown.firstOrNull case final id?) {
|
subtitleOf: (id) => parentSpaces.contains(id)
|
||||||
add(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 {
|
} else {
|
||||||
error.value = "Enter a room ID or alias";
|
error.value = "Enter a room ID or alias";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
decoration: .new(
|
onChanged: (_) => error.value = null,
|
||||||
labelText: "Add room",
|
label: "Add room",
|
||||||
hintText: "Search, or enter a room ID or alias",
|
hint: "Search, or enter a room ID or alias",
|
||||||
errorText: error.value,
|
removeTooltip: "Remove room",
|
||||||
suffixIcon: resolving.value
|
emptyText: "None, so only invited users can join",
|
||||||
? Padding(
|
error: error.value,
|
||||||
padding: .all(12),
|
loading: resolving.value,
|
||||||
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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
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