From ed5cd6e3126fdc20a6a608397cfb6b6bc259f6b0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 21:50:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(draw):=20DrawingCard=20widget=20=E2=80=94?= =?UTF-8?q?=20SVG=20region=20+=20themed=20caption=20(T-318)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display-only card (D-78): renders an SvgDocument in a framed, aspect- ratio-sized region with an optional clide-themed label/description caption beneath. The SVG is content (its own palette); the frame + captions are chrome (SurfaceTokens). Widget-tested via the kernel fixture. Per-object data-* overlay and lightbox remain follow-ons. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/widgets/src/draw/drawing_card.dart | 79 ++++++++++++++++++++++++ test/widgets/draw/drawing_card_test.dart | 36 +++++++++++ 2 files changed, 115 insertions(+) create mode 100644 lib/widgets/src/draw/drawing_card.dart create mode 100644 test/widgets/draw/drawing_card_test.dart diff --git a/lib/widgets/src/draw/drawing_card.dart b/lib/widgets/src/draw/drawing_card.dart new file mode 100644 index 00000000..4391db65 --- /dev/null +++ b/lib/widgets/src/draw/drawing_card.dart @@ -0,0 +1,79 @@ +/// The display-only drawing card (T-318 / D-91 / D-103). +/// +/// Renders an [SvgDocument] — the substrate the SVG engine paints — inside a +/// framed region, with an optional clide-themed caption (label + description) +/// beneath it. The SVG is *content* (its own palette); the frame and caption +/// are clide *chrome* and use `SurfaceTokens`. Display-only per D-78 — no +/// interaction lives on the card. +/// +/// Sizes the SVG to its intrinsic aspect ratio (viewBox / width-height) within +/// a height cap, filling the available width. +library; + +import 'package:clide/kernel/src/theme/tokens.dart'; +import 'package:clide/widgets/src/clide_card_metrics.dart'; +import 'package:clide/widgets/src/clide_settings.dart'; +import 'package:clide/widgets/src/clide_text.dart'; +import 'package:clide/widgets/src/svg/svg_painter.dart'; +import 'package:clide/widgets/src/typography.dart'; +import 'package:clide/src/svg/svg_node.dart'; +import 'package:flutter/widgets.dart'; + +class DrawingCard extends StatelessWidget { + const DrawingCard({super.key, required this.document, this.label, this.description, this.images, this.maxHeight = 360}); + + final SvgDocument document; + final String? label, description; + final SvgImageResolver? images; + final double maxHeight; + + @override + Widget build(BuildContext context) { + final tokens = ClideSettings.theme.of(context).surface; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_has(label)) + Padding( + padding: const EdgeInsets.only(bottom: kClideCardHeaderPadV), + child: ClideText(label!, fontSize: clideFontMeta, fontWeight: FontWeight.w600, color: tokens.globalForeground), + ), + _svgRegion(tokens), + if (_has(description)) + Padding( + padding: const EdgeInsets.only(top: kClideCardHeaderPadV), + child: ClideText(description!, fontSize: clideFontCaption, color: tokens.globalTextMuted), + ), + ], + ); + } + + Widget _svgRegion(SurfaceTokens tokens) { + final view = SvgView(document: document, images: images); + final aspect = _aspect(document); + final sized = aspect != null ? AspectRatio(aspectRatio: aspect, child: view) : SizedBox(height: maxHeight, child: view); + return DecoratedBox( + decoration: BoxDecoration( + color: tokens.panelBackground, + border: Border.all(color: tokens.panelBorder), + borderRadius: BorderRadius.circular(kClideCardRadius), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(kClideCardRadius), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: sized, + ), + ), + ); + } + + static double? _aspect(SvgDocument d) { + final w = d.viewBox?.width ?? d.width; + final h = d.viewBox?.height ?? d.height; + return (w != null && h != null && w > 0 && h > 0) ? w / h : null; + } + + static bool _has(String? s) => s != null && s.isNotEmpty; +} diff --git a/test/widgets/draw/drawing_card_test.dart b/test/widgets/draw/drawing_card_test.dart new file mode 100644 index 00000000..e5ec0831 --- /dev/null +++ b/test/widgets/draw/drawing_card_test.dart @@ -0,0 +1,36 @@ +import 'package:clide/src/svg/svg_document.dart'; +import 'package:clide/widgets/src/draw/drawing_card.dart'; +import 'package:clide/widgets/src/svg/svg_painter.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + Widget card({String? label, String? description}) => SizedBox( + width: 400, + child: DrawingCard( + document: buildSvgDocument(''), + label: label, + description: description, + ), + ); + + testWidgets('renders the SVG region with both captions', (tester) async { + await tester.pumpWidget(anchoredHarness(f, card(label: 'Build pipeline', description: 'how it connects'))); + expect(find.byType(SvgView), findsOneWidget); + expect(find.text('Build pipeline'), findsOneWidget); + expect(find.text('how it connects'), findsOneWidget); + }); + + testWidgets('omits captions when label and description are absent', (tester) async { + await tester.pumpWidget(anchoredHarness(f, card())); + expect(find.byType(SvgView), findsOneWidget); + expect(find.text('Build pipeline'), findsNothing); + }); +}