diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 2b54eb8..d05bb8c 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -761,6 +761,7 @@ "pollsRecent": "Recent Polls", "pollCreateNew": "Create New", "pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.", + "pollQuestions": "Questions", "publisher": "Publisher", "publisherHint": "Enter the publisher name", "publisherCannotBeEmpty": "Publisher cannot be empty", diff --git a/lib/models/poll.dart b/lib/models/poll.dart index 20e8a3e..2c7b7e9 100644 --- a/lib/models/poll.dart +++ b/lib/models/poll.dart @@ -8,7 +8,7 @@ part 'poll.g.dart'; sealed class SnPollWithStats with _$SnPollWithStats { const factory SnPollWithStats({ required Map? userAnswer, - required Map stats, + @Default({}) Map stats, required String id, required List questions, String? title, diff --git a/lib/models/poll.freezed.dart b/lib/models/poll.freezed.dart index fd3f301..b39ddb1 100644 --- a/lib/models/poll.freezed.dart +++ b/lib/models/poll.freezed.dart @@ -213,7 +213,7 @@ return $default(_that.userAnswer,_that.stats,_that.id,_that.questions,_that.titl @JsonSerializable() class _SnPollWithStats implements SnPollWithStats { - const _SnPollWithStats({required final Map? userAnswer, required final Map stats, required this.id, required final List questions, this.title, this.description, this.endedAt, required this.publisherId, required this.createdAt, required this.updatedAt, this.deletedAt}): _userAnswer = userAnswer,_stats = stats,_questions = questions; + const _SnPollWithStats({required final Map? userAnswer, final Map stats = const {}, required this.id, required final List questions, this.title, this.description, this.endedAt, required this.publisherId, required this.createdAt, required this.updatedAt, this.deletedAt}): _userAnswer = userAnswer,_stats = stats,_questions = questions; factory _SnPollWithStats.fromJson(Map json) => _$SnPollWithStatsFromJson(json); final Map? _userAnswer; @@ -226,7 +226,7 @@ class _SnPollWithStats implements SnPollWithStats { } final Map _stats; -@override Map get stats { +@override@JsonKey() Map get stats { if (_stats is EqualUnmodifiableMapView) return _stats; // ignore: implicit_dynamic_type return EqualUnmodifiableMapView(_stats); diff --git a/lib/models/poll.g.dart b/lib/models/poll.g.dart index bed6484..54bbaa1 100644 --- a/lib/models/poll.g.dart +++ b/lib/models/poll.g.dart @@ -9,7 +9,7 @@ part of 'poll.dart'; _SnPollWithStats _$SnPollWithStatsFromJson(Map json) => _SnPollWithStats( userAnswer: json['user_answer'] as Map?, - stats: json['stats'] as Map, + stats: json['stats'] as Map? ?? const {}, id: json['id'] as String, questions: (json['questions'] as List) diff --git a/lib/screens/creators/poll/poll_list.dart b/lib/screens/creators/poll/poll_list.dart index 0c68d33..732ed01 100644 --- a/lib/screens/creators/poll/poll_list.dart +++ b/lib/screens/creators/poll/poll_list.dart @@ -14,17 +14,19 @@ part 'poll_list.g.dart'; @riverpod class PollListNotifier extends _$PollListNotifier - with CursorPagingNotifierMixin { + with CursorPagingNotifierMixin { static const int _pageSize = 20; @override - Future> build(String? pubName) { + Future> build(String? pubName) { // immediately load first page return fetch(cursor: null); } @override - Future> fetch({required String? cursor}) async { + Future> fetch({ + required String? cursor, + }) async { final client = ref.read(apiClientProvider); final offset = cursor == null ? 0 : int.parse(cursor); @@ -42,7 +44,7 @@ class PollListNotifier extends _$PollListNotifier ); final total = int.parse(response.headers.value('X-Total') ?? '0'); final List data = response.data; - final items = data.map((json) => SnPoll.fromJson(json)).toList(); + final items = data.map((json) => SnPollWithStats.fromJson(json)).toList(); final hasMore = offset + items.length < total; final nextCursor = hasMore ? (offset + items.length).toString() : null; @@ -55,6 +57,13 @@ class PollListNotifier extends _$PollListNotifier } } +@riverpod +Future pollWithStats(Ref ref, String id) async { + final apiClient = ref.watch(apiClientProvider); + final resp = await apiClient.get('/sphere/polls/$id'); + return SnPollWithStats.fromJson(resp.data); +} + class CreatorPollListScreen extends HookConsumerWidget { const CreatorPollListScreen({super.key, required this.pubName}); @@ -64,7 +73,7 @@ class CreatorPollListScreen extends HookConsumerWidget { final result = await GoRouter.of( context, ).pushNamed('creatorPollNew', pathParameters: {'name': pubName}); - if (result is SnPoll && context.mounted) { + if (result is SnPollWithStats && context.mounted) { Navigator.of(context).maybePop(result); } } @@ -92,8 +101,11 @@ class CreatorPollListScreen extends HookConsumerWidget { if (index == widgetCount - 1) { return endItemView; } - final poll = data.items[index]; - return _CreatorPollItem(poll: poll, pubName: pubName); + final pollWithStats = data.items[index]; + return _CreatorPollItem( + pollWithStats: pollWithStats, + pubName: pubName, + ); }, ), ), @@ -106,14 +118,14 @@ class CreatorPollListScreen extends HookConsumerWidget { class _CreatorPollItem extends StatelessWidget { final String pubName; - const _CreatorPollItem({required this.poll, required this.pubName}); + const _CreatorPollItem({required this.pollWithStats, required this.pubName}); - final SnPoll poll; + final SnPollWithStats pollWithStats; @override Widget build(BuildContext context) { final theme = Theme.of(context); - final ended = poll.endedAt; + final ended = pollWithStats.endedAt; final endedText = ended == null ? 'No end' @@ -123,15 +135,16 @@ class _CreatorPollItem extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), clipBehavior: Clip.antiAlias, child: ListTile( - title: Text(poll.title ?? 'Untitled poll'), + title: Text(pollWithStats.title ?? 'Untitled poll'), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (poll.description != null && poll.description!.isNotEmpty) + if (pollWithStats.description != null && + pollWithStats.description!.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4), child: Text( - poll.description!, + pollWithStats.description!, maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -139,7 +152,7 @@ class _CreatorPollItem extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 4), child: Text( - 'Questions: ${poll.questions.length} · Ends: $endedText', + 'Questions: ${pollWithStats.questions.length} · Ends: $endedText', style: theme.textTheme.bodySmall, ), ), @@ -159,7 +172,7 @@ class _CreatorPollItem extends StatelessWidget { onTap: () { GoRouter.of(context).pushNamed( 'creatorPollEdit', - pathParameters: {'name': pubName, 'id': poll.id}, + pathParameters: {'name': pubName, 'id': pollWithStats.id}, ); }, ), @@ -170,8 +183,7 @@ class _CreatorPollItem extends StatelessWidget { context: context, useRootNavigator: true, isScrollControlled: true, - builder: - (context) => PollFeedbackSheet(pollId: poll.id, poll: poll), + builder: (context) => PollFeedbackSheet(pollId: pollWithStats.id), ); }, ), diff --git a/lib/screens/creators/poll/poll_list.g.dart b/lib/screens/creators/poll/poll_list.g.dart index cd4e297..ef637ca 100644 --- a/lib/screens/creators/poll/poll_list.g.dart +++ b/lib/screens/creators/poll/poll_list.g.dart @@ -6,7 +6,7 @@ part of 'poll_list.dart'; // RiverpodGenerator // ************************************************************************** -String _$pollListNotifierHash() => r'd3da24ff6bbb8f35b06d57fc41625dc0312508e4'; +String _$pollWithStatsHash() => r'6bb910046ce1e09368f9922dbec52fdc2cc86740'; /// Copied from Dart SDK class _SystemHash { @@ -29,11 +29,133 @@ class _SystemHash { } } +/// See also [pollWithStats]. +@ProviderFor(pollWithStats) +const pollWithStatsProvider = PollWithStatsFamily(); + +/// See also [pollWithStats]. +class PollWithStatsFamily extends Family> { + /// See also [pollWithStats]. + const PollWithStatsFamily(); + + /// See also [pollWithStats]. + PollWithStatsProvider call(String id) { + return PollWithStatsProvider(id); + } + + @override + PollWithStatsProvider getProviderOverride( + covariant PollWithStatsProvider provider, + ) { + return call(provider.id); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'pollWithStatsProvider'; +} + +/// See also [pollWithStats]. +class PollWithStatsProvider extends AutoDisposeFutureProvider { + /// See also [pollWithStats]. + PollWithStatsProvider(String id) + : this._internal( + (ref) => pollWithStats(ref as PollWithStatsRef, id), + from: pollWithStatsProvider, + name: r'pollWithStatsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$pollWithStatsHash, + dependencies: PollWithStatsFamily._dependencies, + allTransitiveDependencies: + PollWithStatsFamily._allTransitiveDependencies, + id: id, + ); + + PollWithStatsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.id, + }) : super.internal(); + + final String id; + + @override + Override overrideWith( + FutureOr Function(PollWithStatsRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: PollWithStatsProvider._internal( + (ref) => create(ref as PollWithStatsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + id: id, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _PollWithStatsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is PollWithStatsProvider && other.id == id; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, id.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin PollWithStatsRef on AutoDisposeFutureProviderRef { + /// The parameter `id` of this provider. + String get id; +} + +class _PollWithStatsProviderElement + extends AutoDisposeFutureProviderElement + with PollWithStatsRef { + _PollWithStatsProviderElement(super.provider); + + @override + String get id => (origin as PollWithStatsProvider).id; +} + +String _$pollListNotifierHash() => r'd5b822e737788be8982f5cb3b501d460441930c1'; + abstract class _$PollListNotifier - extends BuildlessAutoDisposeAsyncNotifier> { + extends + BuildlessAutoDisposeAsyncNotifier> { late final String? pubName; - FutureOr> build(String? pubName); + FutureOr> build(String? pubName); } /// See also [PollListNotifier]. @@ -42,7 +164,7 @@ const pollListNotifierProvider = PollListNotifierFamily(); /// See also [PollListNotifier]. class PollListNotifierFamily - extends Family>> { + extends Family>> { /// See also [PollListNotifier]. const PollListNotifierFamily(); @@ -78,7 +200,7 @@ class PollListNotifierProvider extends AutoDisposeAsyncNotifierProviderImpl< PollListNotifier, - CursorPagingData + CursorPagingData > { /// See also [PollListNotifier]. PollListNotifierProvider(String? pubName) @@ -109,7 +231,7 @@ class PollListNotifierProvider final String? pubName; @override - FutureOr> runNotifierBuild( + FutureOr> runNotifierBuild( covariant PollListNotifier notifier, ) { return notifier.build(pubName); @@ -134,7 +256,7 @@ class PollListNotifierProvider @override AutoDisposeAsyncNotifierProviderElement< PollListNotifier, - CursorPagingData + CursorPagingData > createElement() { return _PollListNotifierProviderElement(this); @@ -157,7 +279,7 @@ class PollListNotifierProvider @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element mixin PollListNotifierRef - on AutoDisposeAsyncNotifierProviderRef> { + on AutoDisposeAsyncNotifierProviderRef> { /// The parameter `pubName` of this provider. String? get pubName; } @@ -166,7 +288,7 @@ class _PollListNotifierProviderElement extends AutoDisposeAsyncNotifierProviderElement< PollListNotifier, - CursorPagingData + CursorPagingData > with PollListNotifierRef { _PollListNotifierProviderElement(super.provider); diff --git a/lib/widgets/poll/poll_feedback.dart b/lib/widgets/poll/poll_feedback.dart index 87490b0..17dc703 100644 --- a/lib/widgets/poll/poll_feedback.dart +++ b/lib/widgets/poll/poll_feedback.dart @@ -1,9 +1,14 @@ +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/models/poll.dart'; import 'package:island/pods/network.dart'; +import 'package:island/screens/creators/poll/poll_list.dart'; import 'package:island/services/time.dart'; import 'package:island/widgets/content/sheet.dart'; +import 'package:island/widgets/poll/poll_stats_widget.dart'; +import 'package:island/widgets/response.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -52,59 +57,60 @@ class PollFeedbackNotifier extends _$PollFeedbackNotifier class PollFeedbackSheet extends HookConsumerWidget { final String pollId; final String? title; - final SnPoll poll; - final Map? stats; // stats object similar to PollSubmit - const PollFeedbackSheet({ - super.key, - required this.pollId, - required this.poll, - this.title, - this.stats, - }); + const PollFeedbackSheet({super.key, required this.pollId, this.title}); @override Widget build(BuildContext context, WidgetRef ref) { + final poll = ref.watch(pollWithStatsProvider(pollId)); + return SheetScaffold( titleText: title ?? 'Poll feedback', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _PollHeader(poll: poll, stats: stats), - const Divider(height: 1), - Expanded( - child: PagingHelperView( - provider: pollFeedbackNotifierProvider(pollId), - futureRefreshable: pollFeedbackNotifierProvider(pollId).future, - notifierRefreshable: - pollFeedbackNotifierProvider(pollId).notifier, - contentBuilder: - (data, widgetCount, endItemView) => ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: widgetCount, - itemBuilder: (context, index) { - if (index == widgetCount - 1) { - // Provided by PagingHelperView to indicate end/loading - return endItemView; - } - final answer = data.items[index]; - return _PollAnswerTile(answer: answer, poll: poll); - }, - separatorBuilder: - (context, index) => - const Divider(height: 1).padding(vertical: 4), - ), + child: poll.when( + data: + (data) => CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: _PollHeader(poll: data)), + SliverToBoxAdapter(child: const Divider(height: 1)), + SliverGap(4), + PagingHelperSliverView( + provider: pollFeedbackNotifierProvider(pollId), + futureRefreshable: + pollFeedbackNotifierProvider(pollId).future, + notifierRefreshable: + pollFeedbackNotifierProvider(pollId).notifier, + contentBuilder: + (val, widgetCount, endItemView) => SliverList.separated( + itemCount: widgetCount, + itemBuilder: (context, index) { + if (index == widgetCount - 1) { + // Provided by PagingHelperView to indicate end/loading + return endItemView; + } + final answer = val.items[index]; + return _PollAnswerTile(answer: answer, poll: data); + }, + separatorBuilder: + (context, index) => + 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, this.stats}); - final SnPoll poll; - final Map? stats; + const _PollHeader({required this.poll}); + final SnPollWithStats poll; @override Widget build(BuildContext context) { @@ -112,18 +118,32 @@ class _PollHeader extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, children: [ - if (poll.title != null) - Text(poll.title!, style: theme.textTheme.titleLarge), - if (poll.description != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - poll.description!, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7), - ), - ), + 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), + ), + ), + ], + ), + Text('pollQuestions').tr().fontSize(17).bold(), + for (final q in poll.questions) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + 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, vertical: 16); @@ -132,7 +152,7 @@ class _PollHeader extends StatelessWidget { class _PollAnswerTile extends StatelessWidget { final SnPollAnswer answer; - final SnPoll poll; + final SnPollWithStats poll; const _PollAnswerTile({required this.answer, required this.poll}); String _formatPerQuestionAnswer( diff --git a/lib/widgets/poll/poll_stats_widget.dart b/lib/widgets/poll/poll_stats_widget.dart new file mode 100644 index 0000000..e0bb682 --- /dev/null +++ b/lib/widgets/poll/poll_stats_widget.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:island/models/poll.dart'; + +class PollStatsWidget extends StatelessWidget { + const PollStatsWidget({ + super.key, + required this.question, + required this.stats, + }); + + final SnPollQuestion question; + final Map? 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), + ), + ), + ], + ); + }, + ), + ], + ); + } +} diff --git a/lib/widgets/poll/poll_submit.dart b/lib/widgets/poll/poll_submit.dart index 6bf67b7..619679f 100644 --- a/lib/widgets/poll/poll_submit.dart +++ b/lib/widgets/poll/poll_submit.dart @@ -4,6 +4,7 @@ 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({ @@ -42,6 +43,7 @@ class _PollSubmitState extends ConsumerState { late final List _questions; int _index = 0; bool _submitting = false; + bool _isModifying = false; // New state to track if user is modifying answers /// Collected answers, keyed by questionId late Map _answers; @@ -64,6 +66,11 @@ class _PollSubmitState extends ConsumerState { _answers = Map.from(widget.initialAnswers ?? {}); 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; + } } } @@ -81,6 +88,8 @@ class _PollSubmitState extends ConsumerState { ); if (!widget.isReadonly) { _loadCurrentIntoLocalState(); + // If poll ID changes, reset modification state + _isModifying = false; } } } @@ -266,16 +275,17 @@ class _PollSubmitState extends ConsumerState { child: Text( widget.poll.description!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).textTheme.bodyMedium?.color?.withOpacity(0.7), - ), + color: Theme.of( + context, + ).textTheme.bodyMedium?.color?.withOpacity(0.7), + ), ), ), ], ), ), - if (widget.showProgress) + if (widget.showProgress && + _isModifying) // Only show progress when modifying Text( '${_index + 1} / ${_questions.length}', style: Theme.of(context).textTheme.labelMedium, @@ -294,8 +304,8 @@ class _PollSubmitState extends ConsumerState { child: Text( '*', style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.error, - ), + color: Theme.of(context).colorScheme.error, + ), ), ), ], @@ -306,10 +316,10 @@ class _PollSubmitState extends ConsumerState { child: Text( q.description!, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).textTheme.bodySmall?.color?.withOpacity(0.7), - ), + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withOpacity(0.7), + ), ), ), ], @@ -317,152 +327,13 @@ class _PollSubmitState extends ConsumerState { } Widget _buildStats(BuildContext context, SnPollQuestion q) { - if (widget.stats == null) return const SizedBox.shrink(); - final raw = widget.stats![q.id]; - if (raw == null) return const SizedBox.shrink(); - - Widget? body; - - switch (q.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 = [...?q.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 const SizedBox.shrink(); - - 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, - ], - ), - ), - ), - ); + 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: @@ -573,22 +444,55 @@ class _PollSubmitState extends ConsumerState { 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 Row( + children: [ + const Spacer(), + FilledButton.icon( + icon: const Icon(Icons.edit), + label: const Text('Modify Answers'), + 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' : 'Back'), - onPressed: _submitting ? null : _back, + 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), + icon: + _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(isLast ? Icons.check : Icons.arrow_forward), label: Text(isLast ? 'Submit' : 'Next'), onPressed: canProceed ? _next : null, ), @@ -596,6 +500,84 @@ class _PollSubmitState extends ConsumerState { ); } + 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, @@ -617,10 +599,10 @@ class _PollSubmitState extends ConsumerState { child: Text( widget.poll.description!, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).textTheme.bodyMedium?.color?.withOpacity(0.7), - ), + color: Theme.of( + context, + ).textTheme.bodyMedium?.color?.withOpacity(0.7), + ), ), ), ], @@ -645,9 +627,11 @@ class _PollSubmitState extends ConsumerState { padding: const EdgeInsets.only(left: 8), child: Text( '*', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.error, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + ), ), ), ], @@ -658,10 +642,10 @@ class _PollSubmitState extends ConsumerState { child: Text( q.description!, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).textTheme.bodySmall?.color?.withOpacity(0.7), - ), + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withOpacity(0.7), + ), ), ), _buildStats(context, q), @@ -678,6 +662,19 @@ class _PollSubmitState extends ConsumerState { return const SizedBox.shrink(); } + // 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: [ + _buildSubmittedView(context), + const SizedBox(height: 16), + _buildNavBar(context), + ], + ); + } + + // If poll is in readonly mode, show readonly view if (widget.isReadonly) { return _buildReadonlyView(context); } @@ -701,76 +698,6 @@ class _PollSubmitState extends ConsumerState { } } -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), - ), - ), - ], - ); - }, - ), - ], - ); - } -} - /// Simple fade/slide transition between questions. class _AnimatedStep extends StatelessWidget { const _AnimatedStep({super.key, required this.child}); @@ -794,4 +721,4 @@ class _AnimatedStep extends StatelessWidget { child: child, ); } -} \ No newline at end of file +} diff --git a/lib/widgets/post/compose_poll.dart b/lib/widgets/post/compose_poll.dart index fe5eb09..65293ef 100644 --- a/lib/widgets/post/compose_poll.dart +++ b/lib/widgets/post/compose_poll.dart @@ -186,10 +186,9 @@ class ComposePollSheet extends HookConsumerWidget { ); } - Widget? _buildPollSubtitle(SnPoll poll) { + Widget? _buildPollSubtitle(SnPollWithStats poll) { try { - final SnPoll dyn = poll; - final List options = dyn.questions; + final List options = poll.questions; if (options.isEmpty) return null; final preview = options.take(3).map((e) => e.title).join(' · '); if (preview.trim().isEmpty) return null; diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 27140dc..07a0f03 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -354,6 +354,7 @@ class PostItem extends HookConsumerWidget { final translatedWidget = (translatedText.value?.isNotEmpty ?? false) ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ @@ -398,6 +399,7 @@ class PostItem extends HookConsumerWidget { : null; final translationSection = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (translatedWidget != null) translatedWidget, if (translatableWidget != null) translatableWidget, diff --git a/lib/widgets/post/post_shared.dart b/lib/widgets/post/post_shared.dart index 8e0d1d8..7589111 100644 --- a/lib/widgets/post/post_shared.dart +++ b/lib/widgets/post/post_shared.dart @@ -639,13 +639,18 @@ class PostBody extends ConsumerWidget { if (!isFullPost && item.type == 1) Container( decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, border: Border.all( color: Theme.of(context).dividerColor.withOpacity(0.5), ), - borderRadius: const BorderRadius.all(Radius.circular(16)), + borderRadius: const BorderRadius.all(Radius.circular(8)), ), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - margin: const EdgeInsets.only(top: 4), + margin: EdgeInsets.only( + top: 4, + left: renderingPadding.horizontal, + right: renderingPadding.vertical, + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch,