flutter_full_screen_scanner 1.0.0 copy "flutter_full_screen_scanner: ^1.0.0" to clipboard
flutter_full_screen_scanner: ^1.0.0 copied to clipboard

A production-ready, federated Flutter plugin for high-performance, full-screen continuous barcode and QR code scanning.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_full_screen_scanner/flutter_full_screen_scanner.dart';
import 'package:image_picker/image_picker.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Scanner Demo',
      theme: ThemeData.dark(useMaterial3: true),
      home: const ScannerScreen(),
    );
  }
}

class ScannerScreen extends StatefulWidget {
  const ScannerScreen({super.key});

  @override
  State<ScannerScreen> createState() => _ScannerScreenState();
}

class _ScannerScreenState extends State<ScannerScreen> {
  final ScannerController _controller = ScannerController();
  final List<ScannerResult> _scannedHistory = [];
  final Map<String, Map<String, dynamic>> _liveBarcodes = {};
  final ImagePicker _picker = ImagePicker();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _scanFromGallery() async {
    final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
    if (image != null) {
      // Pause camera while parsing gallery image
      await _controller.pause();

      final results = await _controller.scanImage(image.path);
      if (results.isNotEmpty && mounted) {
        setState(() {
          _scannedHistory.insertAll(0, results);
        });

        showDialog(
          context: context,
          builder: (ctx) => AlertDialog(
            title: const Text('Gallery Scan Success'),
            content: Text('Found ${results.length} barcode(s).'),
            actions: [
              TextButton(
                onPressed: () {
                  Navigator.of(ctx).pop();
                  _controller.resume();
                },
                child: const Text('OK'),
              ),
            ],
          ),
        );
      } else {
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('No barcodes found in image')),
          );
        }
        await _controller.resume();
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          // The Full Screen Scanner
          FullScreenScanner(
            controller: _controller,
            options: const ScannerOptions(
              allowDuplicate:
                  true, // Set to true so we get continuous frames for the live overlay
              duplicateDelay: 0,
              scanWindow: ScanWindow(
                widthFactor: 0.8,
                heightFactor: 0.3,
              ), // Ultra-wide rectangle
            ),
            onScan: (result) {
              final now = DateTime.now().millisecondsSinceEpoch;
              setState(() {
                _liveBarcodes[result.value] = {
                  'result': result,
                  'lastSeen': now,
                };

                // Add to history only if it's new
                final isDuplicate = _scannedHistory.any(
                  (item) => item.value == result.value,
                );
                if (!isDuplicate) {
                  _scannedHistory.insert(0, result);
                }
              });
            },
            overlayBuilder: (context, state) {
              final now = DateTime.now().millisecondsSinceEpoch;
              // Clean up barcodes not seen in the last 500ms
              _liveBarcodes.removeWhere(
                (key, value) => now - (value['lastSeen'] as int) > 500,
              );

              return Stack(
                children: [
                  Positioned.fill(
                    child: const ScannerCutoutOverlay(
                      scanWindow: ScanWindow(
                        widthFactor: 0.8,
                        heightFactor: 0.3,
                      ),
                      borderColor: Colors.greenAccent,
                      borderWidth: 3.0,
                    ),
                  ),
                  // Draw live barcodes over the camera
                  if (_liveBarcodes.isNotEmpty)
                    Positioned.fill(
                      child: CustomPaint(
                        painter: LiveBarcodeOverlayPainter(
                          liveBarcodes: _liveBarcodes.values
                              .map((e) => e['result'] as ScannerResult)
                              .toList(),
                          history: _scannedHistory,
                        ),
                      ),
                    ),

                  // Flash screen green on successful scan
                  if (state.barcodeDetected)
                    Container(color: Colors.green.withValues(alpha: 0.3)),
                ],
              );
            },
          ),

          // Top AppBar Controls
          Positioned(
            top: 50,
            left: 20,
            right: 20,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                const Text(
                  'Scanner SDK',
                  style: TextStyle(
                    fontSize: 24,
                    fontWeight: FontWeight.bold,
                    color: Colors.white,
                    shadows: [Shadow(color: Colors.black, blurRadius: 4)],
                  ),
                ),
                Row(
                  children: [
                    IconButton(
                      icon: const Icon(
                        Icons.photo_library,
                        color: Colors.white,
                        size: 30,
                      ),
                      onPressed: _scanFromGallery,
                    ),
                    IconButton(
                      icon: const Icon(
                        Icons.flip_camera_ios,
                        color: Colors.white,
                        size: 30,
                      ),
                      onPressed: _controller.switchCamera,
                    ),
                    IconButton(
                      icon: Icon(
                        _controller.flashEnabled
                            ? Icons.flash_on
                            : Icons.flash_off,
                        color: _controller.flashEnabled
                            ? Colors.yellow
                            : Colors.white,
                        size: 30,
                      ),
                      onPressed: () {
                        _controller.toggleFlash();
                        setState(() {}); // Update icon
                      },
                    ),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _showHistoryBottomSheet,
        backgroundColor: Colors.blueAccent,
        child: const Icon(Icons.history, color: Colors.white),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
    );
  }

  void _showHistoryBottomSheet() {
    showModalBottomSheet(
      context: context,
      backgroundColor: Colors.grey[900],
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
      ),
      builder: (context) {
        return Column(
          children: [
            const Padding(
              padding: EdgeInsets.all(16.0),
              child: Text(
                'Continuous Scan History',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 18,
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
            Expanded(
              child: ListView.builder(
                padding: EdgeInsets.zero,
                itemCount: _scannedHistory.length,
                itemBuilder: (context, index) {
                  final item = _scannedHistory[index];
                  return Material(
                    color: Colors.transparent,
                    child: ListTile(
                      leading: SizedBox(
                        width: 50,
                        height: 50,
                        child: item.imageBytes != null
                            ? ClipRRect(
                                borderRadius: BorderRadius.circular(8),
                                child: AspectRatio(
                                  aspectRatio:
                                      (item.imageWidth ?? 1) /
                                      (item.imageHeight ?? 1),
                                  child: CustomPaint(
                                    foregroundPainter: BarcodeLinePainter(
                                      corners: item.corners,
                                      imageWidth:
                                          item.imageWidth?.toDouble() ?? 1080,
                                      imageHeight:
                                          item.imageHeight?.toDouble() ?? 1920,
                                    ),
                                    child: Image.memory(
                                      item.imageBytes!,
                                      fit: BoxFit.contain,
                                    ),
                                  ),
                                ),
                              )
                            : Container(
                                color: Colors.grey[800],
                                child: const Icon(
                                  Icons.qr_code,
                                  color: Colors.white,
                                ),
                              ),
                      ),
                      title: Text(
                        item.value,
                        style: const TextStyle(
                          color: Colors.white,
                          fontWeight: FontWeight.w600,
                        ),
                      ),
                      subtitle: Text(
                        'Type: ${item.type}',
                        style: const TextStyle(
                          color: Colors.grey,
                          fontSize: 12,
                        ),
                      ),
                      onTap: () {
                        if (item.imageBytes != null) {
                          showDialog(
                            context: context,
                            builder: (context) {
                              return Dialog(
                                backgroundColor: Colors.transparent,
                                child: Stack(
                                  alignment: Alignment.topRight,
                                  children: [
                                    ClipRRect(
                                      borderRadius: BorderRadius.circular(12),
                                      child: AspectRatio(
                                        aspectRatio:
                                            (item.imageWidth ?? 1) /
                                            (item.imageHeight ?? 1),
                                        child: CustomPaint(
                                          foregroundPainter: BarcodeLinePainter(
                                            corners: item.corners,
                                            imageWidth:
                                                item.imageWidth?.toDouble() ??
                                                1080,
                                            imageHeight:
                                                item.imageHeight?.toDouble() ??
                                                1920,
                                          ),
                                          child: Image.memory(
                                            item.imageBytes!,
                                            fit: BoxFit.contain,
                                          ),
                                        ),
                                      ),
                                    ),
                                    IconButton(
                                      icon: const Icon(
                                        Icons.close,
                                        color: Colors.white,
                                        size: 30,
                                      ),
                                      onPressed: () => Navigator.pop(context),
                                    ),
                                  ],
                                ),
                              );
                            },
                          );
                        }
                      },
                    ),
                  );
                },
              ),
            ),
          ],
        );
      },
    );
  }
}

class LiveBarcodeOverlayPainter extends CustomPainter {
  final List<ScannerResult> liveBarcodes;
  final List<ScannerResult> history;

  LiveBarcodeOverlayPainter({
    required this.liveBarcodes,
    required this.history,
  });

  @override
  void paint(Canvas canvas, Size size) {
    for (final barcode in liveBarcodes) {
      if (barcode.corners == null || barcode.corners!.isEmpty) continue;

      final imageWidth = barcode.imageWidth?.toDouble() ?? 1080;
      final imageHeight = barcode.imageHeight?.toDouble() ?? 1920;

      // Calculate BoxFit.cover scaling math to map coordinates to screen
      final scaleX = size.width / imageWidth;
      final scaleY = size.height / imageHeight;
      final scale = scaleX > scaleY ? scaleX : scaleY;

      final dxOffset = (imageWidth * scale - size.width) / 2;
      final dyOffset = (imageHeight * scale - size.height) / 2;

      // Find the center of the barcode
      double centerX = 0;
      double centerY = 0;
      for (final corner in barcode.corners!) {
        centerX += (corner.x * scale) - dxOffset;
        centerY += (corner.y * scale) - dyOffset;
      }
      centerX /= barcode.corners!.length;
      centerY /= barcode.corners!.length;

      // Check if already in history
      final inHistory = history.any((h) => h.value == barcode.value);

      if (inHistory) {
        final textSpan = TextSpan(
          text: '${barcode.value}\n(already scanned)',
          style: const TextStyle(
            color: Colors.redAccent,
            fontSize: 16,
            fontWeight: FontWeight.bold,
            backgroundColor: Colors.black54,
          ),
        );

        final textPainter = TextPainter(
          text: textSpan,
          textAlign: TextAlign.center,
          textDirection: TextDirection.ltr,
        );

        textPainter.layout();
        textPainter.paint(
          canvas,
          Offset(
            centerX - textPainter.width / 2,
            centerY - textPainter.height / 2,
          ),
        );
      }
    }
  }

  @override
  bool shouldRepaint(covariant LiveBarcodeOverlayPainter oldDelegate) {
    return true;
  }
}

class BarcodeLinePainter extends CustomPainter {
  final List<Point>? corners;
  final double imageWidth;
  final double imageHeight;

  BarcodeLinePainter({
    required this.corners,
    required this.imageWidth,
    required this.imageHeight,
  });

  @override
  void paint(Canvas canvas, Size size) {
    if (corners == null || corners!.length < 4) return;

    final paint = Paint()
      ..color = Colors.greenAccent
      ..strokeWidth = 3.0
      ..style = PaintingStyle.stroke;

    // Scale factors to map native image coordinates to the Flutter widget size
    final scaleX = size.width / imageWidth;
    final scaleY = size.height / imageHeight;

    // We assume corners are ordered: top-left, top-right, bottom-right, bottom-left
    // (This is standard for both ML Kit and Vision bounding boxes)
    final p0 = Offset(corners![0].x * scaleX, corners![0].y * scaleY);
    final p1 = Offset(corners![1].x * scaleX, corners![1].y * scaleY);
    final p2 = Offset(corners![2].x * scaleX, corners![2].y * scaleY);
    final p3 = Offset(corners![3].x * scaleX, corners![3].y * scaleY);

    // Calculate edge lengths to determine orientation (vertical vs horizontal vs diagonal)
    // corners are guaranteed to be in clockwise order starting from top-left (relative to the barcode).
    final dist01 = (p0 - p1).distance;
    final dist12 = (p1 - p2).distance;

    Offset start, end;
    if (dist01 > dist12) {
      // 0-1 and 2-3 are the long edges. 1-2 and 3-0 are the short edges.
      // Connect midpoints of the short edges to draw the line parallel to the long edges.
      start = Offset((p3.dx + p0.dx) / 2, (p3.dy + p0.dy) / 2);
      end = Offset((p1.dx + p2.dx) / 2, (p1.dy + p2.dy) / 2);
    } else {
      // 1-2 and 3-0 are the long edges. 0-1 and 2-3 are the short edges.
      // Connect midpoints of the short edges.
      start = Offset((p0.dx + p1.dx) / 2, (p0.dy + p1.dy) / 2);
      end = Offset((p2.dx + p3.dx) / 2, (p2.dy + p3.dy) / 2);
    }

    canvas.drawLine(start, end, paint);
  }

  @override
  bool shouldRepaint(covariant BarcodeLinePainter oldDelegate) {
    return oldDelegate.corners != corners;
  }
}