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:
parent
bc3a001374
commit
4e00c83591
2 changed files with 161 additions and 3 deletions
|
|
@ -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)
|
||||||
: ReclaimCard(
|
: shown.isEmpty
|
||||||
|
? const _NoReviewedState()
|
||||||
|
: 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});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue