DEV3 Technologies home

Proof of delivery photo and signature

Capture a recipient signature on a canvas, export it as PNG bytes and pair it with a photo from the camera.

FlutterUploadsfluttersignaturecameradelivery

Code

import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

class SignatureController extends ChangeNotifier {
  final List<List<Offset>> strokes = [];
  bool get isEmpty => strokes.isEmpty;

  void start(Offset p) { strokes.add([p]); notifyListeners(); }
  void extend(Offset p) { strokes.last.add(p); notifyListeners(); }
  void clear() { strokes.clear(); notifyListeners(); }

  /// Renders the strokes to PNG bytes on a white background.
  Future<Uint8List> toPng(Size size) async {
    final rec = ui.PictureRecorder();
    final canvas = Canvas(rec, Offset.zero & size);
    canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white);
    _paint(canvas);
    final img = await rec.endRecording().toImage(size.width.toInt(), size.height.toInt());
    final data = await img.toByteData(format: ui.ImageByteFormat.png);
    return data!.buffer.asUint8List();
  }

  void _paint(Canvas canvas) {
    final pen = Paint()..color = Colors.black..strokeWidth = 3..strokeCap = StrokeCap.round..style = PaintingStyle.stroke;
    for (final s in strokes) {
      final path = Path()..moveTo(s.first.dx, s.first.dy);
      for (final p in s.skip(1)) { path.lineTo(p.dx, p.dy); }
      if (s.length == 1) canvas.drawPoints(ui.PointMode.points, s, pen);
      canvas.drawPath(path, pen);
    }
  }
}

class _Painter extends CustomPainter {
  _Painter(this.c) : super(repaint: c);
  final SignatureController c;
  @override
  void paint(Canvas canvas, Size size) => c._paint(canvas);
  @override
  bool shouldRepaint(_Painter old) => false; // repaint comes from the listenable
}

class ProofCapture extends StatefulWidget {
  const ProofCapture({super.key, required this.onSubmit});
  final Future<void> Function(Uint8List signature, XFile photo) onSubmit;
  @override
  State<ProofCapture> createState() => _ProofCaptureState();
}

class _ProofCaptureState extends State<ProofCapture> {
  final _sig = SignatureController();
  final _padKey = GlobalKey();
  XFile? _photo;

  Future<void> _submit() async {
    final size = (_padKey.currentContext!.findRenderObject() as RenderBox).size;
    await widget.onSubmit(await _sig.toPng(size), _photo!);
  }

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      Container(
        key: _padKey,
        height: 180,
        decoration: BoxDecoration(border: Border.all(), borderRadius: BorderRadius.circular(8)),
        child: GestureDetector(
          onPanStart: (d) => _sig.start(d.localPosition),
          onPanUpdate: (d) => _sig.extend(d.localPosition),
          child: CustomPaint(painter: _Painter(_sig), size: Size.infinite),
        ),
      ),
      Row(children: [
        TextButton(onPressed: _sig.clear, child: const Text('Clear')),
        TextButton.icon(
          icon: const Icon(Icons.photo_camera),
          label: Text(_photo == null ? 'Take photo' : 'Retake photo'),
          onPressed: () async {
            final x = await ImagePicker().pickImage(source: ImageSource.camera, maxWidth: 1600, imageQuality: 80);
            if (x != null) setState(() => _photo = x);
          },
        ),
      ]),
      ListenableBuilder(
        listenable: _sig,
        builder: (_, _) => FilledButton(
          onPressed: _photo != null && !_sig.isEmpty ? _submit : null,
          child: const Text('Confirm delivery'),
        ),
      ),
    ]);
  }
}

Needs: image_picker@^1.1.0

How it works

What it solves

Couriers need evidence that a parcel reached the right person: a signature and a photo of the parcel at the door. Both must be captured offline-friendly on the device and sent as ordinary files.

How it works

SignatureController stores strokes as lists of points and notifies when they change. A CustomPainter that listens to it repaints only the pad. toPng replays the same drawing routine onto a PictureRecorder canvas with a white background and converts the image to PNG bytes, so what is saved matches what is on screen.

The photo comes from image_picker with a size cap and JPEG quality. The Confirm button listens to the signature controller and stays disabled until both a photo and at least one stroke exist. A single tap draws a dot via PointMode.points.

Adapting it

Upload the PNG bytes and photo as multipart fields and include the capture time and coordinates in the same request. Add a recipient name field, or offer an alternative one-time code entry for contactless drops. Increase the stroke width for tablets.

Pitfalls

  • The export uses the pad's laid-out size, so measure it after layout as done through the key.
  • Strokes are in logical pixels; export with a larger image if the server needs high resolution.
  • Declare camera permissions on both platforms.

How to use it

Add image_picker and copy the file to lib/proof/. Add camera permission strings to the iOS and Android configuration.

ProofCapture(onSubmit: (png, photo) => api.uploadProof(png, photo))

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets