implemented full interactive poll support in Talk, including creation, voting, and closing functionality

This commit is contained in:
2026-07-12 17:54:30 +02:00
parent 3f44e9302f
commit 44e45c9b78
15 changed files with 946 additions and 87 deletions
+6 -1
View File
@@ -392,11 +392,16 @@ class _ChatViewState extends State<ChatView> with RouteAware {
ColoredBox(
color: Theme.of(context).colorScheme.surface,
child: TalkNavigator.isSecondaryVisible(context)
? ChatTextfield(widget.room.token, selfId: widget.selfId)
? ChatTextfield(
widget.room.token,
selfId: widget.selfId,
roomType: widget.room.type,
)
: SafeArea(
child: ChatTextfield(
widget.room.token,
selfId: widget.selfId,
roomType: widget.room.type,
),
),
),
+3 -3
View File
@@ -168,12 +168,12 @@ class _ChatBubbleState extends State<ChatBubble>
void _onTap() {
final obj = message.originalData?['object'];
if (obj?.type == RichObjectStringObjectType.talkPoll) {
showChatBubblePollDialog(
showChatBubblePollSheet(
context,
chatToken: widget.chatData.token,
messageToken: widget.bubbleData.token,
room: widget.chatData,
pollId: int.parse(obj!.id),
pollName: obj.name,
refetch: widget.refetch,
);
return;
}
@@ -1,44 +1,112 @@
import 'package:flutter/material.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../api/marianumcloud/talk/get_poll/get_poll_state.dart';
import '../../../../api/marianumcloud/talk/get_poll/get_poll_state_response.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/loading_spinner.dart';
import 'poll_options_list.dart';
/// Opens the poll dialog that lets a user vote on a Talk poll attached to
/// a message. Loads the poll state lazily and renders the option list.
void showChatBubblePollDialog(
/// Opens the poll bottom sheet for a Talk poll attached to a message. Loads the
/// poll state lazily; voting and closing reload the state in place and refresh
/// the chat so the surrounding message list stays in sync.
void showChatBubblePollSheet(
BuildContext context, {
required String chatToken,
required String messageToken,
required GetRoomResponseObject room,
required int pollId,
required String pollName,
required void Function({bool renew}) refetch,
}) {
final pollState = GetPollState(token: messageToken, pollId: pollId).run();
showDialog<void>(
context: context,
builder: (dialogCtx) => AlertDialog(
showDetailsBottomSheet(
context,
header: ListTile(
leading: const Icon(Icons.poll_outlined),
title: Text(pollName, overflow: TextOverflow.ellipsis),
content: FutureBuilder(
future: pollState,
builder: (_, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Column(
mainAxisSize: MainAxisSize.min,
children: [LoadingSpinner()],
);
}
final pollData = snapshot.data!.data;
return SingleChildScrollView(
child: PollOptionsList(pollData: pollData, chatToken: chatToken),
);
},
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Zurück'),
),
],
subtitle: const Text('Umfrage'),
),
children: (sheetCtx) => [
const SizedBox(height: 8),
_PollSheetBody(room: room, pollId: pollId, refetch: refetch),
const SizedBox(height: 16),
],
);
}
class _PollSheetBody extends StatefulWidget {
final GetRoomResponseObject room;
final int pollId;
final void Function({bool renew}) refetch;
const _PollSheetBody({
required this.room,
required this.pollId,
required this.refetch,
});
@override
State<_PollSheetBody> createState() => _PollSheetBodyState();
}
class _PollSheetBodyState extends State<_PollSheetBody> {
Future<GetPollStateResponse>? _future;
@override
void initState() {
super.initState();
_future = GetPollState(token: widget.room.token, pollId: widget.pollId).run();
}
void _reload() {
setState(() {
_future = GetPollState(
token: widget.room.token,
pollId: widget.pollId,
).run();
});
}
void _applyState(GetPollStateResponse state) {
setState(() {
// Block body: an arrow would return the Future, which setState rejects.
_future = Future.value(state);
});
widget.refetch(renew: true);
}
@override
Widget build(BuildContext context) => FutureBuilder<GetPollStateResponse>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(24),
child: LoadingSpinner(),
);
}
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text(
errorToUserMessage(snapshot.error),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _reload,
child: const Text('Erneut versuchen'),
),
],
),
);
}
return PollOptionsList(
pollData: snapshot.data!.data,
room: widget.room,
onStateChanged: _applyState,
);
},
);
}
@@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/send_message/send_message.dart';
import '../../../../api/marianumcloud/talk/send_message/send_message_params.dart';
import '../../../../api/marianumcloud/talk/share_files_to_chat.dart';
@@ -20,12 +21,19 @@ import '../../../../widget/file_pick.dart';
import '../../../../widget/focus_behaviour.dart';
import '../../files/files_upload_dialog.dart';
import 'answer_reference.dart';
import 'poll_create_sheet.dart';
class ChatTextfield extends StatefulWidget {
final String sendToToken;
final String? selfId;
final GetRoomResponseObjectConversationType? roomType;
const ChatTextfield(this.sendToToken, {this.selfId, super.key});
const ChatTextfield(
this.sendToToken, {
this.selfId,
this.roomType,
super.key,
});
@override
State<ChatTextfield> createState() => _ChatTextfieldState();
@@ -160,10 +168,31 @@ class _ChatTextfieldState extends State<ChatTextfield> {
Navigator.of(sheetCtx).pop();
},
),
if (_pollsAllowed)
ListTile(
leading: const Icon(Icons.poll_outlined),
title: const Text('Umfrage erstellen'),
onTap: () {
Navigator.of(sheetCtx).pop();
showPollCreateSheet(
context,
token: widget.sendToToken,
onCreated: () {
if (mounted) context.read<ChatBloc>().refresh();
},
);
},
),
],
);
}
/// Polls can only be created in group/public conversations — the server
/// rejects them in one-to-one chats.
bool get _pollsAllowed =>
widget.roomType == GetRoomResponseObjectConversationType.group ||
widget.roomType == GetRoomResponseObjectConversationType.public;
Future<void> _pickEmoji() async {
final emoji = await showEmojiPicker(context);
if (emoji == null || !mounted) return;
@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import '../../../../api/marianumcloud/talk/create_poll/create_poll.dart';
import '../../../../api/marianumcloud/talk/create_poll/create_poll_params.dart';
import '../../../../api/marianumcloud/talk/get_poll/get_poll_state_response.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/details_bottom_sheet.dart';
/// Opens the poll creation form. On success the server posts the poll message
/// into the chat itself, so the caller only needs to refresh via [onCreated].
void showPollCreateSheet(
BuildContext context, {
required String token,
required VoidCallback onCreated,
}) {
showDetailsBottomSheet(
context,
header: const ListTile(
leading: Icon(Icons.poll_outlined),
title: Text('Umfrage erstellen'),
),
children: (sheetCtx) => [_PollCreateBody(token: token, onCreated: onCreated)],
);
}
class _PollCreateBody extends StatefulWidget {
final String token;
final VoidCallback onCreated;
const _PollCreateBody({required this.token, required this.onCreated});
@override
State<_PollCreateBody> createState() => _PollCreateBodyState();
}
class _PollCreateBodyState extends State<_PollCreateBody> {
final TextEditingController _question = TextEditingController();
final List<TextEditingController> _options = [
TextEditingController(),
TextEditingController(),
];
final AsyncActionController _submitController = AsyncActionController();
bool _hideResults = false;
bool _multipleChoice = false;
String? _validationError;
@override
void dispose() {
_question.dispose();
for (final controller in _options) {
controller.dispose();
}
_submitController.dispose();
super.dispose();
}
void _addOption() {
setState(() => _options.add(TextEditingController()));
}
void _removeOption(int index) {
setState(() {
_options.removeAt(index).dispose();
});
}
Future<void> _submit() async {
if (guardDemoAction(context)) return;
final question = _question.text.trim();
final options = _options
.map((c) => c.text.trim())
.where((text) => text.isNotEmpty)
.toList();
if (question.isEmpty) {
setState(() => _validationError = 'Bitte gib eine Frage ein.');
return;
}
if (options.length < 2) {
setState(
() => _validationError = 'Bitte gib mindestens zwei Optionen an.',
);
return;
}
setState(() => _validationError = null);
await CreatePoll(
token: widget.token,
params: CreatePollParams(
question: question,
options: options,
resultMode: _hideResults
? pollResultModeHidden
: pollResultModePublic,
maxVotes: _multipleChoice ? 0 : 1,
),
).run();
if (!mounted) return;
Navigator.of(context).pop();
widget.onCreated();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: _question,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Frage',
hintText: 'Worüber soll abgestimmt werden?',
),
),
),
...List.generate(_options.length, (index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
Expanded(
child: TextField(
controller: _options[index],
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: 'Option ${index + 1}',
),
),
),
IconButton(
tooltip: 'Option entfernen',
icon: const Icon(Icons.remove_circle_outline),
onPressed: _options.length > 2
? () => _removeOption(index)
: null,
),
],
),
);
}),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _addOption,
icon: const Icon(Icons.add),
label: const Text('Option hinzufügen'),
),
),
SwitchListTile(
value: _hideResults,
onChanged: (value) => setState(() => _hideResults = value),
title: const Text('Ergebnisse bis zum Schließen verbergen'),
),
SwitchListTile(
value: _multipleChoice,
onChanged: (value) => setState(() => _multipleChoice = value),
title: const Text('Mehrfachauswahl erlauben'),
),
if (_validationError != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
_validationError!,
style: TextStyle(color: theme.colorScheme.error),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: AsyncActionButton(
controller: _submitController,
onPressed: _submit,
child: const Text('Umfrage erstellen'),
),
),
],
);
}
}
@@ -1,16 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import '../../../../api/marianumcloud/talk/close_poll/close_poll.dart';
import '../../../../api/marianumcloud/talk/get_poll/get_poll_state_response.dart';
import '../../../../utils/url_opener.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/vote_poll/vote_poll.dart';
import '../../../../api/marianumcloud/talk/vote_poll/vote_poll_params.dart';
import '../../../../model/account_data.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/demo_restricted.dart';
/// Renders a Talk poll with interactive voting. Every displayed state comes
/// from a server response handed back via [onStateChanged]; no optimistic UI.
class PollOptionsList extends StatefulWidget {
final GetPollStateResponseObject pollData;
final String chatToken;
final GetRoomResponseObject room;
final void Function(GetPollStateResponse newState) onStateChanged;
const PollOptionsList({
super.key,
required this.pollData,
required this.chatToken,
required this.room,
required this.onStateChanged,
});
@override
@@ -18,56 +29,265 @@ class PollOptionsList extends StatefulWidget {
}
class _PollOptionsListState extends State<PollOptionsList> {
@override
Widget build(BuildContext context) => Column(
children: [
...widget.pollData.options.map<Widget>((option) {
var optionId = widget.pollData.options.indexOf(option);
var votedSelf = widget.pollData.votedSelf.contains(optionId);
var portionsVisible = widget.pollData.votes is Map<String, dynamic>;
var votes = portionsVisible
? (widget.pollData.votes['option-$optionId'] as num?) ?? 0
: 0;
var numVoters = widget.pollData.numVoters ?? 0;
final portion = numVoters == 0 ? 0.0 : (votes / numVoters);
late Set<int> _selected;
final AsyncActionController _voteController = AsyncActionController();
return ListTile(
isThreeLine: portionsVisible,
dense: true,
title: Text(option, style: Theme.of(context).textTheme.bodyLarge),
leading: Icon(
votedSelf ? Icons.check_circle_outlined : Icons.circle_outlined,
color: votedSelf
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.6)
: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
subtitle: portionsVisible
? Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: portion.clamp(0.0, 1.0),
bool get _isSingleChoice => widget.pollData.maxVotes == 1;
bool get _isInteractive =>
!widget.pollData.isClosed && widget.room.readOnly != 1;
@override
void initState() {
super.initState();
_selected = widget.pollData.votedSelf.toSet();
}
@override
void dispose() {
_voteController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant PollOptionsList oldWidget) {
super.didUpdateWidget(oldWidget);
// Resync the selection when the server state changes so it can't drift.
if (oldWidget.pollData != widget.pollData) {
_selected = widget.pollData.votedSelf.toSet();
}
}
void _toggle(int optionId) {
setState(() {
if (_isSingleChoice) {
_selected = {optionId};
return;
}
if (_selected.contains(optionId)) {
_selected.remove(optionId);
} else {
final max = widget.pollData.maxVotes;
if (max > 0 && _selected.length >= max) return;
_selected.add(optionId);
}
});
}
bool get _selectionChanged {
final voted = widget.pollData.votedSelf.toSet();
return !(voted.length == _selected.length && voted.containsAll(_selected));
}
Future<void> _submitVote() async {
if (guardDemoAction(context)) return;
final result = await VotePoll(
token: widget.room.token,
pollId: widget.pollData.id,
params: VotePollParams(optionIds: _selected.toList()..sort()),
).run();
widget.onStateChanged(result);
}
Future<void> _retractVote() async {
if (guardDemoAction(context)) return;
final result = await VotePoll(
token: widget.room.token,
pollId: widget.pollData.id,
params: VotePollParams(optionIds: const []),
).run();
widget.onStateChanged(result);
}
void _confirmClose() {
if (guardDemoAction(context)) return;
ConfirmDialog(
title: 'Umfrage schließen?',
content:
'Danach kann niemand mehr abstimmen. '
'Die Ergebnisse werden für alle sichtbar.',
confirmButton: 'Schließen',
onConfirmAsync: () async {
final result = await ClosePoll(
token: widget.room.token,
pollId: widget.pollData.id,
).run();
widget.onStateChanged(result);
},
).asDialog(context);
}
@override
Widget build(BuildContext context) {
final poll = widget.pollData;
final theme = Theme.of(context);
final counts = poll.voteCounts;
final resultsVisible = poll.resultsVisible;
final numVoters = poll.numVoters ?? 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
...List.generate(poll.options.length, (optionId) {
final option = poll.options[optionId];
final selected = _selected.contains(optionId);
final votes = counts['option-$optionId'] ?? 0;
final portion = numVoters == 0 ? 0.0 : (votes / numVoters);
return ListTile(
isThreeLine: resultsVisible,
dense: true,
onTap: _isInteractive ? () => _toggle(optionId) : null,
title: Text(option, style: theme.textTheme.bodyLarge),
leading: _isInteractive
? Icon(
_leadingIcon(selected),
color: selected
? theme.colorScheme.primary.withValues(alpha: 0.8)
: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.6,
),
)
: null,
subtitle: resultsVisible
? Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: portion.clamp(0.0, 1.0),
),
),
),
Container(
margin: const EdgeInsets.only(left: 10),
child: Text('${(portion * 100).round()}%'),
),
],
)
: null,
);
}),
ListTile(
title: Linkify(
text:
'Wenn du abstimmen möchtest, verwende die Webversion unter https://cloud.marianum-fulda.de/call/${widget.chatToken}',
onOpen: UrlOpener.onOpen,
style: Theme.of(context).textTheme.bodySmall,
Container(
margin: const EdgeInsets.only(left: 10),
child: Text(
'${votes.toInt()} · ${(portion * 100).round()}%',
),
),
],
)
: null,
);
}),
if (!resultsVisible) _hiddenResultsHint(theme),
if (poll.maxVotes > 1 && _isInteractive)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
'Mehrfachauswahl: bis zu ${poll.maxVotes} Optionen',
style: theme.textTheme.bodySmall,
),
),
const Divider(height: 16),
_metaLine(theme),
_actionBar(poll, theme),
],
);
}
Widget _actionBar(GetPollStateResponseObject poll, ThemeData theme) {
final canClose = poll.canClose(
selfId: AccountData().getUsername(),
participantType: widget.room.participantType,
);
if (!_isInteractive && !canClose) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _voteController,
builder: (context, _) {
final err = _voteController.error;
if (err == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 4),
child: Text(
err,
textAlign: TextAlign.end,
style: TextStyle(color: theme.colorScheme.error, fontSize: 13),
),
);
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Row(
children: [
if (canClose)
TextButton.icon(
onPressed: _confirmClose,
icon: const Icon(Icons.lock_outline, size: 18),
label: const Text('Umfrage schließen'),
),
const Spacer(),
if (_isInteractive) _primaryButton(),
],
),
),
],
);
}
Widget _primaryButton() {
final voted = widget.pollData.votedSelf.isNotEmpty;
// Retract stays a flat AsyncTextButton its spinner uses colorScheme.primary
// and stays visible on the sheet background, unlike the filled variant's.
if (voted && !(_selected.isNotEmpty && _selectionChanged)) {
return AsyncTextButton(
controller: _voteController,
onPressed: _retractVote,
showInlineError: false,
child: const Text('Zurückziehen'),
);
}
final canSubmit = _selected.isNotEmpty && _selectionChanged;
return AsyncActionButton(
controller: _voteController,
onPressed: canSubmit ? _submitVote : null,
showInlineError: false,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
elevation: 0,
),
child: Text(voted ? 'Stimme ändern' : 'Abstimmen'),
);
}
IconData _leadingIcon(bool selected) {
if (_isSingleChoice) {
return selected
? Icons.radio_button_checked
: Icons.radio_button_unchecked;
}
return selected ? Icons.check_box : Icons.check_box_outline_blank;
}
Widget _hiddenResultsHint(ThemeData theme) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Die Ergebnisse werden erst nach dem Schließen der Umfrage angezeigt.',
style: theme.textTheme.bodySmall,
),
);
Widget _metaLine(ThemeData theme) {
final poll = widget.pollData;
final parts = <String>[
'Erstellt von ${poll.actorDisplayName}',
if (poll.isClosed) 'Abgeschlossen' else 'Offen',
if (poll.numVoters != null) '${poll.numVoters} Teilnehmer',
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
parts.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
);
}
}