feat(ui): heatmap view modes (all / jurist-reviewed / marked)

A segmented toggle over the heatmap: 'Alle' shows every evaluation,
'Geprüft (N)' filters to jurist-confirmed ones, 'Markiert' shows all
with a confirm-green check on the reviewed points. Jurists are
optional — the platform works without them and the default is 'Alle';
review only confirms a figure. Empty filtered view gets its own hint.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-19 02:42:47 +02:00
parent bc3a001374
commit 4e00c83591
2 changed files with 161 additions and 3 deletions

View file

@ -10,6 +10,19 @@ import '../widgets/reclaim_card.dart';
import '../widgets/tier_badge.dart'; import '../widgets/tier_badge.dart';
import 'norm_detail_page.dart'; import 'norm_detail_page.dart';
/// Heatmap view modes. The platform works WITHOUT jurists a jurist
/// pass only *confirms* an evaluation so the default shows all.
enum _HeatmapView {
/// Every evaluation, jurist-reviewed or not.
all,
/// Only evaluations a jurist has confirmed.
reviewedOnly,
/// All evaluations, but reviewed ones carry a confirm marker.
marked,
}
class HeatmapPage extends StatefulWidget { class HeatmapPage extends StatefulWidget {
const HeatmapPage({super.key, required this.repository}); const HeatmapPage({super.key, required this.repository});
@ -21,6 +34,7 @@ class HeatmapPage extends StatefulWidget {
class _HeatmapPageState extends State<HeatmapPage> { class _HeatmapPageState extends State<HeatmapPage> {
late Future<List<Evaluation>> _future; late Future<List<Evaluation>> _future;
_HeatmapView _view = _HeatmapView.all;
@override @override
void initState() { void initState() {
@ -45,6 +59,9 @@ class _HeatmapPageState extends State<HeatmapPage> {
child: CircularProgressIndicator()); child: CircularProgressIndicator());
} }
final items = snap.data!; final items = snap.data!;
final shown = _view == _HeatmapView.reviewedOnly
? items.where((e) => e.isReviewed).toList()
: items;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -58,15 +75,29 @@ class _HeatmapPageState extends State<HeatmapPage> {
color: ReclaimColors.mute, color: ReclaimColors.mute,
), ),
), ),
if (items.isNotEmpty) ...[
const SizedBox(height: ReclaimSpace.md),
_ViewModeSelector(
view: _view,
reviewedCount:
items.where((e) => e.isReviewed).length,
total: items.length,
onChanged: (v) => setState(() => _view = v),
),
],
const SizedBox(height: ReclaimSpace.lg), const SizedBox(height: ReclaimSpace.lg),
Expanded( Expanded(
child: items.isEmpty child: items.isEmpty
? _EmptyHubState(repository: widget.repository) ? _EmptyHubState(repository: widget.repository)
: shown.isEmpty
? const _NoReviewedState()
: ReclaimCard( : ReclaimCard(
accent: true, accent: true,
expand: true, expand: true,
child: HeatmapGrid( child: HeatmapGrid(
evaluations: items, evaluations: shown,
markReviewed:
_view == _HeatmapView.marked,
onTap: (e) { onTap: (e) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
@ -88,7 +119,7 @@ class _HeatmapPageState extends State<HeatmapPage> {
spacing: ReclaimSpace.md, spacing: ReclaimSpace.md,
runSpacing: ReclaimSpace.sm, runSpacing: ReclaimSpace.sm,
children: [ children: [
for (final e in items) for (final e in shown)
InkWell( InkWell(
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(context).push(
@ -375,6 +406,97 @@ class _EmptyHubState extends StatelessWidget {
} }
} }
/// Heatmap view-mode toggle: all / jurist-reviewed only / marked.
class _ViewModeSelector extends StatelessWidget {
const _ViewModeSelector({
required this.view,
required this.reviewedCount,
required this.total,
required this.onChanged,
});
final _HeatmapView view;
final int reviewedCount;
final int total;
final ValueChanged<_HeatmapView> onChanged;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Row(
children: [
const Icon(Icons.visibility_outlined,
size: 16, color: ReclaimColors.mute),
const SizedBox(width: ReclaimSpace.sm),
Text('Ansicht',
style: t.labelLarge?.copyWith(color: ReclaimColors.mute)),
const SizedBox(width: ReclaimSpace.md),
SegmentedButton<_HeatmapView>(
showSelectedIcon: false,
style: const ButtonStyle(visualDensity: VisualDensity.compact),
segments: [
const ButtonSegment(
value: _HeatmapView.all,
label: Text('Alle'),
icon: Icon(Icons.scatter_plot_outlined, size: 15),
),
ButtonSegment(
value: _HeatmapView.reviewedOnly,
label: Text('Geprüft ($reviewedCount)'),
icon: const Icon(Icons.verified_outlined, size: 15),
),
const ButtonSegment(
value: _HeatmapView.marked,
label: Text('Markiert'),
icon: Icon(Icons.label_important_outline, size: 15),
),
],
selected: {view},
onSelectionChanged: (s) => onChanged(s.first),
),
],
);
}
}
/// Shown when the 'jurist-reviewed only' filter leaves nothing.
class _NoReviewedState extends StatelessWidget {
const _NoReviewedState();
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ReclaimCard(
accent: true,
expand: true,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.verified_outlined,
size: 40, color: ReclaimColors.mute),
const SizedBox(height: ReclaimSpace.md),
Text('Noch keine juristen-geprüften Auswertungen',
style: t.headlineSmall),
const SizedBox(height: ReclaimSpace.sm),
Text(
'Die Plattform funktioniert auch ohne Juristen — wechsle '
'auf „Alle", um alle Auswertungen zu sehen. Ein '
'Juristen-Review bestätigt eine Auswertung zusätzlich.',
style: t.bodyLarge
?.copyWith(color: ReclaimColors.mute, height: 1.4),
),
],
),
),
),
);
}
}
class _MiniLabel extends StatelessWidget { class _MiniLabel extends StatelessWidget {
const _MiniLabel({required this.symbol, required this.text}); const _MiniLabel({required this.symbol, required this.text});

View file

@ -14,11 +14,16 @@ class HeatmapGrid extends StatefulWidget {
super.key, super.key,
required this.evaluations, required this.evaluations,
this.onTap, this.onTap,
this.markReviewed = false,
}); });
final List<Evaluation> evaluations; final List<Evaluation> evaluations;
final void Function(Evaluation)? onTap; final void Function(Evaluation)? onTap;
/// When true, points whose evaluation a jurist confirmed get a
/// confirm marker (used by the heatmap's "Markiert" view mode).
final bool markReviewed;
@override @override
State<HeatmapGrid> createState() => _HeatmapGridState(); State<HeatmapGrid> createState() => _HeatmapGridState();
} }
@ -45,6 +50,7 @@ class _HeatmapGridState extends State<HeatmapGrid> {
painter: _HeatmapPainter( painter: _HeatmapPainter(
evaluations: widget.evaluations, evaluations: widget.evaluations,
hoverIndex: _hoverIndex, hoverIndex: _hoverIndex,
markReviewed: widget.markReviewed,
theme: Theme.of(context), theme: Theme.of(context),
), ),
size: size, size: size,
@ -110,11 +116,13 @@ class _HeatmapPainter extends CustomPainter {
_HeatmapPainter({ _HeatmapPainter({
required this.evaluations, required this.evaluations,
required this.hoverIndex, required this.hoverIndex,
required this.markReviewed,
required this.theme, required this.theme,
}); });
final List<Evaluation> evaluations; final List<Evaluation> evaluations;
final int? hoverIndex; final int? hoverIndex;
final bool markReviewed;
final ThemeData theme; final ThemeData theme;
@override @override
@ -217,6 +225,33 @@ class _HeatmapPainter extends CustomPainter {
canvas.drawCircle(c, r, body); canvas.drawCircle(c, r, body);
canvas.drawCircle(c, r, ring); canvas.drawCircle(c, r, ring);
// Jurist-confirmed marker (heatmap "Markiert" mode): a small
// confirm-green dot with a white check at the point's NE edge.
if (markReviewed && e.isReviewed) {
final m = Offset(c.dx + r * 0.72, c.dy - r * 0.72);
canvas.drawCircle(
m, 5.5, Paint()..color = ReclaimColors.reviewConfirmed);
canvas.drawCircle(
m,
5.5,
Paint()
..color = theme.colorScheme.surface
..style = PaintingStyle.stroke
..strokeWidth = 1.5);
final check = Path()
..moveTo(m.dx - 2.4, m.dy + 0.2)
..lineTo(m.dx - 0.6, m.dy + 2.0)
..lineTo(m.dx + 2.6, m.dy - 2.2);
canvas.drawPath(
check,
Paint()
..color = theme.colorScheme.surface
..style = PaintingStyle.stroke
..strokeWidth = 1.4
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round);
}
_drawText( _drawText(
canvas, canvas,
'${e.norm.jurabk} ${e.norm.paragraph}', '${e.norm.jurabk} ${e.norm.paragraph}',
@ -303,5 +338,6 @@ class _HeatmapPainter extends CustomPainter {
@override @override
bool shouldRepaint(covariant _HeatmapPainter old) => bool shouldRepaint(covariant _HeatmapPainter old) =>
old.hoverIndex != hoverIndex || old.hoverIndex != hoverIndex ||
old.markReviewed != markReviewed ||
old.evaluations.length != evaluations.length; old.evaluations.length != evaluations.length;
} }