flutter_full_screen_scanner 1.0.5 copy "flutter_full_screen_scanner: ^1.0.5" to clipboard
flutter_full_screen_scanner: ^1.0.5 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 'dart:math' as math;
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();
  ScannerResult? _lastScannedResult;
  DateTime? _lastScanTime;

  @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);
          _lastScannedResult = results.first;
          _lastScanTime = DateTime.now();
        });

        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;
              final nowDateTime = DateTime.now();

              setState(() {
                // Retain imageBytes from previous scan or history if live frame doesn't carry bytes
                final existingBytes = result.imageBytes ??
                    _liveBarcodes[result.value]?['result']?.imageBytes ??
                    _scannedHistory
                        .firstWhere((h) => h.value == result.value,
                            orElse: () => result)
                        .imageBytes ??
                    _lastScannedResult?.imageBytes;

                final finalResult = ScannerResult(
                  value: result.value,
                  type: result.type,
                  imageBytes: existingBytes,
                  corners: result.corners,
                  imageWidth: result.imageWidth,
                  imageHeight: result.imageHeight,
                  timestamp: result.timestamp,
                );

                _liveBarcodes[result.value] = {
                  'result': finalResult,
                  'lastSeen': now,
                };

                // 2-second delay / rate limit for duplicate scans
                if (_lastScanTime == null ||
                    nowDateTime.difference(_lastScanTime!).inMilliseconds >
                        2000 ||
                    _lastScannedResult?.value != finalResult.value) {
                  _lastScannedResult = finalResult;
                  _lastScanTime = nowDateTime;

                  final isDuplicate = _scannedHistory.any(
                    (item) => item.value == finalResult.value,
                  );
                  if (!isDuplicate) {
                    _scannedHistory.insert(0, finalResult);
                  }
                }
              });
            },
            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)),
                ],
              );
            },
          ),

          // Conditional Live Preview Card below overlay cutout
          if (_lastScannedResult != null)
            Builder(
              builder: (context) {
                final isAlreadyInList = _scannedHistory
                            .where((h) => h.value == _lastScannedResult!.value)
                            .length >
                        1 ||
                    (_scannedHistory
                            .any((h) => h.value == _lastScannedResult!.value) &&
                        _scannedHistory.first.value !=
                            _lastScannedResult!.value);
                return Positioned(
                  bottom: 90,
                  left: 16,
                  right: 16,
                  child: Container(
                    padding: const EdgeInsets.all(12),
                    decoration: BoxDecoration(
                      color: Colors.black87,
                      borderRadius: BorderRadius.circular(16),
                      border: Border.all(
                        color: isAlreadyInList
                            ? Colors.amberAccent
                            : Colors.greenAccent,
                        width: 1.5,
                      ),
                      boxShadow: const [
                        BoxShadow(
                          color: Colors.black45,
                          blurRadius: 10,
                          spreadRadius: 2,
                        )
                      ],
                    ),
                    child: Row(
                      children: [
                        if (_lastScannedResult!.imageBytes != null)
                          ClipRRect(
                            borderRadius: BorderRadius.circular(8),
                            child: SizedBox(
                              width: 48,
                              height: 48,
                              child: Image.memory(
                                _lastScannedResult!.imageBytes!,
                                fit: BoxFit.cover,
                                filterQuality: FilterQuality.high,
                                isAntiAlias: true,
                              ),
                            ),
                          )
                        else
                          Container(
                            width: 48,
                            height: 48,
                            decoration: BoxDecoration(
                              color: Colors.grey[800],
                              borderRadius: BorderRadius.circular(8),
                            ),
                            child: Icon(Icons.qr_code,
                                color: isAlreadyInList
                                    ? Colors.amberAccent
                                    : Colors.greenAccent),
                          ),
                        const SizedBox(width: 12),
                        Expanded(
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            mainAxisSize: MainAxisSize.min,
                            children: [
                              Row(
                                children: [
                                  Container(
                                    padding: const EdgeInsets.symmetric(
                                        horizontal: 6, vertical: 2),
                                    decoration: BoxDecoration(
                                      color: isAlreadyInList
                                          ? Colors.amber.withValues(alpha: 0.2)
                                          : Colors.greenAccent
                                              .withValues(alpha: 0.2),
                                      borderRadius: BorderRadius.circular(4),
                                    ),
                                    child: Text(
                                      isAlreadyInList
                                          ? 'ALREADY IN LIST'
                                          : '✓ SCANNED',
                                      style: TextStyle(
                                        color: isAlreadyInList
                                            ? Colors.amberAccent
                                            : Colors.greenAccent,
                                        fontSize: 10,
                                        fontWeight: FontWeight.bold,
                                      ),
                                    ),
                                  ),
                                  const SizedBox(width: 6),
                                  Text(
                                    _lastScannedResult!.type,
                                    style: TextStyle(
                                        color: Colors.grey[400], fontSize: 11),
                                  ),
                                ],
                              ),
                              const SizedBox(height: 4),
                              Text(
                                _lastScannedResult!.value,
                                style: const TextStyle(
                                  color: Colors.white,
                                  fontSize: 15,
                                  fontWeight: FontWeight.bold,
                                ),
                                maxLines: 1,
                                overflow: TextOverflow.ellipsis,
                              ),
                            ],
                          ),
                        ),
                        IconButton(
                          icon: const Icon(Icons.close,
                              color: Colors.grey, size: 20),
                          onPressed: () {
                            setState(() {
                              _lastScannedResult = null;
                            });
                          },
                        ),
                      ],
                    ),
                  ),
                );
              },
            ),

          // 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: CustomPaint(
                                  foregroundPainter: BarcodeLinePainter(
                                    corners: item.corners,
                                    imageWidth:
                                        item.imageWidth?.toDouble() ?? 1080,
                                    imageHeight:
                                        item.imageHeight?.toDouble() ?? 1920,
                                  ),
                                  child: Image.memory(
                                    item.imageBytes!,
                                    fit: BoxFit.contain,
                                    filterQuality: FilterQuality.high,
                                    isAntiAlias: true,
                                  ),
                                ),
                              )
                            : 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,
                                            filterQuality: FilterQuality.high,
                                            isAntiAlias: true,
                                          ),
                                        ),
                                      ),
                                    ),
                                    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: '✓ Scanned: ${barcode.value}',
          style: const TextStyle(
            color: Colors.white,
            fontSize: 13,
            fontWeight: FontWeight.bold,
          ),
        );

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

        textPainter.layout();
        final bgRect = RRect.fromRectAndRadius(
          Rect.fromCenter(
            center: Offset(centerX, centerY),
            width: textPainter.width + 16,
            height: textPainter.height + 10,
          ),
          const Radius.circular(8),
        );
        final bgPaint = Paint()
          ..color = const Color(0xE62E7D32); // Vibrant dark green with opacity
        canvas.drawRRect(bgRect, bgPaint);

        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 = 1.5
      ..isAntiAlias = true
      ..strokeCap = StrokeCap.round
      ..style = PaintingStyle.stroke;

    // Calculate exact BoxFit.contain scaling and translation offsets (dx, dy)
    final scale = math.min(
      size.width / imageWidth,
      size.height / imageHeight,
    );
    final dx = (size.width - imageWidth * scale) / 2;
    final dy = (size.height - imageHeight * scale) / 2;

    final rawPoints = corners!
        .map((c) => Offset(c.x * scale + dx, c.y * scale + dy))
        .toList();

    final linePoints = _calculateCenterLine(rawPoints);
    if (linePoints.length == 2) {
      canvas.drawLine(linePoints[0], linePoints[1], paint);
    }
  }

  List<Offset> _calculateCenterLine(List<Offset> corners) {
    if (corners.isEmpty) {
      return [Offset.zero, Offset.zero];
    }
    if (corners.length < 4) {
      double minX = corners.first.dx, maxX = corners.first.dx;
      double minY = corners.first.dy, maxY = corners.first.dy;
      for (final p in corners) {
        if (p.dx < minX) minX = p.dx;
        if (p.dx > maxX) maxX = p.dx;
        if (p.dy < minY) minY = p.dy;
        if (p.dy > maxY) maxY = p.dy;
      }
      final w = maxX - minX;
      final h = maxY - minY;
      final cx = (minX + maxX) / 2.0;
      final cy = (minY + maxY) / 2.0;
      if (w >= h) {
        return [Offset(minX, cy), Offset(maxX, cy)];
      } else {
        return [Offset(cx, minY), Offset(cx, maxY)];
      }
    }

    // Calculate centroid
    double cx = 0;
    double cy = 0;
    for (final p in corners) {
      cx += p.dx;
      cy += p.dy;
    }
    cx /= 4.0;
    cy /= 4.0;

    // Sort corners clockwise around centroid
    final sorted = List<Offset>.from(corners);
    sorted.sort((a, b) {
      final angleA = math.atan2(a.dy - cy, a.dx - cx);
      final angleB = math.atan2(b.dy - cy, b.dx - cx);
      return angleA.compareTo(angleB);
    });

    final q0 = sorted[0];
    final q1 = sorted[1];
    final q2 = sorted[2];
    final q3 = sorted[3];

    // Compute side lengths
    final d0 = (q0 - q1).distance;
    final d1 = (q1 - q2).distance;
    final d2 = (q2 - q3).distance;
    final d3 = (q3 - q0).distance;

    // Compare opposite pairs of sides to find short vs long
    final sum02 = d0 + d2;
    final sum13 = d1 + d3;

    if (sum02 > sum13) {
      // q0-q1 and q2-q3 are long edges, q1-q2 and q3-q0 are short edges.
      final mid12 = Offset((q1.dx + q2.dx) / 2.0, (q1.dy + q2.dy) / 2.0);
      final mid30 = Offset((q3.dx + q0.dx) / 2.0, (q3.dy + q0.dy) / 2.0);
      return [mid12, mid30];
    } else {
      // q1-q2 and q3-q0 are long edges, q0-q1 and q2-q3 are short edges.
      final mid01 = Offset((q0.dx + q1.dx) / 2.0, (q0.dy + q1.dy) / 2.0);
      final mid23 = Offset((q2.dx + q3.dx) / 2.0, (q2.dy + q3.dy) / 2.0);
      return [mid01, mid23];
    }
  }

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