scribe_canvas 0.6.2 copy "scribe_canvas: ^0.6.2" to clipboard
scribe_canvas: ^0.6.2 copied to clipboard

A high-performance Flutter multi-page canvas library for handwriting, drawing, and annotation with customizable tools and PDF export.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:scribe_canvas/scribe_canvas.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Scribe',
      theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple)),
      debugShowCheckedModeBanner: false,
      home: const MyHomePage(title: 'Scribe'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  // ✅ Controller pattern — no GlobalKey needed
  final ScribeCanvasController _controller = ScribeCanvasController();

  bool _isEraser = false;
  bool _isPanMode = false;
  double _strokeWidth = 4.0;
  double _eraserWidth = 30.0;
  String? _savedStrokes;
  String? _savedLegacyData;
  String? _unifiedSavedData;

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

  void _saveToMemory() {
    final data = _controller.getEncodedData();
    if (data.isNotEmpty) {
      debugPrint("Stroke Data Length -> ${data.length}");
      setState(() => _savedStrokes = data);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Saved to memory (${data.length} chars)')),
      );
    }
  }

  void _reloadFromMemory() {
    if (_savedStrokes != null) {
      _controller.loadEncodedData(_savedStrokes!);
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Reloaded strokes from memory')),
      );
    } else {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('No saved strokes in memory')),
      );
    }
  }

  void _saveLegacyToMemory() {
    final data = _controller.getLegacyEncodedData();
    if (data.isNotEmpty) {
      debugPrint("Legacy Data Length -> ${data.length}");
      setState(() => _savedLegacyData = data);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Saved Legacy to memory (${data.length} chars)')),
      );
    }
  }

  void _reloadLegacyFromMemory() {
    if (_savedLegacyData != null) {
      _controller.loadLegacyEncodedData(_savedLegacyData!);
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Reloaded Legacy strokes from memory')),
      );
    } else {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('No saved Legacy strokes in memory')),
      );
    }
  }

  void _unifiedSave() {
    final data = _controller.saveData();
    if (data.isNotEmpty) {
      debugPrint("Unified Save Data Length -> $data");
      setState(() => _unifiedSavedData = data);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Unified Save (Modern) -> ${data.length} chars')),
      );
    }
  }

  void _unifiedLoad() {
    final dataToLoad = _unifiedSavedData ?? _savedStrokes ?? _savedLegacyData;
    if (dataToLoad != null) {
      debugPrint("Unified Load Data Length -> $dataToLoad");
      _controller.loadData(dataToLoad);
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Unified Load success!')),
      );
    } else {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Nothing to load in memory')),
      );
    }
  }

  Future<void> _showLoadDialog() async {
    final textController = TextEditingController();
    final String? data = await showDialog<String>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Import Stroke Data'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('Paste your encoded data string below (Modern or Legacy):'),
            const SizedBox(height: 12),
            TextField(
              controller: textController,
              maxLines: 5,
              decoration: const InputDecoration(
                hintText: 'isEraser|color|width|points...',
                border: OutlineInputBorder(),
              ),
            ),
          ],
        ),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
          TextButton(
            onPressed: () => Navigator.pop(context, textController.text),
            child: const Text('Load'),
          ),
        ],
      ),
    );

    if (data != null && data.isNotEmpty) {
      _controller.loadData(data.trim());
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Data loaded successfully')),
      );
    }
  }

  void _clearCanvas() => _controller.clear();

  void _clearBackgrounds() => _controller.clearBackgrounds();

  void _toggleEraser() {
    setState(() {
      _isEraser = !_isEraser;
      if (_isEraser) _isPanMode = false;
    });
  }

  void _togglePanMode() {
    setState(() {
      _isPanMode = !_isPanMode;
      if (_isPanMode) _isEraser = false;
    });
  }

  void _savePdf() => _controller.exportToPdf();

  void _resetView() => _controller.resetView();

  void _undo() => _controller.undo();

  void _redo() => _controller.redo();

  Future<void> _pickBackground() async {
    final result = await FilePicker.platform.pickFiles(
      type: FileType.custom,
      allowedExtensions: ['pdf', 'png', 'jpg', 'jpeg'],
      withData: true,
    );

    if (result != null && result.files.single.bytes != null) {
      final file = result.files.single;
      if (file.extension == 'pdf') {
        await _controller.loadPdfBackground(file.bytes!);
      } else {
        await _controller.setBackgroundImage(0, file.bytes!, clearOthers: true);
      }
      setState(() {});
    }
  }

  Future<void> _pickHeader() async {
    final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true);
    if (result != null && result.files.single.bytes != null) {
      await _controller.setHeaderImage(result.files.single.bytes!);
      setState(() {});
    }
  }

  Future<void> _pickFooter() async {
    final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true);
    if (result != null && result.files.single.bytes != null) {
      await _controller.setFooterImage(result.files.single.bytes!);
      setState(() {});
    }
  }

  Future<void> _addNetworkHeader() async {
    final urlController = TextEditingController();
    final String? url = await showDialog<String>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Add Network Header'),
        content: TextField(
          controller: urlController,
          decoration: const InputDecoration(hintText: 'Enter Image URL'),
          autofocus: true,
        ),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
          TextButton(onPressed: () => Navigator.pop(context, urlController.text), child: const Text('Add')),
        ],
      ),
    );
    if (url != null && url.isNotEmpty) {
      await _controller.setNetworkHeaderImage(url.trim());
      setState(() {});
    }
  }

  Future<void> _addNetworkFooter() async {
    final urlController = TextEditingController();
    final String? url = await showDialog<String>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Add Network Footer'),
        content: TextField(
          controller: urlController,
          decoration: const InputDecoration(hintText: 'Enter Image URL'),
          autofocus: true,
        ),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
          TextButton(onPressed: () => Navigator.pop(context, urlController.text), child: const Text('Add')),
        ],
      ),
    );
    if (url != null && url.isNotEmpty) {
      await _controller.setNetworkFooterImage(url.trim());
      setState(() {});
    }
  }

  Future<void> _addNetworkBackground() async {
    final urlController = TextEditingController();
    final String? url = await showDialog<String>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Add Network Background'),
        content: TextField(
          controller: urlController,
          decoration: const InputDecoration(hintText: 'Enter Image URL'),
          autofocus: true,
        ),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
          TextButton(onPressed: () => Navigator.pop(context, urlController.text), child: const Text('Add')),
        ],
      ),
    );
    if (url != null && url.isNotEmpty) {
      await _controller.setNetworkBackgroundImage(0, url.trim(), clearOthers: true);
      setState(() {});
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
        actions: [
          IconButton(
            onPressed: _resetView,
            icon: const Icon(Icons.home_outlined),
            tooltip: 'Reset View (Home)',
          ),
          const VerticalDivider(width: 1),
          IconButton(
            onPressed: _controller.canUndo ? _undo : null,
            icon: const Icon(Icons.undo),
            color: _controller.canUndo ? Colors.black : Colors.grey,
            tooltip: 'Undo (Ctrl+Z)',
          ),
          IconButton(
            onPressed: _controller.canRedo ? _redo : null,
            icon: const Icon(Icons.redo),
            color: _controller.canRedo ? Colors.black : Colors.grey,
            tooltip: 'Redo (Ctrl+Y)',
          ),
          const VerticalDivider(width: 1),
          IconButton(
            onPressed: _toggleEraser,
            icon: Icon(_isEraser ? Icons.edit : Icons.auto_fix_normal),
            tooltip: _isEraser ? 'Switch to Pen' : 'Switch to Eraser',
          ),
          PopupMenuButton<String>(
            icon: const Icon(Icons.more_vert),
            onSelected: (value) {
              if (value == 'pan') _togglePanMode();
              if (value == 'bg') _pickBackground();
              if (value == 'net_bg') _addNetworkBackground();
              if (value == 'header') _pickHeader();
              if (value == 'net_header') _addNetworkHeader();
              if (value == 'footer') _pickFooter();
              if (value == 'net_footer') _addNetworkFooter();
              if (value == 'pdf') _savePdf();
              if (value == 'save_mem') _saveToMemory();
              if (value == 'load_mem') _reloadFromMemory();
              if (value == 'save_legacy') _saveLegacyToMemory();
              if (value == 'load_legacy') _reloadLegacyFromMemory();
              if (value == 'save_unified') _unifiedSave();
              if (value == 'load_unified') _unifiedLoad();
              if (value == 'import_str') _showLoadDialog();
              if (value == 'clear') _clearCanvas();
              if (value == 'clear_bg') _clearBackgrounds();
            },
            itemBuilder: (context) => [
              PopupMenuItem(
                value: 'pan',
                child: ListTile(
                  leading: Icon(
                    _isPanMode ? Icons.pan_tool : Icons.pan_tool_outlined,
                    color: _isPanMode ? Colors.blue : null,
                  ),
                  title: Text(
                    _isPanMode ? 'Switch to Pen' : 'Pan Mode',
                    style: TextStyle(color: _isPanMode ? Colors.blue : null),
                  ),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuItem(
                value: 'bg',
                child: ListTile(
                  leading: Icon(Icons.add_photo_alternate_outlined),
                  title: Text('Add Local Background'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuItem(
                value: 'net_bg',
                child: ListTile(
                  leading: Icon(Icons.cloud_download_outlined),
                  title: Text('Add Network Background'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuDivider(),
              const PopupMenuItem(
                value: 'header',
                child: ListTile(
                  leading: Icon(Icons.vertical_align_top),
                  title: Text('Set Header (Local)'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuItem(
                value: 'net_header',
                child: ListTile(
                  leading: Icon(Icons.cloud_upload),
                  title: Text('Set Header (Network)'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuDivider(),
              const PopupMenuItem(
                value: 'footer',
                child: ListTile(
                  leading: Icon(Icons.vertical_align_bottom),
                  title: Text('Set Footer (Local)'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuItem(
                value: 'net_footer',
                child: ListTile(
                  leading: Icon(Icons.cloud_queue),
                  title: Text('Set Footer (Network)'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuItem(
                value: 'pdf',
                child: ListTile(
                  leading: Icon(Icons.picture_as_pdf),
                  title: Text('Save as PDF'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuDivider(),
              const PopupMenuItem(
                value: 'save_mem',
                child: ListTile(
                  leading: Icon(Icons.save_alt),
                  title: Text('Save Modern Format'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuItem(
                value: 'load_mem',
                child: ListTile(
                  leading: Icon(Icons.refresh),
                  title: Text('Reload Modern Format'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuItem(
                value: 'save_legacy',
                child: ListTile(
                  leading: Icon(Icons.history_edu),
                  title: Text('Save Legacy Format (No Widths)'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuItem(
                value: 'load_legacy',
                child: ListTile(
                  leading: Icon(Icons.history),
                  title: Text('Load Legacy Format'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuDivider(),
              const PopupMenuItem(
                value: 'save_unified',
                child: ListTile(
                  leading: Icon(Icons.auto_awesome),
                  title: Text('Unified Save'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuItem(
                value: 'load_unified',
                child: ListTile(
                  leading: Icon(Icons.auto_mode),
                  title: Text('Unified Load (Auto-Detect)'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuItem(
                value: 'import_str',
                child: ListTile(
                  leading: Icon(Icons.input),
                  title: Text('Import Data String'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuDivider(),
              const PopupMenuItem(
                value: 'clear_bg',
                child: ListTile(
                  leading: Icon(Icons.layers_clear_outlined),
                  title: Text('Clear Backgrounds'),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
              const PopupMenuItem(
                value: 'clear',
                child: ListTile(
                  leading: Icon(Icons.clear, color: Colors.red),
                  title: Text('Clear Canvas', style: TextStyle(color: Colors.red)),
                  contentPadding: EdgeInsets.zero,
                ),
              ),
            ],
          ),
        ],
      ),
      body: Container(
        color: Colors.grey[200],
        child: ScribeCanvas(
          controller: _controller, // ✅ Attach the controller
          strokeWidth: _strokeWidth,
          strokeSizes: const [2.0, 6.0, 12.0, 24.0],
          isEraser: _isEraser,
          eraserWidth: _eraserWidth,
          eraserSizes: const [20.0, 30.0, 40.0, 60.0, 80.0],
          isPanMode: _isPanMode,
          onStrokeEnd: () => setState(() {}),
          onUndo: () => setState(() {}),
          onRedo: () => setState(() {}),
          colors: const [
            Colors.black,
            Colors.redAccent,
            Colors.blueAccent,
            Colors.greenAccent,
            Colors.purpleAccent,
            Colors.orangeAccent,
          ],
          onColorChanged: (color) => debugPrint("Selected Color: $color"),
          onStrokeWidthChanged: (width) => setState(() => _strokeWidth = width),
          onEraserWidthChanged: (width) => setState(() => _eraserWidth = width),
          onToggleEraser: (val) => setState(() {
            _isEraser = val;
            if (_isEraser) _isPanMode = false;
          }),
          multiPage: true,
        ),
      ),
    );
  }
}
3
likes
150
points
283
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A high-performance Flutter multi-page canvas library for handwriting, drawing, and annotation with customizable tools and PDF export.

Homepage

License

MIT (license)

Dependencies

file_picker, flutter, meta, pdf, pdfx, printing

More

Packages that depend on scribe_canvas