feat: Implement polls
This commit is contained in:
parent
f3ea64086b
commit
71fa853f05
12 changed files with 582 additions and 63 deletions
|
|
@ -3411,5 +3411,34 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"donate": "Donate"
|
||||
"donate": "Donate",
|
||||
"startedAPoll": "{username} started a poll.",
|
||||
"@startedAPoll": {
|
||||
"type": "String",
|
||||
"placeholders": {
|
||||
"username": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"poll": "Poll",
|
||||
"startPoll": "Start poll",
|
||||
"endPoll": "End poll",
|
||||
"answersVisible": "Answers visible",
|
||||
"answersHidden": "Answers hidden",
|
||||
"pollQuestion": "Poll question",
|
||||
"answerOption": "Answer option",
|
||||
"addAnswerOption": "Add answer option",
|
||||
"allowMultipleAnswers": "Allow multiple answers",
|
||||
"pollHasBeenEnded": "Poll has been ended",
|
||||
"countVotes": "{count} votes",
|
||||
"@countVotes": {
|
||||
"type": "int",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"answersWillBeVisibleWhenPollHasEnded": "Answers will be visible when poll has ended"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ import 'package:fluffychat/config/themes.dart';
|
|||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pages/chat/chat_view.dart';
|
||||
import 'package:fluffychat/pages/chat/event_info_dialog.dart';
|
||||
import 'package:fluffychat/pages/chat/start_poll_bottom_sheet.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';
|
||||
|
|
@ -1138,26 +1140,34 @@ class ChatController extends State<ChatPageWithRoom>
|
|||
FocusScope.of(context).requestFocus(inputFocus);
|
||||
}
|
||||
|
||||
void onAddPopupMenuButtonSelected(String choice) {
|
||||
room.client.getConfig(); // Preload server file configuration.
|
||||
void onAddPopupMenuButtonSelected(AddPopupMenuActions choice) {
|
||||
room.client.getConfig();
|
||||
|
||||
if (choice == 'file') {
|
||||
sendFileAction();
|
||||
}
|
||||
if (choice == 'image') {
|
||||
sendFileAction(type: FileSelectorType.images);
|
||||
}
|
||||
if (choice == 'video') {
|
||||
sendFileAction(type: FileSelectorType.videos);
|
||||
}
|
||||
if (choice == 'camera') {
|
||||
openCameraAction();
|
||||
}
|
||||
if (choice == 'camera-video') {
|
||||
openVideoCameraAction();
|
||||
}
|
||||
if (choice == 'location') {
|
||||
sendLocationAction();
|
||||
switch (choice) {
|
||||
case AddPopupMenuActions.image:
|
||||
sendFileAction(type: FileSelectorType.images);
|
||||
return;
|
||||
case AddPopupMenuActions.video:
|
||||
sendFileAction(type: FileSelectorType.videos);
|
||||
return;
|
||||
case AddPopupMenuActions.file:
|
||||
sendFileAction();
|
||||
return;
|
||||
case AddPopupMenuActions.poll:
|
||||
showAdaptiveBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => StartPollBottomSheet(room: room),
|
||||
);
|
||||
return;
|
||||
case AddPopupMenuActions.photoCamera:
|
||||
openCameraAction();
|
||||
return;
|
||||
case AddPopupMenuActions.videoCamera:
|
||||
openVideoCameraAction();
|
||||
return;
|
||||
case AddPopupMenuActions.location:
|
||||
sendLocationAction();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1358,3 +1368,13 @@ class ChatController extends State<ChatPageWithRoom>
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum AddPopupMenuActions {
|
||||
image,
|
||||
video,
|
||||
file,
|
||||
poll,
|
||||
photoCamera,
|
||||
videoCamera,
|
||||
location,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,16 +132,15 @@ class ChatInputRow extends StatelessWidget {
|
|||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: PopupMenuButton<String>(
|
||||
child: PopupMenuButton<AddPopupMenuActions>(
|
||||
useRootNavigator: true,
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
iconColor: theme.colorScheme.onPrimaryContainer,
|
||||
onSelected: controller.onAddPopupMenuButtonSelected,
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<String>>[
|
||||
itemBuilder: (BuildContext context) => [
|
||||
if (PlatformInfos.isMobile)
|
||||
PopupMenuItem<String>(
|
||||
value: 'location',
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.location,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
|
|
@ -154,8 +153,22 @@ class ChatInputRow extends StatelessWidget {
|
|||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'image',
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.poll,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
foregroundColor:
|
||||
theme.colorScheme.primaryContainer,
|
||||
child: const Icon(Icons.poll_outlined),
|
||||
),
|
||||
title: Text(L10n.of(context).startPoll),
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.image,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
|
|
@ -168,8 +181,8 @@ class ChatInputRow extends StatelessWidget {
|
|||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'video',
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.video,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
|
|
@ -183,8 +196,8 @@ class ChatInputRow extends StatelessWidget {
|
|||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'file',
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.file,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
|
|
@ -217,8 +230,8 @@ class ChatInputRow extends StatelessWidget {
|
|||
onSelected: controller.onAddPopupMenuButtonSelected,
|
||||
iconColor: theme.colorScheme.onPrimaryContainer,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<String>(
|
||||
value: 'camera-video',
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.videoCamera,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
|
|
@ -231,8 +244,8 @@ class ChatInputRow extends StatelessWidget {
|
|||
contentPadding: const EdgeInsets.all(0),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'camera',
|
||||
PopupMenuItem(
|
||||
value: AddPopupMenuActions.photoCamera,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor:
|
||||
|
|
@ -394,7 +407,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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
171
lib/pages/chat/start_poll_bottom_sheet.dart
Normal file
171
lib/pages/chat/start_poll_bottom_sheet.dart
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
||||
|
||||
class StartPollBottomSheet extends StatefulWidget {
|
||||
final Room room;
|
||||
const StartPollBottomSheet({required this.room, super.key});
|
||||
|
||||
@override
|
||||
State<StartPollBottomSheet> createState() => _StartPollBottomSheetState();
|
||||
}
|
||||
|
||||
class _StartPollBottomSheetState extends State<StartPollBottomSheet> {
|
||||
final TextEditingController _bodyController = TextEditingController();
|
||||
bool _allowMultipleAnswers = false;
|
||||
final List<TextEditingController> _answers = [
|
||||
TextEditingController(),
|
||||
TextEditingController(),
|
||||
];
|
||||
PollKind _pollKind = PollKind.disclosed;
|
||||
|
||||
bool _canCreate = false;
|
||||
|
||||
bool isLoading = false;
|
||||
|
||||
String? _txid;
|
||||
|
||||
void _createPoll() async {
|
||||
try {
|
||||
var id = 0;
|
||||
_txid ??= widget.room.client.generateUniqueTransactionId();
|
||||
await widget.room.startPoll(
|
||||
question: _bodyController.text.trim(),
|
||||
answers: _answers
|
||||
.map(
|
||||
(answerController) => PollAnswer(
|
||||
id: (++id).toString(),
|
||||
mText: answerController.text.trim(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
kind: _pollKind,
|
||||
maxSelections: _allowMultipleAnswers ? _answers.length : 1,
|
||||
txid: _txid,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
} catch (e, s) {
|
||||
Logs().w('Unable to create poll', e, s);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toLocalizedString(context))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateCanCreate([_]) {
|
||||
final newCanCreate = _bodyController.text.trim().isNotEmpty &&
|
||||
!_answers.any((controller) => controller.text.trim().isEmpty);
|
||||
if (_canCreate != newCanCreate) {
|
||||
setState(() {
|
||||
_canCreate = newCanCreate;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const maxAnswers = 10;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: CloseButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
),
|
||||
title: Text(L10n.of(context).startPoll),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
children: [
|
||||
SegmentedButton<PollKind>(
|
||||
selected: {_pollKind},
|
||||
multiSelectionEnabled: false,
|
||||
onSelectionChanged: (pollKind) => setState(() {
|
||||
_pollKind = pollKind.first;
|
||||
}),
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: PollKind.disclosed,
|
||||
label: Text(
|
||||
L10n.of(context).answersVisible,
|
||||
),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: PollKind.undisclosed,
|
||||
label: Text(
|
||||
L10n.of(context).answersHidden,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _bodyController,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
maxLength: 512,
|
||||
onChanged: _updateCanCreate,
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.of(context).pollQuestion,
|
||||
counter: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
..._answers.map(
|
||||
(answerController) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: TextField(
|
||||
controller: answerController,
|
||||
onChanged: _updateCanCreate,
|
||||
maxLength: 64,
|
||||
decoration: InputDecoration(
|
||||
counter: const SizedBox.shrink(),
|
||||
hintText: L10n.of(context).answerOption,
|
||||
suffixIcon: _answers.length == 2
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.cancel_outlined),
|
||||
onPressed: () => setState(() {
|
||||
_answers.remove(answerController..dispose());
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.add_outlined),
|
||||
onPressed: _answers.length < maxAnswers
|
||||
? () => setState(() {
|
||||
_answers.add(TextEditingController());
|
||||
})
|
||||
: null,
|
||||
label: Text(L10n.of(context).addAnswerOption),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Switch.adaptive(
|
||||
value: _allowMultipleAnswers,
|
||||
onChanged: (allow) => setState(() {
|
||||
_allowMultipleAnswers = allow;
|
||||
}),
|
||||
),
|
||||
title: Text(L10n.of(context).allowMultipleAnswers),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: !isLoading && _canCreate ? _createPoll : null,
|
||||
child: isLoading
|
||||
? const LinearProgressIndicator()
|
||||
: Text(L10n.of(context).startPoll),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,8 +16,8 @@ extension IsStateExtension on Event {
|
|||
// always filter out edit and reaction relationships
|
||||
!{RelationshipTypes.edit, RelationshipTypes.reaction}
|
||||
.contains(relationshipType) &&
|
||||
// always filter out m.key.* events
|
||||
!type.startsWith('m.key.verification.') &&
|
||||
// always filter out m.key.* and other known but unimportant events
|
||||
!isKnownHiddenStates &&
|
||||
// event types to hide: redaction and reaction events
|
||||
// if a reaction has been redacted we also want it to be hidden in the timeline
|
||||
!{EventTypes.Reaction, EventTypes.Redaction}.contains(type) &&
|
||||
|
|
@ -39,4 +39,10 @@ extension IsStateExtension on Event {
|
|||
EventTypes.RoomCreate,
|
||||
EventTypes.RoomTombstone,
|
||||
}.contains(type);
|
||||
|
||||
bool get isKnownHiddenStates =>
|
||||
{
|
||||
PollEventContent.responseType,
|
||||
}.contains(type) ||
|
||||
type.startsWith('m.key.verification.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -363,4 +363,10 @@ class MatrixLocals extends MatrixLocalizations {
|
|||
|
||||
@override
|
||||
String get refreshingLastEvent => l10n.loadingPleaseWait;
|
||||
|
||||
@override
|
||||
String startedAPoll(String senderName) => '$senderName started a poll';
|
||||
|
||||
@override
|
||||
String get pollHasBeenEnded => l10n.pollHasBeenEnded;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ EXTERNAL SOURCES:
|
|||
SPEC CHECKSUMS:
|
||||
app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f
|
||||
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
|
||||
desktop_drop: 248706031734554504f939cab1ad4c5fbc9c9c72
|
||||
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
|
||||
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
|
||||
dynamic_color: cb7c2a300ee67ed3bd96c3e852df3af0300bf610
|
||||
emoji_picker_flutter: 51ca408e289d84d1e460016b2a28721ec754fcf7
|
||||
|
|
|
|||
|
|
@ -1114,8 +1114,8 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "krille/refactor-update-user-device-keys"
|
||||
resolved-ref: d35d78f3b7c38671888e87b9aa80d2a93c811b82
|
||||
ref: "krille/implement-polls-msc"
|
||||
resolved-ref: "83c5b82f2ada7b3c47cbf8b8baaf88b5755f2d86"
|
||||
url: "https://github.com/famedly/matrix-dart-sdk.git"
|
||||
source: git
|
||||
version: "3.0.2"
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ dependencies:
|
|||
matrix: #^3.0.1
|
||||
git:
|
||||
url: https://github.com/famedly/matrix-dart-sdk.git
|
||||
ref: krille/refactor-update-user-device-keys
|
||||
ref: krille/implement-polls-msc
|
||||
mime: ^2.0.0
|
||||
native_imaging: ^0.2.0
|
||||
opus_caf_converter_dart: ^1.0.1
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue