409 lines
13 KiB
Dart
409 lines
13 KiB
Dart
import 'package:extera_next/generated/l10n/l10n.dart';
|
|
import 'package:extera_next/utils/poll_events.dart';
|
|
import 'package:extera_next/widgets/matrix.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
|
|
class PollWidget extends StatefulWidget {
|
|
final Color color;
|
|
final Color linkColor;
|
|
final double fontSize;
|
|
final Event event;
|
|
final Timeline timeline;
|
|
|
|
const PollWidget(
|
|
this.event, {
|
|
required this.color,
|
|
required this.linkColor,
|
|
required this.fontSize,
|
|
required this.timeline,
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => PollWidgetState();
|
|
}
|
|
|
|
class PollWidgetState extends State<PollWidget> {
|
|
List<String> selectedAnswers = [];
|
|
List<String> originalVote = []; // Store the original vote to detect changes
|
|
Map<String, int>? pollResults;
|
|
bool hasVoted = false;
|
|
bool isLoading = false;
|
|
bool hasEnded = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadPollData();
|
|
}
|
|
|
|
void _loadPollData() {
|
|
final event = widget.event;
|
|
final content = event.content[PollEvents.PollStart] as Map<String, dynamic>;
|
|
|
|
// Check if user has already voted
|
|
_checkExistingVote();
|
|
|
|
// For disclosed polls, load initial results
|
|
final kind = content['kind'] as String?;
|
|
if (kind == 'org.matrix.msc3381.disclosed') {
|
|
_calculateResults();
|
|
}
|
|
}
|
|
|
|
void _checkExistingVote() async {
|
|
final room = widget.event.room;
|
|
final currentUserId = room.client.userID;
|
|
|
|
final rel = await Matrix.of(context)
|
|
.client
|
|
.getRelatingEventsWithRelTypeAndEventType(room.id, widget.event.eventId,
|
|
"m.reference", "org.matrix.msc3381.poll.response");
|
|
|
|
// Get all poll response events for this poll
|
|
final responses = rel.chunk;
|
|
|
|
if (responses.isNotEmpty) {
|
|
// Use the latest response
|
|
for (final response in responses) {
|
|
final responseContent =
|
|
response.content['org.matrix.msc3381.poll.response']
|
|
as Map<String, dynamic>;
|
|
if (responseContent != null && response.senderId == currentUserId) {
|
|
final List<dynamic> answers = responseContent['answers'];
|
|
setState(() {
|
|
selectedAnswers = answers.cast<String>();
|
|
originalVote =
|
|
List.from(answers.cast<String>()); // Store original vote
|
|
hasVoted = true;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void _calculateResults() async {
|
|
final room = widget.event.room;
|
|
final pollEventId = widget.event.eventId;
|
|
final results = <String, int>{};
|
|
|
|
final rel = await Matrix.of(context)
|
|
.client
|
|
.getRelatingEventsWithRelTypeAndEventType(room.id, pollEventId,
|
|
"m.reference", "org.matrix.msc3381.poll.response");
|
|
|
|
// Get all poll response events for this poll
|
|
final responses = rel.chunk;
|
|
|
|
// Count votes for each answer
|
|
for (final response in responses) {
|
|
final responseContent = response
|
|
.content['org.matrix.msc3381.poll.response'] as Map<String, dynamic>;
|
|
if (responseContent != null) {
|
|
final List<dynamic> answers = responseContent['answers'];
|
|
for (final answer in answers.cast<String>()) {
|
|
results[answer] = (results[answer] ?? 0) + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
setState(() {
|
|
pollResults = results;
|
|
});
|
|
}
|
|
|
|
Future<void> _vote(List<String> answers) async {
|
|
if (isLoading) return;
|
|
|
|
setState(() {
|
|
isLoading = true;
|
|
});
|
|
|
|
try {
|
|
final room = widget.event.room;
|
|
|
|
// Send poll response event
|
|
await room.sendEvent({
|
|
'm.relates_to': {
|
|
'rel_type': 'm.reference',
|
|
'event_id': widget.event.eventId,
|
|
},
|
|
'org.matrix.msc3381.poll.response': {
|
|
'answers': answers,
|
|
},
|
|
}, type: 'org.matrix.msc3381.poll.response');
|
|
|
|
setState(() {
|
|
selectedAnswers = answers;
|
|
originalVote = List.from(answers); // Update original vote after voting
|
|
hasVoted = true;
|
|
isLoading = false;
|
|
});
|
|
|
|
// Recalculate results for disclosed polls
|
|
final content =
|
|
widget.event.content[PollEvents.PollStart] as Map<String, dynamic?>;
|
|
final kind = content['kind'] as String?;
|
|
if (kind == 'org.matrix.msc3381.disclosed') {
|
|
_calculateResults();
|
|
}
|
|
} catch (e) {
|
|
setState(() {
|
|
isLoading = false;
|
|
});
|
|
// Show error message
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Failed to vote: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _onAnswerSelected(String answerId, bool selected) {
|
|
final content =
|
|
widget.event.content[PollEvents.PollStart] as Map<String, dynamic?>;
|
|
final maxSelections = content['max_selections'] as int? ?? 1;
|
|
final List<dynamic> answers = content['answers'];
|
|
|
|
setState(() {
|
|
if (maxSelections == 1) {
|
|
// Single selection - replace current selection
|
|
selectedAnswers = selected ? [answerId] : [];
|
|
} else {
|
|
// Multiple selection
|
|
if (selected) {
|
|
if (selectedAnswers.length < maxSelections) {
|
|
selectedAnswers.add(answerId);
|
|
}
|
|
} else {
|
|
selectedAnswers.remove(answerId);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
bool _isPollEnded() {
|
|
final room = widget.event.room;
|
|
if (widget.timeline == null) return false;
|
|
// Check if there's an end event for this poll
|
|
final endEvents = widget.timeline!.events.where((e) {
|
|
return e.type == 'org.matrix.msc3381.poll.end' &&
|
|
e.senderId == widget.event.senderId &&
|
|
e.relationshipEventId == widget.event.eventId;
|
|
});
|
|
return endEvents.isNotEmpty;
|
|
}
|
|
|
|
bool _shouldShowResults() {
|
|
final content =
|
|
widget.event.content[PollEvents.PollStart] as Map<String, dynamic?>;
|
|
final kind = content['kind'] as String?;
|
|
final isDisclosed = kind == 'org.matrix.msc3381.disclosed';
|
|
final isEnded = _isPollEnded();
|
|
|
|
return isDisclosed ? (isEnded || hasVoted) : isEnded;
|
|
}
|
|
|
|
double _getAnswerPercentage(String answerId) {
|
|
if (pollResults == null || pollResults!.isEmpty) return 0.0;
|
|
final totalVotes = pollResults!.values.reduce((a, b) => a + b);
|
|
if (totalVotes == 0) return 0.0;
|
|
|
|
// if (_shouldShowResults()) {
|
|
// Logs().w("Get answer percentage for $answerId");
|
|
// Logs().w(pollResults.toString());
|
|
// Logs().w("${pollResults![answerId]?.toDouble() ?? 0} / $totalVotes");
|
|
// }
|
|
|
|
return (pollResults![answerId]?.toDouble() ?? 0) / totalVotes.toDouble();
|
|
}
|
|
|
|
// Check if the current selection is different from the original vote
|
|
bool _hasSelectionChanged() {
|
|
if (selectedAnswers.length != originalVote.length) return true;
|
|
|
|
// Sort both lists to compare regardless of order
|
|
final sortedSelected = List.from(selectedAnswers)..sort();
|
|
final sortedOriginal = List.from(originalVote)..sort();
|
|
|
|
for (int i = 0; i < sortedSelected.length; i++) {
|
|
if (sortedSelected[i] != sortedOriginal[i]) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final event = widget.event;
|
|
final content =
|
|
event.content[PollEvents.PollStart] as Map<String, dynamic?>;
|
|
final question = content?['question']?['m.text'] as String? ?? 'Poll';
|
|
final List<dynamic> answers = content?['answers'] ?? [];
|
|
final maxSelections = content?['max_selections'] as int? ?? 1;
|
|
final kind = content?['kind'] as String?;
|
|
|
|
final shouldShowResults = _shouldShowResults();
|
|
final isEnded = _isPollEnded();
|
|
final canVote = !isEnded && !isLoading;
|
|
final hasChanged = _hasSelectionChanged();
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Question
|
|
Text(
|
|
question,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: widget.fontSize,
|
|
color: widget.color,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Answers
|
|
...answers.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final answer = entry.value as Map<String, dynamic>;
|
|
final answerId = answer['id'] as String;
|
|
final answerText = answer['m.text'] as String? ??
|
|
answer['org.matrix.msc1767.text'] as String? ??
|
|
'Answer ${index + 1}';
|
|
final isSelected = selectedAnswers.contains(answerId);
|
|
final percentage = _getAnswerPercentage(answerId);
|
|
final voteCount = pollResults?[answerId] ?? 0;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Answer row
|
|
Row(
|
|
children: [
|
|
// Selection indicator
|
|
if (canVote) ...[
|
|
if (maxSelections == 1)
|
|
Radio<bool>(
|
|
value: true,
|
|
groupValue: isSelected,
|
|
onChanged: (_) =>
|
|
_onAnswerSelected(answerId, !isSelected),
|
|
)
|
|
else
|
|
Checkbox(
|
|
value: isSelected,
|
|
onChanged: (_) =>
|
|
_onAnswerSelected(answerId, !isSelected),
|
|
),
|
|
] else if (isSelected)
|
|
Icon(Icons.check, color: Colors.green, size: 20),
|
|
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: canVote
|
|
? () => _onAnswerSelected(answerId, !isSelected)
|
|
: null,
|
|
child: Text(
|
|
answerText,
|
|
style: TextStyle(
|
|
fontSize: widget.fontSize - 1,
|
|
color: widget.color.withOpacity(0.9),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Vote count and percentage
|
|
if (shouldShowResults && pollResults != null)
|
|
Text(
|
|
'${(percentage * 100).toStringAsFixed(1)}% ($voteCount)',
|
|
style: TextStyle(
|
|
fontSize: widget.fontSize - 2,
|
|
color: widget.color.withOpacity(0.7),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
// Progress bar
|
|
if (shouldShowResults && pollResults != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4, left: 40),
|
|
child: LinearProgressIndicator(
|
|
value: percentage,
|
|
backgroundColor: widget.color.withOpacity(0.2),
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
isSelected
|
|
? Colors.blue
|
|
: widget.color.withOpacity(0.5),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
// Vote button and info
|
|
Row(
|
|
children: [
|
|
if (canVote && selectedAnswers.isNotEmpty)
|
|
// Show "Change Vote" button only when selection has changed
|
|
if (!hasVoted || (hasVoted && hasChanged))
|
|
ElevatedButton(
|
|
onPressed: isLoading ? null : () => _vote(selectedAnswers),
|
|
child: isLoading
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(hasVoted
|
|
? L10n.of(context).changeVote
|
|
: L10n.of(context).vote),
|
|
),
|
|
|
|
const Spacer(),
|
|
|
|
// Poll info
|
|
Text(
|
|
'${maxSelections == 1 ? L10n.of(context).singleChoice : L10n.of(context).multipleChoice} • '
|
|
'${kind == 'org.matrix.msc3381.undisclosed' ? L10n.of(context).anonymousPoll : L10n.of(context).publicPoll} • '
|
|
'${isEnded ? L10n.of(context).endedPoll : L10n.of(context).activePoll}',
|
|
style: TextStyle(
|
|
fontSize: widget.fontSize - 2,
|
|
color: widget.color.withOpacity(0.6),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
if (selectedAnswers.isNotEmpty && maxSelections > 1)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Text(
|
|
L10n.of(context)
|
|
.choicesSelected(selectedAnswers.length, maxSelections),
|
|
style: TextStyle(
|
|
fontSize: widget.fontSize - 2,
|
|
color: widget.color.withOpacity(0.6),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|