865 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			865 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter/services.dart';
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:flutter_riverpod/flutter_riverpod.dart';
 | |
| import 'package:island/models/poll.dart';
 | |
| import 'package:island/pods/network.dart';
 | |
| import 'package:island/widgets/alert.dart';
 | |
| import 'package:island/widgets/poll/poll_stats_widget.dart';
 | |
| 
 | |
| class PollSubmit extends ConsumerStatefulWidget {
 | |
|   const PollSubmit({
 | |
|     super.key,
 | |
|     required this.poll,
 | |
|     required this.onSubmit,
 | |
|     required this.stats,
 | |
|     this.initialAnswers,
 | |
|     this.onCancel,
 | |
|     this.showProgress = true,
 | |
|     this.isReadonly = false,
 | |
|     this.isInitiallyExpanded = false,
 | |
|   });
 | |
| 
 | |
|   final SnPollWithStats poll;
 | |
| 
 | |
|   /// Callback when user submits all answers. Map questionId -> answer.
 | |
|   final void Function(Map<String, dynamic> answers) onSubmit;
 | |
| 
 | |
|   /// Optional initial answers, keyed by questionId.
 | |
|   final Map<String, dynamic>? initialAnswers;
 | |
|   final Map<String, dynamic>? stats;
 | |
| 
 | |
|   /// Optional cancel callback.
 | |
|   final VoidCallback? onCancel;
 | |
| 
 | |
|   /// Whether to show a progress indicator (e.g., "2 / N").
 | |
|   final bool showProgress;
 | |
| 
 | |
|   final bool isReadonly;
 | |
| 
 | |
|   /// Whether the poll should start expanded instead of collapsed.
 | |
|   final bool isInitiallyExpanded;
 | |
| 
 | |
|   @override
 | |
|   ConsumerState<PollSubmit> createState() => _PollSubmitState();
 | |
| }
 | |
| 
 | |
| class _PollSubmitState extends ConsumerState<PollSubmit> {
 | |
|   late final List<SnPollQuestion> _questions;
 | |
|   int _index = 0;
 | |
|   bool _submitting = false;
 | |
|   bool _isModifying = false; // New state to track if user is modifying answers
 | |
|   bool _isCollapsed = true; // New state to track collapse/expand
 | |
| 
 | |
|   /// Collected answers, keyed by questionId
 | |
|   late Map<String, dynamic> _answers;
 | |
| 
 | |
|   /// Local controller for free text input
 | |
|   final TextEditingController _textController = TextEditingController();
 | |
| 
 | |
|   /// Local state holders for inputs to avoid rebuilding whole list
 | |
|   String? _singleChoiceSelected; // optionId
 | |
|   final Set<String> _multiChoiceSelected = {};
 | |
|   bool? _yesNoSelected;
 | |
|   int? _ratingSelected; // 1..5
 | |
| 
 | |
|   @override
 | |
|   void initState() {
 | |
|     super.initState();
 | |
|     // Ensure questions are ordered by `order`
 | |
|     _questions = [...widget.poll.questions]
 | |
|       ..sort((a, b) => a.order.compareTo(b.order));
 | |
|     _answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
 | |
|     // Set initial collapse state based on the parameter
 | |
|     _isCollapsed = !widget.isInitiallyExpanded;
 | |
|     if (!widget.isReadonly) {
 | |
|       _loadCurrentIntoLocalState();
 | |
|       // If initial answers are provided, set _isModifying to false initially
 | |
|       // so the "Modify" button is shown.
 | |
|       if (widget.initialAnswers != null && widget.initialAnswers!.isNotEmpty) {
 | |
|         _isModifying = false;
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   void didUpdateWidget(covariant PollSubmit oldWidget) {
 | |
|     super.didUpdateWidget(oldWidget);
 | |
|     if (oldWidget.poll.id != widget.poll.id) {
 | |
|       _index = 0;
 | |
|       _answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
 | |
|       _questions
 | |
|         ..clear()
 | |
|         ..addAll(
 | |
|           [...widget.poll.questions]
 | |
|             ..sort((a, b) => a.order.compareTo(b.order)),
 | |
|         );
 | |
|       if (!widget.isReadonly) {
 | |
|         _loadCurrentIntoLocalState();
 | |
|         // If poll ID changes, reset modification state
 | |
|         _isModifying = false;
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   void dispose() {
 | |
|     _textController.dispose();
 | |
|     super.dispose();
 | |
|   }
 | |
| 
 | |
|   SnPollQuestion get _current => _questions[_index];
 | |
| 
 | |
|   void _loadCurrentIntoLocalState() {
 | |
|     final q = _current;
 | |
|     final saved = _answers[q.id];
 | |
| 
 | |
|     _singleChoiceSelected = null;
 | |
|     _multiChoiceSelected.clear();
 | |
|     _yesNoSelected = null;
 | |
|     _ratingSelected = null;
 | |
|     _textController.text = '';
 | |
| 
 | |
|     switch (q.type) {
 | |
|       case SnPollQuestionType.singleChoice:
 | |
|         if (saved is String) _singleChoiceSelected = saved;
 | |
|         break;
 | |
|       case SnPollQuestionType.multipleChoice:
 | |
|         if (saved is List) {
 | |
|           _multiChoiceSelected.addAll(saved.whereType<String>());
 | |
|         }
 | |
|         break;
 | |
|       case SnPollQuestionType.yesNo:
 | |
|         if (saved is bool) _yesNoSelected = saved;
 | |
|         break;
 | |
|       case SnPollQuestionType.rating:
 | |
|         if (saved is int) _ratingSelected = saved;
 | |
|         break;
 | |
|       case SnPollQuestionType.freeText:
 | |
|         if (saved is String) _textController.text = saved;
 | |
|         break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   bool _isCurrentAnswered() {
 | |
|     final q = _current;
 | |
|     if (!q.isRequired) return true;
 | |
| 
 | |
|     switch (q.type) {
 | |
|       case SnPollQuestionType.singleChoice:
 | |
|         return _singleChoiceSelected != null;
 | |
|       case SnPollQuestionType.multipleChoice:
 | |
|         return _multiChoiceSelected.isNotEmpty;
 | |
|       case SnPollQuestionType.yesNo:
 | |
|         return _yesNoSelected != null;
 | |
|       case SnPollQuestionType.rating:
 | |
|         return (_ratingSelected ?? 0) > 0;
 | |
|       case SnPollQuestionType.freeText:
 | |
|         return _textController.text.trim().isNotEmpty;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   void _persistCurrentAnswer() {
 | |
|     final q = _current;
 | |
|     switch (q.type) {
 | |
|       case SnPollQuestionType.singleChoice:
 | |
|         if (_singleChoiceSelected == null) {
 | |
|           _answers.remove(q.id);
 | |
|         } else {
 | |
|           _answers[q.id] = _singleChoiceSelected!;
 | |
|         }
 | |
|         break;
 | |
|       case SnPollQuestionType.multipleChoice:
 | |
|         if (_multiChoiceSelected.isEmpty) {
 | |
|           _answers.remove(q.id);
 | |
|         } else {
 | |
|           _answers[q.id] = _multiChoiceSelected.toList(growable: false);
 | |
|         }
 | |
|         break;
 | |
|       case SnPollQuestionType.yesNo:
 | |
|         if (_yesNoSelected == null) {
 | |
|           _answers.remove(q.id);
 | |
|         } else {
 | |
|           _answers[q.id] = _yesNoSelected!;
 | |
|         }
 | |
|         break;
 | |
|       case SnPollQuestionType.rating:
 | |
|         if (_ratingSelected == null) {
 | |
|           _answers.remove(q.id);
 | |
|         } else {
 | |
|           _answers[q.id] = _ratingSelected!;
 | |
|         }
 | |
|         break;
 | |
|       case SnPollQuestionType.freeText:
 | |
|         final text = _textController.text.trim();
 | |
|         if (text.isEmpty) {
 | |
|           _answers.remove(q.id);
 | |
|         } else {
 | |
|           _answers[q.id] = text;
 | |
|         }
 | |
|         break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Future<void> _submitToServer() async {
 | |
|     // Persist current question before final submit
 | |
|     _persistCurrentAnswer();
 | |
| 
 | |
|     setState(() {
 | |
|       _submitting = true;
 | |
|     });
 | |
| 
 | |
|     try {
 | |
|       final dio = ref.read(apiClientProvider);
 | |
| 
 | |
|       await dio.post(
 | |
|         '/sphere/polls/${widget.poll.id}/answer',
 | |
|         data: {'answer': _answers},
 | |
|       );
 | |
| 
 | |
|       // Only call onSubmit after server accepts
 | |
|       widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers));
 | |
| 
 | |
|       showSnackBar('pollAnswerSubmitted'.tr());
 | |
|       HapticFeedback.heavyImpact();
 | |
|     } catch (e) {
 | |
|       showErrorAlert(e);
 | |
|     } finally {
 | |
|       if (mounted) {
 | |
|         setState(() {
 | |
|           _submitting = false;
 | |
|         });
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   void _next() {
 | |
|     if (_submitting) return;
 | |
|     _persistCurrentAnswer();
 | |
|     if (_index < _questions.length - 1) {
 | |
|       setState(() {
 | |
|         _index++;
 | |
|         _loadCurrentIntoLocalState();
 | |
|       });
 | |
|     } else {
 | |
|       // Final submit to API
 | |
|       _submitToServer();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   void _back() {
 | |
|     if (_submitting) return;
 | |
|     _persistCurrentAnswer();
 | |
|     if (_index > 0) {
 | |
|       setState(() {
 | |
|         _index--;
 | |
|         _loadCurrentIntoLocalState();
 | |
|       });
 | |
|     } else {
 | |
|       // at the first question; allow cancel if provided
 | |
|       widget.onCancel?.call();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Widget _buildHeader(BuildContext context) {
 | |
|     final q = _current;
 | |
|     return Column(
 | |
|       crossAxisAlignment: CrossAxisAlignment.start,
 | |
|       children: [
 | |
|         if (widget.poll.title != null || widget.poll.description != null)
 | |
|           Padding(
 | |
|             padding: const EdgeInsets.only(bottom: 12),
 | |
|             child: Column(
 | |
|               crossAxisAlignment: CrossAxisAlignment.start,
 | |
|               children: [
 | |
|                 if (widget.poll.title != null)
 | |
|                   Text(
 | |
|                     widget.poll.title!,
 | |
|                     style: Theme.of(context).textTheme.titleLarge,
 | |
|                   ),
 | |
|                 if (widget.poll.description != null)
 | |
|                   Padding(
 | |
|                     padding: const EdgeInsets.only(top: 4),
 | |
|                     child: Text(
 | |
|                       widget.poll.description!,
 | |
|                       style: Theme.of(context).textTheme.bodyMedium?.copyWith(
 | |
|                         color: Theme.of(
 | |
|                           context,
 | |
|                         ).textTheme.bodyMedium?.color?.withOpacity(0.7),
 | |
|                       ),
 | |
|                     ),
 | |
|                   ),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|         if (widget.showProgress &&
 | |
|             _isModifying) // Only show progress when modifying
 | |
|           Text(
 | |
|             '${_index + 1} / ${_questions.length}',
 | |
|             style: Theme.of(context).textTheme.labelMedium,
 | |
|           ),
 | |
|         Row(
 | |
|           children: [
 | |
|             Expanded(
 | |
|               child: Text(
 | |
|                 q.title,
 | |
|                 style: Theme.of(context).textTheme.titleMedium,
 | |
|               ),
 | |
|             ),
 | |
|             if (q.isRequired)
 | |
|               Padding(
 | |
|                 padding: const EdgeInsets.only(left: 8),
 | |
|                 child: Text(
 | |
|                   '*',
 | |
|                   style: Theme.of(context).textTheme.titleMedium?.copyWith(
 | |
|                     color: Theme.of(context).colorScheme.error,
 | |
|                   ),
 | |
|                 ),
 | |
|               ),
 | |
|           ],
 | |
|         ),
 | |
|         if (q.description != null)
 | |
|           Padding(
 | |
|             padding: const EdgeInsets.only(top: 4),
 | |
|             child: Text(
 | |
|               q.description!,
 | |
|               style: Theme.of(context).textTheme.bodySmall?.copyWith(
 | |
|                 color: Theme.of(
 | |
|                   context,
 | |
|                 ).textTheme.bodySmall?.color?.withOpacity(0.7),
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildStats(BuildContext context, SnPollQuestion q) {
 | |
|     return PollStatsWidget(question: q, stats: widget.stats);
 | |
|   }
 | |
| 
 | |
|   Widget _buildBody(BuildContext context) {
 | |
|     if (widget.initialAnswers != null && !widget.isReadonly && !_isModifying) {
 | |
|       return const SizedBox.shrink(); // Collapse input fields if already submitted and not modifying
 | |
|     }
 | |
|     final q = _current;
 | |
|     switch (q.type) {
 | |
|       case SnPollQuestionType.singleChoice:
 | |
|         return _buildSingleChoice(context, q);
 | |
|       case SnPollQuestionType.multipleChoice:
 | |
|         return _buildMultipleChoice(context, q);
 | |
|       case SnPollQuestionType.yesNo:
 | |
|         return _buildYesNo(context, q);
 | |
|       case SnPollQuestionType.rating:
 | |
|         return _buildRating(context, q);
 | |
|       case SnPollQuestionType.freeText:
 | |
|         return _buildFreeText(context, q);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   Widget _buildSingleChoice(BuildContext context, SnPollQuestion q) {
 | |
|     final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order));
 | |
|     return Column(
 | |
|       children: [
 | |
|         for (final opt in options)
 | |
|           RadioListTile<String>(
 | |
|             value: opt.id,
 | |
|             groupValue: _singleChoiceSelected,
 | |
|             onChanged: (val) => setState(() => _singleChoiceSelected = val),
 | |
|             title: Text(opt.label),
 | |
|             subtitle: opt.description != null ? Text(opt.description!) : null,
 | |
|           ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildMultipleChoice(BuildContext context, SnPollQuestion q) {
 | |
|     final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order));
 | |
|     return Column(
 | |
|       children: [
 | |
|         for (final opt in options)
 | |
|           CheckboxListTile(
 | |
|             value: _multiChoiceSelected.contains(opt.id),
 | |
|             onChanged: (val) {
 | |
|               setState(() {
 | |
|                 if (val == true) {
 | |
|                   _multiChoiceSelected.add(opt.id);
 | |
|                 } else {
 | |
|                   _multiChoiceSelected.remove(opt.id);
 | |
|                 }
 | |
|               });
 | |
|             },
 | |
|             title: Text(opt.label),
 | |
|             subtitle: opt.description != null ? Text(opt.description!) : null,
 | |
|           ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildYesNo(BuildContext context, SnPollQuestion q) {
 | |
|     return Row(
 | |
|       children: [
 | |
|         Expanded(
 | |
|           child: SegmentedButton<bool>(
 | |
|             segments: [
 | |
|               ButtonSegment(value: true, label: Text('yes'.tr())),
 | |
|               ButtonSegment(value: false, label: Text('no'.tr())),
 | |
|             ],
 | |
|             selected: _yesNoSelected == null ? {} : {_yesNoSelected!},
 | |
|             onSelectionChanged: (sel) {
 | |
|               setState(() {
 | |
|                 _yesNoSelected = sel.isEmpty ? null : sel.first;
 | |
|               });
 | |
|             },
 | |
|             multiSelectionEnabled: false,
 | |
|             emptySelectionAllowed: true,
 | |
|           ),
 | |
|         ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildRating(BuildContext context, SnPollQuestion q) {
 | |
|     const max = 5;
 | |
|     return Row(
 | |
|       mainAxisAlignment: MainAxisAlignment.center,
 | |
|       children: List.generate(max, (i) {
 | |
|         final value = i + 1;
 | |
|         final selected = (_ratingSelected ?? 0) >= value;
 | |
|         return IconButton(
 | |
|           icon: Icon(
 | |
|             selected ? Icons.star : Icons.star_border,
 | |
|             color: selected ? Colors.amber : null,
 | |
|           ),
 | |
|           onPressed: () {
 | |
|             setState(() {
 | |
|               _ratingSelected = value;
 | |
|             });
 | |
|           },
 | |
|         );
 | |
|       }),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildFreeText(BuildContext context, SnPollQuestion q) {
 | |
|     return TextField(
 | |
|       controller: _textController,
 | |
|       maxLines: 6,
 | |
|       decoration: const InputDecoration(border: OutlineInputBorder()),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildNavBar(BuildContext context) {
 | |
|     final isLast = _index == _questions.length - 1;
 | |
|     final canProceed = _isCurrentAnswered() && !_submitting;
 | |
| 
 | |
|     if (widget.initialAnswers != null && !_isModifying && !widget.isReadonly) {
 | |
|       // If poll is submitted and not in modification mode, show "Modify" button
 | |
|       return FilledButton.icon(
 | |
|         icon: const Icon(Icons.edit),
 | |
|         label: Text('modifyAnswers'.tr()),
 | |
|         onPressed: () {
 | |
|           setState(() {
 | |
|             _isModifying = true;
 | |
|             _index = 0; // Reset to first question for modification
 | |
|             _loadCurrentIntoLocalState();
 | |
|           });
 | |
|         },
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return Row(
 | |
|       children: [
 | |
|         OutlinedButton.icon(
 | |
|           icon: const Icon(Icons.arrow_back),
 | |
|           label: Text(_index == 0 ? 'cancel'.tr() : 'back'.tr()),
 | |
|           onPressed:
 | |
|               _submitting
 | |
|                   ? null
 | |
|                   : () {
 | |
|                     if (_index == 0 && _isModifying) {
 | |
|                       // If at first question and in modification mode, go back to submitted view
 | |
|                       setState(() {
 | |
|                         _isModifying = false;
 | |
|                       });
 | |
|                     } else {
 | |
|                       _back();
 | |
|                     }
 | |
|                   },
 | |
|         ),
 | |
|         const Spacer(),
 | |
|         FilledButton.icon(
 | |
|           icon:
 | |
|               _submitting
 | |
|                   ? const SizedBox(
 | |
|                     width: 16,
 | |
|                     height: 16,
 | |
|                     child: CircularProgressIndicator(strokeWidth: 2),
 | |
|                   )
 | |
|                   : Icon(isLast ? Icons.check : Icons.arrow_forward),
 | |
|           label: Text(isLast ? 'submit'.tr() : 'next'.tr()),
 | |
|           onPressed: canProceed ? _next : null,
 | |
|         ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildSubmittedView(BuildContext context) {
 | |
|     return Column(
 | |
|       crossAxisAlignment: CrossAxisAlignment.start,
 | |
|       children: [
 | |
|         if (widget.poll.title != null || widget.poll.description != null)
 | |
|           Padding(
 | |
|             padding: const EdgeInsets.only(bottom: 12),
 | |
|             child: Column(
 | |
|               crossAxisAlignment: CrossAxisAlignment.start,
 | |
|               children: [
 | |
|                 if (widget.poll.title?.isNotEmpty ?? false)
 | |
|                   Text(
 | |
|                     widget.poll.title!,
 | |
|                     style: Theme.of(context).textTheme.titleLarge,
 | |
|                   ),
 | |
|                 if (widget.poll.description?.isNotEmpty ?? false)
 | |
|                   Padding(
 | |
|                     padding: const EdgeInsets.only(top: 4),
 | |
|                     child: Text(
 | |
|                       widget.poll.description!,
 | |
|                       style: Theme.of(context).textTheme.bodyMedium?.copyWith(
 | |
|                         color: Theme.of(
 | |
|                           context,
 | |
|                         ).textTheme.bodyMedium?.color?.withOpacity(0.7),
 | |
|                       ),
 | |
|                     ),
 | |
|                   ),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|         for (final q in _questions)
 | |
|           Padding(
 | |
|             padding: const EdgeInsets.only(bottom: 16.0),
 | |
|             child: Column(
 | |
|               crossAxisAlignment: CrossAxisAlignment.start,
 | |
|               children: [
 | |
|                 Row(
 | |
|                   children: [
 | |
|                     Expanded(
 | |
|                       child: Text(
 | |
|                         q.title,
 | |
|                         style: Theme.of(context).textTheme.titleMedium,
 | |
|                       ),
 | |
|                     ),
 | |
|                     if (q.isRequired)
 | |
|                       Padding(
 | |
|                         padding: const EdgeInsets.only(left: 8),
 | |
|                         child: Text(
 | |
|                           '*',
 | |
|                           style: Theme.of(
 | |
|                             context,
 | |
|                           ).textTheme.titleMedium?.copyWith(
 | |
|                             color: Theme.of(context).colorScheme.error,
 | |
|                           ),
 | |
|                         ),
 | |
|                       ),
 | |
|                   ],
 | |
|                 ),
 | |
|                 if (q.description != null)
 | |
|                   Padding(
 | |
|                     padding: const EdgeInsets.only(top: 4),
 | |
|                     child: Text(
 | |
|                       q.description!,
 | |
|                       style: Theme.of(context).textTheme.bodySmall?.copyWith(
 | |
|                         color: Theme.of(
 | |
|                           context,
 | |
|                         ).textTheme.bodySmall?.color?.withOpacity(0.7),
 | |
|                       ),
 | |
|                     ),
 | |
|                   ),
 | |
|                 _buildStats(context, q),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildReadonlyView(BuildContext context) {
 | |
|     return Column(
 | |
|       crossAxisAlignment: CrossAxisAlignment.start,
 | |
|       children: [
 | |
|         if (widget.poll.title != null || widget.poll.description != null)
 | |
|           Padding(
 | |
|             padding: const EdgeInsets.only(bottom: 12),
 | |
|             child: Column(
 | |
|               crossAxisAlignment: CrossAxisAlignment.start,
 | |
|               children: [
 | |
|                 if (widget.poll.title != null)
 | |
|                   Text(
 | |
|                     widget.poll.title!,
 | |
|                     style: Theme.of(context).textTheme.titleLarge,
 | |
|                   ),
 | |
|                 if (widget.poll.description != null)
 | |
|                   Padding(
 | |
|                     padding: const EdgeInsets.only(top: 4),
 | |
|                     child: Text(
 | |
|                       widget.poll.description!,
 | |
|                       style: Theme.of(context).textTheme.bodyMedium?.copyWith(
 | |
|                         color: Theme.of(
 | |
|                           context,
 | |
|                         ).textTheme.bodyMedium?.color?.withOpacity(0.7),
 | |
|                       ),
 | |
|                     ),
 | |
|                   ),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|         for (final q in _questions)
 | |
|           Padding(
 | |
|             padding: const EdgeInsets.only(bottom: 16.0),
 | |
|             child: Column(
 | |
|               crossAxisAlignment: CrossAxisAlignment.start,
 | |
|               children: [
 | |
|                 Row(
 | |
|                   children: [
 | |
|                     Expanded(
 | |
|                       child: Text(
 | |
|                         q.title,
 | |
|                         style: Theme.of(context).textTheme.titleMedium,
 | |
|                       ),
 | |
|                     ),
 | |
|                     if (q.isRequired)
 | |
|                       Padding(
 | |
|                         padding: const EdgeInsets.only(left: 8),
 | |
|                         child: Text(
 | |
|                           '*',
 | |
|                           style: Theme.of(
 | |
|                             context,
 | |
|                           ).textTheme.titleMedium?.copyWith(
 | |
|                             color: Theme.of(context).colorScheme.error,
 | |
|                           ),
 | |
|                         ),
 | |
|                       ),
 | |
|                   ],
 | |
|                 ),
 | |
|                 if (q.description != null)
 | |
|                   Padding(
 | |
|                     padding: const EdgeInsets.only(top: 4),
 | |
|                     child: Text(
 | |
|                       q.description!,
 | |
|                       style: Theme.of(context).textTheme.bodySmall?.copyWith(
 | |
|                         color: Theme.of(
 | |
|                           context,
 | |
|                         ).textTheme.bodySmall?.color?.withOpacity(0.7),
 | |
|                       ),
 | |
|                     ),
 | |
|                   ),
 | |
|                 _buildStats(context, q),
 | |
|               ],
 | |
|             ),
 | |
|           ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Widget _buildCollapsedView(BuildContext context) {
 | |
|     return Column(
 | |
|       crossAxisAlignment: CrossAxisAlignment.start,
 | |
|       children: [
 | |
|         Row(
 | |
|           children: [
 | |
|             Expanded(
 | |
|               child: Column(
 | |
|                 crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                 children: [
 | |
|                   if (widget.poll.title != null)
 | |
|                     Text(
 | |
|                       widget.poll.title!,
 | |
|                       style: Theme.of(context).textTheme.titleMedium?.copyWith(
 | |
|                         fontWeight: FontWeight.w600,
 | |
|                       ),
 | |
|                       maxLines: 1,
 | |
|                       overflow: TextOverflow.ellipsis,
 | |
|                     ),
 | |
|                   if (widget.poll.description != null)
 | |
|                     Padding(
 | |
|                       padding: const EdgeInsets.only(top: 2),
 | |
|                       child: Text(
 | |
|                         widget.poll.description!,
 | |
|                         style: Theme.of(context).textTheme.bodySmall?.copyWith(
 | |
|                           color: Theme.of(
 | |
|                             context,
 | |
|                           ).textTheme.bodySmall?.color?.withOpacity(0.7),
 | |
|                         ),
 | |
|                         maxLines: 2,
 | |
|                         overflow: TextOverflow.ellipsis,
 | |
|                       ),
 | |
|                     )
 | |
|                   else
 | |
|                     Padding(
 | |
|                       padding: const EdgeInsets.only(top: 2),
 | |
|                       child: Text(
 | |
|                         '${_questions.length} question${_questions.length == 1 ? '' : 's'}',
 | |
|                         style: Theme.of(context).textTheme.bodySmall?.copyWith(
 | |
|                           color: Theme.of(
 | |
|                             context,
 | |
|                           ).textTheme.bodySmall?.color?.withOpacity(0.7),
 | |
|                         ),
 | |
|                       ),
 | |
|                     ),
 | |
|                 ],
 | |
|               ),
 | |
|             ),
 | |
|             IconButton(
 | |
|               icon: Icon(
 | |
|                 _isCollapsed ? Icons.expand_more : Icons.expand_less,
 | |
|                 size: 20,
 | |
|               ),
 | |
|               onPressed: () {
 | |
|                 setState(() {
 | |
|                   _isCollapsed = !_isCollapsed;
 | |
|                 });
 | |
|               },
 | |
|               visualDensity: VisualDensity.compact,
 | |
|               tooltip: _isCollapsed ? 'expandPoll'.tr() : 'collapsePoll'.tr(),
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     if (_questions.isEmpty) {
 | |
|       return const SizedBox.shrink();
 | |
|     }
 | |
| 
 | |
|     // If collapsed, show collapsed view for all states
 | |
|     if (_isCollapsed) {
 | |
|       return _buildCollapsedView(context);
 | |
|     }
 | |
| 
 | |
|     // If poll is already submitted and not in readonly mode, and not in modification mode, show submitted view
 | |
|     if (widget.initialAnswers != null && !widget.isReadonly && !_isModifying) {
 | |
|       return Column(
 | |
|         crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|         children: [
 | |
|           _buildCollapsedView(context),
 | |
|           const SizedBox(height: 8),
 | |
|           AnimatedSwitcher(
 | |
|             duration: const Duration(milliseconds: 300),
 | |
|             transitionBuilder: (child, anim) {
 | |
|               final offset = Tween<Offset>(
 | |
|                 begin: const Offset(0, -0.1),
 | |
|                 end: Offset.zero,
 | |
|               ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
 | |
|               final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut);
 | |
|               return FadeTransition(
 | |
|                 opacity: fade,
 | |
|                 child: SlideTransition(position: offset, child: child),
 | |
|               );
 | |
|             },
 | |
|             child: Column(
 | |
|               key: const ValueKey('submitted_expanded'),
 | |
|               crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|               children: [_buildSubmittedView(context), _buildNavBar(context)],
 | |
|             ),
 | |
|           ),
 | |
|         ],
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     // If poll is in readonly mode, show readonly view
 | |
|     if (widget.isReadonly) {
 | |
|       return Column(
 | |
|         crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|         children: [
 | |
|           _buildCollapsedView(context),
 | |
|           const SizedBox(height: 8),
 | |
|           AnimatedSwitcher(
 | |
|             duration: const Duration(milliseconds: 300),
 | |
|             transitionBuilder: (child, anim) {
 | |
|               final offset = Tween<Offset>(
 | |
|                 begin: const Offset(0, -0.1),
 | |
|                 end: Offset.zero,
 | |
|               ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
 | |
|               final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut);
 | |
|               return FadeTransition(
 | |
|                 opacity: fade,
 | |
|                 child: SlideTransition(position: offset, child: child),
 | |
|               );
 | |
|             },
 | |
|             child: _buildReadonlyView(context),
 | |
|           ),
 | |
|         ],
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return Column(
 | |
|       crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|       children: [
 | |
|         _buildCollapsedView(context),
 | |
|         const SizedBox(height: 8),
 | |
|         AnimatedSwitcher(
 | |
|           duration: const Duration(milliseconds: 300),
 | |
|           transitionBuilder: (child, anim) {
 | |
|             final offset = Tween<Offset>(
 | |
|               begin: const Offset(0, -0.1),
 | |
|               end: Offset.zero,
 | |
|             ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOut));
 | |
|             final fade = CurvedAnimation(parent: anim, curve: Curves.easeOut);
 | |
|             return FadeTransition(
 | |
|               opacity: fade,
 | |
|               child: SlideTransition(position: offset, child: child),
 | |
|             );
 | |
|           },
 | |
|           child: Column(
 | |
|             key: const ValueKey('normal_expanded'),
 | |
|             crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|             children: [
 | |
|               _buildHeader(context),
 | |
|               const SizedBox(height: 12),
 | |
|               _AnimatedStep(
 | |
|                 key: ValueKey(_current.id),
 | |
|                 child: Column(
 | |
|                   crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|                   children: [
 | |
|                     _buildBody(context),
 | |
|                     _buildStats(context, _current),
 | |
|                   ],
 | |
|                 ),
 | |
|               ),
 | |
|               const SizedBox(height: 16),
 | |
|               _buildNavBar(context),
 | |
|             ],
 | |
|           ),
 | |
|         ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| /// Simple fade/slide transition between questions.
 | |
| class _AnimatedStep extends StatelessWidget {
 | |
|   const _AnimatedStep({super.key, required this.child});
 | |
|   final Widget child;
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return AnimatedSwitcher(
 | |
|       duration: const Duration(milliseconds: 250),
 | |
|       transitionBuilder: (child, anim) {
 | |
|         final offset = Tween<Offset>(
 | |
|           begin: const Offset(0.1, 0),
 | |
|           end: Offset.zero,
 | |
|         ).animate(anim);
 | |
|         final fade = CurvedAnimation(parent: anim, curve: Curves.easeInOut);
 | |
|         return FadeTransition(
 | |
|           opacity: fade,
 | |
|           child: SlideTransition(position: offset, child: child),
 | |
|         );
 | |
|       },
 | |
|       child: child,
 | |
|     );
 | |
|   }
 | |
| }
 |