🎨 Use feature based folder structure
This commit is contained in:
1131
lib/polls/poll/poll_editor.dart
Normal file
1131
lib/polls/poll/poll_editor.dart
Normal file
File diff suppressed because it is too large
Load Diff
252
lib/polls/polls_widgets/poll/poll_feedback.dart
Normal file
252
lib/polls/polls_widgets/poll/poll_feedback.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_pfc.dart';
|
||||
import 'package:island/creators/creators/poll/poll_list.dart';
|
||||
import 'package:island/pagination/pagination.dart';
|
||||
import 'package:island/polls/polls_widgets/poll/poll_stats_widget.dart';
|
||||
import 'package:island/posts/posts_models/poll.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/services/time.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/shared/widgets/pagination_list.dart';
|
||||
import 'package:island/shared/widgets/response.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
final pollFeedbackNotifierProvider = AsyncNotifierProvider.autoDispose.family(
|
||||
PollFeedbackNotifier.new,
|
||||
);
|
||||
|
||||
class PollFeedbackNotifier extends AsyncNotifier<PaginationState<SnPollAnswer>>
|
||||
with AsyncPaginationController<SnPollAnswer> {
|
||||
static const int pageSize = 20;
|
||||
|
||||
final String arg;
|
||||
PollFeedbackNotifier(this.arg);
|
||||
|
||||
@override
|
||||
Future<List<SnPollAnswer>> fetch() async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
final queryParams = {'offset': fetchedCount, 'take': pageSize};
|
||||
|
||||
final response = await client.get(
|
||||
'/sphere/polls/$arg/feedback',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
final List<dynamic> data = response.data;
|
||||
return data.map((json) => SnPollAnswer.fromJson(json)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
class PollFeedbackSheet extends HookConsumerWidget {
|
||||
final String pollId;
|
||||
final String? title;
|
||||
const PollFeedbackSheet({super.key, required this.pollId, this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final poll = ref.watch(pollWithStatsProvider(pollId));
|
||||
final provider = pollFeedbackNotifierProvider(pollId);
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: title ?? 'Poll feedback',
|
||||
child: poll.when(
|
||||
data: (data) => CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _PollHeader(poll: data)),
|
||||
SliverToBoxAdapter(child: const Divider(height: 1)),
|
||||
SliverGap(4),
|
||||
PaginationList(
|
||||
provider: provider,
|
||||
notifier: provider.notifier,
|
||||
isSliver: true,
|
||||
isRefreshable: false,
|
||||
itemBuilder: (context, index, answer) {
|
||||
return Column(
|
||||
children: [
|
||||
_PollAnswerTile(answer: answer, poll: data),
|
||||
if (index <
|
||||
(ref.read(provider).value?.items.length ?? 0) - 1)
|
||||
const Divider(height: 1).padding(vertical: 4),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
SliverGap(4 + MediaQuery.of(context).padding.bottom),
|
||||
],
|
||||
),
|
||||
error: (err, _) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => ref.invalidate(pollWithStatsProvider(pollId)),
|
||||
),
|
||||
loading: () => ResponseLoadingWidget(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PollHeader extends StatelessWidget {
|
||||
const _PollHeader({required this.poll});
|
||||
final SnPollWithStats poll;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (poll.title != null || (poll.description?.isNotEmpty ?? false))
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (poll.title != null)
|
||||
Text(poll.title!, style: theme.textTheme.titleLarge),
|
||||
if (poll.description?.isNotEmpty ?? false)
|
||||
Text(
|
||||
poll.description!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 20, top: 16),
|
||||
ExpansionTile(
|
||||
title: Text('pollQuestions').tr().fontSize(17).bold(),
|
||||
tilePadding: EdgeInsets.symmetric(horizontal: 20),
|
||||
children: poll.questions
|
||||
.map(
|
||||
(q) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (q.title.isNotEmpty) Text(q.title).bold(),
|
||||
if (q.description?.isNotEmpty ?? false)
|
||||
Text(q.description!),
|
||||
PollStatsWidget(question: q, stats: poll.stats),
|
||||
],
|
||||
).padding(horizontal: 20, top: 8, bottom: 16),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PollAnswerTile extends StatelessWidget {
|
||||
final SnPollAnswer answer;
|
||||
final SnPollWithStats poll;
|
||||
const _PollAnswerTile({required this.answer, required this.poll});
|
||||
|
||||
String _formatPerQuestionAnswer(
|
||||
SnPollQuestion q,
|
||||
Map<String, dynamic> ansMap,
|
||||
) {
|
||||
switch (q.type) {
|
||||
case SnPollQuestionType.singleChoice:
|
||||
final val = ansMap[q.id];
|
||||
if (val is String) {
|
||||
final opt = q.options?.firstWhere(
|
||||
(o) => o.id == val,
|
||||
orElse: () => SnPollOption(id: val, label: '#$val', order: 0),
|
||||
);
|
||||
return opt?.label ?? '#$val';
|
||||
}
|
||||
return '—';
|
||||
case SnPollQuestionType.multipleChoice:
|
||||
final val = ansMap[q.id];
|
||||
if (val is List) {
|
||||
final ids = val.whereType<String>().toList();
|
||||
if (ids.isEmpty) return '—';
|
||||
final labels = ids.map((id) {
|
||||
final opt = q.options?.firstWhere(
|
||||
(o) => o.id == id,
|
||||
orElse: () => SnPollOption(id: id, label: '#$id', order: 0),
|
||||
);
|
||||
return opt?.label ?? '#$id';
|
||||
}).toList();
|
||||
return labels.join(', ');
|
||||
}
|
||||
return '—';
|
||||
case SnPollQuestionType.yesNo:
|
||||
final val = ansMap[q.id];
|
||||
if (val is bool) {
|
||||
return val ? 'Yes' : 'No';
|
||||
}
|
||||
return '—';
|
||||
case SnPollQuestionType.rating:
|
||||
final val = ansMap[q.id];
|
||||
if (val is int) return val.toString();
|
||||
if (val is num) return val.toString();
|
||||
return '—';
|
||||
case SnPollQuestionType.freeText:
|
||||
final val = ansMap[q.id];
|
||||
if (val is String && val.trim().isNotEmpty) return val;
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Submit date/time (title)
|
||||
final submitText = answer.account == null
|
||||
? answer.createdAt.formatSystem()
|
||||
: '${answer.account!.nick} · ${answer.createdAt.formatSystem()}';
|
||||
|
||||
// Compose content from poll questions if provided, otherwise fallback to joined key-values
|
||||
String content;
|
||||
if (poll.questions.isNotEmpty) {
|
||||
final questions = [...poll.questions]
|
||||
..sort((a, b) => a.order.compareTo(b.order));
|
||||
final buffer = StringBuffer();
|
||||
for (final q in questions) {
|
||||
final formatted = _formatPerQuestionAnswer(q, answer.answer);
|
||||
buffer.writeln('${q.title}: $formatted');
|
||||
}
|
||||
content = buffer.toString().trimRight();
|
||||
} else {
|
||||
// Fallback formatting without poll context. We still want to show the question title
|
||||
// instead of the raw question id key if we can derive it from the answer map itself.
|
||||
// Since we don't have poll metadata here, we cannot resolve the title; therefore we
|
||||
// will show only values line-by-line without exposing the raw id.
|
||||
if (answer.answer.isEmpty) {
|
||||
content = '—';
|
||||
} else {
|
||||
final parts = <String>[];
|
||||
answer.answer.forEach((key, value) {
|
||||
var question = poll.questions.firstWhere((q) => q.id == key);
|
||||
if (value is List) {
|
||||
parts.add('${question.title}: ${value.join(', ')}');
|
||||
} else {
|
||||
parts.add('${question.title}: $value');
|
||||
}
|
||||
});
|
||||
content = parts.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
isThreeLine: true,
|
||||
leading: answer.account == null
|
||||
? const CircleAvatar(
|
||||
radius: 16,
|
||||
child: Icon(Icons.how_to_vote, size: 16),
|
||||
)
|
||||
: AccountPfcRegion(
|
||||
uname: answer.account!.name,
|
||||
child: ProfilePictureWidget(
|
||||
file: answer.account!.profile.picture,
|
||||
),
|
||||
),
|
||||
title: Text(submitText),
|
||||
subtitle: Text(content),
|
||||
trailing: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
233
lib/polls/polls_widgets/poll/poll_stats_widget.dart
Normal file
233
lib/polls/polls_widgets/poll/poll_stats_widget.dart
Normal file
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/posts/posts_models/poll.dart';
|
||||
|
||||
class PollStatsWidget extends StatelessWidget {
|
||||
const PollStatsWidget({
|
||||
super.key,
|
||||
required this.question,
|
||||
required this.stats,
|
||||
});
|
||||
|
||||
final SnPollQuestion question;
|
||||
final Map<String, dynamic>? stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (stats == null) return const SizedBox.shrink();
|
||||
final raw = stats![question.id];
|
||||
if (raw == null) return const SizedBox.shrink();
|
||||
|
||||
Widget? body;
|
||||
|
||||
switch (question.type) {
|
||||
case SnPollQuestionType.rating:
|
||||
// rating: avg score (double or int)
|
||||
final avg = (raw['rating'] as num?)?.toDouble();
|
||||
if (avg == null) break;
|
||||
final theme = Theme.of(context);
|
||||
body = Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.star, color: Colors.amber.shade600, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
avg.toStringAsFixed(1),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
break;
|
||||
|
||||
case SnPollQuestionType.yesNo:
|
||||
// yes/no: map {true: count, false: count}
|
||||
if (raw is Map) {
|
||||
final int yes =
|
||||
(raw['true'] is int)
|
||||
? raw['true'] as int
|
||||
: int.tryParse('${raw['true']}') ?? 0;
|
||||
final int no =
|
||||
(raw['false'] is int)
|
||||
? raw['false'] as int
|
||||
: int.tryParse('${raw['false']}') ?? 0;
|
||||
final total = (yes + no).clamp(0, 1 << 31);
|
||||
final yesPct = total == 0 ? 0.0 : yes / total;
|
||||
final noPct = total == 0 ? 0.0 : no / total;
|
||||
final theme = Theme.of(context);
|
||||
body = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_BarStatRow(
|
||||
label: 'Yes',
|
||||
count: yes,
|
||||
fraction: yesPct,
|
||||
color: Colors.green.shade600,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_BarStatRow(
|
||||
label: 'No',
|
||||
count: no,
|
||||
fraction: noPct,
|
||||
color: Colors.red.shade600,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Total: $total',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case SnPollQuestionType.singleChoice:
|
||||
case SnPollQuestionType.multipleChoice:
|
||||
// map optionId -> count
|
||||
if (raw is Map) {
|
||||
final options = [...?question.options]
|
||||
..sort((a, b) => a.order.compareTo(b.order));
|
||||
final List<_OptionCount> items = [];
|
||||
int total = 0;
|
||||
for (final opt in options) {
|
||||
final dynamic v = raw[opt.id];
|
||||
final int count = v is int ? v : int.tryParse('$v') ?? 0;
|
||||
total += count;
|
||||
items.add(_OptionCount(id: opt.id, label: opt.label, count: count));
|
||||
}
|
||||
if (items.isNotEmpty) {
|
||||
items.sort(
|
||||
(a, b) => b.count.compareTo(a.count),
|
||||
); // show highest first
|
||||
}
|
||||
body = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final it in items)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: _BarStatRow(
|
||||
label: it.label,
|
||||
count: it.count,
|
||||
fraction: total == 0 ? 0 : it.count / total,
|
||||
),
|
||||
),
|
||||
if (items.isNotEmpty)
|
||||
Text(
|
||||
'Total: $total',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case SnPollQuestionType.freeText:
|
||||
// No stats
|
||||
break;
|
||||
}
|
||||
|
||||
if (body == null) return Text('No stats available');
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.35),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Stats',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
body,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OptionCount {
|
||||
final String id;
|
||||
final String label;
|
||||
final int count;
|
||||
const _OptionCount({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.count,
|
||||
});
|
||||
}
|
||||
|
||||
class _BarStatRow extends StatelessWidget {
|
||||
const _BarStatRow({
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.fraction,
|
||||
this.color,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int count;
|
||||
final double fraction;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final barColor = color ?? Theme.of(context).colorScheme.primary;
|
||||
final bgColor = Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceVariant.withOpacity(0.6);
|
||||
final fg =
|
||||
(fraction.isNaN || fraction.isInfinite)
|
||||
? 0.0
|
||||
: fraction.clamp(0.0, 1.0);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$label · $count', style: Theme.of(context).textTheme.labelMedium),
|
||||
const SizedBox(height: 4),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final filled = width * fg;
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 8,
|
||||
width: width,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 8,
|
||||
width: filled,
|
||||
decoration: BoxDecoration(
|
||||
color: barColor,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
895
lib/polls/polls_widgets/poll/poll_submit.dart
Normal file
895
lib/polls/polls_widgets/poll/poll_submit.dart
Normal file
@@ -0,0 +1,895 @@
|
||||
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/creators/creators/poll/poll_list.dart';
|
||||
import 'package:island/polls/polls_widgets/poll/poll_stats_widget.dart';
|
||||
import 'package:island/posts/posts_models/poll.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
|
||||
class PollSubmit extends ConsumerStatefulWidget {
|
||||
const PollSubmit({
|
||||
super.key,
|
||||
required this.pollId,
|
||||
required this.onSubmit,
|
||||
this.initialAnswers,
|
||||
this.onCancel,
|
||||
this.showProgress = true,
|
||||
this.isReadonly = false,
|
||||
this.isInitiallyExpanded = false,
|
||||
});
|
||||
|
||||
final String pollId;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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> {
|
||||
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
|
||||
|
||||
/// Flag to track if user has edited the current question to prevent provider rebuilds from resetting state
|
||||
bool _userHasEdited = false;
|
||||
|
||||
/// Listener for text controller to mark as edited when user types
|
||||
late final VoidCallback _controllerListener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controllerListener = () {
|
||||
_userHasEdited = true;
|
||||
};
|
||||
_textController.addListener(_controllerListener);
|
||||
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
|
||||
// Set initial collapse state based on the parameter
|
||||
_isCollapsed = !widget.isInitiallyExpanded;
|
||||
if (!widget.isReadonly) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// Load initial answers into local state
|
||||
if (_questions != null) {
|
||||
_loadCurrentIntoLocalState();
|
||||
_userHasEdited = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeFromPollData(SnPollWithStats poll) {
|
||||
// Initialize answers from poll data if available
|
||||
if (poll.userAnswer != null && poll.userAnswer!.answer.isNotEmpty) {
|
||||
_answers = Map<String, dynamic>.from(poll.userAnswer!.answer);
|
||||
if (!widget.isReadonly && !_isModifying) {
|
||||
_isModifying = false; // Show modify button if user has answered
|
||||
}
|
||||
}
|
||||
_loadCurrentIntoLocalState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant PollSubmit oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.pollId != widget.pollId) {
|
||||
_index = 0;
|
||||
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
|
||||
// Reset modification state when poll changes
|
||||
_isModifying = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.removeListener(_controllerListener);
|
||||
_textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
SnPollQuestion get _current => _questions![_index];
|
||||
|
||||
void _loadCurrentIntoLocalState() {
|
||||
final q = _current;
|
||||
final saved = _answers[q.id];
|
||||
|
||||
if (!_userHasEdited) {
|
||||
_singleChoiceSelected = null;
|
||||
_multiChoiceSelected.clear();
|
||||
_yesNoSelected = null;
|
||||
_ratingSelected = null;
|
||||
|
||||
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.removeListener(_controllerListener);
|
||||
_textController.text = saved;
|
||||
_textController.addListener(_controllerListener);
|
||||
}
|
||||
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(SnPollWithStats poll) async {
|
||||
// Persist current question before final submit
|
||||
_persistCurrentAnswer();
|
||||
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final dio = ref.read(apiClientProvider);
|
||||
|
||||
await dio.post(
|
||||
'/sphere/polls/${poll.id}/answer',
|
||||
data: {'answer': _answers},
|
||||
);
|
||||
|
||||
// Refresh poll data to show submitted answer
|
||||
ref.invalidate(pollWithStatsProvider(widget.pollId));
|
||||
|
||||
// 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(SnPollWithStats poll) {
|
||||
if (_submitting) return;
|
||||
_persistCurrentAnswer();
|
||||
if (_index < _questions!.length - 1) {
|
||||
setState(() {
|
||||
_index++;
|
||||
_userHasEdited = false;
|
||||
_loadCurrentIntoLocalState();
|
||||
});
|
||||
} else {
|
||||
// Final submit to API
|
||||
_submitToServer(poll);
|
||||
}
|
||||
}
|
||||
|
||||
void _back() {
|
||||
if (_submitting) return;
|
||||
_persistCurrentAnswer();
|
||||
if (_index > 0) {
|
||||
setState(() {
|
||||
_index--;
|
||||
_userHasEdited = false;
|
||||
_loadCurrentIntoLocalState();
|
||||
});
|
||||
} else {
|
||||
// at the first question; allow cancel if provided
|
||||
widget.onCancel?.call();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, SnPollWithStats poll) {
|
||||
final q = _current;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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,
|
||||
Map<String, dynamic>? stats,
|
||||
) {
|
||||
return PollStatsWidget(question: q, stats: stats);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context, SnPollWithStats poll) {
|
||||
final hasUserAnswer =
|
||||
poll.userAnswer != null && poll.userAnswer!.answer.isNotEmpty;
|
||||
if (hasUserAnswer && !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;
|
||||
_userHasEdited = true;
|
||||
}),
|
||||
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);
|
||||
}
|
||||
_userHasEdited = true;
|
||||
});
|
||||
},
|
||||
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;
|
||||
_userHasEdited = true;
|
||||
});
|
||||
},
|
||||
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;
|
||||
_userHasEdited = true;
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFreeText(BuildContext context, SnPollQuestion q) {
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
maxLines: 6,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavBar(BuildContext context, SnPollWithStats poll) {
|
||||
final isLast = _index == _questions!.length - 1;
|
||||
final canProceed = _isCurrentAnswered() && !_submitting;
|
||||
final hasUserAnswer =
|
||||
poll.userAnswer != null && poll.userAnswer!.answer.isNotEmpty;
|
||||
|
||||
if (hasUserAnswer && !_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
|
||||
_userHasEdited = false;
|
||||
_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(poll) : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubmittedView(BuildContext context, SnPollWithStats poll) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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, poll.stats),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadonlyView(BuildContext context, SnPollWithStats poll) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (poll.title != null || poll.description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (poll.title != null)
|
||||
Text(
|
||||
poll.title!,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
if (poll.description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
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, poll.stats),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCollapsedView(BuildContext context, SnPollWithStats poll) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (poll.title != null)
|
||||
Text(
|
||||
poll.title!,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (poll.description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
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) {
|
||||
final pollAsync = ref.watch(pollWithStatsProvider(widget.pollId));
|
||||
|
||||
return pollAsync.when(
|
||||
loading: () => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text('Failed to load poll: $error'),
|
||||
),
|
||||
),
|
||||
data: (poll) {
|
||||
// Initialize questions when data is available
|
||||
_questions = [...poll.questions]
|
||||
..sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
// Initialize answers from poll data
|
||||
_initializeFromPollData(poll);
|
||||
|
||||
if (_questions!.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// If collapsed, show collapsed view for all states
|
||||
if (_isCollapsed) {
|
||||
return _buildCollapsedView(context, poll);
|
||||
}
|
||||
|
||||
// If poll is already submitted and not in readonly mode, and not in modification mode, show submitted view
|
||||
final hasUserAnswer =
|
||||
poll.userAnswer != null && poll.userAnswer!.answer.isNotEmpty;
|
||||
if (hasUserAnswer && !widget.isReadonly && !_isModifying) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildCollapsedView(context, poll),
|
||||
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, poll),
|
||||
_buildNavBar(context, poll),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// If poll is in readonly mode, show readonly view
|
||||
if (widget.isReadonly) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildCollapsedView(context, poll),
|
||||
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, poll),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildCollapsedView(context, poll),
|
||||
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, poll),
|
||||
const SizedBox(height: 12),
|
||||
_AnimatedStep(
|
||||
key: ValueKey(_current.id),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildBody(context, poll),
|
||||
_buildStats(context, _current, poll.stats),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildNavBar(context, poll),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user