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
@@ -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<GetPollStateResponse> {
ClosePoll({required String token, required int pollId})
: super('v1/poll/$token/$pollId', null);
@override
GetPollStateResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
@override
Future<http.Response> request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.delete(uri, headers: headers);
}
@@ -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<http.Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) {
if (body is! CreatePollParams) return null;
return http.post(uri, headers: headers, body: jsonEncode(body.toJson()));
}
}
@@ -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<String> 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<String, dynamic> json) =>
_$CreatePollParamsFromJson(json);
Map<String, dynamic> toJson() => _$CreatePollParamsToJson(this);
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_poll_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) =>
CreatePollParams(
question: json['question'] as String,
options: (json['options'] as List<dynamic>)
.map((e) => e as String)
.toList(),
resultMode: (json['resultMode'] as num).toInt(),
maxVotes: (json['maxVotes'] as num).toInt(),
);
Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) =>
<String, dynamic>{
'question': instance.question,
'options': instance.options,
'resultMode': instance.resultMode,
'maxVotes': instance.maxVotes,
};
@@ -4,6 +4,18 @@ import '../../../api_response.dart';
part 'get_poll_state_response.g.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<int> pollModeratorParticipantTypes = {1, 2, 6};
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetPollStateResponse extends ApiResponse { class GetPollStateResponse extends ApiResponse {
GetPollStateResponseObject data; GetPollStateResponseObject data;
@@ -50,4 +62,32 @@ class GetPollStateResponseObject {
factory GetPollStateResponseObject.fromJson(Map<String, dynamic> json) => factory GetPollStateResponseObject.fromJson(Map<String, dynamic> json) =>
_$GetPollStateResponseObjectFromJson(json); _$GetPollStateResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetPollStateResponseObjectToJson(this); Map<String, dynamic> 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<String, num> get voteCounts {
final raw = votes;
if (raw is! Map) return const {};
final result = <String, num>{};
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;
}
} }
@@ -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<GetPollStateResponse> {
// 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<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
@override
Future<http.Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) {
if (body is! VotePollParams) return null;
return http.post(uri, headers: headers, body: jsonEncode(body.toJson()));
}
}
@@ -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<int> optionIds;
VotePollParams({required this.optionIds});
factory VotePollParams.fromJson(Map<String, dynamic> json) =>
_$VotePollParamsFromJson(json);
Map<String, dynamic> toJson() => _$VotePollParamsToJson(this);
}
@@ -0,0 +1,17 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'vote_poll_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
VotePollParams _$VotePollParamsFromJson(Map<String, dynamic> json) =>
VotePollParams(
optionIds: (json['optionIds'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
);
Map<String, dynamic> _$VotePollParamsToJson(VotePollParams instance) =>
<String, dynamic>{'optionIds': instance.optionIds};
+6 -1
View File
@@ -392,11 +392,16 @@ class _ChatViewState extends State<ChatView> with RouteAware {
ColoredBox( ColoredBox(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: TalkNavigator.isSecondaryVisible(context) child: TalkNavigator.isSecondaryVisible(context)
? ChatTextfield(widget.room.token, selfId: widget.selfId) ? ChatTextfield(
widget.room.token,
selfId: widget.selfId,
roomType: widget.room.type,
)
: SafeArea( : SafeArea(
child: ChatTextfield( child: ChatTextfield(
widget.room.token, widget.room.token,
selfId: widget.selfId, selfId: widget.selfId,
roomType: widget.room.type,
), ),
), ),
), ),
+3 -3
View File
@@ -168,12 +168,12 @@ class _ChatBubbleState extends State<ChatBubble>
void _onTap() { void _onTap() {
final obj = message.originalData?['object']; final obj = message.originalData?['object'];
if (obj?.type == RichObjectStringObjectType.talkPoll) { if (obj?.type == RichObjectStringObjectType.talkPoll) {
showChatBubblePollDialog( showChatBubblePollSheet(
context, context,
chatToken: widget.chatData.token, room: widget.chatData,
messageToken: widget.bubbleData.token,
pollId: int.parse(obj!.id), pollId: int.parse(obj!.id),
pollName: obj.name, pollName: obj.name,
refetch: widget.refetch,
); );
return; return;
} }
@@ -1,44 +1,112 @@
import 'package:flutter/material.dart'; 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.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 '../../../../widget/loading_spinner.dart';
import 'poll_options_list.dart'; import 'poll_options_list.dart';
/// Opens the poll dialog that lets a user vote on a Talk poll attached to /// Opens the poll bottom sheet for a Talk poll attached to a message. Loads the
/// a message. Loads the poll state lazily and renders the option list. /// poll state lazily; voting and closing reload the state in place and refresh
void showChatBubblePollDialog( /// the chat so the surrounding message list stays in sync.
void showChatBubblePollSheet(
BuildContext context, { BuildContext context, {
required String chatToken, required GetRoomResponseObject room,
required String messageToken,
required int pollId, required int pollId,
required String pollName, required String pollName,
required void Function({bool renew}) refetch,
}) { }) {
final pollState = GetPollState(token: messageToken, pollId: pollId).run(); showDetailsBottomSheet(
showDialog<void>( context,
context: context, header: ListTile(
builder: (dialogCtx) => AlertDialog( leading: const Icon(Icons.poll_outlined),
title: Text(pollName, overflow: TextOverflow.ellipsis), title: Text(pollName, overflow: TextOverflow.ellipsis),
content: FutureBuilder( subtitle: const Text('Umfrage'),
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'),
),
],
), ),
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:nextcloud/nextcloud.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.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.dart';
import '../../../../api/marianumcloud/talk/send_message/send_message_params.dart'; import '../../../../api/marianumcloud/talk/send_message/send_message_params.dart';
import '../../../../api/marianumcloud/talk/share_files_to_chat.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 '../../../../widget/focus_behaviour.dart';
import '../../files/files_upload_dialog.dart'; import '../../files/files_upload_dialog.dart';
import 'answer_reference.dart'; import 'answer_reference.dart';
import 'poll_create_sheet.dart';
class ChatTextfield extends StatefulWidget { class ChatTextfield extends StatefulWidget {
final String sendToToken; final String sendToToken;
final String? selfId; 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 @override
State<ChatTextfield> createState() => _ChatTextfieldState(); State<ChatTextfield> createState() => _ChatTextfieldState();
@@ -160,10 +168,31 @@ class _ChatTextfieldState extends State<ChatTextfield> {
Navigator.of(sheetCtx).pop(); 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 { Future<void> _pickEmoji() async {
final emoji = await showEmojiPicker(context); final emoji = await showEmojiPicker(context);
if (emoji == null || !mounted) return; 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/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 '../../../../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 { class PollOptionsList extends StatefulWidget {
final GetPollStateResponseObject pollData; final GetPollStateResponseObject pollData;
final String chatToken; final GetRoomResponseObject room;
final void Function(GetPollStateResponse newState) onStateChanged;
const PollOptionsList({ const PollOptionsList({
super.key, super.key,
required this.pollData, required this.pollData,
required this.chatToken, required this.room,
required this.onStateChanged,
}); });
@override @override
@@ -18,56 +29,265 @@ class PollOptionsList extends StatefulWidget {
} }
class _PollOptionsListState extends State<PollOptionsList> { class _PollOptionsListState extends State<PollOptionsList> {
@override late Set<int> _selected;
Widget build(BuildContext context) => Column( final AsyncActionController _voteController = AsyncActionController();
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);
return ListTile( bool get _isSingleChoice => widget.pollData.maxVotes == 1;
isThreeLine: portionsVisible, bool get _isInteractive =>
dense: true, !widget.pollData.isClosed && widget.room.readOnly != 1;
title: Text(option, style: Theme.of(context).textTheme.bodyLarge),
leading: Icon( @override
votedSelf ? Icons.check_circle_outlined : Icons.circle_outlined, void initState() {
color: votedSelf super.initState();
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.6) _selected = widget.pollData.votedSelf.toSet();
: Theme.of( }
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), @override
), void dispose() {
subtitle: portionsVisible _voteController.dispose();
? Row( super.dispose();
children: [ }
Expanded(
child: LinearProgressIndicator( @override
value: portion.clamp(0.0, 1.0), 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(
Container( margin: const EdgeInsets.only(left: 10),
margin: const EdgeInsets.only(left: 10), child: Text(
child: Text('${(portion * 100).round()}%'), '${votes.toInt()} · ${(portion * 100).round()}%',
), ),
], ),
) ],
: null, )
); : null,
}), );
ListTile( }),
title: Linkify( if (!resultsVisible) _hiddenResultsHint(theme),
text: if (poll.maxVotes > 1 && _isInteractive)
'Wenn du abstimmen möchtest, verwende die Webversion unter https://cloud.marianum-fulda.de/call/${widget.chatToken}', Padding(
onOpen: UrlOpener.onOpen, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
style: Theme.of(context).textTheme.bodySmall, 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,
), ),
), ),
], );
); }
} }
+127
View File
@@ -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, dynamic>{},
String actorType = 'users',
String actorId = 'creator',
int status = pollStatusOpen,
int resultMode = pollResultModePublic,
int maxVotes = 1,
List<int> 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}',
);
});
});
}