add power level room settings
This commit is contained in:
parent
fcfe4eed1e
commit
b0dd452926
6 changed files with 944 additions and 179 deletions
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