feat: Implement polls
This commit is contained in:
parent
f3ea64086b
commit
71fa853f05
12 changed files with 582 additions and 63 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
233
lib/pages/chat/events/poll.dart
Normal file
233
lib/pages/chat/events/poll.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue