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/l10n/l10n.dart';
|
||||||
import 'package:fluffychat/pages/chat/chat_view.dart';
|
import 'package:fluffychat/pages/chat/chat_view.dart';
|
||||||
import 'package:fluffychat/pages/chat/event_info_dialog.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/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/error_reporter.dart';
|
||||||
import 'package:fluffychat/utils/file_selector.dart';
|
import 'package:fluffychat/utils/file_selector.dart';
|
||||||
import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.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);
|
FocusScope.of(context).requestFocus(inputFocus);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onAddPopupMenuButtonSelected(String choice) {
|
void onAddPopupMenuButtonSelected(AddPopupMenuActions choice) {
|
||||||
room.client.getConfig(); // Preload server file configuration.
|
room.client.getConfig();
|
||||||
|
|
||||||
if (choice == 'file') {
|
switch (choice) {
|
||||||
sendFileAction();
|
case AddPopupMenuActions.image:
|
||||||
}
|
sendFileAction(type: FileSelectorType.images);
|
||||||
if (choice == 'image') {
|
return;
|
||||||
sendFileAction(type: FileSelectorType.images);
|
case AddPopupMenuActions.video:
|
||||||
}
|
sendFileAction(type: FileSelectorType.videos);
|
||||||
if (choice == 'video') {
|
return;
|
||||||
sendFileAction(type: FileSelectorType.videos);
|
case AddPopupMenuActions.file:
|
||||||
}
|
sendFileAction();
|
||||||
if (choice == 'camera') {
|
return;
|
||||||
openCameraAction();
|
case AddPopupMenuActions.poll:
|
||||||
}
|
showAdaptiveBottomSheet(
|
||||||
if (choice == 'camera-video') {
|
context: context,
|
||||||
openVideoCameraAction();
|
builder: (context) => StartPollBottomSheet(room: room),
|
||||||
}
|
);
|
||||||
if (choice == 'location') {
|
return;
|
||||||
sendLocationAction();
|
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,
|
alignment: Alignment.center,
|
||||||
decoration: const BoxDecoration(),
|
decoration: const BoxDecoration(),
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: PopupMenuButton<String>(
|
child: PopupMenuButton<AddPopupMenuActions>(
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
icon: const Icon(Icons.add_circle_outline),
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
iconColor: theme.colorScheme.onPrimaryContainer,
|
iconColor: theme.colorScheme.onPrimaryContainer,
|
||||||
onSelected: controller.onAddPopupMenuButtonSelected,
|
onSelected: controller.onAddPopupMenuButtonSelected,
|
||||||
itemBuilder: (BuildContext context) =>
|
itemBuilder: (BuildContext context) => [
|
||||||
<PopupMenuEntry<String>>[
|
|
||||||
if (PlatformInfos.isMobile)
|
if (PlatformInfos.isMobile)
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem(
|
||||||
value: 'location',
|
value: AddPopupMenuActions.location,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
|
|
@ -154,8 +153,22 @@ class ChatInputRow extends StatelessWidget {
|
||||||
contentPadding: const EdgeInsets.all(0),
|
contentPadding: const EdgeInsets.all(0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem(
|
||||||
value: 'image',
|
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(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
|
|
@ -168,8 +181,8 @@ class ChatInputRow extends StatelessWidget {
|
||||||
contentPadding: const EdgeInsets.all(0),
|
contentPadding: const EdgeInsets.all(0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem(
|
||||||
value: 'video',
|
value: AddPopupMenuActions.video,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
|
|
@ -183,8 +196,8 @@ class ChatInputRow extends StatelessWidget {
|
||||||
contentPadding: const EdgeInsets.all(0),
|
contentPadding: const EdgeInsets.all(0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem(
|
||||||
value: 'file',
|
value: AddPopupMenuActions.file,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
|
|
@ -217,8 +230,8 @@ class ChatInputRow extends StatelessWidget {
|
||||||
onSelected: controller.onAddPopupMenuButtonSelected,
|
onSelected: controller.onAddPopupMenuButtonSelected,
|
||||||
iconColor: theme.colorScheme.onPrimaryContainer,
|
iconColor: theme.colorScheme.onPrimaryContainer,
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem(
|
||||||
value: 'camera-video',
|
value: AddPopupMenuActions.videoCamera,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
|
|
@ -231,8 +244,8 @@ class ChatInputRow extends StatelessWidget {
|
||||||
contentPadding: const EdgeInsets.all(0),
|
contentPadding: const EdgeInsets.all(0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem(
|
||||||
value: 'camera',
|
value: AddPopupMenuActions.photoCamera,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
|
|
@ -394,7 +407,7 @@ class _ChatAccountPicker extends StatelessWidget {
|
||||||
onSelected: (mxid) => _popupMenuButtonSelected(mxid, context),
|
onSelected: (mxid) => _popupMenuButtonSelected(mxid, context),
|
||||||
itemBuilder: (BuildContext context) => clients
|
itemBuilder: (BuildContext context) => clients
|
||||||
.map(
|
.map(
|
||||||
(client) => PopupMenuItem<String>(
|
(client) => PopupMenuItem(
|
||||||
value: client!.userID,
|
value: client!.userID,
|
||||||
child: FutureBuilder<Profile>(
|
child: FutureBuilder<Profile>(
|
||||||
future: client.fetchOwnProfile(),
|
future: client.fetchOwnProfile(),
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ class Message extends StatelessWidget {
|
||||||
EventTypes.Sticker,
|
EventTypes.Sticker,
|
||||||
EventTypes.Encrypted,
|
EventTypes.Encrypted,
|
||||||
EventTypes.CallInvite,
|
EventTypes.CallInvite,
|
||||||
|
PollEventContent.startType,
|
||||||
}.contains(event.type)) {
|
}.contains(event.type)) {
|
||||||
if (event.type.startsWith('m.call.')) {
|
if (event.type.startsWith('m.call.')) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
import 'package:fluffychat/config/setting_keys.dart';
|
import 'package:fluffychat/config/setting_keys.dart';
|
||||||
import 'package:fluffychat/l10n/l10n.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/pages/chat/events/video_player.dart';
|
||||||
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
|
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
|
||||||
import 'package:fluffychat/utils/date_time_extension.dart';
|
import 'package:fluffychat/utils/date_time_extension.dart';
|
||||||
|
|
@ -234,27 +235,11 @@ class MessageContent extends StatelessWidget {
|
||||||
textmessage:
|
textmessage:
|
||||||
default:
|
default:
|
||||||
if (event.redacted) {
|
if (event.redacted) {
|
||||||
return FutureBuilder<User?>(
|
return RedactionWidget(
|
||||||
future: event.redactedBecause?.fetchSenderUser(),
|
event: event,
|
||||||
builder: (context, snapshot) {
|
buttonTextColor: buttonTextColor,
|
||||||
final reason =
|
onInfoTab: onInfoTab,
|
||||||
event.redactedBecause?.content.tryGet<String>('reason');
|
fontSize: fontSize,
|
||||||
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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
var html = AppSettings.renderHtml.value && event.isRichMessage
|
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:
|
case EventTypes.CallInvite:
|
||||||
return FutureBuilder<User?>(
|
return FutureBuilder<User?>(
|
||||||
future: event.fetchSenderUser(),
|
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 {
|
class _ButtonContent extends StatelessWidget {
|
||||||
final void Function() onPressed;
|
final void Function() onPressed;
|
||||||
final String label;
|
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
|
// always filter out edit and reaction relationships
|
||||||
!{RelationshipTypes.edit, RelationshipTypes.reaction}
|
!{RelationshipTypes.edit, RelationshipTypes.reaction}
|
||||||
.contains(relationshipType) &&
|
.contains(relationshipType) &&
|
||||||
// always filter out m.key.* events
|
// always filter out m.key.* and other known but unimportant events
|
||||||
!type.startsWith('m.key.verification.') &&
|
!isKnownHiddenStates &&
|
||||||
// event types to hide: redaction and reaction events
|
// event types to hide: redaction and reaction events
|
||||||
// if a reaction has been redacted we also want it to be hidden in the timeline
|
// if a reaction has been redacted we also want it to be hidden in the timeline
|
||||||
!{EventTypes.Reaction, EventTypes.Redaction}.contains(type) &&
|
!{EventTypes.Reaction, EventTypes.Redaction}.contains(type) &&
|
||||||
|
|
@ -39,4 +39,10 @@ extension IsStateExtension on Event {
|
||||||
EventTypes.RoomCreate,
|
EventTypes.RoomCreate,
|
||||||
EventTypes.RoomTombstone,
|
EventTypes.RoomTombstone,
|
||||||
}.contains(type);
|
}.contains(type);
|
||||||
|
|
||||||
|
bool get isKnownHiddenStates =>
|
||||||
|
{
|
||||||
|
PollEventContent.responseType,
|
||||||
|
}.contains(type) ||
|
||||||
|
type.startsWith('m.key.verification.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -363,4 +363,10 @@ class MatrixLocals extends MatrixLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get refreshingLastEvent => l10n.loadingPleaseWait;
|
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:
|
SPEC CHECKSUMS:
|
||||||
app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f
|
app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f
|
||||||
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
|
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
|
||||||
desktop_drop: 248706031734554504f939cab1ad4c5fbc9c9c72
|
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
|
||||||
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
|
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
|
||||||
dynamic_color: cb7c2a300ee67ed3bd96c3e852df3af0300bf610
|
dynamic_color: cb7c2a300ee67ed3bd96c3e852df3af0300bf610
|
||||||
emoji_picker_flutter: 51ca408e289d84d1e460016b2a28721ec754fcf7
|
emoji_picker_flutter: 51ca408e289d84d1e460016b2a28721ec754fcf7
|
||||||
|
|
|
||||||
|
|
@ -1114,8 +1114,8 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: "krille/refactor-update-user-device-keys"
|
ref: "krille/implement-polls-msc"
|
||||||
resolved-ref: d35d78f3b7c38671888e87b9aa80d2a93c811b82
|
resolved-ref: "83c5b82f2ada7b3c47cbf8b8baaf88b5755f2d86"
|
||||||
url: "https://github.com/famedly/matrix-dart-sdk.git"
|
url: "https://github.com/famedly/matrix-dart-sdk.git"
|
||||||
source: git
|
source: git
|
||||||
version: "3.0.2"
|
version: "3.0.2"
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ dependencies:
|
||||||
matrix: #^3.0.1
|
matrix: #^3.0.1
|
||||||
git:
|
git:
|
||||||
url: https://github.com/famedly/matrix-dart-sdk.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
|
mime: ^2.0.0
|
||||||
native_imaging: ^0.2.0
|
native_imaging: ^0.2.0
|
||||||
opus_caf_converter_dart: ^1.0.1
|
opus_caf_converter_dart: ^1.0.1
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue