feat: initial F∆I Law-Heatmap Phase 0 scaffold
First end-to-end-buildable cut of the pflicht-/graph-basierte
Wirkungsanalyse von Rechtsnormen, running on Ch∆In. Demo runs
without external dependencies and without a hub connection so
the methodology + UI stack can be reviewed before any Verbands-
Kooperation or Beirats-Validierung kicks in.
Tree layout
MACHBARKEITSSTUDIE.md v0.3 — pflicht-basierte Methodik,
4-Tier-Evidenz, 8 law*-Module
auf Ch∆In statt der alten F∆I-
Plattform.
RUN.md one-page how-to-run.
flows/durchstich-gewo-14.yaml
vertical Phase-0 flow: pull →
normalize → duties → citations →
cohort → skm → frust → benefit →
attribution → system.approval.
app/ Flutter macOS client, design
language pulled from fai_web
(ink #08090A, paper #F4F3EF,
petrol signal #2E8F9E). Mock
repository ships five fixture
norms with real source URLs and a
permanent T4 demo-banner so no
figure can be mistaken for a
validated one.
Module repos (separate, see chain-modules*/):
text-akoma-normalize, text-deontic-extract, graph-citation-
extract, text-readability-score, stats-cohort-size, graph-
shapley-attribution, econ-skm-score, law-benefit-score.
What runs today
- flutter analyze: 0 issues
- flutter test: 2/2 (landing + nav-to-shell smoke)
- flutter build macos (via app/build-macos.sh) and ad-hoc
codesign, app launches and the Dart VM service comes up
- native cargo test green on every module
- cargo build --release --target wasm32-wasip2 produces a
130 KiB artefact for text-akoma-normalize
What is deliberately mock / stub
- gRPC wire from the client to a chain serve hub (Repository
abstraction is in place; live impl is the next step)
- NKR Bürokratiekosten-Datenbank ingestion (for the canonical
h-Werte that close out the Engpass per Studie §6.7)
- DESTATIS GENESIS-API adapter for stats.cohort_size
License: Apache-2.0. Author/contact in MACHBARKEITSSTUDIE.md.
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
commit
8ee6a294a1
64 changed files with 5641 additions and 0 deletions
98
app/lib/widgets/delta_mark.dart
Normal file
98
app/lib/widgets/delta_mark.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/lawheatmap_tokens.dart';
|
||||
|
||||
/// The F∆I / Ch∆In brand mark: an upward triangle (the `∆`) with
|
||||
/// a petrol "eye" at the centre. Geometry mirrors fai_web's
|
||||
/// Logo.astro — the triangle IS the "A"; the eye-dot IS the dot
|
||||
/// of the "i".
|
||||
///
|
||||
/// Subtle 9s breathing pulse on the eye (matches fai_web's
|
||||
/// `.eye-pulse`). Set [animated] to false for static contexts
|
||||
/// (icons, screenshots).
|
||||
class DeltaMark extends StatefulWidget {
|
||||
const DeltaMark({super.key, this.size = 64, this.animated = true});
|
||||
|
||||
final double size;
|
||||
final bool animated;
|
||||
|
||||
@override
|
||||
State<DeltaMark> createState() => _DeltaMarkState();
|
||||
}
|
||||
|
||||
class _DeltaMarkState extends State<DeltaMark>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 9),
|
||||
);
|
||||
if (widget.animated) {
|
||||
_controller.repeat();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
final t = _controller.value;
|
||||
// Breathe: scale 1 → 1.28 → 1 over the cycle.
|
||||
final pulse = 1.0 + 0.28 * (1 - (math.cos(t * 2 * math.pi).abs()));
|
||||
return CustomPaint(
|
||||
size: Size.square(widget.size),
|
||||
painter: _DeltaPainter(pulse: widget.animated ? pulse : 1.0),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeltaPainter extends CustomPainter {
|
||||
_DeltaPainter({required this.pulse});
|
||||
|
||||
final double pulse;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width;
|
||||
final h = size.height;
|
||||
|
||||
// Upward triangle, geometric centre.
|
||||
final stroke = Paint()
|
||||
..color = const Color(0xFFE9E7E2)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = w * 0.04
|
||||
..strokeJoin = StrokeJoin.miter;
|
||||
|
||||
final inset = w * 0.08;
|
||||
final tri = Path()
|
||||
..moveTo(w / 2, inset)
|
||||
..lineTo(w - inset, h - inset)
|
||||
..lineTo(inset, h - inset)
|
||||
..close();
|
||||
canvas.drawPath(tri, stroke);
|
||||
|
||||
// Eye — petrol fill, centred on the triangle's centroid.
|
||||
final eyeCentre = Offset(w / 2, inset + (h - 2 * inset) * 2 / 3);
|
||||
final eyeR = w * 0.06 * pulse;
|
||||
final eye = Paint()..color = LawHeatmapColors.signal;
|
||||
canvas.drawCircle(eyeCentre, eyeR, eye);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _DeltaPainter old) => old.pulse != pulse;
|
||||
}
|
||||
47
app/lib/widgets/demo_banner.dart
Normal file
47
app/lib/widgets/demo_banner.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/lawheatmap_tokens.dart';
|
||||
|
||||
/// Permanent banner shown above all data screens in mock mode.
|
||||
/// Carries the political-defensibility caveat: this is demo data,
|
||||
/// not peer-reviewed, not Juristen-approved.
|
||||
class DemoBanner extends StatelessWidget {
|
||||
const DemoBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: LawHeatmapSpace.lg,
|
||||
vertical: LawHeatmapSpace.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: LawHeatmapColors.tierT4.withValues(alpha: 0.12),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: LawHeatmapColors.tierT4.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.science_outlined,
|
||||
size: 18, color: LawHeatmapColors.tierT4),
|
||||
const SizedBox(width: LawHeatmapSpace.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Demo-Daten — Phase 0, ohne Verbands-Erhebung. '
|
||||
'Keine Juristen-Approval, keine Beirats-Validierung. '
|
||||
'Alle Werte tragen Tier T4, bis T1/T2-Pipeline läuft.',
|
||||
style: t.labelSmall?.copyWith(
|
||||
color: LawHeatmapColors.tierT4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
app/lib/widgets/evidence_sidebar.dart
Normal file
59
app/lib/widgets/evidence_sidebar.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/models.dart';
|
||||
import '../theme/lawheatmap_tokens.dart';
|
||||
import 'lawheatmap_card.dart';
|
||||
import 'source_chip.dart';
|
||||
|
||||
/// Sidebar listing the citable sources behind an evaluation.
|
||||
/// Surfaces the methodological audit story: every figure has a
|
||||
/// source and an evidence tier.
|
||||
class EvidenceSidebar extends StatelessWidget {
|
||||
const EvidenceSidebar({super.key, required this.evaluation});
|
||||
|
||||
final Evaluation evaluation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return LawHeatmapCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Quellen', style: t.headlineSmall),
|
||||
const SizedBox(height: LawHeatmapSpace.sm),
|
||||
Text(
|
||||
'Jede Zahl trägt die Tier-Stufe ihres schwächsten '
|
||||
'Bestandteils (Regel der niedrigsten Stufe, §6.7).',
|
||||
style: t.labelSmall?.copyWith(color: LawHeatmapColors.mute),
|
||||
),
|
||||
const SizedBox(height: LawHeatmapSpace.lg),
|
||||
for (final s in evaluation.sources) SourceChip(source: s),
|
||||
const Divider(height: LawHeatmapSpace.xl),
|
||||
Text('Audit-Event', style: t.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
evaluation.auditEventId,
|
||||
style: t.labelSmall?.copyWith(
|
||||
fontFamily: LawHeatmapTypography.mono,
|
||||
color: LawHeatmapColors.mute,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: LawHeatmapSpace.sm),
|
||||
Text(
|
||||
'Quell-SHA-256:',
|
||||
style: t.labelSmall?.copyWith(color: LawHeatmapColors.mute),
|
||||
),
|
||||
SelectableText(
|
||||
evaluation.norm.sourceSha256,
|
||||
style: t.labelSmall?.copyWith(
|
||||
fontFamily: LawHeatmapTypography.mono,
|
||||
color: LawHeatmapColors.mute,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
306
app/lib/widgets/heatmap_grid.dart
Normal file
306
app/lib/widgets/heatmap_grid.dart
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/models.dart';
|
||||
import '../theme/lawheatmap_tokens.dart';
|
||||
|
||||
/// 2D scatter heatmap — x: Schaden (€/Jahr, log-scaled),
|
||||
/// y: Nutzen (0–5), point area: Betroffenheit (sqrt-scaled).
|
||||
///
|
||||
/// Server delivers JSON; client pixels.
|
||||
class HeatmapGrid extends StatefulWidget {
|
||||
const HeatmapGrid({
|
||||
super.key,
|
||||
required this.evaluations,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final List<Evaluation> evaluations;
|
||||
final void Function(Evaluation)? onTap;
|
||||
|
||||
@override
|
||||
State<HeatmapGrid> createState() => _HeatmapGridState();
|
||||
}
|
||||
|
||||
class _HeatmapGridState extends State<HeatmapGrid> {
|
||||
int? _hoverIndex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
return MouseRegion(
|
||||
onHover: (e) => _setHover(_hitTest(e.localPosition, size)),
|
||||
onExit: (_) => _setHover(null),
|
||||
child: GestureDetector(
|
||||
onTapDown: (e) {
|
||||
final idx = _hitTest(e.localPosition, size);
|
||||
if (idx != null && widget.onTap != null) {
|
||||
widget.onTap!(widget.evaluations[idx]);
|
||||
}
|
||||
},
|
||||
child: CustomPaint(
|
||||
painter: _HeatmapPainter(
|
||||
evaluations: widget.evaluations,
|
||||
hoverIndex: _hoverIndex,
|
||||
theme: Theme.of(context),
|
||||
),
|
||||
size: size,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _setHover(int? idx) {
|
||||
if (idx != _hoverIndex) setState(() => _hoverIndex = idx);
|
||||
}
|
||||
|
||||
int? _hitTest(Offset pos, Size size) {
|
||||
final layout = _Layout(size);
|
||||
for (var i = 0; i < widget.evaluations.length; i++) {
|
||||
final c = layout.project(widget.evaluations[i]);
|
||||
final r = layout.radius(widget.evaluations[i]);
|
||||
if ((pos - c).distance <= r + 4) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _Layout {
|
||||
_Layout(this.size)
|
||||
: padding = const EdgeInsets.fromLTRB(72, 28, 28, 48);
|
||||
|
||||
final Size size;
|
||||
final EdgeInsets padding;
|
||||
|
||||
double get plotLeft => padding.left;
|
||||
double get plotRight => size.width - padding.right;
|
||||
double get plotTop => padding.top;
|
||||
double get plotBottom => size.height - padding.bottom;
|
||||
double get plotWidth => plotRight - plotLeft;
|
||||
double get plotHeight => plotBottom - plotTop;
|
||||
|
||||
/// x: log10(€/year), clipped to [3, 9].
|
||||
static double xLog(double eur) {
|
||||
if (eur <= 1) return 3;
|
||||
final v = math.log(eur) / math.ln10;
|
||||
return v.clamp(3.0, 9.0);
|
||||
}
|
||||
|
||||
Offset project(Evaluation e) {
|
||||
final x = xLog(e.skmEurPerYear);
|
||||
final y = e.benefitScore.clamp(0.0, 5.0);
|
||||
final dx = plotLeft + ((x - 3) / 6) * plotWidth;
|
||||
final dy = plotBottom - (y / 5) * plotHeight;
|
||||
return Offset(dx, dy);
|
||||
}
|
||||
|
||||
double radius(Evaluation e) {
|
||||
final n = e.affectedCount.clamp(1, 10_000_000);
|
||||
final r = math.sqrt(n / 1000) * 1.6;
|
||||
return r.clamp(6.0, 44.0);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeatmapPainter extends CustomPainter {
|
||||
_HeatmapPainter({
|
||||
required this.evaluations,
|
||||
required this.hoverIndex,
|
||||
required this.theme,
|
||||
});
|
||||
|
||||
final List<Evaluation> evaluations;
|
||||
final int? hoverIndex;
|
||||
final ThemeData theme;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final layout = _Layout(size);
|
||||
final onSurface = theme.colorScheme.onSurface;
|
||||
const mute = LawHeatmapColors.mute;
|
||||
|
||||
// Plot frame.
|
||||
final frame = Paint()
|
||||
..color = onSurface.withValues(alpha: 0.14)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1;
|
||||
canvas.drawRect(
|
||||
Rect.fromLTRB(
|
||||
layout.plotLeft,
|
||||
layout.plotTop,
|
||||
layout.plotRight,
|
||||
layout.plotBottom,
|
||||
),
|
||||
frame,
|
||||
);
|
||||
|
||||
// Grid lines (y = 1..4, x = 4..8).
|
||||
final gridPaint = Paint()
|
||||
..color = onSurface.withValues(alpha: 0.08)
|
||||
..strokeWidth = 1;
|
||||
for (var y = 1; y <= 4; y++) {
|
||||
final dy = layout.plotBottom - (y / 5) * layout.plotHeight;
|
||||
canvas.drawLine(
|
||||
Offset(layout.plotLeft, dy),
|
||||
Offset(layout.plotRight, dy),
|
||||
gridPaint,
|
||||
);
|
||||
}
|
||||
for (var x = 4; x <= 8; x++) {
|
||||
final dx = layout.plotLeft + ((x - 3) / 6) * layout.plotWidth;
|
||||
canvas.drawLine(
|
||||
Offset(dx, layout.plotTop),
|
||||
Offset(dx, layout.plotBottom),
|
||||
gridPaint,
|
||||
);
|
||||
}
|
||||
|
||||
// Axis labels.
|
||||
const axisStyle = TextStyle(
|
||||
color: mute,
|
||||
fontSize: 10,
|
||||
fontFamily: LawHeatmapTypography.body,
|
||||
);
|
||||
_drawText(
|
||||
canvas,
|
||||
'Schaden €/Jahr (log)',
|
||||
Offset(layout.plotLeft, layout.plotBottom + 22),
|
||||
axisStyle,
|
||||
);
|
||||
_drawText(
|
||||
canvas,
|
||||
'Nutzen 0–5',
|
||||
Offset(8, layout.plotTop - 18),
|
||||
axisStyle,
|
||||
);
|
||||
// X tick labels for 10^4 .. 10^9
|
||||
for (var x = 4; x <= 9; x++) {
|
||||
final dx = layout.plotLeft + ((x - 3) / 6) * layout.plotWidth;
|
||||
_drawText(
|
||||
canvas,
|
||||
'10^$x',
|
||||
Offset(dx - 12, layout.plotBottom + 6),
|
||||
axisStyle,
|
||||
);
|
||||
}
|
||||
// Y tick labels for 0..5
|
||||
for (var y = 0; y <= 5; y++) {
|
||||
final dy = layout.plotBottom - (y / 5) * layout.plotHeight;
|
||||
_drawText(
|
||||
canvas,
|
||||
'$y',
|
||||
Offset(layout.plotLeft - 18, dy - 6),
|
||||
axisStyle,
|
||||
);
|
||||
}
|
||||
|
||||
// Points.
|
||||
for (var i = 0; i < evaluations.length; i++) {
|
||||
final e = evaluations[i];
|
||||
final c = layout.project(e);
|
||||
final r = layout.radius(e);
|
||||
final hot = e.skmEurPerYear > 50000000;
|
||||
final color = Color.lerp(
|
||||
LawHeatmapColors.benefitSaturated,
|
||||
LawHeatmapColors.harmWarm,
|
||||
hot ? 0.5 : 0.0,
|
||||
)!;
|
||||
final body = Paint()..color = color.withValues(alpha: 0.7);
|
||||
final ring = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = i == hoverIndex ? 3 : 1.5;
|
||||
canvas.drawCircle(c, r, body);
|
||||
canvas.drawCircle(c, r, ring);
|
||||
|
||||
_drawText(
|
||||
canvas,
|
||||
'${e.norm.jurabk} ${e.norm.paragraph}',
|
||||
Offset(c.dx + r + 6, c.dy - 6),
|
||||
TextStyle(
|
||||
color: onSurface,
|
||||
fontSize: 11,
|
||||
fontFamily: LawHeatmapTypography.body,
|
||||
fontWeight: i == hoverIndex ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Hover detail box.
|
||||
if (hoverIndex != null) {
|
||||
final e = evaluations[hoverIndex!];
|
||||
final c = layout.project(e);
|
||||
final r = layout.radius(e);
|
||||
final box = Rect.fromLTWH(c.dx + r + 8, c.dy + 8, 240, 64);
|
||||
final boxPaint = Paint()
|
||||
..color = theme.colorScheme.surface.withValues(alpha: 0.92);
|
||||
final boxBorder = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..color = LawHeatmapColors.signal.withValues(alpha: 0.6);
|
||||
final rrect = RRect.fromRectAndRadius(box, LawHeatmapRadius.sm);
|
||||
canvas.drawRRect(rrect, boxPaint);
|
||||
canvas.drawRRect(rrect, boxBorder);
|
||||
_drawText(
|
||||
canvas,
|
||||
e.norm.title,
|
||||
box.topLeft + const Offset(10, 8),
|
||||
TextStyle(
|
||||
color: onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: LawHeatmapTypography.body,
|
||||
),
|
||||
);
|
||||
_drawText(
|
||||
canvas,
|
||||
'€/Jahr: ${_eur(e.skmEurPerYear)} Betroffene: ${_kmu(e.affectedCount)}',
|
||||
box.topLeft + const Offset(10, 26),
|
||||
const TextStyle(
|
||||
color: mute,
|
||||
fontSize: 11,
|
||||
fontFamily: LawHeatmapTypography.body,
|
||||
),
|
||||
);
|
||||
_drawText(
|
||||
canvas,
|
||||
'Tier: ${e.tierLowest.short} Klick → Detail',
|
||||
box.topLeft + const Offset(10, 44),
|
||||
const TextStyle(
|
||||
color: mute,
|
||||
fontSize: 11,
|
||||
fontFamily: LawHeatmapTypography.body,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _eur(double v) {
|
||||
if (v >= 1e9) return '${(v / 1e9).toStringAsFixed(1)} Mrd';
|
||||
if (v >= 1e6) return '${(v / 1e6).toStringAsFixed(1)} Mio';
|
||||
if (v >= 1e3) return '${(v / 1e3).toStringAsFixed(0)} k';
|
||||
return v.toStringAsFixed(0);
|
||||
}
|
||||
|
||||
String _kmu(int n) {
|
||||
if (n >= 1_000_000) return '${(n / 1e6).toStringAsFixed(1)} Mio';
|
||||
if (n >= 1_000) return '${(n / 1e3).toStringAsFixed(0)} k';
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
void _drawText(Canvas canvas, String text, Offset at, TextStyle style) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
tp.paint(canvas, at);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _HeatmapPainter old) =>
|
||||
old.hoverIndex != hoverIndex ||
|
||||
old.evaluations.length != evaluations.length;
|
||||
}
|
||||
53
app/lib/widgets/lawheatmap_card.dart
Normal file
53
app/lib/widgets/lawheatmap_card.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/lawheatmap_tokens.dart';
|
||||
|
||||
/// Surface container — visual equivalent of fai_chain_studio's
|
||||
/// `ChainCard`, retuned to the fai_web palette.
|
||||
class LawHeatmapCard extends StatelessWidget {
|
||||
const LawHeatmapCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding =
|
||||
const EdgeInsets.all(LawHeatmapSpace.lg),
|
||||
this.accent = false,
|
||||
this.expand = false,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
/// If true, draws a thin petrol accent bar at the top —
|
||||
/// used for "this card carries a primary action".
|
||||
final bool accent;
|
||||
|
||||
/// If true, the card grows to fill its parent's bounded
|
||||
/// dimensions and forces the child to expand. Use when the
|
||||
/// card is placed inside an `Expanded` widget and the child
|
||||
/// is itself a free-sizing widget (e.g. CustomPaint).
|
||||
final bool expand;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final padded = Padding(padding: padding, child: child);
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
if (accent)
|
||||
Container(
|
||||
height: 2,
|
||||
decoration: const BoxDecoration(
|
||||
color: LawHeatmapColors.signal,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: LawHeatmapRadius.md,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (expand) Expanded(child: padded) else padded,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
app/lib/widgets/source_chip.dart
Normal file
54
app/lib/widgets/source_chip.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/models.dart';
|
||||
import '../theme/lawheatmap_tokens.dart';
|
||||
import 'tier_badge.dart';
|
||||
|
||||
/// A citable source line: tier badge + label + (planned) link.
|
||||
/// External link opening lands with `url_launcher` in week 1 — for
|
||||
/// now the chip surfaces the URL via tooltip + copy hint.
|
||||
class SourceChip extends StatelessWidget {
|
||||
const SourceChip({super.key, required this.source});
|
||||
|
||||
final Source source;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: LawHeatmapSpace.sm),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: TierBadge(tier: source.tier, compact: true),
|
||||
),
|
||||
const SizedBox(width: LawHeatmapSpace.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(source.label, style: t.bodyLarge),
|
||||
Text(
|
||||
source.url,
|
||||
style: t.labelSmall?.copyWith(
|
||||
color: LawHeatmapColors.mute,
|
||||
),
|
||||
),
|
||||
if (source.note != null)
|
||||
Text(
|
||||
source.note!,
|
||||
style: t.labelSmall?.copyWith(
|
||||
color: LawHeatmapColors.mute,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
app/lib/widgets/tier_badge.dart
Normal file
59
app/lib/widgets/tier_badge.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/models.dart';
|
||||
import '../theme/lawheatmap_tokens.dart';
|
||||
|
||||
/// Tier badge — surfaces the lowest evidence tier in a composed
|
||||
/// value. Every figure in the app carries one of these next to it.
|
||||
class TierBadge extends StatelessWidget {
|
||||
const TierBadge({super.key, required this.tier, this.compact = false});
|
||||
|
||||
final EvidenceTier tier;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
final color = switch (tier) {
|
||||
EvidenceTier.t1 => LawHeatmapColors.tierT1,
|
||||
EvidenceTier.t2 => LawHeatmapColors.tierT2,
|
||||
EvidenceTier.t3 => LawHeatmapColors.tierT3,
|
||||
EvidenceTier.t4 => LawHeatmapColors.tierT4,
|
||||
};
|
||||
return Tooltip(
|
||||
message: '${tier.short} — ${tier.description}',
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 6 : LawHeatmapSpace.sm,
|
||||
vertical: compact ? 2 : 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.18),
|
||||
border: Border.all(color: color.withValues(alpha: 0.55)),
|
||||
borderRadius: const BorderRadius.all(LawHeatmapRadius.sm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
tier.short,
|
||||
style: (compact ? t.labelSmall : t.labelLarge)?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue