From 44e45c9b7894f30b2e37c13415d0355fc51a96ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 12 Jul 2026 17:54:30 +0200 Subject: [PATCH] implemented full interactive poll support in Talk, including creation, voting, and closing functionality --- .../talk/close_poll/close_poll.dart | 28 ++ .../talk/create_poll/create_poll.dart | 31 ++ .../talk/create_poll/create_poll_params.dart | 27 ++ .../create_poll/create_poll_params.g.dart | 25 ++ .../get_poll/get_poll_state_response.dart | 40 +++ .../talk/vote_poll/vote_poll.dart | 41 +++ .../talk/vote_poll/vote_poll_params.dart | 16 + .../talk/vote_poll/vote_poll_params.g.dart | 17 + lib/view/pages/talk/chat_view.dart | 7 +- lib/view/pages/talk/widgets/chat_bubble.dart | 6 +- .../pages/talk/widgets/chat_bubble_poll.dart | 128 +++++-- .../pages/talk/widgets/chat_textfield.dart | 31 +- .../pages/talk/widgets/poll_create_sheet.dart | 185 ++++++++++ .../pages/talk/widgets/poll_options_list.dart | 324 +++++++++++++++--- test/api/talk/poll_state_test.dart | 127 +++++++ 15 files changed, 946 insertions(+), 87 deletions(-) create mode 100644 lib/api/marianumcloud/talk/close_poll/close_poll.dart create mode 100644 lib/api/marianumcloud/talk/create_poll/create_poll.dart create mode 100644 lib/api/marianumcloud/talk/create_poll/create_poll_params.dart create mode 100644 lib/api/marianumcloud/talk/create_poll/create_poll_params.g.dart create mode 100644 lib/api/marianumcloud/talk/vote_poll/vote_poll.dart create mode 100644 lib/api/marianumcloud/talk/vote_poll/vote_poll_params.dart create mode 100644 lib/api/marianumcloud/talk/vote_poll/vote_poll_params.g.dart create mode 100644 lib/view/pages/talk/widgets/poll_create_sheet.dart create mode 100644 test/api/talk/poll_state_test.dart diff --git a/lib/api/marianumcloud/talk/close_poll/close_poll.dart b/lib/api/marianumcloud/talk/close_poll/close_poll.dart new file mode 100644 index 0000000..5b9c68f --- /dev/null +++ b/lib/api/marianumcloud/talk/close_poll/close_poll.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../../api_params.dart'; +import '../get_poll/get_poll_state_response.dart'; +import '../talk_api.dart'; + +/// Schließt eine Umfrage endgültig — nur Ersteller oder Moderatoren. +class ClosePoll extends TalkApi { + ClosePoll({required String token, required int pollId}) + : super('v1/poll/$token/$pollId', null); + + @override + GetPollStateResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetPollStateResponse.fromJson( + decoded['ocs'] as Map, + ); + } + + @override + Future request( + Uri uri, + ApiParams? body, + Map? headers, + ) => http.delete(uri, headers: headers); +} diff --git a/lib/api/marianumcloud/talk/create_poll/create_poll.dart b/lib/api/marianumcloud/talk/create_poll/create_poll.dart new file mode 100644 index 0000000..3369fc0 --- /dev/null +++ b/lib/api/marianumcloud/talk/create_poll/create_poll.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'create_poll_params.dart'; + +/// Erstellt eine Umfrage; der Server postet die Poll-Nachricht selbst in den +/// Chat, danach genügt ein Chat-Refresh. Nur in Gruppen-Chats erlaubt. +class CreatePoll extends TalkApi { + CreatePoll({required String token, required CreatePollParams params}) + : super( + 'v1/poll/$token', + params, + headers: {'Content-Type': 'application/json'}, + ); + + @override + Null assemble(String raw) => null; + + @override + Future? request( + Uri uri, + ApiParams? body, + Map? headers, + ) { + if (body is! CreatePollParams) return null; + return http.post(uri, headers: headers, body: jsonEncode(body.toJson())); + } +} diff --git a/lib/api/marianumcloud/talk/create_poll/create_poll_params.dart b/lib/api/marianumcloud/talk/create_poll/create_poll_params.dart new file mode 100644 index 0000000..96130ce --- /dev/null +++ b/lib/api/marianumcloud/talk/create_poll/create_poll_params.dart @@ -0,0 +1,27 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_params.dart'; + +part 'create_poll_params.g.dart'; + +@JsonSerializable() +class CreatePollParams extends ApiParams { + String question; + List options; + + /// 0 = Ergebnisse öffentlich, 1 = bis zum Schließen verborgen. + int resultMode; + + /// Stimmen pro Teilnehmer; 0 = unbegrenzt. + int maxVotes; + + CreatePollParams({ + required this.question, + required this.options, + required this.resultMode, + required this.maxVotes, + }); + factory CreatePollParams.fromJson(Map json) => + _$CreatePollParamsFromJson(json); + Map toJson() => _$CreatePollParamsToJson(this); +} diff --git a/lib/api/marianumcloud/talk/create_poll/create_poll_params.g.dart b/lib/api/marianumcloud/talk/create_poll/create_poll_params.g.dart new file mode 100644 index 0000000..46dc517 --- /dev/null +++ b/lib/api/marianumcloud/talk/create_poll/create_poll_params.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'create_poll_params.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CreatePollParams _$CreatePollParamsFromJson(Map json) => + CreatePollParams( + question: json['question'] as String, + options: (json['options'] as List) + .map((e) => e as String) + .toList(), + resultMode: (json['resultMode'] as num).toInt(), + maxVotes: (json['maxVotes'] as num).toInt(), + ); + +Map _$CreatePollParamsToJson(CreatePollParams instance) => + { + 'question': instance.question, + 'options': instance.options, + 'resultMode': instance.resultMode, + 'maxVotes': instance.maxVotes, + }; diff --git a/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart index 928dba0..4cea7c3 100644 --- a/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart @@ -4,6 +4,18 @@ import '../../../api_response.dart'; part 'get_poll_state_response.g.dart'; +/// Poll-`status`-Werte der Talk-API. +const int pollStatusOpen = 0; +const int pollStatusClosed = 1; + +/// Poll-`resultMode`-Werte der Talk-API. +const int pollResultModePublic = 0; +const int pollResultModeHidden = 1; + +/// `participantType`-Werte (aus dem Room), die eine Umfrage schließen dürfen: +/// Owner (1), Moderator (2) und Gast-Moderator (6). +const Set pollModeratorParticipantTypes = {1, 2, 6}; + @JsonSerializable(explicitToJson: true) class GetPollStateResponse extends ApiResponse { GetPollStateResponseObject data; @@ -50,4 +62,32 @@ class GetPollStateResponseObject { factory GetPollStateResponseObject.fromJson(Map json) => _$GetPollStateResponseObjectFromJson(json); Map toJson() => _$GetPollStateResponseObjectToJson(this); + + bool get isClosed => status == pollStatusClosed; + + bool get resultsHidden => resultMode == pollResultModeHidden; + + /// Ergebnisse sichtbar: öffentliche Umfragen jederzeit, verborgene erst nach + /// dem Schließen. Der Typ von `votes` taugt nicht als Signal (siehe unten). + bool get resultsVisible => resultMode == pollResultModePublic || isClosed; + + /// Normalisiert das dynamische `votes`-Feld zu einer Map: der Server liefert + /// bei verborgenen Ergebnissen (und ohne Stimmen) eine leere Liste statt Map. + Map get voteCounts { + final raw = votes; + if (raw is! Map) return const {}; + final result = {}; + raw.forEach((key, value) { + if (key is String && value is num) result[key] = value; + }); + return result; + } + + /// Darf der Nutzer die (offene) Umfrage schließen: als Ersteller oder Moderator. + bool canClose({required String selfId, required int participantType}) { + if (isClosed) return false; + final isCreator = actorType == 'users' && actorId == selfId; + final isModerator = pollModeratorParticipantTypes.contains(participantType); + return isCreator || isModerator; + } } diff --git a/lib/api/marianumcloud/talk/vote_poll/vote_poll.dart b/lib/api/marianumcloud/talk/vote_poll/vote_poll.dart new file mode 100644 index 0000000..829b3ea --- /dev/null +++ b/lib/api/marianumcloud/talk/vote_poll/vote_poll.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../../../api_params.dart'; +import '../get_poll/get_poll_state_response.dart'; +import '../talk_api.dart'; +import 'vote_poll_params.dart'; + +class VotePoll extends TalkApi { + // Body als echtes JSON (nicht form-encoded wie die anderen Endpunkte): nur + // so kommt das int-Array an; sonst liest der Server optionIds als [] und + // löscht die eigene Stimme (Ursache des Readonly-Fallbacks, Issue #42). + VotePoll({ + required String token, + required int pollId, + required VotePollParams params, + }) : super( + 'v1/poll/$token/$pollId', + params, + headers: {'Content-Type': 'application/json'}, + ); + + @override + GetPollStateResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetPollStateResponse.fromJson( + decoded['ocs'] as Map, + ); + } + + @override + Future? request( + Uri uri, + ApiParams? body, + Map? headers, + ) { + if (body is! VotePollParams) return null; + return http.post(uri, headers: headers, body: jsonEncode(body.toJson())); + } +} diff --git a/lib/api/marianumcloud/talk/vote_poll/vote_poll_params.dart b/lib/api/marianumcloud/talk/vote_poll/vote_poll_params.dart new file mode 100644 index 0000000..aac0db3 --- /dev/null +++ b/lib/api/marianumcloud/talk/vote_poll/vote_poll_params.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_params.dart'; + +part 'vote_poll_params.g.dart'; + +@JsonSerializable() +class VotePollParams extends ApiParams { + /// Indizes der gewählten Optionen; leer = eigene Stimme zurückziehen. + List optionIds; + + VotePollParams({required this.optionIds}); + factory VotePollParams.fromJson(Map json) => + _$VotePollParamsFromJson(json); + Map toJson() => _$VotePollParamsToJson(this); +} diff --git a/lib/api/marianumcloud/talk/vote_poll/vote_poll_params.g.dart b/lib/api/marianumcloud/talk/vote_poll/vote_poll_params.g.dart new file mode 100644 index 0000000..e97aa57 --- /dev/null +++ b/lib/api/marianumcloud/talk/vote_poll/vote_poll_params.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'vote_poll_params.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +VotePollParams _$VotePollParamsFromJson(Map json) => + VotePollParams( + optionIds: (json['optionIds'] as List) + .map((e) => (e as num).toInt()) + .toList(), + ); + +Map _$VotePollParamsToJson(VotePollParams instance) => + {'optionIds': instance.optionIds}; diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index f3680d6..122b4c0 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -392,11 +392,16 @@ class _ChatViewState extends State 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, ), ), ), diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index ed18b1a..a498eaf 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -168,12 +168,12 @@ class _ChatBubbleState extends State 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; } diff --git a/lib/view/pages/talk/widgets/chat_bubble_poll.dart b/lib/view/pages/talk/widgets/chat_bubble_poll.dart index 2f5b3a6..92512d6 100644 --- a/lib/view/pages/talk/widgets/chat_bubble_poll.dart +++ b/lib/view/pages/talk/widgets/chat_bubble_poll.dart @@ -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( - 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? _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( + 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, + ); + }, ); } diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 143f5af..0cb8dc5 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -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 createState() => _ChatTextfieldState(); @@ -160,10 +168,31 @@ class _ChatTextfieldState extends State { 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().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 _pickEmoji() async { final emoji = await showEmojiPicker(context); if (emoji == null || !mounted) return; diff --git a/lib/view/pages/talk/widgets/poll_create_sheet.dart b/lib/view/pages/talk/widgets/poll_create_sheet.dart new file mode 100644 index 0000000..a18446a --- /dev/null +++ b/lib/view/pages/talk/widgets/poll_create_sheet.dart @@ -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 _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 _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'), + ), + ), + ], + ); + } +} diff --git a/lib/view/pages/talk/widgets/poll_options_list.dart b/lib/view/pages/talk/widgets/poll_options_list.dart index 637b153..fab6e68 100644 --- a/lib/view/pages/talk/widgets/poll_options_list.dart +++ b/lib/view/pages/talk/widgets/poll_options_list.dart @@ -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 { - @override - Widget build(BuildContext context) => Column( - children: [ - ...widget.pollData.options.map((option) { - var optionId = widget.pollData.options.indexOf(option); - var votedSelf = widget.pollData.votedSelf.contains(optionId); - var portionsVisible = widget.pollData.votes is Map; - 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 _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 _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 _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 = [ + '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, ), ), - ], - ); + ); + } } diff --git a/test/api/talk/poll_state_test.dart b/test/api/talk/poll_state_test.dart new file mode 100644 index 0000000..f453b4c --- /dev/null +++ b/test/api/talk/poll_state_test.dart @@ -0,0 +1,127 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/create_poll/create_poll_params.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/get_poll/get_poll_state_response.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/vote_poll/vote_poll_params.dart'; + +GetPollStateResponseObject _poll({ + dynamic votes = const {}, + String actorType = 'users', + String actorId = 'creator', + int status = pollStatusOpen, + int resultMode = pollResultModePublic, + int maxVotes = 1, + List votedSelf = const [], + int? numVoters, +}) => GetPollStateResponseObject( + 1, + 'Frage?', + const ['A', 'B'], + votes, + actorType, + actorId, + 'Creator', + status, + resultMode, + maxVotes, + votedSelf, + numVoters, + null, +); + +void main() { + group('GetPollStateResponseObject.voteCounts', () { + test('map input is returned as num map', () { + final poll = _poll(votes: {'option-0': 2, 'option-1': 5}); + expect(poll.voteCounts, {'option-0': 2, 'option-1': 5}); + }); + + test('empty list (hidden results) becomes empty map', () { + expect(_poll(votes: const []).voteCounts, isEmpty); + }); + + test('null votes becomes empty map', () { + expect(_poll(votes: null).voteCounts, isEmpty); + }); + + test('non-num values are filtered out', () { + final poll = _poll(votes: {'option-0': 3, 'option-1': 'x'}); + expect(poll.voteCounts, {'option-0': 3}); + }); + }); + + group('GetPollStateResponseObject status/resultMode', () { + test('isClosed reflects status', () { + expect(_poll(status: pollStatusOpen).isClosed, isFalse); + expect(_poll(status: pollStatusClosed).isClosed, isTrue); + }); + + test('resultsHidden reflects resultMode', () { + expect(_poll(resultMode: pollResultModePublic).resultsHidden, isFalse); + expect(_poll(resultMode: pollResultModeHidden).resultsHidden, isTrue); + }); + }); + + group('GetPollStateResponseObject.canClose', () { + test('creator may close', () { + final poll = _poll(actorId: 'me'); + expect(poll.canClose(selfId: 'me', participantType: 3), isTrue); + }); + + test('moderator types may close', () { + final poll = _poll(actorId: 'someone'); + for (final type in [1, 2, 6]) { + expect( + poll.canClose(selfId: 'me', participantType: type), + isTrue, + reason: 'participantType $type should be allowed', + ); + } + }); + + test('regular participant may not close', () { + final poll = _poll(actorId: 'someone'); + expect(poll.canClose(selfId: 'me', participantType: 3), isFalse); + }); + + test('closed poll can never be closed again', () { + final poll = _poll(actorId: 'me', status: pollStatusClosed); + expect(poll.canClose(selfId: 'me', participantType: 1), isFalse); + }); + + test('guest with matching id is not treated as creator', () { + final poll = _poll(actorType: 'guests', actorId: 'me'); + expect(poll.canClose(selfId: 'me', participantType: 3), isFalse); + }); + }); + + group('poll params serialization', () { + // Regression: der ursprüngliche Bug entstand, weil optionIds nicht als + // valides JSON serialisiert wurde und der Server ein leeres Array sah. + test('VotePollParams encodes optionIds as a JSON array', () { + final json = jsonEncode(VotePollParams(optionIds: [0, 2]).toJson()); + expect(json, '{"optionIds":[0,2]}'); + }); + + test('empty VotePollParams encodes an empty array', () { + final json = jsonEncode(VotePollParams(optionIds: []).toJson()); + expect(json, '{"optionIds":[]}'); + }); + + test('CreatePollParams encodes all fields', () { + final json = jsonEncode( + CreatePollParams( + question: 'Q?', + options: const ['A', 'B'], + resultMode: pollResultModeHidden, + maxVotes: 0, + ).toJson(), + ); + expect( + json, + '{"question":"Q?","options":["A","B"],"resultMode":1,"maxVotes":0}', + ); + }); + }); +}