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),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|