flutter_sketchpad 0.0.1
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.
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),
],
),
);
},
);
}
}