diff --git a/lib/screens/creators/poll/poll_list.dart b/lib/screens/creators/poll/poll_list.dart index 89ff31d..d58c7f3 100644 --- a/lib/screens/creators/poll/poll_list.dart +++ b/lib/screens/creators/poll/poll_list.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/poll.dart'; import 'package:island/pods/network.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; @@ -57,11 +59,9 @@ class CreatorPollListScreen extends HookConsumerWidget { final String pubName; Future _createPoll(BuildContext context) async { - // Use named route defined in router with :name param (creatorPollNew) final result = await GoRouter.of( context, ).pushNamed('creatorPollNew', pathParameters: {'name': pubName}); - // If PollEditorScreen returns a created SnPoll on success, pop back with it if (result is SnPoll && context.mounted) { Navigator.of(context).maybePop(result); } @@ -91,7 +91,7 @@ class CreatorPollListScreen extends HookConsumerWidget { return endItemView; } final poll = data.items[index]; - return _CreatorPollItem(poll: poll); + return _CreatorPollItem(poll: poll, pubName: pubName); }, ), ), @@ -103,7 +103,8 @@ class CreatorPollListScreen extends HookConsumerWidget { } class _CreatorPollItem extends StatelessWidget { - const _CreatorPollItem({required this.poll}); + final String pubName; + const _CreatorPollItem({required this.poll, required this.pubName}); final SnPoll poll; @@ -143,24 +144,23 @@ class _CreatorPollItem extends StatelessWidget { ], ), trailing: PopupMenuButton( - onSelected: (v) { - switch (v) { - case 'edit': - // Use global router helper if desired - // context.push('/creators/${poll.publisher?.name ?? ''}/polls/${poll.id}/edit'); - Navigator.of(context).pushNamed( - 'creatorPollEdit', - arguments: { - 'name': poll.publisher?.name ?? '', - 'id': poll.id, - }, - ); - break; - } - }, itemBuilder: (context) => [ - const PopupMenuItem(value: 'edit', child: Text('Edit')), + PopupMenuItem( + child: Row( + children: [ + const Icon(Symbols.edit), + const Gap(16), + Text('Edit'), + ], + ), + onTap: () { + GoRouter.of(context).pushNamed( + 'creatorPollEdit', + pathParameters: {'name': pubName, 'id': poll.id}, + ); + }, + ), ], ), onTap: () { diff --git a/lib/screens/poll/poll_editor.dart b/lib/screens/poll/poll_editor.dart index abf138d..f69ed89 100644 --- a/lib/screens/poll/poll_editor.dart +++ b/lib/screens/poll/poll_editor.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -6,7 +8,6 @@ import 'package:dio/dio.dart'; import 'package:gap/gap.dart'; import 'package:island/pods/network.dart'; import 'package:island/widgets/alert.dart'; -import 'package:island/widgets/post/publishers_modal.dart'; import 'package:island/models/poll.dart'; import 'package:uuid/uuid.dart'; @@ -63,14 +64,38 @@ class PollEditor extends Notifier { ); } - void setEditingId(String? id) { - state = PollEditorState( - id: id, - title: state.title, - description: state.description, - endedAt: state.endedAt, - questions: [...state.questions], - ); + Future setEditingId(BuildContext context, String? id) async { + if (id == null || id.isEmpty) return; + + showLoadingModal(context); + final dio = ref.read(apiClientProvider); + try { + final res = await dio.get('/sphere/polls/$id'); + + // Handle both plain object and wrapped response formats. + final dynamic payload = res.data; + final Map json = + payload is Map && payload['data'] is Map + ? Map.from(payload['data'] as Map) + : Map.from(payload as Map); + + final poll = SnPoll.fromJson(json); + + state = PollEditorState( + id: poll.id, + title: poll.title, + description: poll.description, + endedAt: poll.endedAt, + questions: poll.questions, + ); + } on DioException catch (e) { + log('Failed to load poll $id: ${e.message}'); + // Keep state with id set; UI may handle error display. + } catch (e) { + log('Unexpected error loading poll $id: $e'); + } finally { + if (context.mounted) hideLoadingModal(context); + } } void addQuestion(SnPollQuestionType type) { @@ -313,32 +338,10 @@ class PollEditorScreen extends ConsumerWidget { // Submit helpers declared before build to avoid forward reference issues - static Future _submitPoll(BuildContext context, WidgetRef ref) async { + Future _submitPoll(BuildContext context, WidgetRef ref) async { final model = ref.watch(pollEditorProvider); final dio = ref.read(apiClientProvider); - // Pick publisher (required) - final pickedPublisher = await showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - builder: (_) => const PublisherModal(), - ); - - if (pickedPublisher == null) { - showSnackBar('Publisher is required'); - return; - } - - final String publisherName = - pickedPublisher.name ?? pickedPublisher['name'] ?? ''; - if (publisherName.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Invalid publisher selected')), - ); - return; - } - // Build payload final body = { 'title': model.title, @@ -376,33 +379,21 @@ class PollEditorScreen extends ConsumerWidget { await (isUpdate ? dio.patch( path, - queryParameters: {'pub': publisherName}, + queryParameters: {'pub': initialPublisher}, data: body, ) : dio.post( path, - queryParameters: {'pub': publisherName}, + queryParameters: {'pub': initialPublisher}, data: body, )); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(isUpdate ? 'Poll updated.' : 'Poll created.')), - ); + showSnackBar(isUpdate ? 'Poll updated.' : 'Poll created.'); if (!context.mounted) return; Navigator.of(context).maybePop(res.data); - } on DioException catch (e) { - final msg = - e.response?.data is Map && (e.response!.data['message'] != null) - ? e.response!.data['message'].toString() - : e.message ?? 'Network error'; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Failed: $msg'))); } catch (e) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Unexpected error: $e'))); + showErrorAlert(e); } } @@ -417,7 +408,9 @@ class PollEditorScreen extends ConsumerWidget { // initialize editing state if provided if (initialPollId != null && model.id != initialPollId) { - notifier.setEditingId(initialPollId); + Future(() { + if (context.mounted) notifier.setEditingId(context, initialPollId); + }); } return Scaffold( @@ -437,6 +430,7 @@ class PollEditorScreen extends ConsumerWidget { ), body: SafeArea( child: Form( + key: ValueKey(model.id), child: ListView( padding: const EdgeInsets.all(16), children: [ diff --git a/lib/widgets/poll/poll_submit.dart b/lib/widgets/poll/poll_submit.dart index 9d34da6..b7b56d1 100644 --- a/lib/widgets/poll/poll_submit.dart +++ b/lib/widgets/poll/poll_submit.dart @@ -23,6 +23,7 @@ class PollSubmit extends ConsumerStatefulWidget { super.key, required this.poll, required this.onSubmit, + required this.stats, this.initialAnswers, this.onCancel, this.showProgress = true, @@ -35,6 +36,7 @@ class PollSubmit extends ConsumerStatefulWidget { /// Optional initial answers, keyed by questionId. final Map? initialAnswers; + final Map? stats; /// Optional cancel callback. final VoidCallback? onCancel; @@ -321,6 +323,153 @@ 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(); + 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, + ], + ), + ), + ), + ); + } + Widget _buildBody(BuildContext context) { final q = _current; switch (q.type) { @@ -467,7 +616,13 @@ class _PollSubmitState extends ConsumerState { children: [ _buildHeader(context), const SizedBox(height: 12), - _AnimatedStep(key: ValueKey(_current.id), child: _buildBody(context)), + _AnimatedStep( + key: ValueKey(_current.id), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [_buildBody(context), _buildStats(context, _current)], + ), + ), const SizedBox(height: 16), _buildNavBar(context), ], @@ -475,6 +630,77 @@ 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}); diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 6539491..d3b8a98 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -566,9 +566,10 @@ class PostItem extends HookConsumerWidget { ), child: PollSubmit( initialAnswers: embedData['poll']?['user_answer']?['answer'], + stats: embedData['poll']?['stats'], poll: SnPollWithStats.fromJson(embedData['poll']), onSubmit: (_) {}, - ).padding(horizontal: 12, vertical: 8), + ).padding(horizontal: 16, vertical: 12), ), _ => const Placeholder(), },