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 createState() => PollWidgetState(); } class PollWidgetState extends State { List selectedAnswers = []; List originalVote = []; // Store the original vote to detect changes Map? 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; // 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; if (responseContent != null) { final List answers = responseContent['answers']; setState(() { selectedAnswers = answers.cast(); originalVote = List.from(answers.cast()); // Store original vote hasVoted = true; }); } } } void _calculateResults() { final room = widget.event.room; final pollEventId = widget.event.eventId; final results = {}; // 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; if (responseContent != null) { final List answers = responseContent['answers']; for (final answer in answers.cast()) { results[answer] = (results[answer] ?? 0) + 1; } } } setState(() { pollResults = results; }); } Future _vote(List 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; 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; final maxSelections = content['max_selections'] as int? ?? 1; final List 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; 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; final question = content?['question']?['m.text'] as String? ?? 'Poll'; final List 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; 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( 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( 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), ), ), ), ], ), ); } }