From 0c7bc747d24772c350e5f02cb08af61fc7b9b84c Mon Sep 17 00:00:00 2001 From: krille-chan Date: Sun, 2 Feb 2025 10:49:27 +0100 Subject: [PATCH] feat: Implement polls --- assets/l10n/intl_en.arb | 24 +- lib/pages/chat/chat.dart | 55 +++-- lib/pages/chat/chat_input_row.dart | 41 ++-- lib/pages/chat/events/message.dart | 2 + lib/pages/chat/events/message_content.dart | 4 + lib/pages/chat/events/poll_event.dart | 106 +++++++++ lib/pages/chat/poll_edit_bottom_sheet.dart | 222 ++++++++++++++++++ .../filtered_timeline_extension.dart | 1 + .../matrix_sdk_extensions/matrix_locals.dart | 3 + pubspec.lock | 4 +- pubspec.yaml | 4 +- 11 files changed, 433 insertions(+), 33 deletions(-) create mode 100644 lib/pages/chat/events/poll_event.dart create mode 100644 lib/pages/chat/poll_edit_bottom_sheet.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index bd4238c96..84f1090a6 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2821,5 +2821,27 @@ "invalidUrl": "Invalid url", "addLink": "Add link", "unableToJoinChat": "Unable to join chat. Maybe the other party has already closed the conversation.", - "previous": "Previous" + "previous": "Previous", + "poll": "Poll", + "question": "Question", + "answer": "Answer", + "resultsDisclosed": "Results disclosed", + "resultsUndisclosed": "Results undisclosed", + "addAnswer": "Add answer", + "deleteAnswer": "Delete answer", + "startedAPoll": "{sender} started a poll", + "@startedAPoll": { + "type": "text", + "placeholders": { + "sender": {} + } + }, + "countVotes": "{votes, plural, =1{1 vote} other{{votes} votes}} - {percentage}%", + "@countVotes": { + "type": "integer", + "placeholders": { + "votes": {}, + "percentage": {} + } + } } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index fd32c26fa..8a4d44c11 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -13,6 +13,8 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:matrix/matrix.dart'; +import 'package:matrix/msc_extensions/msc_3381_polls/models/poll_event_content.dart'; +import 'package:matrix/msc_extensions/msc_3381_polls/poll_room_extension.dart'; import 'package:record/record.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -21,10 +23,13 @@ import 'package:universal_html/html.dart' as html; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pages/chat/chat_input_row.dart'; import 'package:fluffychat/pages/chat/chat_view.dart'; import 'package:fluffychat/pages/chat/event_info_dialog.dart'; +import 'package:fluffychat/pages/chat/poll_edit_bottom_sheet.dart'; import 'package:fluffychat/pages/chat/recording_dialog.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; +import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/error_reporter.dart'; import 'package:fluffychat/utils/file_selector.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; @@ -1117,24 +1122,44 @@ class ChatController extends State FocusScope.of(context).requestFocus(inputFocus); } - void onAddPopupMenuButtonSelected(String choice) { - if (choice == 'file') { - sendFileAction(); - } - if (choice == 'image') { - sendImageAction(); - } - if (choice == 'camera') { - openCameraAction(); - } - if (choice == 'camera-video') { - openVideoCameraAction(); - } - if (choice == 'location') { - sendLocationAction(); + void onAddPopupMenuButtonSelected(AttachmentButtonAction choice) { + switch (choice) { + case AttachmentButtonAction.file: + sendFileAction(); + break; + case AttachmentButtonAction.image: + sendImageAction(); + break; + case AttachmentButtonAction.camera: + openCameraAction(); + break; + case AttachmentButtonAction.video: + openVideoCameraAction(); + break; + case AttachmentButtonAction.location: + sendLocationAction(); + break; + case AttachmentButtonAction.poll: + sendPollAction(); + break; } } + void sendPollAction() async { + final poll = await showAdaptiveBottomSheet( + context: context, + builder: (context) => const PollEditBottomSheet(), + ); + + if (poll == null) return; + await room.startPoll( + question: poll.question.mText, + answers: poll.answers, + maxSelections: poll.maxSelections, + kind: poll.kind ?? PollKind.undisclosed, + ); + } + unpinEvent(String eventId) async { final response = await showOkCancelAlertDialog( context: context, diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index 9d4fe4602..dd3f10321 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -102,13 +102,12 @@ class ChatInputRow extends StatelessWidget { alignment: Alignment.center, clipBehavior: Clip.hardEdge, decoration: const BoxDecoration(), - child: PopupMenuButton( + child: PopupMenuButton( icon: const Icon(Icons.add_outlined), onSelected: controller.onAddPopupMenuButtonSelected, - itemBuilder: (BuildContext context) => - >[ - PopupMenuItem( - value: 'file', + itemBuilder: (BuildContext context) => [ + PopupMenuItem( + value: AttachmentButtonAction.file, child: ListTile( leading: const CircleAvatar( backgroundColor: Colors.green, @@ -119,8 +118,8 @@ class ChatInputRow extends StatelessWidget { contentPadding: const EdgeInsets.all(0), ), ), - PopupMenuItem( - value: 'image', + PopupMenuItem( + value: AttachmentButtonAction.image, child: ListTile( leading: const CircleAvatar( backgroundColor: Colors.blue, @@ -132,8 +131,8 @@ class ChatInputRow extends StatelessWidget { ), ), if (PlatformInfos.isMobile) - PopupMenuItem( - value: 'camera', + PopupMenuItem( + value: AttachmentButtonAction.camera, child: ListTile( leading: const CircleAvatar( backgroundColor: Colors.purple, @@ -145,8 +144,8 @@ class ChatInputRow extends StatelessWidget { ), ), if (PlatformInfos.isMobile) - PopupMenuItem( - value: 'camera-video', + PopupMenuItem( + value: AttachmentButtonAction.video, child: ListTile( leading: const CircleAvatar( backgroundColor: Colors.red, @@ -158,8 +157,8 @@ class ChatInputRow extends StatelessWidget { ), ), if (PlatformInfos.isMobile) - PopupMenuItem( - value: 'location', + PopupMenuItem( + value: AttachmentButtonAction.location, child: ListTile( leading: const CircleAvatar( backgroundColor: Colors.brown, @@ -170,6 +169,18 @@ class ChatInputRow extends StatelessWidget { contentPadding: const EdgeInsets.all(0), ), ), + PopupMenuItem( + value: AttachmentButtonAction.poll, + child: ListTile( + leading: const CircleAvatar( + backgroundColor: Colors.deepOrange, + foregroundColor: Colors.white, + child: Icon(Icons.ballot_outlined), + ), + title: Text(L10n.of(context).poll), + contentPadding: const EdgeInsets.all(0), + ), + ), ], ), ), @@ -309,7 +320,7 @@ class _ChatAccountPicker extends StatelessWidget { onSelected: (mxid) => _popupMenuButtonSelected(mxid, context), itemBuilder: (BuildContext context) => clients .map( - (client) => PopupMenuItem( + (client) => PopupMenuItem( value: client!.userID, child: FutureBuilder( future: client.fetchOwnProfile(), @@ -338,3 +349,5 @@ class _ChatAccountPicker extends StatelessWidget { ); } } + +enum AttachmentButtonAction { file, image, camera, video, location, poll } diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index eab969b57..4b688aac0 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; +import 'package:matrix/msc_extensions/msc_3381_polls/models/poll_event_content.dart'; import 'package:swipe_to_action/swipe_to_action.dart'; import 'package:fluffychat/config/themes.dart'; @@ -66,6 +67,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(); diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 87c1809c0..d62446b9c 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:fluffychat/pages/chat/events/poll_event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -12,6 +13,7 @@ import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:matrix/msc_extensions/msc_3381_polls/models/poll_event_content.dart'; import '../../../config/app_config.dart'; import '../../../utils/platform_infos.dart'; import '../../../utils/url_launcher.dart'; @@ -292,6 +294,8 @@ class MessageContent extends StatelessWidget { ); }, ); + case PollEventContent.startType: + return PollEvent(event, textColor: textColor, timeline: timeline); default: return FutureBuilder( future: event.fetchSenderUser(), diff --git a/lib/pages/chat/events/poll_event.dart b/lib/pages/chat/events/poll_event.dart new file mode 100644 index 000000000..9ad168a5d --- /dev/null +++ b/lib/pages/chat/events/poll_event.dart @@ -0,0 +1,106 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/msc_extensions/msc_3381_polls/poll_event_extension.dart'; + +class PollEvent extends StatelessWidget { + final Event event; + final Timeline timeline; + final Color textColor; + const PollEvent( + this.event, { + required this.textColor, + required this.timeline, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; + final content = event.parsedPollEventContent.pollStartContent; + final answers = event.getPollResponses(timeline); + final answersLength = answers.length; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + content.question.mText, + style: TextStyle(color: textColor, fontSize: fontSize), + ), + for (final answer in content.answers) + Builder( + builder: (context) { + final votes = + answers.values.where((v) => v.contains(answer.id)).length; + final percentage = answersLength == 0 ? 0 : votes / answersLength; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + SizedBox( + height: 32, + child: Material( + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + color: Colors.transparent, + child: InkWell( + onTap: () { + final ownAnswers = + answers[event.room.client.userID] ?? {}; + if (ownAnswers.contains(answer.id)) { + ownAnswers.remove(answer.id); + } else { + ownAnswers.add(answer.id); + } + showFutureLoadingDialog( + context: context, + future: () => event.answerPoll(ownAnswers.toList()), + ); + }, + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + child: Stack( + children: [ + LinearProgressIndicator( + minHeight: 32, + backgroundColor: textColor.withAlpha(16), + color: textColor.withAlpha(64), + value: percentage.toDouble(), + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + ), + Center( + child: Text( + answer.mText, + style: TextStyle(color: textColor), + ), + ), + ], + ), + ), + ), + ), + if (answersLength > 0) + Text( + L10n.of(context).countVotes( + votes, + (percentage * 100).round(), + ), + style: theme.textTheme.labelSmall + ?.copyWith(color: textColor), + ), + ], + ); + }, + ), + ], + ); + } +} diff --git a/lib/pages/chat/poll_edit_bottom_sheet.dart b/lib/pages/chat/poll_edit_bottom_sheet.dart new file mode 100644 index 000000000..8ed039930 --- /dev/null +++ b/lib/pages/chat/poll_edit_bottom_sheet.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/msc_extensions/msc_3381_polls/models/poll_event_content.dart'; + +import 'package:fluffychat/config/app_config.dart'; + +class PollEditBottomSheet extends StatefulWidget { + final PollStartContent? oldPoll; + const PollEditBottomSheet({this.oldPoll, super.key}); + + @override + State createState() => _PollEditBottomSheetState(); +} + +class _PollEditBottomSheetState extends State { + final TextEditingController _questionController = TextEditingController(); + final List _answerController = [ + TextEditingController(), + TextEditingController(), + ]; + PollKind _kind = PollKind.disclosed; + int _maxSelection = 1; + + bool _canFinish = false; + + @override + void initState() { + final oldPoll = widget.oldPoll; + if (oldPoll != null) { + _questionController.text = oldPoll.question.mText; + _answerController.clear(); + _answerController.addAll( + oldPoll.answers.map( + (answer) => TextEditingController(text: answer.mText), + ), + ); + _kind = oldPoll.kind ?? _kind; + _maxSelection = oldPoll.maxSelections; + } + super.initState(); + } + + void _checkCanFinish([_]) { + final canFinish = _questionController.text.isNotEmpty && + _answerController.length >= 2 && + !_answerController.any((c) => c.text.isEmpty); + if (canFinish != _canFinish) { + setState(() { + _canFinish = canFinish; + }); + } + } + + void _deleteAnswer(int i) { + setState(() { + _answerController.removeAt(i); + if (_maxSelection > _answerController.length) _maxSelection--; + }); + _checkCanFinish(); + } + + void _addAnswer() { + setState(() { + _answerController.add(TextEditingController()); + }); + } + + void _updateMaxSelection(int? maxSelection) { + if (maxSelection == null) return; + setState(() { + _maxSelection = maxSelection; + }); + } + + void _updateKind(PollKind? kind) { + if (kind == null) return; + setState(() { + _kind = kind; + }); + } + + void _finish() { + _checkCanFinish(); + context.pop( + PollStartContent( + maxSelections: _maxSelection, + question: PollQuestion( + mText: _questionController.text, + ), + kind: _kind, + answers: _answerController + .map((c) => c.text) + .where((text) => text.isNotEmpty) + .mapIndexed( + (i, text) => PollAnswer( + mText: text, + id: '$i$text'.hashCode.toString(), + ), + ) + .toList(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: CloseButton( + onPressed: () => context.pop(null), + ), + title: Text(L10n.of(context).poll), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 12.0), + child: ElevatedButton( + onPressed: _canFinish ? _finish : null, + child: Text(L10n.of(context).send), + ), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextField( + controller: _questionController, + minLines: 1, + maxLines: 4, + maxLength: 1024, + onChanged: _checkCanFinish, + decoration: InputDecoration( + counterText: '', + labelText: L10n.of(context).question, + ), + ), + const SizedBox(height: 32), + for (var i = 0; i < _answerController.length; i++) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: TextField( + controller: _answerController[i], + maxLength: 128, + onChanged: _checkCanFinish, + decoration: InputDecoration( + labelText: L10n.of(context).answer, + counterText: '', + suffixIcon: i > 1 + ? IconButton( + icon: const Icon(Icons.delete_outlined), + tooltip: L10n.of(context).deleteAnswer, + onPressed: () => _deleteAnswer(i), + ) + : null, + ), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => _addAnswer(), + icon: const Icon(Icons.add_outlined), + label: Text(L10n.of(context).addAnswer), + ), + ), + const SizedBox(height: 32), + Wrap( + children: [ + DropdownButton( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), + underline: const SizedBox.shrink(), + value: _kind, + items: PollKind.values + .map( + (kind) => DropdownMenuItem( + value: kind, + child: Text(kind.getLocalizedString(context)), + ), + ) + .toList(), + onChanged: _updateKind, + ), + const SizedBox( + width: 16, + height: 16, + ), + DropdownButton( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), + underline: const SizedBox.shrink(), + value: _maxSelection, + items: [ + for (var i = 1; i <= _answerController.length; i++) + DropdownMenuItem( + value: i, + child: Text('Max selection: $i'), + ), + ], + onChanged: _updateMaxSelection, + ), + ], + ), + ], + ), + ); + } +} + +extension on PollKind { + String getLocalizedString(BuildContext context) { + switch (this) { + case PollKind.disclosed: + return L10n.of(context).resultsDisclosed; + case PollKind.undisclosed: + return L10n.of(context).resultsUndisclosed; + } + } +} diff --git a/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart b/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart index cd9d223ee..2910deb2b 100644 --- a/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart +++ b/lib/utils/matrix_sdk_extensions/filtered_timeline_extension.dart @@ -59,5 +59,6 @@ extension IsStateExtension on Event { EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted, + 'org.matrix.msc3381.poll.start', }.contains(type); } diff --git a/lib/utils/matrix_sdk_extensions/matrix_locals.dart b/lib/utils/matrix_sdk_extensions/matrix_locals.dart index 165130c0b..b4920ba75 100644 --- a/lib/utils/matrix_sdk_extensions/matrix_locals.dart +++ b/lib/utils/matrix_sdk_extensions/matrix_locals.dart @@ -350,4 +350,7 @@ class MatrixLocals extends MatrixLocalizations { @override String get cancelledSend => l10n.sendCanceled; + + @override + String startedAPoll(String senderName) => l10n.startedAPoll(senderName); } diff --git a/pubspec.lock b/pubspec.lock index 11096eaf5..895506bd1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1158,8 +1158,8 @@ packages: dependency: "direct main" description: path: "." - ref: HEAD - resolved-ref: "928f6ba96f259ab586ccfeb6c1674aa61ea6d16a" + ref: "krille/implement-polls-msc" + resolved-ref: "2d08d24c87c3baa02e6a73e58d790df29a8546fd" url: "https://github.com/famedly/matrix-dart-sdk.git" source: git version: "0.36.0" diff --git a/pubspec.yaml b/pubspec.yaml index 71f4d6d26..c4177f08c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,7 +63,9 @@ dependencies: latlong2: ^0.9.1 linkify: ^5.0.0 matrix: - git: https://github.com/famedly/matrix-dart-sdk.git + git: + url: https://github.com/famedly/matrix-dart-sdk.git + ref: krille/implement-polls-msc mime: ^1.0.6 native_imaging: ^0.1.1 opus_caf_converter_dart: ^1.0.1