hand_drawn_toolkit 0.5.0 copy "hand_drawn_toolkit: ^0.5.0" to clipboard
hand_drawn_toolkit: ^0.5.0 copied to clipboard

A lightweight Flutter package for rendering hand-drawn, sketchy lines, borders, and containers.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:hand_drawn_toolkit/hand_drawn_toolkit.dart';

// ── Palette ───────────────────────────────────────────────────────────────
const _ink = Color(0xFF2C2C2C);
const _inkLight = Color(0xFF6B6B6B);
const _accent = Color(0xFF4A7C6F);
const _cardFill = Color(0xFFFAF7F2);

// ── Notebook grid ─────────────────────────────────────────────────────────
const _notebookFontSize = 15.0;
const _notebookLineHeight = 28.0;

// ── Sub-grid preset reused across a few charts ────────────────────────────
const _subGrid = GridConfig(
  horizontalSubGridLinesBetweenTicks: 3,
  verticalSubGridLinesBetweenTicks: 3,
);

void main() => runApp(const MyJournalApp());

const _months = [
  'January',
  'February',
  'March',
  'April',
  'May',
  'June',
  'July',
  'August',
  'September',
  'October',
  'November',
  'December',
];

String _formatDate(DateTime date) =>
    '${_months[date.month - 1]} ${date.day}, ${date.year}';

class MyJournalApp extends StatelessWidget {
  const MyJournalApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Hand Drawn Toolkit Example',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        scaffoldBackgroundColor: const Color(0xFFF5F0E8),
        textTheme: const TextTheme(
          bodyMedium: TextStyle(fontFamily: 'Georgia'),
        ),
      ),
      home: const JournalPage(),
    );
  }
}

class JournalPage extends StatefulWidget {
  const JournalPage({super.key});

  @override
  State<JournalPage> createState() => _JournalPageState();
}

class _JournalPageState extends State<JournalPage> {
  bool _packed = false;

  final List<_TaskItem> _tasks = [
    const _TaskItem('Read a chapter of a good book'),
    const _TaskItem('Sketch something from observation'),
    const _TaskItem('Take a 20-minute walk', status: _TaskStatus.completed),
    const _TaskItem('Write morning pages', status: _TaskStatus.skipped),
  ];

  void _cycleStatus(int index) =>
      setState(() => _tasks[index] = _tasks[index].cycled());

  final Map<String, String?> _hits = {};
  void _setHit(String id, String? label) {
    setState(() {
      if (label == null) {
        _hits.remove(id);
      } else {
        _hits[id] = label;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: SingleChildScrollView(
          padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const Text(
                'Hand Drawn Toolkit',
                style: TextStyle(
                  fontSize: 32,
                  fontWeight: FontWeight.bold,
                  color: _ink,
                  letterSpacing: 0.5,
                ),
              ),
              const SizedBox(height: 2),
              SizedBox(
                width: 170,
                height: 8,
                child: CustomPaint(
                  painter: HandDrawnLinePainter(
                    color: _ink,
                    strokeWidth: 2.0,
                    irregularity: 2.0,
                    seed: 42,
                    buildPath: (size, h) => h.lineHorizontal(size),
                  ),
                ),
              ),
              const SizedBox(height: 12),
              Text(
                _formatDate(DateTime.now()),
                style: const TextStyle(
                  fontSize: 14,
                  color: _inkLight,
                  fontStyle: FontStyle.italic,
                ),
              ),
              const SizedBox(height: 8),
              const HandDrawnDivider(color: _inkLight, seed: 42),
              const SizedBox(height: 24),

              const HandDrawnContainer(
                backgroundColor: _cardFill,
                strokeColor: _ink,
                strokeWidth: 1.8,
                irregularity: 3.0,
                seed: 6,
                padding: EdgeInsets.all(20),
                child: Text(
                  'Hand Drawn Toolkit is a lightweight Flutter package for '
                  'rendering sketchy, organic lines, borders, and containers. '
                  'It generates random perpendicular offsets along a path and '
                  'smooths them with a three-point moving average to produce '
                  'natural-looking wobble.\n\nThe package has zero external '
                  'dependencies and relies entirely on the Flutter SDK. All '
                  'randomness is seed-based, so identical parameters always '
                  'produce identical strokes.',
                  style: TextStyle(fontSize: 15, height: 1.6, color: _ink),
                ),
              ),

              const SizedBox(height: 28),
              const Text(
                'Key Components',
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.w600,
                  color: _ink,
                ),
              ),
              const SizedBox(height: 14),
              const _GoalItem(
                seed: 9,
                text:
                    'HandDrawnContainer wraps any '
                    'child widget with a sketchy rectangular border and solid '
                    'background fill.',
              ),
              const SizedBox(height: 10),
              const _GoalItem(
                seed: 20,
                text:
                    'HandDrawnDivider is a drop-in '
                    "replacement for Flutter's Divider, supporting both "
                    'orientations.',
              ),
              const SizedBox(height: 10),
              const _GoalItem(
                seed: 49,
                text:
                    'HandDrawnLinePainter provides '
                    'full control via a buildPath callback for custom shapes.',
              ),

              const SizedBox(height: 28),
              const HandDrawnDivider(
                color: _inkLight,
                indent: 32,
                endIndent: 32,
                seed: 40,
              ),
              const SizedBox(height: 28),

              const Text(
                'Status Square',
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.w600,
                  color: _ink,
                ),
              ),
              const SizedBox(height: 8),
              const Text(
                'Tap each square to cycle through empty, checked, '
                'and dashed.',
                style: TextStyle(fontSize: 14, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 16),
              HandDrawnContainer(
                backgroundColor: _cardFill,
                strokeColor: _ink,
                strokeWidth: 1.4,
                irregularity: 2.5,
                seed: 77,
                padding: const EdgeInsets.symmetric(
                  horizontal: 16,
                  vertical: 10,
                ),
                child: DefaultTextStyle(
                  style: const TextStyle(
                    fontSize: _notebookFontSize,
                    color: _ink,
                  ),
                  child: NotebookEntry(
                    children: [
                      for (var i = 0; i < _tasks.length; i++) ...[
                        if (i > 0) '\n',
                        HandDrawnStatusSquare(
                          color: _tasks[i].color,
                          isFilled: _tasks[i].isFilled,
                          indicator: _tasks[i].indicator,
                          size: 16,
                          seed: i * 13 + 5,
                          onTap: () => _cycleStatus(i),
                        ),
                        NotebookSpan(
                          '  ${_tasks[i].label}',
                          style: TextStyle(
                            decoration: _tasks[i].isFilled
                                ? TextDecoration.lineThrough
                                : null,
                            decorationColor: _inkLight,
                          ),
                        ),
                      ],
                    ],
                  ),
                ),
              ),

              const SizedBox(height: 28),
              const HandDrawnDivider(
                color: _inkLight,
                indent: 32,
                endIndent: 32,
                seed: 55,
              ),
              const SizedBox(height: 28),

              const Text(
                'Text Field',
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.w600,
                  color: _ink,
                ),
              ),
              const SizedBox(height: 16),
              const HandDrawnTextField(
                hintText: 'Title your entry…',
                backgroundColor: _cardFill,
                textColor: _ink,
                hintColor: _inkLight,
                dividerColor: Color(0xFFD8D3CB),
                fontSize: 16,
                seed: 33,
              ),
              const SizedBox(height: 12),
              const HandDrawnTextField(
                hintText: 'Write your thoughts…',
                maxLines: 4,
                backgroundColor: _cardFill,
                textColor: _ink,
                hintColor: _inkLight,
                dividerColor: Color(0xFFD8D3CB),
                fontSize: 14,
                seed: 34,
              ),

              const SizedBox(height: 28),
              const HandDrawnDivider(
                color: _inkLight,
                indent: 32,
                endIndent: 32,
                seed: 65,
              ),
              const SizedBox(height: 28),

              const Text(
                'Notebook',
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.w600,
                  color: _ink,
                ),
              ),
              const SizedBox(height: 16),
              HandDrawnContainer(
                backgroundColor: _cardFill,
                strokeColor: _ink,
                strokeWidth: 1.4,
                irregularity: 2.5,
                seed: 88,
                padding: const EdgeInsets.only(left: 16, right: 16, bottom: 10),
                child: HandDrawnNotebook(
                  lineHeight: _notebookLineHeight,
                  lineColor: const Color(0xFFB0AAA0),
                  irregularity: 2.5,
                  seed: 10,
                  child: DefaultTextStyle(
                    style: const TextStyle(
                      fontSize: _notebookFontSize,
                      color: _ink,
                    ),
                    child: NotebookEntry(
                      children: [
                        'First line on the grid\n'
                            'Second line sits neatly\n'
                            'Third line, same wobble',
                      ],
                    ),
                  ),
                ),
              ),

              const SizedBox(height: 28),
              const HandDrawnDivider(
                color: _inkLight,
                indent: 32,
                endIndent: 32,
                seed: 72,
              ),
              const SizedBox(height: 28),
              const Text(
                'Notebook Entries',
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.w600,
                  color: _ink,
                ),
              ),
              const SizedBox(height: 8),
              const Text(
                'A NotebookEntry lays one flowing run of text, styled spans, and '
                'inline widgets onto the ruled lines. It sizes itself to its '
                'content, wrapping text across as many lines as it needs.',
                style: TextStyle(fontSize: 14, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 16),

              // 1. Mixed content + wrapping.
              _caption(
                'Text, a styled span, and an inline widget flow together and '
                'wrap across lines.',
              ),
              _notebookCard(
                seed: 91,
                ruleSeed: 14,
                entry: NotebookEntry(
                  children: const [
                    'Shopping list: ',
                    HandDrawnStatusSquare(
                      color: _accent,
                      isFilled: true,
                      indicator: StatusIndicator.check,
                      size: 16,
                    ),
                    ' eggs, ',
                    NotebookSpan(
                      'whole milk',
                      style: TextStyle(
                        color: _accent,
                        fontWeight: FontWeight.w600,
                      ),
                    ),
                    ', a loaf of sourdough, fresh basil, and a wedge of '
                        'parmesan — the text wraps onto as many ruled lines as it '
                        'needs.',
                  ],
                ),
              ),
              const SizedBox(height: 20),

              // 2. Interactive inline widget.
              _caption('Inline widgets stay interactive — tap the checkbox.'),
              _notebookCard(
                seed: 92,
                ruleSeed: 16,
                entry: NotebookEntry(
                  children: [
                    'Tap the box to pack: ',
                    HandDrawnStatusSquare(
                      color: _packed ? _accent : _ink,
                      isFilled: _packed,
                      indicator: _packed
                          ? StatusIndicator.check
                          : StatusIndicator.none,
                      size: 16,
                      seed: 7,
                      onTap: () => setState(() => _packed = !_packed),
                    ),
                    _packed ? ' sunscreen (packed)' : ' sunscreen',
                  ],
                ),
              ),
              const SizedBox(height: 20),

              // 3. Hard line breaks.
              _caption(r"Hard breaks ('\n') start a new ruled line on demand."),
              _notebookCard(
                seed: 93,
                ruleSeed: 18,
                entry: NotebookEntry(
                  children: const [
                    'Hard line breaks start a new ruled line on demand.\n'
                        'Use them for verse, addresses, or short notes —\n'
                        'each break drops to the next line of the grid.',
                  ],
                ),
              ),
              const SizedBox(height: 20),

              // 4. scaleDown — oversized widget.
              _caption(
                'Default fit (scaleDown): an oversized inline widget is scaled '
                'down to one line.',
              ),
              _notebookCard(
                seed: 94,
                ruleSeed: 20,
                entry: NotebookEntry(
                  children: const [
                    'A 44px square shrinks to the line: ',
                    HandDrawnStatusSquare(
                      color: _accent,
                      isFilled: true,
                      size: 44,
                    ),
                    ' and text keeps flowing.',
                  ],
                ),
              ),
              const SizedBox(height: 20),

              // 5. clip — oversized widget.
              _caption(
                'fit: clip — the same oversized widget is cropped to the line '
                'height instead of scaled.',
              ),
              _notebookCard(
                seed: 95,
                ruleSeed: 22,
                entry: NotebookEntry(
                  fit: NotebookFit.clip,
                  children: const [
                    'A 44px square cropped to the line: ',
                    HandDrawnStatusSquare(
                      color: _accent,
                      isFilled: true,
                      size: 44,
                    ),
                    ' only its middle band shows.',
                  ],
                ),
              ),
              const SizedBox(height: 20),

              // 6. scaleDown — oversized text.
              _caption(
                'scaleDown also shrinks oversized text to fit the line.',
              ),
              _notebookCard(
                seed: 96,
                ruleSeed: 24,
                textStyle: const TextStyle(fontSize: 40, color: _ink),
                entry: NotebookEntry(
                  children: const ['Big text shrinks to fit.'],
                ),
              ),
              const SizedBox(height: 20),

              // 7. clip — oversized text.
              _caption(
                'fit: clip crops oversized text to the line height instead.',
              ),
              _notebookCard(
                seed: 97,
                ruleSeed: 26,
                textStyle: const TextStyle(fontSize: 40, color: _ink),
                entry: NotebookEntry(
                  fit: NotebookFit.clip,
                  children: const ['Big text gets cropped.'],
                ),
              ),
              const SizedBox(height: 20),

              // 8. wrap: false — single line, horizontal scroll.
              _caption(
                'wrap: false keeps everything on one line; place it in a '
                'horizontal scroll view to scroll the overflow.',
              ),
              _notebookCard(
                seed: 98,
                ruleSeed: 28,
                entry: SingleChildScrollView(
                  scrollDirection: Axis.horizontal,
                  child: NotebookEntry(
                    wrap: false,
                    children: const [
                      'This single line never wraps — it just keeps going to the '
                          'right past the edge of the page, where it can be scrolled '
                          'into view.',
                    ],
                  ),
                ),
              ),

              const SizedBox(height: 20),

              // 9–11. Vertical alignment of a short line within its row.
              _caption(
                'textAlignVertical positions a short line within its taller '
                'row. (When the line fills the row, there is nothing to '
                'position.)',
              ),
              _notebookCard(
                seed: 99,
                ruleSeed: 30,
                textStyle: const TextStyle(fontSize: 14, color: _ink),
                entry: NotebookEntry(
                  textAlignVertical: TextAlignVertical.top,
                  children: const [
                    'Top: ',
                    HandDrawnStatusSquare(
                      color: _accent,
                      isFilled: true,
                      size: 14,
                    ),
                    ' the line rides at the top of the row.',
                  ],
                ),
              ),
              const SizedBox(height: 12),
              _notebookCard(
                seed: 100,
                ruleSeed: 32,
                textStyle: const TextStyle(fontSize: 14, color: _ink),
                entry: NotebookEntry(
                  textAlignVertical: TextAlignVertical.center,
                  children: const [
                    'Center: ',
                    HandDrawnStatusSquare(
                      color: _accent,
                      isFilled: true,
                      size: 14,
                    ),
                    ' the line sits in the middle (the default).',
                  ],
                ),
              ),
              const SizedBox(height: 12),
              _notebookCard(
                seed: 101,
                ruleSeed: 34,
                textStyle: const TextStyle(fontSize: 14, color: _ink),
                entry: NotebookEntry(
                  textAlignVertical: TextAlignVertical.bottom,
                  children: const [
                    'Bottom: ',
                    HandDrawnStatusSquare(
                      color: _accent,
                      isFilled: true,
                      size: 14,
                    ),
                    ' the line rests on the rule.',
                  ],
                ),
              ),

              const SizedBox(height: 28),
              const HandDrawnDivider(
                color: _inkLight,
                indent: 32,
                endIndent: 32,
                seed: 70,
              ),
              const SizedBox(height: 28),

              // ══ CHARTS ═══════════════════════════════════════════════
              const Text(
                'Charts',
                style: TextStyle(
                  fontSize: 24,
                  fontWeight: FontWeight.bold,
                  color: _ink,
                ),
              ),
              const SizedBox(height: 8),
              const Text(
                'All three chart types — bar, line, scatter — share a common '
                'axis, grid, and label system. Line and scatter charts can opt '
                'into zero-crossing axes for mixed positive/negative ranges, '
                'and any chart can customize its grid via GridConfig.',
                style: TextStyle(fontSize: 14, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 20),

              _sectionHeading('Bar Chart'),
              const SizedBox(height: 12),
              HandDrawnBarChart(
                data: _sampleSimpleBarData(),
                height: 240,
                seed: 1,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Stacked Bar Chart'),
              const SizedBox(height: 4),
              const Text(
                'Each bar is composed of multiple stacked segments. '
                'Legend renders in a boxed band below.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              HandDrawnBarChart(
                data: _sampleBarData(),
                legendConfig: ChartLegendConfig.externalBottomBoxed,
                height: 280,
                seed: 10,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Grouped Bar Chart'),
              const SizedBox(height: 4),
              const Text(
                'Multiple bars share one category label, with a right-side '
                'boxed legend. Q4 combines grouped + stacked — each region '
                'adds a stacked bonus segment.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              HandDrawnBarChart(
                data: _sampleGroupedBarData(),
                legendConfig: ChartLegendConfig.externalRightBoxed,
                height: 280,
                seed: 11,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Bar Chart with Negative Values'),
              const SizedBox(height: 4),
              const Text(
                'Positive segments stack up from y = 0; negative segments '
                'stack down. Zero values still occupy a slot.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              HandDrawnBarChart(
                data: _sampleNegativeBarData(),
                height: 260,
                seed: 12,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Bar Chart with Rotated Labels'),
              const SizedBox(height: 4),
              const Text(
                'Long category names stay readable when rotated; the X tick '
                "band's reserved height adjusts automatically.",
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              HandDrawnBarChart(
                data: _sampleRotatedLabelsBarData(),
                xLabelConfig: ChartLabelConfig.diagonalLeft,
                height: 280,
                seed: 13,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Line Chart'),
              const SizedBox(height: 12),
              HandDrawnLineChart(
                data: _sampleSimpleLineData(),
                grid: GridConfig.none,
                height: 240,
                seed: 2,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Multi-Series Line Chart'),
              const SizedBox(height: 12),
              HandDrawnLineChart(
                data: _sampleLineData(),
                grid: GridConfig.standard,
                height: 260,
                seed: 20,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Negative Y Line Chart'),
              const SizedBox(height: 4),
              const Text(
                'Horizontal axis drawn at y = 0; line fill anchors '
                'to the zero baseline.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              HandDrawnLineChart(
                data: _sampleNegYLineData(),
                grid: GridConfig.horizontalOnly,
                height: 260,
                seed: 21,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Negative X Line Chart'),
              const SizedBox(height: 4),
              const Text(
                'Vertical axis drawn at x = 0.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              HandDrawnLineChart(
                data: _sampleNegXLineData(),
                grid: GridConfig.verticalOnly,
                height: 260,
                seed: 22,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Negative X and Y Line Chart'),
              const SizedBox(height: 4),
              const Text(
                'Four-quadrant view with sub-grid lines between each '
                'tick on both axes for finer value reading.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              HandDrawnLineChart(
                data: _sampleNegXYLineData(),
                grid: _subGrid,
                height: 280,
                seed: 23,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Function Chart'),
              const SizedBox(height: 4),
              const Text(
                'FunctionSeriesData samples a function across the numeric x-domain. '
                '`displayXs` controls where visible dots are drawn — the curve itself '
                'stays smooth between them, rendered as one coherent hand-drawn stroke.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              HandDrawnLineChart(
                data: _sampleParabolaData(),
                grid: GridConfig.standard,
                height: 260,
                seed: 24,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Multi-Function Chart'),
              const SizedBox(height: 4),
              const Text(
                'Multiple functions render on one chart with auto-generated legend '
                'entries, exactly like multi-series line charts.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              HandDrawnLineChart(
                data: _sampleFunctionComparisonData(),
                grid: GridConfig.standard,
                height: 280,
                seed: 25,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Discontinuous Function'),
              const SizedBox(height: 4),
              const Text(
                'Non-finite evaluations split the curve into independent runs. No '
                'false bridge is drawn across x = 0 — each side is drawn as its own '
                'hand-drawn stroke with its own fill.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              HandDrawnLineChart(
                data: _sampleDiscontinuousFunctionData(),
                grid: _subGrid,
                height: 280,
                seed: 26,
                clipToChartArea: true,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Line Chart with Boxed Bottom Legend'),
              const SizedBox(height: 4),
              const Text(
                'External boxed legend below the chart, wrapping for many '
                'series.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              HandDrawnLineChart(
                data: _sampleLineData(),
                legendConfig: ChartLegendConfig.externalBottomBoxed,
                grid: GridConfig.standard,
                height: 280,
                seed: 27,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Line Chart with Boxed Right Legend'),
              const SizedBox(height: 4),
              const Text(
                'Right-side boxed legend; the plot area shrinks horizontally '
                'to make room.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              HandDrawnLineChart(
                data: _sampleLineData(),
                legendConfig: ChartLegendConfig.externalRightBoxed,
                grid: GridConfig.standard,
                height: 260,
                seed: 28,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Standalone Legend Composition'),
              const SizedBox(height: 4),
              const Text(
                "Suppress the chart's legend, then place HandDrawnLegend "
                'wherever the layout calls for it.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 12),
              Column(
                children: [
                  HandDrawnLegend(
                    entries: ChartLegendEntries.fromLineChartData(
                      _sampleLineData(),
                    ),
                  ),
                  const SizedBox(height: 8),
                  HandDrawnLineChart(
                    data: _sampleLineData(),
                    legendConfig: ChartLegendConfig.hidden,
                    grid: GridConfig.standard,
                    height: 240,
                    seed: 29,
                  ),
                ],
              ),
              const SizedBox(height: 24),

              _sectionHeading('Scatter Plot'),
              const SizedBox(height: 12),
              HandDrawnScatterPlot(
                data: _sampleScatterData(),
                grid: _subGrid,
                height: 260,
                seed: 30,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Negative Y Scatter Plot'),
              const SizedBox(height: 12),
              HandDrawnScatterPlot(
                data: _sampleNegYScatterData(),
                grid: GridConfig.standard,
                height: 260,
                seed: 31,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Negative X Scatter Plot'),
              const SizedBox(height: 12),
              HandDrawnScatterPlot(
                data: _sampleNegXScatterData(),
                grid: GridConfig.none,
                height: 260,
                seed: 32,
              ),
              const SizedBox(height: 24),

              _sectionHeading('Negative X and Y Scatter Plot'),
              const SizedBox(height: 12),
              HandDrawnScatterPlot(
                data: _sampleNegXYScatterData(),
                grid: GridConfig.verticalOnly,
                height: 280,
                seed: 33,
              ),

              const SizedBox(height: 28),
              const HandDrawnDivider(
                color: _inkLight,
                indent: 32,
                endIndent: 32,
                seed: 80,
              ),
              const SizedBox(height: 28),

              // ══ TABLE ═══════════════════════════════════════════════
              const Text(
                'Table',
                style: TextStyle(
                  fontSize: 24,
                  fontWeight: FontWeight.bold,
                  color: _ink,
                ),
              ),
              const SizedBox(height: 16),
              const HandDrawnTable(
                title: 'Reading Log',
                columns: [
                  HandDrawnTableColumn(header: 'TITLE', flex: 3),
                  HandDrawnTableColumn(
                    header: 'PAGES',
                    width: 100,
                    alignment: Alignment.centerRight,
                  ),
                  HandDrawnTableColumn(
                    header: 'RATING',
                    width: 100,
                    alignment: Alignment.center,
                  ),
                ],
                rows: [
                  HandDrawnTableRow(
                    cells: ['Dune', '412', '★★★★★'],
                    highlight: true,
                  ),
                  HandDrawnTableRow(cells: ['Neuromancer', '271', '★★★★']),
                  HandDrawnTableRow(cells: ['Foundation', '244', '★★★★']),
                  HandDrawnTableRow(cells: ['Snow Crash', '480', '★★★']),
                ],
                rowDividers: TableDividerStyle(
                  seed: 60,
                  irregularity: 2,
                  uniform: false,
                ),
                columnDividers: TableDividerStyle(seed: 70, irregularity: 2),
              ),
              const SizedBox(height: 20),
              _sectionHeading('Resizable Columns'),
              const SizedBox(height: 12),
              const _ResizableTableDemo(),

              const SizedBox(height: 28),
              const HandDrawnDivider(
                color: _inkLight,
                indent: 32,
                endIndent: 32,
                seed: 85,
              ),
              const SizedBox(height: 28),

              // ══ INTERACTIVE CHARTS ═════════════════════════════════
              const Text(
                'Interactive Charts',
                style: TextStyle(
                  fontSize: 24,
                  fontWeight: FontWeight.bold,
                  color: _ink,
                ),
              ),
              const SizedBox(height: 8),
              const Text(
                'Every chart type exposes computeLayout() and hitTest(), so '
                'consumers can build their own tap, hover, and drag behaviors. '
                'Below, each chart above is wired to report what '
                'you tap.',
                style: TextStyle(fontSize: 14, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 20),

              _sectionHeading('Bar Chart — tap a bar'),
              const SizedBox(height: 4),
              _hitLabel(_hits['bar_simple']),
              const SizedBox(height: 8),
              _InteractiveBarChart(
                data: _sampleSimpleBarData(),
                seed: 1,
                unitLabel: 'k steps',
                onHit: (l) => _setHit('bar_simple', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Stacked Bar Chart — tap a segment'),
              const SizedBox(height: 4),
              _hitLabel(_hits['bar_stacked']),
              const SizedBox(height: 8),
              _InteractiveBarChart(
                data: _sampleBarData(),
                seed: 10,
                legendConfig: ChartLegendConfig.externalBottomBoxed,
                onHit: (l) => _setHit('bar_stacked', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Grouped Bar Chart — tap a grouped segment'),
              const SizedBox(height: 4),
              _hitLabel(_hits['bar_grouped']),
              const SizedBox(height: 8),
              _InteractiveBarChart(
                data: _sampleGroupedBarData(),
                seed: 11,
                unitLabel: 'k',
                legendConfig: ChartLegendConfig.externalRightBoxed,
                onHit: (l) => _setHit('bar_grouped', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Bar Chart with Negative Values — tap a segment'),
              const SizedBox(height: 4),
              _hitLabel(_hits['bar_negative']),
              const SizedBox(height: 8),
              _InteractiveBarChart(
                data: _sampleNegativeBarData(),
                seed: 12,
                unitLabel: 'k',
                onHit: (l) => _setHit('bar_negative', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Bar Chart with Rotated Labels — tap a bar'),
              const SizedBox(height: 4),
              _hitLabel(_hits['bar_rotated']),
              const SizedBox(height: 8),
              _InteractiveBarChart(
                data: _sampleRotatedLabelsBarData(),
                seed: 13,
                unitLabel: 'k users',
                xLabelConfig: ChartLabelConfig.diagonalLeft,
                onHit: (l) => _setHit('bar_rotated', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Line Chart — tap the line'),
              const SizedBox(height: 4),
              _hitLabel(_hits['line_simple']),
              const SizedBox(height: 8),
              _InteractiveLineChart(
                data: _sampleSimpleLineData(),
                seed: 2,
                grid: GridConfig.none,
                onHit: (l) => _setHit('line_simple', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Multi-Series Line Chart — tap a series'),
              const SizedBox(height: 4),
              _hitLabel(_hits['line_multi']),
              const SizedBox(height: 8),
              _InteractiveLineChart(
                data: _sampleLineData(),
                seed: 20,
                grid: GridConfig.standard,
                onHit: (l) => _setHit('line_multi', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Negative Y Line Chart — tap the line'),
              const SizedBox(height: 4),
              _hitLabel(_hits['line_negy']),
              const SizedBox(height: 8),
              _InteractiveLineChart(
                data: _sampleNegYLineData(),
                seed: 21,
                grid: GridConfig.horizontalOnly,
                onHit: (l) => _setHit('line_negy', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Negative X Line Chart — tap the line'),
              const SizedBox(height: 4),
              _hitLabel(_hits['line_negx']),
              const SizedBox(height: 8),
              _InteractiveLineChart(
                data: _sampleNegXLineData(),
                seed: 22,
                grid: GridConfig.verticalOnly,
                onHit: (l) => _setHit('line_negx', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Negative X and Y Line Chart — tap the line'),
              const SizedBox(height: 4),
              _hitLabel(_hits['line_negxy']),
              const SizedBox(height: 8),
              _InteractiveLineChart(
                data: _sampleNegXYLineData(),
                seed: 23,
                grid: _subGrid,
                onHit: (l) => _setHit('line_negxy', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading(
                'Function Chart — tap a dot or anywhere along the curve',
              ),
              const SizedBox(height: 4),
              const Text(
                'Point hits target only the sparse visible dots. Segment hits target '
                'anywhere along the sampled curve.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 4),
              _hitLabel(_hits['line_function']),
              const SizedBox(height: 8),
              _InteractiveLineChart(
                data: _sampleParabolaData(),
                seed: 24,
                grid: GridConfig.standard,
                onHit: (l) => _setHit('line_function', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Multi-Function Chart — tap a series'),
              const SizedBox(height: 4),
              const Text(
                'Same point-vs-segment hit semantics as the single-function chart, '
                'but hits also report which series was tapped.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 4),
              _hitLabel(_hits['line_function_multi']),
              const SizedBox(height: 8),
              _InteractiveLineChart(
                data: _sampleFunctionComparisonData(),
                seed: 25,
                grid: GridConfig.standard,
                onHit: (l) => _setHit('line_function_multi', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Discontinuous Function — tap either side'),
              const SizedBox(height: 4),
              const Text(
                "There's no segment spanning the discontinuity — taps near x = 0 "
                'fall through, while each side responds independently.',
                style: TextStyle(fontSize: 13, height: 1.55, color: _inkLight),
              ),
              const SizedBox(height: 4),
              _hitLabel(_hits['line_discontinuous']),
              const SizedBox(height: 8),
              _InteractiveLineChart(
                data: _sampleDiscontinuousFunctionData(),
                seed: 26,
                grid: _subGrid,
                onHit: (l) => _setHit('line_discontinuous', l),
                clipToChartArea: true,
              ),
              const SizedBox(height: 24),

              _sectionHeading(
                'Line Chart with Boxed Bottom Legend — tap a series',
              ),
              const SizedBox(height: 4),
              _hitLabel(_hits['line_legend_bottom']),
              const SizedBox(height: 8),
              _InteractiveLineChart(
                data: _sampleLineData(),
                seed: 27,
                grid: GridConfig.standard,
                legendConfig: ChartLegendConfig.externalBottomBoxed,
                onHit: (l) => _setHit('line_legend_bottom', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading(
                'Line Chart with Boxed Right Legend — tap a series',
              ),
              const SizedBox(height: 4),
              _hitLabel(_hits['line_legend_right']),
              const SizedBox(height: 8),
              _InteractiveLineChart(
                data: _sampleLineData(),
                seed: 28,
                grid: GridConfig.standard,
                legendConfig: ChartLegendConfig.externalRightBoxed,
                onHit: (l) => _setHit('line_legend_right', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Standalone Legend Composition — tap a series'),
              const SizedBox(height: 4),
              _hitLabel(_hits['line_legend_standalone']),
              const SizedBox(height: 8),
              Column(
                children: [
                  HandDrawnLegend(
                    entries: ChartLegendEntries.fromLineChartData(
                      _sampleLineData(),
                    ),
                  ),
                  const SizedBox(height: 8),
                  _InteractiveLineChart(
                    data: _sampleLineData(),
                    seed: 29,
                    grid: GridConfig.standard,
                    legendConfig: ChartLegendConfig.hidden,
                    onHit: (l) => _setHit('line_legend_standalone', l),
                  ),
                ],
              ),
              const SizedBox(height: 24),

              _sectionHeading('Scatter Plot — tap a point'),
              const SizedBox(height: 4),
              _hitLabel(_hits['scatter_simple']),
              const SizedBox(height: 8),
              _InteractiveScatterPlot(
                data: _sampleScatterData(),
                seed: 30,
                grid: _subGrid,
                onHit: (l) => _setHit('scatter_simple', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Negative Y Scatter Plot — tap a point'),
              const SizedBox(height: 4),
              _hitLabel(_hits['scatter_negy']),
              const SizedBox(height: 8),
              _InteractiveScatterPlot(
                data: _sampleNegYScatterData(),
                seed: 31,
                grid: GridConfig.standard,
                onHit: (l) => _setHit('scatter_negy', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Negative X Scatter Plot — tap a point'),
              const SizedBox(height: 4),
              _hitLabel(_hits['scatter_negx']),
              const SizedBox(height: 8),
              _InteractiveScatterPlot(
                data: _sampleNegXScatterData(),
                seed: 32,
                grid: GridConfig.none,
                onHit: (l) => _setHit('scatter_negx', l),
              ),
              const SizedBox(height: 24),

              _sectionHeading('Negative X and Y Scatter Plot — tap a point'),
              const SizedBox(height: 4),
              _hitLabel(_hits['scatter_negxy']),
              const SizedBox(height: 8),
              _InteractiveScatterPlot(
                data: _sampleNegXYScatterData(),
                seed: 33,
                grid: GridConfig.verticalOnly,
                onHit: (l) => _setHit('scatter_negxy', l),
              ),

              const SizedBox(height: 28),

              const HandDrawnContainer(
                backgroundColor: Color(0xFFF0F6F4),
                strokeColor: _accent,
                strokeWidth: 2.0,
                irregularity: 4.5,
                seed: 5,
                padding: EdgeInsets.all(18),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Tip',
                      style: TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.w600,
                        color: _accent,
                      ),
                    ),
                    SizedBox(height: 8),
                    Text(
                      'Use a unique seed for each adjacent element to avoid '
                      'identical wobble patterns lining up. Irregularity '
                      'around 2.0–4.0 works for borders; 0.5–1.5 for '
                      'dividers and grid lines.',
                      style: TextStyle(fontSize: 14, height: 1.55, color: _ink),
                    ),
                  ],
                ),
              ),

              const SizedBox(height: 28),
              const Text(
                'Custom Paths',
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.w600,
                  color: _ink,
                ),
              ),
              const SizedBox(height: 16),
              SizedBox(
                width: double.infinity,
                height: 40,
                child: CustomPaint(
                  painter: HandDrawnLinePainter(
                    color: _ink,
                    strokeWidth: 3.0,
                    irregularity: 2.0,
                    seed: 100,
                    segments: 100,
                    buildPath: (size, h) {
                      final offsets = h.smoothedOffsets();
                      final dx = size.width / h.segments;
                      final path = Path()..moveTo(0, size.height);
                      for (int i = 1; i <= h.segments; i++) {
                        final t = i / h.segments;
                        final y = size.height * (1 - t) + offsets[i];
                        path.lineTo(dx * i, y);
                      }
                      return path;
                    },
                  ),
                ),
              ),
              const SizedBox(height: 16),
            ],
          ),
        ),
      ),
    );
  }
}

// ══ SAMPLE DATA ════════════════════════════════════════════════════════════

BarChartData _sampleSimpleBarData() {
  const blue = Color(0xFF6B9BD2);
  return const BarChartData(
    title: 'Daily Steps',
    yAxisLabel: 'Steps (k)',
    maxY: 12,
    bars: [
      BarGroup(
        label: 'Mon',
        segments: [BarSegment(category: 'Steps', value: 6.2, color: blue)],
      ),
      BarGroup(
        label: 'Tue',
        segments: [BarSegment(category: 'Steps', value: 8.5, color: blue)],
      ),
      BarGroup(
        label: 'Wed',
        segments: [BarSegment(category: 'Steps', value: 4.8, color: blue)],
      ),
      BarGroup(
        label: 'Thu',
        segments: [BarSegment(category: 'Steps', value: 9.3, color: blue)],
      ),
      BarGroup(
        label: 'Fri',
        segments: [BarSegment(category: 'Steps', value: 7.1, color: blue)],
      ),
    ],
    legend: [LegendEntry(label: 'Steps', color: blue)],
  );
}

BarChartData _sampleBarData() {
  return const BarChartData(
    title: 'Weekly Activity',
    yAxisLabel: 'Minutes',
    maxY: 120,
    bars: [
      BarGroup(
        label: 'Mon',
        segments: [
          BarSegment(category: 'Exercise', value: 30, color: Color(0xFF6BAF7A)),
          BarSegment(category: 'Reading', value: 25, color: Color(0xFF6B9BD2)),
          BarSegment(category: 'Creative', value: 15, color: Color(0xFFE8943A)),
        ],
      ),
      BarGroup(
        label: 'Tue',
        segments: [
          BarSegment(category: 'Exercise', value: 50, color: Color(0xFF6BAF7A)),
          BarSegment(category: 'Reading', value: 20, color: Color(0xFF6B9BD2)),
          BarSegment(category: 'Creative', value: 25, color: Color(0xFFE8943A)),
        ],
      ),
      BarGroup(
        label: 'Wed',
        segments: [
          BarSegment(category: 'Exercise', value: 25, color: Color(0xFF6BAF7A)),
          BarSegment(category: 'Reading', value: 40, color: Color(0xFF6B9BD2)),
          BarSegment(category: 'Creative', value: 20, color: Color(0xFFE8943A)),
        ],
      ),
      BarGroup(
        label: 'Thu',
        segments: [
          BarSegment(category: 'Exercise', value: 40, color: Color(0xFF6BAF7A)),
          BarSegment(category: 'Reading', value: 15, color: Color(0xFF6B9BD2)),
          BarSegment(category: 'Creative', value: 30, color: Color(0xFFE8943A)),
        ],
      ),
      BarGroup(
        label: 'Fri',
        segments: [
          BarSegment(category: 'Exercise', value: 45, color: Color(0xFF6BAF7A)),
          BarSegment(category: 'Reading', value: 30, color: Color(0xFF6B9BD2)),
          BarSegment(category: 'Creative', value: 10, color: Color(0xFFE8943A)),
        ],
      ),
    ],
    legend: [
      LegendEntry(label: 'Exercise', color: Color(0xFF6BAF7A)),
      LegendEntry(label: 'Reading', color: Color(0xFF6B9BD2)),
      LegendEntry(label: 'Creative', color: Color(0xFFE8943A)),
    ],
  );
}

BarChartData _sampleGroupedBarData() {
  const blue = Color(0xFF6B9BD2);
  const orange = Color(0xFFE8943A);
  const purple = Color(0xFF7B68C4);
  const green = Color(0xFF6BAF7A);
  return const BarChartData(
    title: 'Quarterly Revenue by Region',
    yAxisLabel: 'USD (k)',
    maxY: 100,
    bars: [],
    categories: [
      BarCategory(
        label: 'Q1',
        bars: [
          BarGroup(
            label: 'North',
            segments: [BarSegment(category: 'North', value: 42, color: blue)],
          ),
          BarGroup(
            label: 'South',
            segments: [BarSegment(category: 'South', value: 35, color: orange)],
          ),
          BarGroup(
            label: 'West',
            segments: [BarSegment(category: 'West', value: 28, color: purple)],
          ),
        ],
      ),
      BarCategory(
        label: 'Q2',
        bars: [
          BarGroup(
            label: 'North',
            segments: [BarSegment(category: 'North', value: 55, color: blue)],
          ),
          BarGroup(
            label: 'South',
            segments: [BarSegment(category: 'South', value: 48, color: orange)],
          ),
          BarGroup(
            label: 'West',
            segments: [BarSegment(category: 'West', value: 40, color: purple)],
          ),
        ],
      ),
      BarCategory(
        label: 'Q3',
        bars: [
          BarGroup(
            label: 'North',
            segments: [BarSegment(category: 'North', value: 38, color: blue)],
          ),
          BarGroup(
            label: 'South',
            segments: [BarSegment(category: 'South', value: 62, color: orange)],
          ),
          BarGroup(
            label: 'West',
            segments: [BarSegment(category: 'West', value: 45, color: purple)],
          ),
        ],
      ),
      BarCategory(
        label: 'Q4',
        bars: [
          BarGroup(
            label: 'North',
            segments: [
              BarSegment(category: 'North', value: 50, color: blue),
              BarSegment(category: 'Bonus', value: 10, color: green),
            ],
          ),
          BarGroup(
            label: 'South',
            segments: [
              BarSegment(category: 'South', value: 70, color: orange),
              BarSegment(category: 'Bonus', value: 12, color: green),
            ],
          ),
          BarGroup(
            label: 'West',
            segments: [
              BarSegment(category: 'West', value: 55, color: purple),
              BarSegment(category: 'Bonus', value: 8, color: green),
            ],
          ),
        ],
      ),
    ],
    legend: [
      LegendEntry(label: 'North', color: blue),
      LegendEntry(label: 'South', color: orange),
      LegendEntry(label: 'West', color: purple),
      LegendEntry(label: 'Bonus', color: green),
    ],
  );
}

BarChartData _sampleNegativeBarData() {
  const green = Color(0xFF6BAF7A);
  const red = Color(0xFFD46B6B);
  return const BarChartData(
    title: 'Quarterly P/L',
    yAxisLabel: 'USD (k)',
    minY: -30,
    maxY: 50,
    axisDisplay: AxisDisplay(horizontal: AxisDisplayMode.zeroCrossing),
    bars: [
      // Mixed-sign stack: gains dominate.
      BarGroup(
        label: 'Q1',
        segments: [
          BarSegment(category: 'Gain', value: 30, color: green),
          BarSegment(category: 'Loss', value: -5, color: red),
        ],
      ),
      // All-negative bar.
      BarGroup(
        label: 'Q2',
        segments: [BarSegment(category: 'Loss', value: -18, color: red)],
      ),
      // Zero bar — occupies its slot, renders nothing.
      BarGroup(
        label: 'Q3',
        segments: [BarSegment(category: 'Gain', value: 0, color: green)],
      ),
      // All-positive bar.
      BarGroup(
        label: 'Q4',
        segments: [BarSegment(category: 'Gain', value: 38, color: green)],
      ),
    ],
    legend: [
      LegendEntry(label: 'Gain', color: green),
      LegendEntry(label: 'Loss', color: red),
    ],
  );
}

BarChartData _sampleRotatedLabelsBarData() {
  const blue = Color(0xFF6B9BD2);
  return const BarChartData(
    title: 'Monthly Active Users',
    yAxisLabel: 'Users (k)',
    maxY: 28,
    bars: [
      BarGroup(
        label: 'October 2024',
        segments: [BarSegment(category: 'Users', value: 13.1, color: blue)],
      ),
      BarGroup(
        label: 'November 2024',
        segments: [BarSegment(category: 'Users', value: 14.8, color: blue)],
      ),
      BarGroup(
        label: 'December 2024',
        segments: [BarSegment(category: 'Users', value: 16.2, color: blue)],
      ),
      BarGroup(
        label: 'January 2025',
        segments: [BarSegment(category: 'Users', value: 18.5, color: blue)],
      ),
      BarGroup(
        label: 'February 2025',
        segments: [BarSegment(category: 'Users', value: 19.7, color: blue)],
      ),
      BarGroup(
        label: 'March 2025',
        segments: [BarSegment(category: 'Users', value: 21.3, color: blue)],
      ),
    ],
    legend: [LegendEntry(label: 'Users', color: blue)],
  );
}

LineChartData _sampleSimpleLineData() {
  return const LineChartData(
    title: 'Weekly Runs',
    xAxisLabel: 'Day',
    yAxisLabel: 'Miles',
    minX: 0,
    maxX: 6,
    minY: 0,
    maxY: 8,
    xLabels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    series: [
      LineSeriesData(
        name: 'Miles',
        color: Color(0xFF4A7C6F),
        points: [
          LinePoint(x: 0, y: 3.1),
          LinePoint(x: 1, y: 4.5),
          LinePoint(x: 2, y: 2.8),
          LinePoint(x: 3, y: 5.2),
          LinePoint(x: 4, y: 3.9),
          LinePoint(x: 5, y: 6.8),
          LinePoint(x: 6, y: 4.1),
        ],
      ),
    ],
  );
}

LineChartData _sampleLineData() {
  return const LineChartData(
    title: 'Mood Tracker',
    xAxisLabel: 'Day',
    yAxisLabel: 'Score',
    minX: 0,
    maxX: 6,
    minY: 0,
    maxY: 10,
    xLabels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    series: [
      LineSeriesData(
        name: 'Energy',
        color: Color(0xFFE8943A),
        points: [
          LinePoint(x: 0, y: 6),
          LinePoint(x: 1, y: 7),
          LinePoint(x: 2, y: 5),
          LinePoint(x: 3, y: 8),
          LinePoint(x: 4, y: 7),
          LinePoint(x: 5, y: 9),
          LinePoint(x: 6, y: 8),
        ],
      ),
      LineSeriesData(
        name: 'Focus',
        color: Color(0xFF7B68C4),
        points: [
          LinePoint(x: 0, y: 5),
          LinePoint(x: 1, y: 6),
          LinePoint(x: 2, y: 4),
          LinePoint(x: 3, y: 7),
          LinePoint(x: 4, y: 8),
          LinePoint(x: 5, y: 6),
          LinePoint(x: 6, y: 7),
        ],
      ),
    ],
  );
}

LineChartData _sampleNegYLineData() {
  return const LineChartData(
    title: 'Monthly Profit / Loss',
    xAxisLabel: 'Month',
    yAxisLabel: 'USD (k)',
    minX: 0,
    maxX: 7,
    minY: -40,
    maxY: 60,
    xLabels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'],
    axisDisplay: AxisDisplay(horizontal: AxisDisplayMode.zeroCrossing),
    series: [
      LineSeriesData(
        name: 'Net',
        color: Color(0xFF7B68C4),
        showFill: false,
        points: [
          LinePoint(x: 0, y: 25),
          LinePoint(x: 1, y: -15),
          LinePoint(x: 2, y: -30),
          LinePoint(x: 3, y: 10),
          LinePoint(x: 4, y: 35),
          LinePoint(x: 5, y: 50),
          LinePoint(x: 6, y: 20),
          LinePoint(x: 7, y: -5),
        ],
      ),
    ],
  );
}

LineChartData _sampleNegXLineData() {
  return const LineChartData(
    title: 'Population Density vs Distance from City Center',
    xAxisLabel: 'Distance (km)',
    yAxisLabel: 'People / km²',
    minX: -20,
    maxX: 20,
    minY: 0,
    maxY: 5000,
    axisDisplay: AxisDisplay(vertical: AxisDisplayMode.zeroCrossing),
    series: [
      LineSeriesData(
        name: 'East-West Transect',
        color: Color(0xFF4A7C6F),
        points: [
          LinePoint(x: -20, y: 300),
          LinePoint(x: -15, y: 900),
          LinePoint(x: -10, y: 2200),
          LinePoint(x: -5, y: 4200),
          LinePoint(x: 0, y: 4800),
          LinePoint(x: 5, y: 4100),
          LinePoint(x: 10, y: 2100),
          LinePoint(x: 15, y: 800),
          LinePoint(x: 20, y: 250),
        ],
      ),
    ],
  );
}

LineChartData _sampleNegXYLineData() {
  return const LineChartData(
    title: 'Pendulum Position Over Time',
    xAxisLabel: 'X displacement (cm)',
    yAxisLabel: 'Y displacement (cm)',
    minX: -10,
    maxX: 10,
    minY: -10,
    maxY: 10,
    axisDisplay: AxisDisplay(
      horizontal: AxisDisplayMode.zeroCrossing,
      vertical: AxisDisplayMode.zeroCrossing,
    ),
    series: [
      LineSeriesData(
        name: 'Path',
        color: Color(0xFFE8943A),
        showFill: false,
        points: [
          LinePoint(x: -8, y: -2),
          LinePoint(x: -6, y: 2),
          LinePoint(x: -3, y: 6),
          LinePoint(x: 0, y: 8),
          LinePoint(x: 3, y: 6),
          LinePoint(x: 6, y: 2),
          LinePoint(x: 8, y: -2),
          LinePoint(x: 6, y: -6),
          LinePoint(x: 3, y: -8),
          LinePoint(x: 0, y: -7),
          LinePoint(x: -3, y: -8),
          LinePoint(x: -6, y: -6),
        ],
      ),
    ],
  );
}

// Top-level function definitions. Using top-level functions rather than
// inline closures keeps FunctionSeriesData equality stable across widget
// rebuilds, so LineChartData == / hashCode behave as expected.
double _parabola(double x) => x * x;
double _cubic(double x) => 0.1 * x * x * x - x;
double _reciprocal(double x) => 1 / x;

LineChartData _sampleParabolaData() {
  return const LineChartData(
    title: 'Parabola: f(x) = x²',
    xAxisLabel: 'x',
    yAxisLabel: 'f(x)',
    minX: -5,
    maxX: 5,
    minY: 0,
    maxY: 25,
    axisDisplay: AxisDisplay(vertical: AxisDisplayMode.zeroCrossing),
    series: [],
    functionSeries: [
      FunctionSeriesData(
        name: 'f(x) = x²',
        color: Color(0xFF6B9BD2),
        function: _parabola,
        displayXs: [-4, -2, 0, 2, 4],
      ),
    ],
  );
}

LineChartData _sampleFunctionComparisonData() {
  return const LineChartData(
    title: 'Two Functions on One Chart',
    xAxisLabel: 'x',
    yAxisLabel: 'f(x)',
    minX: -5,
    maxX: 5,
    minY: -10,
    maxY: 25,
    axisDisplay: AxisDisplay(
      horizontal: AxisDisplayMode.zeroCrossing,
      vertical: AxisDisplayMode.zeroCrossing,
    ),
    series: [],
    functionSeries: [
      FunctionSeriesData(
        name: 'f(x) = x²',
        color: Color(0xFF6B9BD2),
        function: _parabola,
        displayXs: [-4, -2, 0, 2, 4],
      ),
      FunctionSeriesData(
        name: 'g(x) = 0.1x³ − x',
        color: Color(0xFFE8943A),
        function: _cubic,
        displayXs: [-4, -2, 0, 2, 4],
      ),
    ],
  );
}

LineChartData _sampleDiscontinuousFunctionData() {
  return const LineChartData(
    title: 'Discontinuity: f(x) = 1/x',
    xAxisLabel: 'x',
    yAxisLabel: 'f(x)',
    minX: -4,
    maxX: 4,
    minY: -5,
    maxY: 5,
    axisDisplay: AxisDisplay(
      horizontal: AxisDisplayMode.zeroCrossing,
      vertical: AxisDisplayMode.zeroCrossing,
    ),
    series: [],
    functionSeries: [
      FunctionSeriesData(
        name: 'f(x) = 1/x',
        color: Color(0xFF7B68C4),
        function: _reciprocal,
        displayXs: [-3, -2, -1, 1, 2, 3],
      ),
    ],
  );
}

ScatterPlotData _sampleScatterData() {
  return const ScatterPlotData(
    title: 'Sleep vs Productivity',
    xAxisLabel: 'Hours of Sleep',
    yAxisLabel: 'Productivity Score',
    minX: 4,
    maxX: 10,
    minY: 0,
    maxY: 100,
    points: [
      ScatterPoint(x: 5.0, y: 35),
      ScatterPoint(x: 5.5, y: 42),
      ScatterPoint(x: 6.0, y: 55),
      ScatterPoint(x: 6.5, y: 50),
      ScatterPoint(x: 7.0, y: 68),
      ScatterPoint(x: 7.0, y: 72),
      ScatterPoint(x: 7.5, y: 78, size: 7),
      ScatterPoint(x: 8.0, y: 82, size: 7),
      ScatterPoint(x: 8.0, y: 75),
      ScatterPoint(x: 8.5, y: 88, size: 8),
      ScatterPoint(x: 9.0, y: 85),
      ScatterPoint(x: 9.5, y: 80),
    ],
  );
}

ScatterPlotData _sampleNegYScatterData() {
  return const ScatterPlotData(
    title: 'Daily Temperature Variance',
    xAxisLabel: 'Day of month',
    yAxisLabel: 'Δ°C from average',
    minX: 1,
    maxX: 14,
    minY: -8,
    maxY: 8,
    axisDisplay: AxisDisplay(horizontal: AxisDisplayMode.zeroCrossing),
    points: [
      ScatterPoint(x: 1, y: -3),
      ScatterPoint(x: 2, y: -5),
      ScatterPoint(x: 3, y: 2),
      ScatterPoint(x: 4, y: 4),
      ScatterPoint(x: 5, y: -1),
      ScatterPoint(x: 6, y: 6),
      ScatterPoint(x: 7, y: 5),
      ScatterPoint(x: 8, y: -2),
      ScatterPoint(x: 9, y: -6),
      ScatterPoint(x: 10, y: 1),
      ScatterPoint(x: 11, y: 3),
      ScatterPoint(x: 12, y: 7),
      ScatterPoint(x: 13, y: -4),
      ScatterPoint(x: 14, y: -7),
    ],
  );
}

ScatterPlotData _sampleNegXScatterData() {
  return const ScatterPlotData(
    title: 'Wind Speed vs East-West Position',
    xAxisLabel: 'Position (km, + = east)',
    yAxisLabel: 'Wind speed (km/h)',
    minX: -30,
    maxX: 30,
    minY: 0,
    maxY: 50,
    axisDisplay: AxisDisplay(vertical: AxisDisplayMode.zeroCrossing),
    points: [
      ScatterPoint(x: -28, y: 12),
      ScatterPoint(x: -22, y: 18),
      ScatterPoint(x: -15, y: 25),
      ScatterPoint(x: -8, y: 32),
      ScatterPoint(x: -3, y: 38),
      ScatterPoint(x: 0, y: 42, size: 7),
      ScatterPoint(x: 4, y: 40),
      ScatterPoint(x: 10, y: 35),
      ScatterPoint(x: 18, y: 28),
      ScatterPoint(x: 25, y: 20),
      ScatterPoint(x: 29, y: 14),
    ],
  );
}

ScatterPlotData _sampleNegXYScatterData() {
  return const ScatterPlotData(
    title: 'Sales vs Forecast Variance',
    xAxisLabel: 'Forecast Δ (units)',
    yAxisLabel: 'Sales Δ (units)',
    minX: -50,
    maxX: 50,
    minY: -40,
    maxY: 40,
    axisDisplay: AxisDisplay(
      horizontal: AxisDisplayMode.zeroCrossing,
      vertical: AxisDisplayMode.zeroCrossing,
    ),
    points: [
      ScatterPoint(x: -35, y: -25),
      ScatterPoint(x: -20, y: -10),
      ScatterPoint(x: -15, y: 8),
      ScatterPoint(x: -5, y: -3),
      ScatterPoint(x: 5, y: 12),
      ScatterPoint(x: 18, y: 22, size: 7),
      ScatterPoint(x: 25, y: -8),
      ScatterPoint(x: 30, y: 18),
      ScatterPoint(x: 40, y: 30, size: 8),
      ScatterPoint(x: -30, y: 15),
      ScatterPoint(x: -40, y: -32),
      ScatterPoint(x: 10, y: -5),
    ],
  );
}

// ══ INTERACTIVE CHART WIDGETS ══════════════════════════════════════════════

class _InteractiveBarChart extends StatelessWidget {
  const _InteractiveBarChart({
    required this.data,
    required this.onHit,
    this.seed = 10,
    this.unitLabel = 'min',
    this.xLabelConfig = ChartLabelConfig.horizontal,
    this.legendConfig = ChartLegendConfig.inlineBottom,
  });

  final BarChartData data;
  final ValueChanged<String?> onHit;
  final int seed;
  final String unitLabel;
  final ChartLabelConfig xLabelConfig;
  final ChartLegendConfig legendConfig;

  @override
  Widget build(BuildContext context) {
    final painter = HandDrawnBarChartPainter(
      data: data,
      seed: seed,
      xLabelConfig: xLabelConfig,
      legendConfig: legendConfig,
    );
    return LayoutBuilder(
      builder: (context, constraints) {
        final size = Size(constraints.maxWidth, 240);
        final layout = painter.computeLayout(size);
        return GestureDetector(
          onTapDown: (details) {
            final hit = layout.hitTest(details.localPosition);
            if (hit != null) {
              final s = hit.segment;
              final header = data.hasGroupedBars
                  ? '${s.barLabel} / ${s.innerBarLabel}'
                  : s.barLabel;
              final fmt = s.value.truncateToDouble() == s.value ? 0 : 1;
              onHit(
                '$header — ${s.category}: '
                '${s.value.toStringAsFixed(fmt)} $unitLabel',
              );
            } else {
              onHit(null);
            }
          },
          child: CustomPaint(size: size, painter: painter),
        );
      },
    );
  }
}

class _InteractiveLineChart extends StatelessWidget {
  const _InteractiveLineChart({
    required this.data,
    required this.onHit,
    this.seed = 20,
    this.grid = GridConfig.standard,
    this.clipToChartArea = true,
    this.legendConfig = ChartLegendConfig.inlineBottom,
  });

  final LineChartData data;
  final ValueChanged<String?> onHit;
  final int seed;
  final GridConfig grid;
  final bool clipToChartArea;
  final ChartLegendConfig legendConfig;

  @override
  Widget build(BuildContext context) {
    final painter = HandDrawnLineChartPainter(
      data: data,
      seed: seed,
      grid: grid,
      clipToChartArea: clipToChartArea,
      legendConfig: legendConfig,
    );
    return LayoutBuilder(
      builder: (context, constraints) {
        final size = Size(constraints.maxWidth, 240);
        final layout = painter.computeLayout(size);
        return GestureDetector(
          onTapDown: (details) {
            final hit = layout.hitTest(details.localPosition);
            if (hit != null) {
              final label = switch (hit) {
                LinePointHit(
                  :final seriesName,
                  :final pointIndex,
                  :final point,
                ) =>
                  '$seriesName point $pointIndex: '
                      '(${point.x.toStringAsFixed(1)}, '
                      '${point.y.toStringAsFixed(1)})',
                LineSegmentHit(
                  :final seriesName,
                  :final interpolatedX,
                  :final interpolatedY,
                ) =>
                  '$seriesName segment at '
                      'x=${interpolatedX.toStringAsFixed(1)}, '
                      'y=${interpolatedY.toStringAsFixed(1)}',
              };
              onHit(label);
            } else {
              onHit(null);
            }
          },
          child: CustomPaint(size: size, painter: painter),
        );
      },
    );
  }
}

class _InteractiveScatterPlot extends StatelessWidget {
  const _InteractiveScatterPlot({
    required this.data,
    required this.onHit,
    this.seed = 30,
    this.grid = GridConfig.standard,
  });

  final ScatterPlotData data;
  final ValueChanged<String?> onHit;
  final int seed;
  final GridConfig grid;

  @override
  Widget build(BuildContext context) {
    final painter = HandDrawnScatterPlotPainter(
      data: data,
      seed: seed,
      grid: grid,
    );
    return LayoutBuilder(
      builder: (context, constraints) {
        final size = Size(constraints.maxWidth, 240);
        final layout = painter.computeLayout(size);
        return GestureDetector(
          onTapDown: (details) {
            final hit = layout.hitTest(details.localPosition);
            if (hit != null) {
              final p = hit.point.rawPoint;
              onHit(
                'Point ${hit.point.pointIndex}: '
                '(${p.x.toStringAsFixed(1)}, '
                '${p.y.toStringAsFixed(1)})',
              );
            } else {
              onHit(null);
            }
          },
          child: CustomPaint(size: size, painter: painter),
        );
      },
    );
  }
}

// ══ RESIZABLE TABLE DEMO ═══════════════════════════════════════════════════

class _ResizableTableDemo extends StatefulWidget {
  const _ResizableTableDemo();

  @override
  State<_ResizableTableDemo> createState() => _ResizableTableDemoState();
}

class _ResizableTableDemoState extends State<_ResizableTableDemo> {
  static const _minColWidth = 40.0;
  static const _tablePadding = 12.0;
  static const _handleWidth = 16.0;
  static const _initialRatios = [3.0, 1.0, 1.0];

  List<double>? _widths;

  final _rows = const [
    HandDrawnTableRow(cells: ['Dune', '412', '★★★★★'], highlight: true),
    HandDrawnTableRow(cells: ['Neuromancer', '271', '★★★★']),
    HandDrawnTableRow(cells: ['Foundation', '244', '★★★★']),
  ];

  List<double> _initWidths(double contentWidth) {
    final totalRatio = _initialRatios.fold(0.0, (s, r) => s + r);
    return [for (final r in _initialRatios) contentWidth * r / totalRatio];
  }

  void _onDrag(int boundary, double delta) {
    setState(() {
      final maxGrow = _widths![boundary + 1] - _minColWidth;
      final maxShrink = _widths![boundary] - _minColWidth;
      final clamped = delta.clamp(-maxShrink, maxGrow);
      _widths![boundary] += clamped;
      _widths![boundary + 1] -= clamped;
    });
  }

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final contentWidth = constraints.maxWidth - _tablePadding * 2;
        _widths ??= _initWidths(contentWidth);
        return Stack(
          children: [
            HandDrawnTable(
              columns: [
                HandDrawnTableColumn(header: 'TITLE', width: _widths![0]),
                HandDrawnTableColumn(
                  header: 'PAGES',
                  width: _widths![1],
                  alignment: Alignment.centerRight,
                ),
                HandDrawnTableColumn(
                  header: 'RATING',
                  width: _widths![2],
                  alignment: Alignment.center,
                ),
              ],
              rows: _rows,
              rowDividers: const TableDividerStyle(irregularity: 3),
              columnDividers: const TableDividerStyle(irregularity: 3),
            ),
            for (int i = 0; i < _widths!.length - 1; i++)
              Positioned(
                left:
                    _tablePadding +
                    _widths!.take(i + 1).fold(0.0, (s, w) => s + w) -
                    _handleWidth / 2,
                top: 0,
                bottom: 0,
                width: _handleWidth,
                child: MouseRegion(
                  cursor: SystemMouseCursors.resizeColumn,
                  child: GestureDetector(
                    onHorizontalDragUpdate: (d) => _onDrag(i, d.delta.dx),
                  ),
                ),
              ),
          ],
        );
      },
    );
  }
}

// ══ UI HELPERS ═════════════════════════════════════════════════════════════

Widget _sectionHeading(String text) => Text(
  text,
  style: const TextStyle(
    fontSize: 16,
    fontWeight: FontWeight.w600,
    color: _ink,
  ),
);

Widget _hitLabel(String? label) => Text(
  label ?? 'Tap a data element…',
  style: TextStyle(
    fontSize: 13,
    color: label != null ? _accent : _inkLight,
    fontStyle: label != null ? FontStyle.normal : FontStyle.italic,
  ),
);

Widget _notebookCard({
  required int seed,
  required int ruleSeed,
  required Widget entry,
  TextStyle textStyle = const TextStyle(
    fontSize: _notebookFontSize,
    color: _ink,
  ),
  double irregularity = 2.5,
}) {
  return HandDrawnContainer(
    backgroundColor: _cardFill,
    strokeColor: _ink,
    strokeWidth: 1.4,
    irregularity: 2.5,
    seed: seed,
    // Vertical padding gives the ruled lines breathing room so the bottom
    // rule's wobble stays inside the border.
    padding: const EdgeInsets.only(left: 16, right: 16, bottom: 10),
    child: HandDrawnNotebook(
      lineHeight: _notebookLineHeight,
      lineColor: const Color(0xFFB0AAA0),
      irregularity: irregularity,
      seed: ruleSeed,
      child: DefaultTextStyle(style: textStyle, child: entry),
    ),
  );
}

Widget _caption(String text) => Padding(
  padding: const EdgeInsets.only(bottom: 8),
  child: Text(
    text,
    style: const TextStyle(fontSize: 13, height: 1.5, color: _inkLight),
  ),
);

// ══ STATUS SQUARE DEMO HELPERS ═════════════════════════════════════════════

enum _TaskStatus { pending, completed, skipped }

class _TaskItem {
  const _TaskItem(this.label, {this.status = _TaskStatus.pending});

  final String label;
  final _TaskStatus status;

  _TaskItem cycled() {
    final next = switch (status) {
      _TaskStatus.pending => _TaskStatus.completed,
      _TaskStatus.completed => _TaskStatus.skipped,
      _TaskStatus.skipped => _TaskStatus.pending,
    };
    return _TaskItem(label, status: next);
  }

  Color get color => switch (status) {
    _TaskStatus.pending => _ink,
    _TaskStatus.completed => _accent,
    _TaskStatus.skipped => _inkLight,
  };

  bool get isFilled => status != _TaskStatus.pending;

  StatusIndicator get indicator => switch (status) {
    _TaskStatus.pending => StatusIndicator.none,
    _TaskStatus.completed => StatusIndicator.check,
    _TaskStatus.skipped => StatusIndicator.dash,
  };
}

class _GoalItem extends StatelessWidget {
  const _GoalItem({required this.seed, required this.text});

  final int seed;
  final String text;

  @override
  Widget build(BuildContext context) {
    return HandDrawnContainer(
      backgroundColor: _cardFill,
      strokeColor: _ink,
      strokeWidth: 1.4,
      irregularity: 2.2,
      seed: seed,
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('•  ', style: TextStyle(fontSize: 15, color: _ink)),
          Expanded(
            child: Text(
              text,
              style: const TextStyle(fontSize: 14, height: 1.5, color: _ink),
            ),
          ),
        ],
      ),
    );
  }
}
1
likes
160
points
171
downloads

Documentation

API reference

Publisher

verified publisherresengi.io

Weekly Downloads

A lightweight Flutter package for rendering hand-drawn, sketchy lines, borders, and containers.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter

More

Packages that depend on hand_drawn_toolkit