feat: Implement polls

This commit is contained in:
Christian Kußowski 2025-11-02 13:10:55 +01:00
commit 71fa853f05
No known key found for this signature in database
GPG key ID: E067ECD60F1A0652
12 changed files with 582 additions and 63 deletions

View file

@ -83,6 +83,7 @@ class Message extends StatelessWidget {
EventTypes.Sticker,
EventTypes.Encrypted,
EventTypes.CallInvite,
PollEventContent.startType,
}.contains(event.type)) {
if (event.type.startsWith('m.call.')) {
return const SizedBox.shrink();

View file

@ -6,6 +6,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/events/poll.dart';
import 'package:fluffychat/pages/chat/events/video_player.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
@ -234,27 +235,11 @@ class MessageContent extends StatelessWidget {
textmessage:
default:
if (event.redacted) {
return FutureBuilder<User?>(
future: event.redactedBecause?.fetchSenderUser(),
builder: (context, snapshot) {
final reason =
event.redactedBecause?.content.tryGet<String>('reason');
final redactedBy = snapshot.data?.calcDisplayname() ??
event.redactedBecause?.senderId.localpart ??
L10n.of(context).user;
return _ButtonContent(
label: reason == null
? L10n.of(context).redactedBy(redactedBy)
: L10n.of(context).redactedByBecause(
redactedBy,
reason,
),
icon: '🗑️',
textColor: buttonTextColor.withAlpha(128),
onPressed: () => onInfoTab!(event),
fontSize: fontSize,
);
},
return RedactionWidget(
event: event,
buttonTextColor: buttonTextColor,
onInfoTab: onInfoTab,
fontSize: fontSize,
);
}
var html = AppSettings.renderHtml.value && event.isRichMessage
@ -296,6 +281,21 @@ class MessageContent extends StatelessWidget {
),
);
}
case PollEventContent.startType:
if (event.redacted) {
return RedactionWidget(
event: event,
buttonTextColor: buttonTextColor,
onInfoTab: onInfoTab,
fontSize: fontSize,
);
}
return PollWidget(
event: event,
timeline: timeline,
textColor: textColor,
linkColor: linkColor,
);
case EventTypes.CallInvite:
return FutureBuilder<User?>(
future: event.fetchSenderUser(),
@ -333,6 +333,46 @@ class MessageContent extends StatelessWidget {
}
}
class RedactionWidget extends StatelessWidget {
const RedactionWidget({
super.key,
required this.event,
required this.buttonTextColor,
required this.onInfoTab,
required this.fontSize,
});
final Event event;
final Color buttonTextColor;
final void Function(Event p1)? onInfoTab;
final double fontSize;
@override
Widget build(BuildContext context) {
return FutureBuilder<User?>(
future: event.redactedBecause?.fetchSenderUser(),
builder: (context, snapshot) {
final reason = event.redactedBecause?.content.tryGet<String>('reason');
final redactedBy = snapshot.data?.calcDisplayname() ??
event.redactedBecause?.senderId.localpart ??
L10n.of(context).user;
return _ButtonContent(
label: reason == null
? L10n.of(context).redactedBy(redactedBy)
: L10n.of(context).redactedByBecause(
redactedBy,
reason,
),
icon: '🗑️',
textColor: buttonTextColor.withAlpha(128),
onPressed: () => onInfoTab!(event),
fontSize: fontSize,
);
},
);
}
}
class _ButtonContent extends StatelessWidget {
final void Function() onPressed;
final String label;

View file

@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import 'package:async/async.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:matrix/matrix.dart' hide Result;
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/utils/url_launcher.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
class PollWidget extends StatelessWidget {
final Event event;
final Timeline timeline;
final Color textColor;
final Color linkColor;
const PollWidget({
required this.event,
required this.timeline,
required this.textColor,
required this.linkColor,
super.key,
});
void _endPoll(BuildContext context) => showFutureLoadingDialog(
context: context,
future: () => event.endPoll(),
);
void _toggleVote(
BuildContext context,
String answerId,
int maxSelection,
) async {
final userId = event.room.client.userID!;
final answerIds = event.getPollResponses(timeline)[userId] ?? {};
if (!answerIds.remove(answerId)) {
answerIds.add(answerId);
if (answerIds.length > maxSelection) {
answerIds.clear();
answerIds.add(answerId);
}
}
showFutureLoadingDialog(
context: context,
future: () => event.answerPoll(answerIds.toList()),
);
}
@override
Widget build(BuildContext context) {
final eventContentResult = Result(() => event.parsedPollEventContent);
final eventContent = eventContentResult.asValue?.value;
if (eventContent == null) {
Logs().w('Invalid poll event', eventContentResult.error);
return const Text('Unable to parse poll event...');
}
final responses = event.getPollResponses(timeline);
final pollHasBeenEnded = event.getPollHasBeenEnded(timeline);
final canVote = event.room.canSendEvent(PollEventContent.responseType) &&
!pollHasBeenEnded;
final maxPolls = responses.length;
final answersVisible =
eventContent.pollStartContent.kind == PollKind.disclosed ||
pollHasBeenEnded;
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Linkify(
text: eventContent.pollStartContent.question.mText,
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
style: TextStyle(
color: textColor,
fontSize: AppSettings.fontSizeFactor.value *
AppConfig.messageFontSize,
),
options: const LinkifyOptions(humanize: false),
linkStyle: TextStyle(
color: linkColor,
fontSize: AppSettings.fontSizeFactor.value *
AppConfig.messageFontSize,
decoration: TextDecoration.underline,
decorationColor: linkColor,
),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
),
),
Divider(color: linkColor.withAlpha(64)),
...eventContent.pollStartContent.answers.map(
(answer) {
final votedUserIds = responses.entries
.where((entry) => entry.value.contains(answer.id))
.map((entry) => entry.key)
.toSet();
return Material(
color: Colors.transparent,
clipBehavior: Clip.hardEdge,
child: CheckboxListTile.adaptive(
value: responses[event.room.client.userID!]
?.contains(answer.id) ??
false,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
checkboxScaleFactor: 1.5,
checkboxShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(32),
),
onChanged: !canVote
? null
: (_) => _toggleVote(
context,
answer.id,
eventContent.pollStartContent.maxSelections,
),
title: Text(
answer.mText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: textColor,
fontSize: AppConfig.messageFontSize *
AppSettings.fontSizeFactor.value,
),
),
subtitle: answersVisible
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Text(
L10n.of(context)
.countVotes(votedUserIds.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: linkColor,
fontSize:
12 * AppSettings.fontSizeFactor.value,
),
),
const SizedBox(width: 2),
...votedUserIds.map((userId) {
final user = event.room
.getState(EventTypes.RoomMember, userId)
?.asUser(event.room);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 2.0,
),
child: Avatar(
mxContent: user?.avatarUrl,
name: user?.calcDisplayname() ??
userId.localpart,
size: 12 *
AppSettings.fontSizeFactor.value,
),
);
}),
const SizedBox(width: 2),
],
),
),
LinearProgressIndicator(
color: linkColor,
backgroundColor: linkColor.withAlpha(128),
borderRadius:
BorderRadius.circular(AppConfig.borderRadius),
value: maxPolls == 0
? 0
: votedUserIds.length / maxPolls,
),
],
)
: null,
),
);
},
),
if (!pollHasBeenEnded && event.senderId == event.room.client.userID)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton(
onPressed: () => _endPoll(context),
style: OutlinedButton.styleFrom(
foregroundColor: linkColor,
side: BorderSide(color: linkColor.withAlpha(64)),
),
child: Text(L10n.of(context).endPoll),
),
)
else if (!answersVisible)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
L10n.of(context).answersWillBeVisibleWhenPollHasEnded,
style: TextStyle(
color: linkColor,
fontSize: 12 * AppSettings.fontSizeFactor.value,
fontStyle: FontStyle.italic,
),
),
)
else if (pollHasBeenEnded)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
L10n.of(context).pollHasBeenEnded,
style: TextStyle(
color: linkColor,
fontSize: 12 * AppSettings.fontSizeFactor.value,
fontStyle: FontStyle.italic,
),
),
),
],
),
);
}
}