scribe_canvas 0.6.2
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,
),
),
);
}
}