278 lines
		
	
	
		
			9.6 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			278 lines
		
	
	
		
			9.6 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
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/account/account_pfc.dart';
 | 
						|
import 'package:island/widgets/content/cloud_files.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';
 | 
						|
 | 
						|
part 'poll_feedback.g.dart';
 | 
						|
 | 
						|
@riverpod
 | 
						|
class PollFeedbackNotifier extends _$PollFeedbackNotifier
 | 
						|
    with CursorPagingNotifierMixin<SnPollAnswer> {
 | 
						|
  static const int _pageSize = 20;
 | 
						|
 | 
						|
  @override
 | 
						|
  Future<CursorPagingData<SnPollAnswer>> build(String id) {
 | 
						|
    // immediately load first page
 | 
						|
    return fetch(cursor: null);
 | 
						|
  }
 | 
						|
 | 
						|
  @override
 | 
						|
  Future<CursorPagingData<SnPollAnswer>> fetch({
 | 
						|
    required String? cursor,
 | 
						|
  }) async {
 | 
						|
    final client = ref.read(apiClientProvider);
 | 
						|
    final offset = cursor == null ? 0 : int.parse(cursor);
 | 
						|
 | 
						|
    final queryParams = {'offset': offset, 'take': _pageSize};
 | 
						|
 | 
						|
    final response = await client.get(
 | 
						|
      '/sphere/polls/$id/feedback',
 | 
						|
      queryParameters: queryParams,
 | 
						|
    );
 | 
						|
    final total = int.parse(response.headers.value('X-Total') ?? '0');
 | 
						|
    final List<dynamic> data = response.data;
 | 
						|
    final items = data.map((json) => SnPollAnswer.fromJson(json)).toList();
 | 
						|
 | 
						|
    final hasMore = offset + items.length < total;
 | 
						|
    final nextCursor = hasMore ? (offset + items.length).toString() : null;
 | 
						|
 | 
						|
    return CursorPagingData(
 | 
						|
      items: items,
 | 
						|
      hasMore: hasMore,
 | 
						|
      nextCursor: nextCursor,
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
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));
 | 
						|
 | 
						|
    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),
 | 
						|
                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});
 | 
						|
  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),
 | 
						|
              )
 | 
						|
              : AccountPfcGestureDetector(
 | 
						|
                uname: answer.account!.name,
 | 
						|
                child: ProfilePictureWidget(
 | 
						|
                  file: answer.account!.profile.picture,
 | 
						|
                ),
 | 
						|
              ),
 | 
						|
      title: Text(submitText),
 | 
						|
      subtitle: Text(content),
 | 
						|
      trailing: null,
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |