flutter_sketchpad 0.0.1 copy "flutter_sketchpad: ^0.0.1" to clipboard
flutter_sketchpad: ^0.0.1 copied to clipboard

Annotate images in Flutter: pressure-sensitive freehand drawing, text, shapes, layers, measurement tools, and sharp vector-rendered PNG export.

example/lib/main.dart

import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;

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

import 'sample_background.dart';

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

/// Demonstrates every feature of `flutter_sketchpad`.
class ExampleApp extends StatelessWidget {
  /// Creates the example app.
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'flutter_sketchpad',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF7C3AED)),
        useMaterial3: true,
      ),
      home: const EditorPage(),
    );
  }
}

/// Palette offered by the example's colour row.
const List<int> kPalette = <int>[
  0xFF000000,
  0xFFEF4444,
  0xFF2563EB,
  0xFF16A34A,
  0xFFF59E0B,
  0xFF7C3AED,
];

/// Every shape the package can draw, with the icon the example offers it under.
const List<ShapeChoice> kShapes = <ShapeChoice>[
  ShapeChoice(ShapeKind.circle, Icons.circle_outlined, 'Circle'),
  ShapeChoice(ShapeKind.box, Icons.crop_square, 'Box'),
  ShapeChoice(ShapeKind.triangle, Icons.change_history, 'Triangle'),
  ShapeChoice(ShapeKind.diamond, Icons.diamond_outlined, 'Diamond'),
  ShapeChoice(ShapeKind.star, Icons.star_outline, 'Star'),
  ShapeChoice(ShapeKind.arrow, Icons.north_east, 'Arrow'),
  ShapeChoice(ShapeKind.doubleArrow, Icons.swap_horiz, 'Double arrow'),
  ShapeChoice(ShapeKind.line, Icons.horizontal_rule, 'Line'),
  ShapeChoice(ShapeKind.cross, Icons.close, 'Cross'),
  ShapeChoice(ShapeKind.check, Icons.check, 'Check'),
];

/// One entry of [kShapes].
class ShapeChoice {
  /// Offers [kind] under [icon], described by [label].
  const ShapeChoice(this.kind, this.icon, this.label);

  /// The shape to add.
  final ShapeKind kind;

  /// Icon for the toolbar button.
  final IconData icon;

  /// Human-readable name.
  final String label;
}

/// Pixels per centimetre in the generated background, matching its scale bar.
const double kSamplePixelsPerCm = 30;

/// An annotation editor over a generated background image.
class EditorPage extends StatefulWidget {
  /// Creates the editor page.
  const EditorPage({super.key});

  @override
  State<EditorPage> createState() => _EditorPageState();
}

class _EditorPageState extends State<EditorPage> {
  final SketchpadController _controller = SketchpadController();

  ui.Image? _background;
  String? _savedJson;

  @override
  void initState() {
    super.initState();
    _controller.addListener(_onChanged);
    _loadBackground();
  }

  @override
  void dispose() {
    _controller
      ..removeListener(_onChanged)
      ..dispose();
    _background?.dispose();
    super.dispose();
  }

  void _onChanged() {
    if (mounted) setState(() {});
  }

  Future<void> _loadBackground() async {
    final ui.Image image = await buildSampleBackground();
    if (!mounted) {
      image.dispose();
      return;
    }
    setState(() => _background = image);
    // The scene's coordinates are image pixels, so the controller has to be
    // told the size once the image is decoded.
    _controller.setImageSize(
      ui.Size(image.width.toDouble(), image.height.toDouble()),
    );
  }

  /// Centre of the image, where new annotations are placed.
  ui.Offset get _imageCentre {
    final ui.Size size = _controller.scene.authoredImageSize;
    return ui.Offset(size.width / 2, size.height / 2);
  }

  Future<void> _addText() async {
    final String? text = await _promptForText();
    if (text == null || text.trim().isEmpty) return;
    _controller.addText(text.trim(), position: _imageCentre);
  }

  Future<void> _editText(TextOverlay overlay) async {
    final String? text = await _promptForText(initial: overlay.text);
    if (text == null) return;
    _controller.updateText(overlay, text);
  }

  Future<void> _pickColor() async {
    final int? picked = await showDialog<int>(
      context: context,
      builder: (BuildContext context) =>
          _ColorPickerDialog(initial: _controller.color),
    );
    // Applied once, on confirm: the setter restyles the selected overlay and
    // records an undo step, which a live slider would do on every tick.
    if (picked != null) _controller.color = picked;
  }

  Future<String?> _promptForText({String initial = ''}) {
    return showDialog<String>(
      context: context,
      builder: (BuildContext context) => _TextPromptDialog(initial: initial),
    );
  }

  void _toggleCalibration() {
    final bool calibrated = _controller.calibration != null;
    _controller.calibration = calibrated
        ? null
        : const SketchCalibration(
            pixelsPerUnit: kSamplePixelsPerCm,
            unit: 'cm',
          );
    _showSnack(
      calibrated
          ? 'Calibration cleared — measurements now read in pixels.'
          : 'Calibrated to the 10 cm scale bar. Drag with the ruler tool.',
    );
  }

  Future<void> _exportPng() async {
    final Uint8List? bytes = await _controller.exportPng(
      background: _background,
    );
    if (bytes == null || !mounted) return;
    await showDialog<void>(
      context: context,
      builder: (BuildContext context) => AlertDialog(
        title: Text('Exported PNG — ${(bytes.length / 1024).round()} KB'),
        content: SizedBox(
          width: 320,
          child: ColoredBox(
            color: const Color(0xFFEEEEEE),
            child: Image.memory(bytes),
          ),
        ),
        actions: <Widget>[
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Close'),
          ),
        ],
      ),
    );
  }

  void _saveJson() {
    _savedJson = _controller.scene.toJsonString();
    final int bytes = _savedJson!.length;
    _showSnack(
      'Scene saved as ${(bytes / 1024).toStringAsFixed(1)} KB of JSON — '
      'reload it to keep editing.',
    );
    setState(() {});
  }

  void _loadJson() {
    final String? json = _savedJson;
    if (json == null) return;
    final ui.Image? background = _background;
    _controller.loadScene(
      SketchScene.fromJsonString(json),
      backgroundSize: background == null
          ? null
          : ui.Size(background.width.toDouble(), background.height.toDouble()),
    );
    _showSnack('Scene reloaded — strokes are live again, not a flat image.');
  }

  void _showSnack(String message) {
    ScaffoldMessenger.of(context)
      ..hideCurrentSnackBar()
      ..showSnackBar(SnackBar(content: Text(message)));
  }

  void _showLayers() {
    showModalBottomSheet<void>(
      context: context,
      showDragHandle: true,
      builder: (BuildContext context) => _LayersSheet(controller: _controller),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF1C1C1E),
      appBar: AppBar(
        title: const Text('flutter_sketchpad'),
        actions: <Widget>[
          IconButton(
            tooltip: 'Undo',
            onPressed: _controller.canUndo ? _controller.undo : null,
            icon: const Icon(Icons.undo),
          ),
          IconButton(
            tooltip: 'Redo',
            onPressed: _controller.canRedo ? _controller.redo : null,
            icon: const Icon(Icons.redo),
          ),
          IconButton(
            tooltip: 'Clear',
            onPressed: _controller.isEmpty ? null : _controller.clear,
            icon: const Icon(Icons.delete_sweep_outlined),
          ),
          IconButton(
            tooltip: 'Layers',
            onPressed: _showLayers,
            icon: const Icon(Icons.layers_outlined),
          ),
        ],
      ),
      body: Column(
        children: <Widget>[
          Expanded(
            child: _background == null
                ? const Center(child: CircularProgressIndicator())
                : Sketchpad(
                    controller: _controller,
                    background: _background,
                    onOverlayDoubleTap: (SketchOverlay overlay) {
                      if (overlay is TextOverlay) _editText(overlay);
                    },
                  ),
          ),
          _Toolbar(
            controller: _controller,
            onAddText: _addText,
            onCalibrate: _toggleCalibration,
            onExportPng: _exportPng,
            onSaveJson: _saveJson,
            onLoadJson: _savedJson == null ? null : _loadJson,
            onPickColor: _pickColor,
            imageCentre: _imageCentre,
          ),
        ],
      ),
    );
  }
}

/// The example's tool, colour, and action rows.
class _Toolbar extends StatelessWidget {
  const _Toolbar({
    required this.controller,
    required this.onAddText,
    required this.onCalibrate,
    required this.onExportPng,
    required this.onSaveJson,
    required this.onLoadJson,
    required this.onPickColor,
    required this.imageCentre,
  });

  final SketchpadController controller;
  final VoidCallback onAddText;
  final VoidCallback onCalibrate;
  final VoidCallback onExportPng;
  final VoidCallback onSaveJson;
  final VoidCallback? onLoadJson;
  final VoidCallback onPickColor;
  final ui.Offset imageCentre;

  @override
  Widget build(BuildContext context) {
    return Container(
      color: const Color(0xFF2C2C2E),
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          _row(<Widget>[
            _tool(SketchTool.none, Icons.pan_tool_outlined, 'Pan'),
            _tool(SketchTool.pen, Icons.edit_outlined, 'Pen'),
            _tool(SketchTool.highlighter, Icons.brush_outlined, 'Highlighter'),
            _tool(
              SketchTool.eraser,
              Icons.cleaning_services_outlined,
              'Eraser',
            ),
            _tool(SketchTool.measure, Icons.straighten, 'Measure'),
            const _Divider(),
            _action(Icons.text_fields, 'Add text', onAddText),
          ]),
          const SizedBox(height: 6),
          _row(<Widget>[
            for (final ShapeChoice shape in kShapes)
              _action(
                shape.icon,
                'Add ${shape.label.toLowerCase()}',
                () => controller.addShape(shape.kind, position: imageCentre),
              ),
          ]),
          const SizedBox(height: 6),
          _row(<Widget>[
            for (final int color in kPalette) _swatch(color),
            _customSwatch(),
            const _Divider(),
            SizedBox(
              width: 130,
              child: Slider(
                min: 1,
                max: 40,
                value: controller.strokeWidth.clamp(1, 40),
                onChanged: (double v) => controller.strokeWidth = v,
              ),
            ),
          ]),
          const SizedBox(height: 4),
          _row(<Widget>[
            _labelled(
              controller.calibration == null ? Icons.square_foot : Icons.check,
              controller.calibration == null ? 'Calibrate' : 'Calibrated',
              onCalibrate,
            ),
            _labelled(Icons.image_outlined, 'Export PNG', onExportPng),
            _labelled(Icons.save_outlined, 'Save JSON', onSaveJson),
            _labelled(Icons.folder_open, 'Reload', onLoadJson),
          ]),
        ],
      ),
    );
  }

  Widget _row(List<Widget> children) => SingleChildScrollView(
    scrollDirection: Axis.horizontal,
    padding: const EdgeInsets.symmetric(horizontal: 12),
    child: Row(children: children),
  );

  Widget _tool(SketchTool tool, IconData icon, String tooltip) {
    final bool active = controller.tool == tool;
    return IconButton(
      tooltip: tooltip,
      onPressed: () => controller.tool = tool,
      icon: Icon(icon),
      style: IconButton.styleFrom(
        backgroundColor: active ? Colors.white : Colors.transparent,
        foregroundColor: active ? Colors.black : Colors.white70,
      ),
    );
  }

  Widget _action(IconData icon, String tooltip, VoidCallback onPressed) {
    return IconButton(
      tooltip: tooltip,
      onPressed: onPressed,
      icon: Icon(icon, color: Colors.white70),
    );
  }

  Widget _labelled(IconData icon, String label, VoidCallback? onPressed) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 4),
      child: TextButton.icon(
        onPressed: onPressed,
        icon: Icon(icon, size: 18),
        label: Text(label),
        style: TextButton.styleFrom(foregroundColor: Colors.white70),
      ),
    );
  }

  /// Opens the full colour picker, previewing whatever colour is active.
  ///
  /// Shown as selected whenever the active colour is not one of [kPalette], so
  /// a custom colour still reads as the current choice.
  Widget _customSwatch() {
    final bool active = !kPalette.contains(controller.color);
    return Tooltip(
      message: 'Custom colour',
      child: GestureDetector(
        onTap: onPickColor,
        child: Container(
          margin: const EdgeInsets.symmetric(horizontal: 5),
          width: active ? 30 : 24,
          height: active ? 30 : 24,
          decoration: BoxDecoration(
            color: Color(controller.color),
            shape: BoxShape.circle,
            border: Border.all(color: Colors.white, width: active ? 3 : 1.5),
            gradient: active
                ? null
                : const SweepGradient(
                    colors: <Color>[
                      Color(0xFFFF0000),
                      Color(0xFFFFFF00),
                      Color(0xFF00FF00),
                      Color(0xFF00FFFF),
                      Color(0xFF0000FF),
                      Color(0xFFFF00FF),
                      Color(0xFFFF0000),
                    ],
                  ),
          ),
          child: active
              ? const Icon(Icons.tune, size: 16, color: Colors.white)
              : null,
        ),
      ),
    );
  }

  Widget _swatch(int color) {
    final bool active = controller.color == color;
    return GestureDetector(
      onTap: () => controller.color = color,
      child: Container(
        margin: const EdgeInsets.symmetric(horizontal: 5),
        width: active ? 30 : 24,
        height: active ? 30 : 24,
        decoration: BoxDecoration(
          color: Color(color),
          shape: BoxShape.circle,
          border: Border.all(color: Colors.white, width: active ? 3 : 1.5),
        ),
      ),
    );
  }
}

/// Prompts for the text of an annotation.
///
/// Stateful so the [TextEditingController] is disposed when the dialog is
/// really gone. Disposing it when `showDialog`'s future completes is too
/// early: that fires on pop, while the route is still animating out and the
/// field still rebuilding, which reads a disposed controller.
class _TextPromptDialog extends StatefulWidget {
  const _TextPromptDialog({required this.initial});

  final String initial;

  @override
  State<_TextPromptDialog> createState() => _TextPromptDialogState();
}

class _TextPromptDialogState extends State<_TextPromptDialog> {
  late final TextEditingController _field = TextEditingController(
    text: widget.initial,
  );

  @override
  void dispose() {
    _field.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: Text(widget.initial.isEmpty ? 'Add text' : 'Edit text'),
      content: TextField(
        controller: _field,
        autofocus: true,
        maxLines: null,
        decoration: const InputDecoration(hintText: 'Type a note'),
        onSubmitted: (String value) => Navigator.pop(context, value),
      ),
      actions: <Widget>[
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('Cancel'),
        ),
        FilledButton(
          onPressed: () => Navigator.pop(context, _field.text),
          child: const Text('Done'),
        ),
      ],
    );
  }
}

/// Picks any colour at all, as HSV plus opacity, and pops the ARGB integer.
///
/// The package stores colour as a plain 32-bit ARGB `int` and never restricts
/// the value, so a palette is only ever an app's own choice. This shows the
/// full range: the swatch row is a set of shortcuts, not the available colours.
class _ColorPickerDialog extends StatefulWidget {
  const _ColorPickerDialog({required this.initial});

  /// The ARGB colour to open on.
  final int initial;

  @override
  State<_ColorPickerDialog> createState() => _ColorPickerDialogState();
}

class _ColorPickerDialogState extends State<_ColorPickerDialog> {
  late double _hue;
  late double _saturation;
  late double _value;
  late double _alpha;

  @override
  void initState() {
    super.initState();
    final List<double> hsv = _toHsv(widget.initial);
    _hue = hsv[0];
    _saturation = hsv[1];
    _value = hsv[2];
    _alpha = ((widget.initial >> 24) & 0xFF) / 255.0;
  }

  /// The picked colour as the ARGB integer the controller stores.
  int get _argb => _toArgb(_hue, _saturation, _value, _alpha);

  /// The picked colour at full opacity, for the track previews.
  int get _opaque => _toArgb(_hue, _saturation, _value, 1.0);

  String get _hex =>
      '#${_argb.toRadixString(16).padLeft(8, '0').toUpperCase()}';

  /// Converts hue/saturation/value/alpha into a 32-bit ARGB integer.
  ///
  /// Done with plain arithmetic rather than [HSVColor] plus a colour-to-int
  /// accessor, because those accessors have changed name and type across
  /// Flutter versions and the package supports back to 3.22.
  static int _toArgb(double h, double s, double v, double a) {
    final double chroma = v * s;
    final double sector = (h % 360) / 60.0;
    final double x = chroma * (1 - ((sector % 2) - 1).abs());
    final double m = v - chroma;
    final double r;
    final double g;
    final double b;
    switch (sector.floor()) {
      case 0:
        r = chroma;
        g = x;
        b = 0;
      case 1:
        r = x;
        g = chroma;
        b = 0;
      case 2:
        r = 0;
        g = chroma;
        b = x;
      case 3:
        r = 0;
        g = x;
        b = chroma;
      case 4:
        r = x;
        g = 0;
        b = chroma;
      default:
        r = chroma;
        g = 0;
        b = x;
    }
    int channel(double t) => (((t + m) * 255).round()).clamp(0, 255);
    return ((a * 255).round().clamp(0, 255) << 24) |
        (channel(r) << 16) |
        (channel(g) << 8) |
        channel(b);
  }

  /// The hue, saturation and value of the opaque part of [argb].
  static List<double> _toHsv(int argb) {
    final double r = ((argb >> 16) & 0xFF) / 255.0;
    final double g = ((argb >> 8) & 0xFF) / 255.0;
    final double b = (argb & 0xFF) / 255.0;
    final double top = math.max(r, math.max(g, b));
    final double bottom = math.min(r, math.min(g, b));
    final double delta = top - bottom;
    double hue = 0;
    if (delta != 0) {
      if (top == r) {
        hue = 60 * (((g - b) / delta) % 6);
      } else if (top == g) {
        hue = 60 * ((b - r) / delta + 2);
      } else {
        hue = 60 * ((r - g) / delta + 4);
      }
    }
    return <double>[hue % 360, top == 0 ? 0 : delta / top, top];
  }

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: const Text('Colour'),
      content: SizedBox(
        width: 320,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            // Preview over a light ground, so a low opacity reads as
            // transparency rather than as a paler colour.
            Container(
              height: 56,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(8),
                border: Border.all(color: Colors.black26),
                color: const Color(0xFFE0E0E0),
              ),
              child: Container(
                decoration: BoxDecoration(
                  color: Color(_argb),
                  borderRadius: BorderRadius.circular(7),
                ),
              ),
            ),
            const SizedBox(height: 6),
            Text(_hex, style: const TextStyle(fontSize: 12)),
            _channel(
              label: 'Hue',
              value: _hue,
              max: 360,
              gradient: <Color>[
                for (int i = 0; i <= 6; i++) Color(_toArgb(i * 60.0, 1, 1, 1)),
              ],
              onChanged: (double v) => setState(() => _hue = v),
            ),
            _channel(
              label: 'Saturation',
              value: _saturation,
              max: 1,
              gradient: <Color>[
                Color(_toArgb(_hue, 0, _value, 1)),
                Color(_toArgb(_hue, 1, _value, 1)),
              ],
              onChanged: (double v) => setState(() => _saturation = v),
            ),
            _channel(
              label: 'Brightness',
              value: _value,
              max: 1,
              gradient: <Color>[
                const Color(0xFF000000),
                Color(_toArgb(_hue, _saturation, 1, 1)),
              ],
              onChanged: (double v) => setState(() => _value = v),
            ),
            _channel(
              label: 'Opacity',
              value: _alpha,
              max: 1,
              gradient: <Color>[Color(_opaque & 0x00FFFFFF), Color(_opaque)],
              onChanged: (double v) => setState(() => _alpha = v),
            ),
          ],
        ),
      ),
      actions: <Widget>[
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('Cancel'),
        ),
        FilledButton(
          onPressed: () => Navigator.pop(context, _argb),
          child: const Text('Use colour'),
        ),
      ],
    );
  }

  /// One labelled slider whose track shows the range it selects from.
  Widget _channel({
    required String label,
    required double value,
    required double max,
    required List<Color> gradient,
    required ValueChanged<double> onChanged,
  }) {
    return Row(
      children: <Widget>[
        SizedBox(
          width: 76,
          child: Text(label, style: const TextStyle(fontSize: 12)),
        ),
        Expanded(
          child: Stack(
            alignment: Alignment.center,
            children: <Widget>[
              Container(
                height: 10,
                margin: const EdgeInsets.symmetric(horizontal: 10),
                decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(5),
                  border: Border.all(color: Colors.black26),
                  gradient: LinearGradient(colors: gradient),
                ),
              ),
              // The track is drawn above, so the slider contributes only its
              // thumb and its gesture handling.
              SliderTheme(
                data: SliderTheme.of(context).copyWith(
                  activeTrackColor: Colors.transparent,
                  inactiveTrackColor: Colors.transparent,
                  overlayColor: Colors.transparent,
                  thumbColor: Colors.white,
                  trackHeight: 10,
                ),
                child: Slider(
                  value: value.clamp(0, max),
                  max: max,
                  onChanged: onChanged,
                ),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

class _Divider extends StatelessWidget {
  const _Divider();

  @override
  Widget build(BuildContext context) => Container(
    width: 1,
    height: 26,
    margin: const EdgeInsets.symmetric(horizontal: 8),
    color: Colors.white24,
  );
}

/// Layer list with visibility, opacity, and delete.
class _LayersSheet extends StatelessWidget {
  const _LayersSheet({required this.controller});

  final SketchpadController controller;

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: controller,
      builder: (BuildContext context, Widget? child) {
        final List<SketchLayer> layers = controller.layers;
        return SafeArea(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: <Widget>[
              ListTile(
                title: const Text('Layers'),
                subtitle: const Text(
                  'Hide one pass of annotations to compare it with another.',
                ),
                trailing: IconButton(
                  tooltip: 'Add layer',
                  icon: const Icon(Icons.add),
                  onPressed: () => controller.addLayer(),
                ),
              ),
              // Reversed so the topmost layer appears at the top of the list.
              for (final SketchLayer layer in layers.reversed)
                ListTile(
                  selected: layer.id == controller.activeLayerId,
                  leading: IconButton(
                    tooltip: layer.visible ? 'Hide' : 'Show',
                    icon: Icon(
                      layer.visible ? Icons.visibility : Icons.visibility_off,
                    ),
                    onPressed: () =>
                        controller.setLayerVisible(layer.id, !layer.visible),
                  ),
                  title: Text(layer.name),
                  subtitle: Slider(
                    value: layer.opacity,
                    onChanged: (double v) =>
                        controller.setLayerOpacity(layer.id, v),
                  ),
                  trailing: IconButton(
                    tooltip: 'Delete layer',
                    icon: const Icon(Icons.delete_outline),
                    onPressed: layers.length <= 1
                        ? null
                        : () => controller.removeLayer(layer.id),
                  ),
                  onTap: () => controller.setActiveLayer(layer.id),
                ),
              const SizedBox(height: 8),
            ],
          ),
        );
      },
    );
  }
}
1
likes
160
points
65
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Annotate images in Flutter: pressure-sensitive freehand drawing, text, shapes, layers, measurement tools, and sharp vector-rendered PNG export.

Repository (GitHub)
View/report issues

Topics

#drawing #annotation #canvas #image #sketch

License

MIT (license)

Dependencies

flutter, perfect_freehand

More

Packages that depend on flutter_sketchpad