photo_markup 1.0.2
photo_markup: ^1.0.2 copied to clipboard
Draw boxes, arrows, freehand strokes and text over a photo, read it back as resolution-independent vector data, and flatten it to a JPEG or PNG.
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:photo_markup/photo_markup.dart';
void main() => runApp(const ExampleApp());
/// The bundled photo every screen in this demo annotates.
const AssetImage kSamplePhoto = AssetImage('assets/sample_photo.jpg');
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'photo_markup example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF3B82F6)),
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
/// The persisted form of the markup. In a real app this is the string you
/// would write to a database column, a file, or an API — it is the source of
/// truth, not the flattened image.
String? _storedJson;
/// Derived artifact: photo + markup baked into JPEG bytes.
Uint8List? _flattened;
/// Decoded view of [_storedJson]. Re-decoding on every build is what proves
/// the round-trip actually works.
MarkupDocument get _document => _storedJson == null
? MarkupDocument.empty(4 / 3)
: MarkupCodec.decodeJson(_storedJson!, fallbackAspectRatio: 4 / 3);
Future<void> _openEditor() async {
final result = await Navigator.of(context).push<MarkupResult>(
MaterialPageRoute(
builder: (_) => PhotoMarkupEditor(
image: kSamplePhoto,
// Re-open with whatever was drawn last time.
initialAnnotations: _document.annotations,
// Keep the exported image small enough to show inline.
targetWidth: 1200,
),
),
);
if (result == null) return; // user cancelled
setState(() {
_storedJson = MarkupCodec.encodeJson(result.document);
_flattened = result.flattenedImage;
});
}
void _reset() => setState(() {
_storedJson = null;
_flattened = null;
});
@override
Widget build(BuildContext context) {
final doc = _document;
return Scaffold(
appBar: AppBar(
title: const Text('photo_markup'),
actions: [
IconButton(
onPressed: _storedJson == null ? null : _reset,
icon: const Icon(Icons.restart_alt),
tooltip: 'Reset',
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _openEditor,
icon: const Icon(Icons.edit),
label: Text(doc.isEmpty ? 'Mark up' : 'Edit markup'),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
children: [
const _SectionTitle(
'PhotoMarkupViewer',
'Read-only overlay, rendered from the decoded JSON.',
),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: PhotoMarkupViewer(image: kSamplePhoto, document: doc),
),
if (doc.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 12),
child: Text(
'Nothing drawn yet — tap "Mark up" to open the editor.',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
if (doc.isNotEmpty) ...[
const _SectionTitle(
'Resolution independence',
'The same document painted at three sizes. Coordinates are '
'normalized, so strokes scale with the image instead of '
'staying a fixed pixel width.',
),
LayoutBuilder(
builder: (context, constraints) {
const widths = <double>[64, 120, 200];
const gap = 12.0;
final natural =
widths.reduce((a, b) => a + b) + gap * (widths.length - 1);
// Narrow phones can't fit 420px of previews, so shrink the
// whole set proportionally rather than overflow the Row.
final scale = math.min(1.0, constraints.maxWidth / natural);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (index, width) in widths.indexed)
Padding(
padding: EdgeInsets.only(
left: index == 0 ? 0 : gap * scale,
),
child: SizedBox(
width: width * scale,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: PhotoMarkupViewer(
image: kSamplePhoto,
document: doc,
),
),
),
),
],
);
},
),
],
if (_flattened != null) ...[
_SectionTitle(
'Flattened export',
'JPEG bytes returned by MarkupResult.flattenedImage '
'(${(_flattened!.lengthInBytes / 1024).toStringAsFixed(1)} kB). '
'A derived file — the JSON below is the source of truth.',
),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(_flattened!),
),
],
if (_storedJson != null) ...[
_SectionTitle(
'Persisted JSON',
'${doc.annotations.length} annotation(s), '
'${_storedJson!.length} chars. This is what you store.',
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: SelectableText(
_storedJson!,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
),
],
],
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.title, this.subtitle);
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 24, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}