From 4e00c8359142d584d18a48240e115ecd91798292 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 19 Jun 2026 02:42:47 +0200 Subject: [PATCH] feat(ui): heatmap view modes (all / jurist-reviewed / marked) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/lib/pages/heatmap_page.dart | 128 +++++++++++++++++++++++++++++- app/lib/widgets/heatmap_grid.dart | 36 +++++++++ 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/app/lib/pages/heatmap_page.dart b/app/lib/pages/heatmap_page.dart index 3e1200c..2af5fa0 100644 --- a/app/lib/pages/heatmap_page.dart +++ b/app/lib/pages/heatmap_page.dart @@ -10,6 +10,19 @@ import '../widgets/reclaim_card.dart'; import '../widgets/tier_badge.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 { const HeatmapPage({super.key, required this.repository}); @@ -21,6 +34,7 @@ class HeatmapPage extends StatefulWidget { class _HeatmapPageState extends State { late Future> _future; + _HeatmapView _view = _HeatmapView.all; @override void initState() { @@ -45,6 +59,9 @@ class _HeatmapPageState extends State { child: CircularProgressIndicator()); } final items = snap.data!; + final shown = _view == _HeatmapView.reviewedOnly + ? items.where((e) => e.isReviewed).toList() + : items; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -58,15 +75,29 @@ class _HeatmapPageState extends State { 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), Expanded( child: items.isEmpty ? _EmptyHubState(repository: widget.repository) - : ReclaimCard( + : shown.isEmpty + ? const _NoReviewedState() + : ReclaimCard( accent: true, expand: true, child: HeatmapGrid( - evaluations: items, + evaluations: shown, + markReviewed: + _view == _HeatmapView.marked, onTap: (e) { Navigator.of(context).push( MaterialPageRoute( @@ -88,7 +119,7 @@ class _HeatmapPageState extends State { spacing: ReclaimSpace.md, runSpacing: ReclaimSpace.sm, children: [ - for (final e in items) + for (final e in shown) InkWell( onTap: () { 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 { const _MiniLabel({required this.symbol, required this.text}); diff --git a/app/lib/widgets/heatmap_grid.dart b/app/lib/widgets/heatmap_grid.dart index 75c6d30..0ec4f13 100644 --- a/app/lib/widgets/heatmap_grid.dart +++ b/app/lib/widgets/heatmap_grid.dart @@ -14,11 +14,16 @@ class HeatmapGrid extends StatefulWidget { super.key, required this.evaluations, this.onTap, + this.markReviewed = false, }); final List evaluations; 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 State createState() => _HeatmapGridState(); } @@ -45,6 +50,7 @@ class _HeatmapGridState extends State { painter: _HeatmapPainter( evaluations: widget.evaluations, hoverIndex: _hoverIndex, + markReviewed: widget.markReviewed, theme: Theme.of(context), ), size: size, @@ -110,11 +116,13 @@ class _HeatmapPainter extends CustomPainter { _HeatmapPainter({ required this.evaluations, required this.hoverIndex, + required this.markReviewed, required this.theme, }); final List evaluations; final int? hoverIndex; + final bool markReviewed; final ThemeData theme; @override @@ -217,6 +225,33 @@ class _HeatmapPainter extends CustomPainter { canvas.drawCircle(c, r, body); 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( canvas, '${e.norm.jurabk} ${e.norm.paragraph}', @@ -303,5 +338,6 @@ class _HeatmapPainter extends CustomPainter { @override bool shouldRepaint(covariant _HeatmapPainter old) => old.hoverIndex != hoverIndex || + old.markReviewed != markReviewed || old.evaluations.length != evaluations.length; }