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 '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<HeatmapPage> {
late Future<List<Evaluation>> _future;
_HeatmapView _view = _HeatmapView.all;
@override
void initState() {
@ -45,6 +59,9 @@ class _HeatmapPageState extends State<HeatmapPage> {
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<HeatmapPage> {
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<HeatmapPage> {
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});