399 lines
13 KiB
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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
} |