implemented full interactive poll support in Talk, including creation, voting, and closing functionality
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user