234 lines
		
	
	
		
			6.8 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			234 lines
		
	
	
		
			6.8 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
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<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),
 | 
						|
                  ),
 | 
						|
                ),
 | 
						|
              ],
 | 
						|
            );
 | 
						|
          },
 | 
						|
        ),
 | 
						|
      ],
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |