doc_scan_lite 0.0.1
doc_scan_lite: ^0.0.1 copied to clipboard
Lightweight classical-CV document edge detection and perspective-crop FFI plugin — no ML models, no Play Services.
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:camera/camera.dart';
import 'package:doc_scan_lite/doc_scan_lite.dart';
import 'package:flutter/material.dart';
late List<CameraDescription> _cameras;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
_cameras = await availableCameras();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'doc_scan_lite example',
theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
home: _cameras.isEmpty
? const Scaffold(body: Center(child: Text('No camera available')))
: ScannerScreen(camera: _cameras.first),
);
}
}
class ScannerScreen extends StatefulWidget {
const ScannerScreen({super.key, required this.camera});
final CameraDescription camera;
@override
State<ScannerScreen> createState() => _ScannerScreenState();
}
class _ScannerScreenState extends State<ScannerScreen>
with WidgetsBindingObserver {
late CameraController _cameraController;
late final Future<void> _initFuture;
final DocScanController _scanController = DocScanController();
ui.Image? _capturedPreview;
bool _capturing = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_cameraController = CameraController(
widget.camera,
ResolutionPreset.high,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.yuv420,
);
_initFuture = _init();
}
Future<void> _init() async {
await _cameraController.initialize();
await _scanController.start();
await _startStream();
}
Future<void> _startStream() async {
if (_cameraController.value.isStreamingImages) return;
await _cameraController.startImageStream(_scanController.processFrame);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!_cameraController.value.isInitialized) return;
if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.paused) {
// Release the camera while backgrounded; the OS may reclaim it anyway.
_cameraController.dispose();
} else if (state == AppLifecycleState.resumed) {
_reinitializeCamera();
}
}
Future<void> _reinitializeCamera() async {
_cameraController = CameraController(
widget.camera,
ResolutionPreset.high,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.yuv420,
);
await _cameraController.initialize();
if (!mounted) return;
await _startStream();
if (mounted) setState(() {});
}
Future<void> _capture() async {
if (_capturing) return;
setState(() => _capturing = true);
final captured = await _scanController.captureAndCropLatest();
final preview = captured == null
? null
: await _grayscaleToImage(captured.bytes, captured.width, captured.height);
if (!mounted) return;
setState(() {
_capturing = false;
_capturedPreview = preview;
});
if (captured == null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No document detected — align it in frame first.')),
);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_cameraController.dispose();
_scanController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('doc_scan_lite')),
body: FutureBuilder<void>(
future: _initFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Camera init failed: ${snapshot.error}'));
}
return Stack(
fit: StackFit.expand,
children: [
DocScannerPreview(
cameraController: _cameraController,
controller: _scanController,
),
Positioned(
bottom: 24,
left: 0,
right: 0,
child: Center(
child: AnimatedBuilder(
animation: _scanController,
builder: (context, _) {
return FloatingActionButton.extended(
onPressed: _capturing ? null : _capture,
icon: _capturing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.crop),
label: Text(
_scanController.isLocked ? 'Capture' : 'Align document',
),
);
},
),
),
),
if (_capturedPreview != null) _buildCapturedOverlay(),
],
);
},
),
);
}
Widget _buildCapturedOverlay() {
return Positioned.fill(
child: ColoredBox(
color: Colors.black87,
child: SafeArea(
child: Column(
children: [
Expanded(
child: InteractiveViewer(
child: Center(child: RawImage(image: _capturedPreview)),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: TextButton(
onPressed: () => setState(() => _capturedPreview = null),
child: const Text(
'Close',
style: TextStyle(color: Colors.white),
),
),
),
],
),
),
),
);
}
}
/// Expands single-channel grayscale bytes into RGBA (dart:ui has no
/// single-channel decode path) and decodes them into a displayable
/// [ui.Image].
Future<ui.Image> _grayscaleToImage(Uint8List gray, int width, int height) {
final rgba = Uint8List(width * height * 4);
for (var i = 0; i < gray.length; i++) {
final v = gray[i];
final o = i * 4;
rgba[o] = v;
rgba[o + 1] = v;
rgba[o + 2] = v;
rgba[o + 3] = 255;
}
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
rgba,
width,
height,
ui.PixelFormat.rgba8888,
completer.complete,
);
return completer.future;
}