scribe_canvas 0.3.0
scribe_canvas: ^0.3.0 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:scribe/scribe.dart';
import 'package:file_picker/file_picker.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Scribe',
theme: ThemeData(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> {
final GlobalKey<ScribeCanvasState> _canvasKey =
GlobalKey<ScribeCanvasState>();
bool _isEraser = false;
bool _isPanMode = false;
double _strokeWidth = 4.0;
double _eraserWidth = 30.0;
String? _savedStrokes;
String? _savedLegacyData;
void _saveToMemory() {
final data = _canvasKey.currentState?.getEncodedData();
if (data != null && data.isNotEmpty) {
debugPrint("Stroke Data Length -> ${data.length}, $data");
setState(() {
_savedStrokes = data;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Saved to memory (${data.length} chars)')),
);
}
}
void _reloadFromMemory() {
if (_savedStrokes != null) {
_canvasKey.currentState?.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 = _canvasKey.currentState?.getLegacyEncodedData();
if (data != null && 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) {
_canvasKey.currentState?.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 _clearCanvas() {
_canvasKey.currentState?.clear();
}
void _toggleEraser() {
setState(() {
_isEraser = !_isEraser;
if (_isEraser) _isPanMode = false; // Disable pan if switching to eraser
});
}
void _togglePanMode() {
setState(() {
_isPanMode = !_isPanMode;
if (_isPanMode) _isEraser = false; // Disable eraser if switching to pan
});
}
void _savePdf() {
_canvasKey.currentState?.exportToPdf();
}
void _resetView() {
_canvasKey.currentState?.resetView();
}
void _undo() {
_canvasKey.currentState?.undo();
}
void _redo() {
_canvasKey.currentState?.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 _canvasKey.currentState?.loadPdfBackground(file.bytes!);
} else {
await _canvasKey.currentState?.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 _canvasKey.currentState?.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 _canvasKey.currentState?.setFooterImage(result.files.single.bytes!);
setState(() {});
}
}
Future<void> _addNetworkHeader() async {
final TextEditingController controller = TextEditingController();
final String? url = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add Network Header'),
content: TextField(
controller: controller,
decoration: const InputDecoration(hintText: 'Enter Image URL'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('Add'),
),
],
),
);
if (url != null && url.isNotEmpty) {
await _canvasKey.currentState?.setNetworkHeaderImage(url.trim());
setState(() {});
}
}
Future<void> _addNetworkFooter() async {
final TextEditingController controller = TextEditingController();
final String? url = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add Network Footer'),
content: TextField(
controller: controller,
decoration: const InputDecoration(hintText: 'Enter Image URL'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('Add'),
),
],
),
);
if (url != null && url.isNotEmpty) {
await _canvasKey.currentState?.setNetworkFooterImage(url.trim());
setState(() {});
}
}
Future<void> _addNetworkBackground() async {
final TextEditingController controller = TextEditingController();
final String? url = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add Network Background'),
content: TextField(
controller: controller,
decoration: const InputDecoration(hintText: 'Enter Image URL'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('Add'),
),
],
),
);
if (url != null && url.isNotEmpty) {
await _canvasKey.currentState?.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: _canvasKey.currentState?.canUndo ?? false ? _undo : null,
icon: const Icon(Icons.undo),
color: _canvasKey.currentState?.canUndo ?? false
? Colors.black
: Colors.grey,
tooltip: 'Undo (Ctrl+Z)',
),
IconButton(
onPressed: _canvasKey.currentState?.canRedo ?? false ? _redo : null,
icon: const Icon(Icons.redo),
color: _canvasKey.currentState?.canRedo ?? false
? 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 == 'clear') _clearCanvas();
},
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: 'load_mem',
child: ListTile(
leading: Icon(Icons.refresh),
title: Text('Reload from Memory'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'save_legacy',
child: ListTile(
leading: Icon(Icons.history_edu),
title: Text('Save Legacy Format'),
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: '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(
key: _canvasKey,
color: Colors.blueAccent,
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(() {}),
onStrokeWidthChanged: (width) => setState(() => _strokeWidth = width),
onEraserWidthChanged: (width) => setState(() => _eraserWidth = width),
onToggleEraser: (val) => setState(() {
_isEraser = val;
if (_isEraser) _isPanMode = false;
}),
multiPage: true,
),
),
);
}
}
// TODO
// add header image as file/network image
// add footer image as file/network image
// multiple stroke size,
// multiple eraser size,
// separate icon for eraser and pen,
// tap on pen/eraser to increase size
// multi color option