feat: Implement polls

krille/polls
krille-chan 4 weeks ago
parent 8cb06d602b
commit 0c7bc747d2
No known key found for this signature in database

@ -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": {}
}
}
}

@ -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<ChatPageWithRoom>
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<PollStartContent>(
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,

@ -102,13 +102,12 @@ class ChatInputRow extends StatelessWidget {
alignment: Alignment.center,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: PopupMenuButton<String>(
child: PopupMenuButton<AttachmentButtonAction>(
icon: const Icon(Icons.add_outlined),
onSelected: controller.onAddPopupMenuButtonSelected,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
PopupMenuItem<String>(
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<String>(
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<String>(
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<String>(
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<String>(
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<String>(
(client) => PopupMenuItem(
value: client!.userID,
child: FutureBuilder<Profile>(
future: client.fetchOwnProfile(),
@ -338,3 +349,5 @@ class _ChatAccountPicker extends StatelessWidget {
);
}
}
enum AttachmentButtonAction { file, image, camera, video, location, poll }

@ -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();

@ -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<User?>(
future: event.fetchSenderUser(),

@ -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),
),
],
);
},
),
],
);
}
}

@ -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<PollEditBottomSheet> createState() => _PollEditBottomSheetState();
}
class _PollEditBottomSheetState extends State<PollEditBottomSheet> {
final TextEditingController _questionController = TextEditingController();
final List<TextEditingController> _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<PollKind>(
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<int>(
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;
}
}
}

@ -59,5 +59,6 @@ extension IsStateExtension on Event {
EventTypes.Message,
EventTypes.Sticker,
EventTypes.Encrypted,
'org.matrix.msc3381.poll.start',
}.contains(type);
}

@ -350,4 +350,7 @@ class MatrixLocals extends MatrixLocalizations {
@override
String get cancelledSend => l10n.sendCanceled;
@override
String startedAPoll(String senderName) => l10n.startedAPoll(senderName);
}

@ -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"

@ -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

Loading…
Cancel
Save