implemented full interactive poll support in Talk, including creation, voting, and closing functionality
This commit is contained in:
@@ -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';
|
||||
|
||||
/// 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)
|
||||
class GetPollStateResponse extends ApiResponse {
|
||||
GetPollStateResponseObject data;
|
||||
@@ -50,4 +62,32 @@ class GetPollStateResponseObject {
|
||||
factory GetPollStateResponseObject.fromJson(Map<String, dynamic> json) =>
|
||||
_$GetPollStateResponseObjectFromJson(json);
|
||||
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};
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user