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';
|
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};
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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