parent
40f5d0f459
commit
f6aaf49164
9 changed files with 1041 additions and 144 deletions
|
|
@ -20,9 +20,11 @@ import "package:nexus/controllers/power_level.dart";
|
|||
import "package:nexus/controllers/image_packs.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/content/poll_start.dart";
|
||||
import "package:nexus/models/image_pack.dart";
|
||||
import "package:nexus/widgets/mxc_emoji.dart";
|
||||
import "package:nexus/widgets/sticker_picker.dart";
|
||||
import "package:nexus/widgets/create_poll_sheet.dart";
|
||||
import "package:nexus/models/relation_type.dart";
|
||||
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
|
||||
import "package:nexus/widgets/composer/paste_files_action.dart";
|
||||
|
|
@ -30,6 +32,7 @@ import "package:pasteboard/pasteboard.dart";
|
|||
import "package:path/path.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
import "package:nexus/widgets/composer/relation_preview.dart";
|
||||
import "package:nexus/widgets/composer/composer_replacement.dart";
|
||||
import "package:nexus/main.dart";
|
||||
|
||||
class const Composer(
|
||||
|
|
@ -49,6 +52,11 @@ class const Composer(
|
|||
required bool shouldMention,
|
||||
})
|
||||
onSendSticker,
|
||||
required final Future<void> Function(
|
||||
PollStart poll, {
|
||||
required bool shouldMention,
|
||||
})
|
||||
onSendPoll,
|
||||
final FocusNode? node,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
|
|
@ -70,6 +78,11 @@ class const Composer(
|
|||
.value
|
||||
?.any((pack) => pack.stickers.isNotEmpty) ??
|
||||
false);
|
||||
final canSendPolls = ref.watch(
|
||||
PowerLevelController.provider(
|
||||
.new(eventType: .pollStart, roomId: roomId),
|
||||
),
|
||||
);
|
||||
final relatedEvent = this.relatedEvent == null
|
||||
? null
|
||||
: ref.watch(
|
||||
|
|
@ -95,15 +108,24 @@ class const Composer(
|
|||
/// The sticker to send, which replaces the text field until it is sent or
|
||||
/// removed, as stickers can't have a caption
|
||||
final sticker = useState<(String, PackImage)?>(null);
|
||||
|
||||
final poll = useState<PollStart?>(null);
|
||||
useEffect(() {
|
||||
sticker.value = null;
|
||||
poll.value = null;
|
||||
return null;
|
||||
}, [roomId]);
|
||||
|
||||
final hasReplacement = sticker.value != null || poll.value != null;
|
||||
|
||||
// The relation type stays as edit after cancelling an edit
|
||||
final isEditing = relationType == .edit && relatedEvent != null;
|
||||
|
||||
useEffect(() {
|
||||
if (relationType == .edit && relatedEvent != null) {
|
||||
textBefore.value ??= controller.text;
|
||||
sticker.value = null;
|
||||
poll.value = null;
|
||||
|
||||
final text =
|
||||
relatedEvent.localContent?.editSource ??
|
||||
|
|
@ -216,11 +238,16 @@ class const Composer(
|
|||
}
|
||||
|
||||
void send() {
|
||||
if (sticker.value case (final shortcode, final image)?) {
|
||||
onSendSticker(shortcode, image, shouldMention: shouldMention.value);
|
||||
if (hasReplacement) {
|
||||
if (sticker.value case (final shortcode, final image)?) {
|
||||
onSendSticker(shortcode, image, shouldMention: shouldMention.value);
|
||||
} else if (poll.value case final draft?) {
|
||||
onSendPoll(draft, shouldMention: shouldMention.value);
|
||||
}
|
||||
onDismiss();
|
||||
// The draft is kept, so it can still be sent after the sticker
|
||||
// The draft is kept, so it can still be sent after the replacement
|
||||
sticker.value = null;
|
||||
poll.value = null;
|
||||
node?.requestFocus();
|
||||
return;
|
||||
}
|
||||
|
|
@ -302,7 +329,7 @@ class const Composer(
|
|||
)
|
||||
? [
|
||||
IconButton(
|
||||
onPressed: sticker.value != null
|
||||
onPressed: hasReplacement
|
||||
? null
|
||||
: () => context.showAdaptiveSheet(
|
||||
(context) => EmojiPicker(
|
||||
|
|
@ -338,8 +365,7 @@ class const Composer(
|
|||
),
|
||||
AdaptiveMenuButton(
|
||||
tooltip: "Add media",
|
||||
enabled:
|
||||
attachment == null && sticker.value == null,
|
||||
enabled: attachment == null && !hasReplacement,
|
||||
itemBuilder: (context) => [
|
||||
for (final (source, title, icon) in [
|
||||
if (Platform.isAndroid || Platform.isIOS)
|
||||
|
|
@ -386,7 +412,7 @@ class const Composer(
|
|||
if (canSendStickers)
|
||||
PopupMenuItem(
|
||||
// Stickers can't replace messages
|
||||
enabled: relationType != .edit,
|
||||
enabled: !isEditing,
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(context) => StickerPicker(
|
||||
roomId: roomId,
|
||||
|
|
@ -401,6 +427,22 @@ class const Composer(
|
|||
leading: Icon(Icons.sticky_note_2),
|
||||
),
|
||||
),
|
||||
if (canSendPolls)
|
||||
PopupMenuItem(
|
||||
enabled: !isEditing,
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(context) => CreatePollSheet(
|
||||
onCreate: (created) {
|
||||
Navigator.of(context).pop();
|
||||
poll.value = created;
|
||||
},
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text("Poll"),
|
||||
leading: Icon(Icons.poll),
|
||||
),
|
||||
),
|
||||
],
|
||||
icon: Icon(Icons.add),
|
||||
),
|
||||
|
|
@ -409,35 +451,49 @@ class const Composer(
|
|||
child: AnimatedSize(
|
||||
duration: Durations.short4,
|
||||
alignment: .bottomCenter,
|
||||
child: switch (sticker.value) {
|
||||
(final shortcode, final image)? => Focus(
|
||||
autofocus: true,
|
||||
child: switch ((sticker.value, poll.value)) {
|
||||
(_, final draft?) => ComposerReplacement(
|
||||
removeTooltip: "Remove poll",
|
||||
onRemove: () {
|
||||
poll.value = null;
|
||||
node?.requestFocus();
|
||||
},
|
||||
onKeyEvent: sendOnEnter,
|
||||
child: Padding(
|
||||
padding: .symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: .loose(.square(128)),
|
||||
child: MxcEmoji(
|
||||
image.url,
|
||||
label: image.body ?? shortcode,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
IconButton(
|
||||
tooltip: "Remove sticker",
|
||||
onPressed: () {
|
||||
sticker.value = null;
|
||||
node?.requestFocus();
|
||||
},
|
||||
icon: Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.poll,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(draft.question),
|
||||
subtitle: Text(
|
||||
draft.answers
|
||||
.map((answer) => answer.text)
|
||||
.join(" · "),
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
null => FlutterTagger(
|
||||
((final shortcode, final image)?, _) =>
|
||||
ComposerReplacement(
|
||||
removeTooltip: "Remove sticker",
|
||||
onRemove: () {
|
||||
sticker.value = null;
|
||||
node?.requestFocus();
|
||||
},
|
||||
onKeyEvent: sendOnEnter,
|
||||
Align(
|
||||
alignment: .centerLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: .loose(.square(128)),
|
||||
child: MxcEmoji(
|
||||
image.url,
|
||||
label: image.body ?? shortcode,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
(null, null) => FlutterTagger(
|
||||
triggerStrategy: .eager,
|
||||
// Characters that continue a search, for
|
||||
// e.g. :+1, :custom_emoji or @user:server
|
||||
|
|
|
|||
30
lib/widgets/composer/composer_replacement.dart
Normal file
30
lib/widgets/composer/composer_replacement.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
/// Replaces the composer's text field with something to send, like a sticker.
|
||||
class const ComposerReplacement(
|
||||
final Widget child, {
|
||||
required final String removeTooltip,
|
||||
required final VoidCallback onRemove,
|
||||
final FocusOnKeyEventCallback? onKeyEvent,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: onKeyEvent,
|
||||
child: Padding(
|
||||
padding: .symmetric(vertical: 8),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
Expanded(child: child),
|
||||
IconButton(
|
||||
tooltip: removeTooltip,
|
||||
onPressed: onRemove,
|
||||
icon: Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
109
lib/widgets/create_poll_sheet.dart
Normal file
109
lib/widgets/create_poll_sheet.dart
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/models/content/poll_start.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
class const CreatePollSheet({
|
||||
required final void Function(PollStart poll) onCreate,
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final questionController = useTextEditingController();
|
||||
final question = useValueListenable(questionController).text.trim();
|
||||
|
||||
// Keyed by ID, so fields keep their text when one above is removed
|
||||
final nextId = useRef(2);
|
||||
final answers = useState<IMap<int, String>>(.new({0: "", 1: ""}));
|
||||
final filledAnswers = answers.value.entries
|
||||
.map(
|
||||
(answer) =>
|
||||
PollAnswer(id: "${answer.key}", text: answer.value.trim()),
|
||||
)
|
||||
.where((answer) => answer.text.isNotEmpty)
|
||||
.toIList();
|
||||
|
||||
final allowMultiple = useState(false);
|
||||
|
||||
final canCreate = question.isNotEmpty && filledAnswers.length >= 2;
|
||||
|
||||
void submit() => onCreate(
|
||||
.new(
|
||||
question: question,
|
||||
answers: filledAnswers,
|
||||
maxSelections: allowMultiple.value ? filledAnswers.length : 1,
|
||||
),
|
||||
);
|
||||
|
||||
return SheetLayout(
|
||||
title: const Text("Create a Poll"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: canCreate ? submit : null,
|
||||
child: const Text("Create"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
TextField(
|
||||
controller: questionController,
|
||||
autofocus: true,
|
||||
textCapitalization: .sentences,
|
||||
decoration: const .new(labelText: "Question"),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOut,
|
||||
alignment: .topCenter,
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
children: answers.value.entries
|
||||
.mapIndexed(
|
||||
(index, answer) => TextFormField(
|
||||
key: ValueKey(answer.key),
|
||||
initialValue: answer.value,
|
||||
textCapitalization: .sentences,
|
||||
onChanged: (text) =>
|
||||
answers.value = answers.value.add(answer.key, text),
|
||||
decoration: .new(
|
||||
labelText: "Answer ${index + 1}",
|
||||
suffixIcon: answers.value.length > 2
|
||||
? IconButton(
|
||||
tooltip: "Remove answer",
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => answers.value = answers.value
|
||||
.remove(answer.key),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: .centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: answers.value.length < PollStart.maxAnswers
|
||||
? () => answers.value = answers.value.add(nextId.value++, "")
|
||||
: null,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Add Answer"),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.checklist),
|
||||
title: const Text("Allow multiple answers"),
|
||||
subtitle: const Text("Voters can choose more than one"),
|
||||
value: allowMultiple.value,
|
||||
onChanged: (value) => allowMultiple.value = value,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/content/sticker.dart";
|
||||
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
|
||||
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||
|
|
@ -10,10 +9,7 @@ class const EventPreview(final Event event, {super.key})
|
|||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showSender = switch (event.content) {
|
||||
MessageContent() || StickerContent() => true,
|
||||
_ => false,
|
||||
};
|
||||
final showSender = event.hasMessageContent;
|
||||
|
||||
return IgnorePointer(
|
||||
child: Padding(
|
||||
|
|
|
|||
67
lib/widgets/poll_option.dart
Normal file
67
lib/widgets/poll_option.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
class const PollOption(
|
||||
final String text, {
|
||||
required final bool picked,
|
||||
required final bool isSingleChoice,
|
||||
|
||||
/// From 0 to 1
|
||||
required final double share,
|
||||
final bool isWinner = false,
|
||||
final bool loading = false,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final emphasis = isWinner ? const TextStyle(fontWeight: .bold) : null;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
spacing: 10,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 20,
|
||||
child: loading
|
||||
? const CircularProgressIndicator(strokeWidth: 2.5)
|
||||
: Icon(
|
||||
switch ((isSingleChoice, picked)) {
|
||||
(true, true) => Icons.radio_button_checked,
|
||||
(true, false) => Icons.radio_button_unchecked,
|
||||
(false, true) => Icons.check_box,
|
||||
(false, false) => Icons.check_box_outline_blank,
|
||||
},
|
||||
size: 20,
|
||||
color: picked
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodyLarge?.merge(emphasis),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${(share * 100).round()}%",
|
||||
style: theme.textTheme.labelLarge
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant)
|
||||
.merge(emphasis),
|
||||
),
|
||||
],
|
||||
),
|
||||
TweenAnimationBuilder(
|
||||
tween: Tween(end: share),
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOut,
|
||||
builder: (context, value, _) => LinearProgressIndicator(value: value),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,12 @@ import "package:material_ui/material_ui.dart";
|
|||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/event.dart";
|
||||
import "package:nexus/controllers/poll_votes.dart";
|
||||
import "package:nexus/controllers/room_chat.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
|
||||
import "package:nexus/helpers/extensions/send_failed.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_menu.dart";
|
||||
import "package:nexus/helpers/extensions/success_scheme.dart";
|
||||
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
||||
|
|
@ -22,6 +24,7 @@ import "package:nexus/models/content/join_rules.dart";
|
|||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/content/name.dart";
|
||||
import "package:nexus/models/content/pinned_events.dart";
|
||||
import "package:nexus/models/content/poll_end.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/content/server_acl.dart";
|
||||
import "package:nexus/models/content/topic.dart";
|
||||
|
|
@ -181,6 +184,29 @@ class const EventRenderer(
|
|||
"changed the room avatar",
|
||||
),
|
||||
CreateContent() => generic(Icons.add, "created the room"),
|
||||
PollEndContent() => switch (event.relatesTo) {
|
||||
final pollId? => switch (ref
|
||||
.watch(
|
||||
EventController.provider(
|
||||
.new(roomId: roomId, eventId: pollId),
|
||||
),
|
||||
)
|
||||
.value) {
|
||||
final poll?
|
||||
when ref
|
||||
.watch(
|
||||
PollVotesController.provider(
|
||||
.new(roomId: roomId, eventRowId: poll.rowId),
|
||||
),
|
||||
)
|
||||
.value
|
||||
?.endEventId ==
|
||||
event.eventId =>
|
||||
generic(Icons.poll, "ended a poll"),
|
||||
_ => null,
|
||||
},
|
||||
null => null,
|
||||
},
|
||||
final PowerLevelsContent content => generic(
|
||||
Icons.power,
|
||||
"changed the room's power levels",
|
||||
|
|
@ -427,7 +453,7 @@ class const EventRenderer(
|
|||
if (readReceiptsChat case final chat? when event.isShownAsMessage)
|
||||
ReadReceiptsRow(event.eventId, chat: chat),
|
||||
|
||||
if (event.sendError != null && event.sendError != "not sent")
|
||||
if (event.sendFailed)
|
||||
Padding(
|
||||
padding: .only(bottom: 4),
|
||||
child: Row(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import "package:nexus/helpers/extensions/get_filename.dart";
|
|||
import "package:nexus/helpers/extensions/linkify_html.dart";
|
||||
import "package:nexus/models/content/encrypted.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/content/poll_start.dart";
|
||||
import "package:nexus/models/content/sticker.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/widgets/file_card.dart";
|
||||
|
|
@ -23,6 +24,7 @@ import "package:nexus/widgets/url_preview.dart";
|
|||
import "package:nexus/widgets/event_preview.dart";
|
||||
import "package:nexus/widgets/players/video.dart";
|
||||
import "package:nexus/widgets/players/audio.dart";
|
||||
import "package:nexus/widgets/renderers/poll.dart";
|
||||
|
||||
class const MessageRenderer(
|
||||
final Event event, {
|
||||
|
|
@ -54,6 +56,14 @@ class const MessageRenderer(
|
|||
constraints: .loose(.square(200)),
|
||||
child: MessageImage(url, info: info, encrypted: false),
|
||||
),
|
||||
PollStartContent(:final poll) =>
|
||||
textOnly
|
||||
? Text(
|
||||
"Poll: ${poll.question}",
|
||||
maxLines: maxLines,
|
||||
overflow: .ellipsis,
|
||||
)
|
||||
: PollRenderer(event, poll),
|
||||
// TODO: Handle locations
|
||||
// LocationMessageContent(:final body , :final geoUri) =>
|
||||
MessageContent content => switch (content) {
|
||||
|
|
|
|||
220
lib/widgets/renderers/poll.dart
Normal file
220
lib/widgets/renderers/poll.dart
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
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:m3e_buttons/m3e_buttons.dart";
|
||||
import "package:m3e_card_list/m3e_card_list.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/poll_votes.dart";
|
||||
import "package:nexus/controllers/power_level.dart";
|
||||
import "package:nexus/controllers/room_chat.dart";
|
||||
import "package:nexus/controllers/room_power_levels.dart";
|
||||
import "package:nexus/helpers/extensions/event_id_is_fake.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/content/poll_start.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/widgets/poll_option.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
class const PollRenderer(final Event event, final PollStart poll, {super.key})
|
||||
extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
/// The answer being voted for, or no answer while ending the poll
|
||||
final pending = useState<({String? answerId})?>(null);
|
||||
final isEnding = switch (pending.value) {
|
||||
(answerId: null) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
final votesProvider = PollVotesController.provider(
|
||||
.new(roomId: event.roomId, eventRowId: event.rowId),
|
||||
);
|
||||
final votesState = ref.watch(votesProvider);
|
||||
final votes = votesState.value ?? const .new();
|
||||
final userId = ref.watch(
|
||||
ClientStateController.provider.select((value) => value?.userId),
|
||||
);
|
||||
final powerLevels = ref.watch(
|
||||
RoomPowerLevelsController.provider(event.roomId),
|
||||
);
|
||||
|
||||
final isOpen = !event.eventId.isFake && !votes.hasEnded;
|
||||
final canVote =
|
||||
isOpen &&
|
||||
votesState.hasValue &&
|
||||
ref.watch(
|
||||
PowerLevelController.provider(
|
||||
.new(eventType: .pollResponse, roomId: event.roomId),
|
||||
),
|
||||
);
|
||||
final canEnd =
|
||||
isOpen &&
|
||||
userId != null &&
|
||||
powerLevels.canEndPoll(userId, pollSender: event.sender);
|
||||
final ownVote =
|
||||
(userId == null ? null : votes.votes[userId]) ?? const .empty();
|
||||
final votersByAnswer = votes.votersByAnswer;
|
||||
final mostVotes =
|
||||
votersByAnswer.values.map((voters) => voters.length).maxOrNull ?? 0;
|
||||
final isSingleChoice = poll.selectionLimit == 1;
|
||||
|
||||
final notifier = RoomChatController.provider((
|
||||
roomId: event.roomId,
|
||||
contextualEvent: null,
|
||||
)).notifier;
|
||||
|
||||
Future<void> run(
|
||||
({String? answerId}) action,
|
||||
Future<void> Function() send,
|
||||
) async {
|
||||
pending.value = action;
|
||||
await send().onError(showError);
|
||||
// Otherwise the old votes show until they're refetched
|
||||
await ref.read(votesProvider.future).onError((_, _) => const .new());
|
||||
if (context.mounted) pending.value = null;
|
||||
}
|
||||
|
||||
Future<void> toggle(String answerId) {
|
||||
final picked = ownVote.contains(answerId)
|
||||
? ownVote.remove(answerId)
|
||||
: isSingleChoice
|
||||
? IList([answerId])
|
||||
: ownVote.add(answerId);
|
||||
|
||||
return run(
|
||||
(answerId: answerId),
|
||||
() => ref
|
||||
.read(notifier)
|
||||
.votePoll(
|
||||
event,
|
||||
// Past the limit, the oldest pick is replaced
|
||||
picked.length > poll.selectionLimit ? picked.removeAt(0) : picked,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void confirmEnd() => context.showAdaptiveSheet(
|
||||
(sheetContext) => SheetLayout(
|
||||
title: const Text("End Poll"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(sheetContext).pop,
|
||||
child: const Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
run((answerId: null), () => ref.read(notifier).endPoll(event));
|
||||
},
|
||||
child: const Text("End"),
|
||||
),
|
||||
],
|
||||
children: const [
|
||||
Text(
|
||||
"Are you sure you want to end this poll? "
|
||||
"No one will be able to vote after it ends.",
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final voterCount = votes.voterCount;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const .new(maxWidth: 400),
|
||||
child: Column(
|
||||
crossAxisAlignment: .stretch,
|
||||
mainAxisSize: .min,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: .start,
|
||||
spacing: 2,
|
||||
children: [
|
||||
Text(
|
||||
poll.question.isEmpty ? "Untitled poll" : poll.question,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
[
|
||||
votes.hasEnded
|
||||
? "Ended"
|
||||
: isSingleChoice
|
||||
? "Choose one"
|
||||
: "Choose up to ${poll.selectionLimit}",
|
||||
"$voterCount ${voterCount == 1 ? "vote" : "votes"}",
|
||||
].join(" · "),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (votesState case AsyncError(:final error))
|
||||
Text(
|
||||
"Failed to load votes: $error",
|
||||
style: .new(color: colorScheme.error),
|
||||
),
|
||||
if (votes.undecryptable case final count when count > 0)
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(Icons.lock, size: 16, color: colorScheme.error),
|
||||
Flexible(
|
||||
child: Text(
|
||||
"$count ${count == 1 ? "response" : "responses"} couldn't "
|
||||
"be decrypted, so the results may be incomplete",
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
M3ECardColumn(
|
||||
color: colorScheme.surface,
|
||||
outerRadius: 16,
|
||||
padding: const .symmetric(horizontal: 16, vertical: 12),
|
||||
onTap: canVote && pending.value == null
|
||||
? (index) => toggle(poll.answers[index].id)
|
||||
: null,
|
||||
children: poll.answers.map((answer) {
|
||||
final count = votersByAnswer[answer.id]?.length ?? 0;
|
||||
|
||||
return PollOption(
|
||||
answer.text,
|
||||
picked: ownVote.contains(answer.id),
|
||||
isSingleChoice: isSingleChoice,
|
||||
share: voterCount == 0 ? 0 : count / voterCount,
|
||||
isWinner: votes.hasEnded && count > 0 && count == mostVotes,
|
||||
loading: pending.value?.answerId == answer.id,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (canEnd)
|
||||
Align(
|
||||
alignment: .centerRight,
|
||||
child: M3EButton.icon(
|
||||
style: .tonal,
|
||||
size: .xs,
|
||||
onPressed: pending.value == null ? confirmEnd : null,
|
||||
icon: isEnding
|
||||
? const SizedBox.square(
|
||||
dimension: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.stop_circle),
|
||||
label: const Text("End Poll"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,9 @@ final class const RoomChat({
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final relatedEvent = useState<int?>(null);
|
||||
final relationType = useState(RelationType.reply);
|
||||
final repliedEventRowId = relationType.value == .reply
|
||||
? relatedEvent.value
|
||||
: null;
|
||||
final pendingJump = ref.read(JumpRequestController.provider);
|
||||
final contextualEvent = useState<int?>(
|
||||
pendingJump != null && pendingJump.roomId == this.roomId
|
||||
|
|
@ -334,11 +337,19 @@ final class const RoomChat({
|
|||
image,
|
||||
shouldMention: shouldMention,
|
||||
repliedEventRowId:
|
||||
relationType.value == .reply
|
||||
? relatedEvent.value
|
||||
: null,
|
||||
repliedEventRowId,
|
||||
)
|
||||
.onError(showError),
|
||||
onSendPoll:
|
||||
(poll, {required shouldMention}) =>
|
||||
notifier
|
||||
.sendPoll(
|
||||
poll,
|
||||
shouldMention: shouldMention,
|
||||
repliedEventRowId:
|
||||
repliedEventRowId,
|
||||
)
|
||||
.onError(showError),
|
||||
relationType: relationType.value,
|
||||
relatedEvent: relatedEvent.value,
|
||||
onDismiss: () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue