ExteraNext/lib/pages/chat/events/poll_content.dart

399 lines
13 KiB
Dart

import 'package:extera_next/utils/poll_events.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;
@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() {
final room = widget.event.room;
final currentUserId = room.client.userID;
// Find existing poll response events from current user
final responses = widget.timeline!.events.where((e) {
return e.type == 'org.matrix.msc3381.poll.response' &&
e.senderId == currentUserId &&
e.relationshipEventId == widget.event.eventId;
}).toList();
if (responses.isNotEmpty) {
// Use the latest response
final latestResponse = responses.last;
final responseContent = latestResponse
.content['org.matrix.msc3381.poll.response'] as Map<String, dynamic>;
if (responseContent != null) {
final List<dynamic> answers = responseContent['answers'];
setState(() {
selectedAnswers = answers.cast<String>();
originalVote = List.from(answers.cast<String>()); // Store original vote
hasVoted = true;
});
}
}
}
void _calculateResults() {
final room = widget.event.room;
final pollEventId = widget.event.eventId;
final results = <String, int>{};
// Get all poll response events for this poll
final responses = widget.timeline!.events.where((e) {
return e.type == 'org.matrix.msc3381.poll.response' &&
e.relationshipEventId == pollEventId;
}).toList();
// 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;
}
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;
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 ? 'Change Vote' : 'Vote'),
),
const Spacer(),
// Poll info
Text(
'${maxSelections == 1 ? 'Single' : 'Multiple'} choice • '
'${kind?.contains('undisclosed') == true ? 'Anonymous' : 'Public'}'
'${isEnded ? 'Ended' : 'Active'}',
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(
'${selectedAnswers.length} of $maxSelections selected',
style: TextStyle(
fontSize: widget.fontSize - 2,
color: widget.color.withOpacity(0.6),
),
),
),
],
),
);
}
}