flutter_full_screen_scanner 1.0.7 copy "flutter_full_screen_scanner: ^1.0.7" to clipboard
flutter_full_screen_scanner: ^1.0.7 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:async';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter_full_screen_scanner/flutter_full_screen_scanner.dart'
    as fss;

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 FullScreenScannerPage(),
    );
  }
}

enum ScanMode { barcode, qr }

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

  @override
  State<FullScreenScannerPage> createState() => _FullScreenScannerPageState();
}

class _FullScreenScannerPageState extends State<FullScreenScannerPage>
    with WidgetsBindingObserver {
  late final fss.ScannerController _fssController;
  StreamSubscription? _fssSubscription;

  ScanMode _scanMode = ScanMode.barcode;
  bool _isCapturing = false;

  final List<String> _scannedHistory = [];
  List<String> _currentDetectedBarcodes = [];
  List<String> _duplicateBarcodes = [];

  int _scanCountdown = 0;
  Timer? _countdownTimer;
  Timer? _clearDuplicateTimer;

  bool _isBarcodeDetectedInWindow = false;
  final Map<String, DateTime> _lastDuplicateToastTimes = {};

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);

    _fssController = fss.ScannerController();
    _fssSubscription = _fssController.events.listen((event) {
      if (event.type == fss.ScannerEventType.scanned) {
        final result = event.data as fss.ScannerResult;
        _handleFssScanResult(result);
      }
    });
  }

  void _handleFssScanResult(fss.ScannerResult result) async {
    debugPrint("[Dart Received] value: ${result.value}, type: ${result.type}");
    if (_isCapturing) {
      debugPrint("[Dart Skipped] reason: isCapturing active");
      return;
    }

    final String rawCode = result.value;
    final String code = _sanitizeBarcode(rawCode);
    if (code.isEmpty) {
      debugPrint("[Dart Skipped] reason: sanitized code is empty");
      return;
    }

    // Filter based on scan mode
    final isQr = result.type.toLowerCase().contains('qr');
    if (_scanMode == ScanMode.qr && !isQr) {
      debugPrint(
          "[Dart Skipped] reason: scanMode mismatch (expected QR, got non-QR)");
      return;
    }
    if (_scanMode == ScanMode.barcode && isQr) {
      debugPrint(
          "[Dart Skipped] reason: scanMode mismatch (expected Barcode, got QR)");
      return;
    }

    // Filter by scan window in Dart using the exact CustomPainter coordinate mapping
    final corners = result.corners;
    if (corners != null && corners.length >= 4) {
      final screenWidth = MediaQuery.of(context).size.width;
      final screenHeight = MediaQuery.of(context).size.height;
      final isBarcodeMode = _scanMode == ScanMode.barcode;
      final boxWidth =
          isBarcodeMode ? (screenWidth * 0.70) : (screenWidth * 0.50);
      final boxHeight = isBarcodeMode ? 60.0 : (screenWidth * 0.50);

      final left = (screenWidth - boxWidth) / 2;
      final right = (screenWidth + boxWidth) / 2;
      final top = (screenHeight - boxHeight) / 2;
      final bottom = (screenHeight + boxHeight) / 2;

      final imageWidth = result.imageWidth?.toDouble() ?? 1080.0;
      final imageHeight = result.imageHeight?.toDouble() ?? 1920.0;

      final scaleX = screenWidth / imageWidth;
      final scaleY = screenHeight / imageHeight;
      final scale = scaleX > scaleY ? scaleX : scaleY;

      final dxOffset = (imageWidth * scale - screenWidth) / 2;
      final dyOffset = (imageHeight * scale - screenHeight) / 2;

      double sumX = 0;
      double sumY = 0;
      for (final pt in corners) {
        sumX += pt.x * scale - dxOffset;
        sumY += pt.y * scale - dyOffset;
      }

      final centroidX = sumX / corners.length;
      final centroidY = sumY / corners.length;

      debugPrint(
          "[Dart Centroid] centroidX: $centroidX, centroidY: $centroidY | box bounds: X [$left .. $right], Y [$top .. $bottom]");

      // 1. Ensure barcode is not cut off by screen boundaries (at least 5px margin)
      final screenMargin = 5.0;
      for (final pt in corners) {
        final px = pt.x * scale - dxOffset;
        final py = pt.y * scale - dyOffset;
        if (px < screenMargin ||
            px > screenWidth - screenMargin ||
            py < screenMargin ||
            py > screenHeight - screenMargin) {
          debugPrint("[Dart Skipped] reason: barcode cut off by screen edges");
          return;
        }
      }

      // 2. Ensure the barcode center (centroid) is inside the scan window vertically
      if (centroidY < top || centroidY > bottom) {
        debugPrint(
            "[Dart Skipped] reason: barcode center outside scan window vertically");
        return;
      }

      // 3. Ensure centroid is inside the scan window horizontally
      if (centroidX < left || centroidX > right) {
        debugPrint(
            "[Dart Skipped] reason: barcode center outside scan window horizontally");
        return;
      }
    } else {
      debugPrint(
          "[Dart Skipped] reason: corners count < 4 or null (${corners?.length})");
    }

    if (_scannedHistory.contains(code)) {
      debugPrint("[Dart Skipped] reason: duplicate barcode in history: $code");
      _showDuplicateToast(code);
      return;
    }

    try {
      _isCapturing = true;

      setState(() {
        _currentDetectedBarcodes = [code];
        _duplicateBarcodes = [];
        _isBarcodeDetectedInWindow = true;
        _scanCountdown = 2; // 2 seconds cooldown
        _scannedHistory.insert(0, code);
      });

      // Start 2-second countdown timer. Scanner is locked during this period
      _countdownTimer?.cancel();
      _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
        if (!mounted) {
          timer.cancel();
          return;
        }
        setState(() {
          if (_scanCountdown > 1) {
            _scanCountdown--;
          } else {
            _scanCountdown = 0;
            _isCapturing = false;
            _currentDetectedBarcodes = [];
            _isBarcodeDetectedInWindow = false;
            timer.cancel();
          }
        });
      });
    } catch (e) {
      debugPrint("Error in _handleFssScanResult: $e");
      setState(() {
        _isCapturing = false;
        _isBarcodeDetectedInWindow = false;
        _scanCountdown = 0;
      });
    }
  }

  void _showDuplicateToast(String code) {
    final now = DateTime.now();
    final lastSeen = _lastDuplicateToastTimes[code];
    if (lastSeen != null &&
        now.difference(lastSeen) < const Duration(seconds: 2)) {
      return;
    }
    _lastDuplicateToastTimes[code] = now;

    _clearDuplicateTimer?.cancel();
    _clearDuplicateTimer = Timer(const Duration(seconds: 2), () {
      if (mounted) {
        setState(() {
          _duplicateBarcodes = [];
        });
      }
    });

    if (!_duplicateBarcodes.contains(code)) {
      setState(() {
        _duplicateBarcodes = [code];
        _currentDetectedBarcodes = [];
        _isBarcodeDetectedInWindow = false;
      });
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _fssSubscription?.cancel();
    _fssController.dispose();
    _countdownTimer?.cancel();
    _clearDuplicateTimer?.cancel();
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.inactive ||
        state == AppLifecycleState.paused) {
      _fssController.pause();
    } else if (state == AppLifecycleState.resumed) {
      _fssController.resume();
    }
  }

  void _showToast(String msg) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(msg),
        duration: const Duration(seconds: 2),
      ),
    );
  }

  Future<void> _showScannedHistory(BuildContext context) async {
    try {
      _fssController.pause();
    } catch (e) {
      debugPrint("Error pausing scanner: $e");
    }

    await showModalBottomSheet(
      context: context,
      backgroundColor: Colors.transparent,
      builder: (context) => Container(
        decoration: const BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Container(
              margin: const EdgeInsets.only(top: 10),
              width: 40,
              height: 4,
              decoration: BoxDecoration(
                  color: Colors.grey[300],
                  borderRadius: BorderRadius.circular(2)),
            ),
            Padding(
              padding: const EdgeInsets.all(20),
              child: Row(
                children: [
                  const Icon(Icons.history, color: Colors.blueAccent),
                  const SizedBox(width: 10),
                  Text(
                    "Scanned History (${_scannedHistory.length})",
                    style: const TextStyle(
                        fontSize: 18,
                        fontWeight: FontWeight.bold,
                        color: Colors.blueAccent),
                  ),
                ],
              ),
            ),
            if (_scannedHistory.isEmpty)
              const Padding(
                padding: EdgeInsets.symmetric(vertical: 40),
                child: Text("No barcodes scanned yet",
                    style: TextStyle(color: Colors.grey)),
              )
            else
              ConstrainedBox(
                constraints: BoxConstraints(
                    maxHeight: MediaQuery.of(context).size.height * 0.4),
                child: ListView.separated(
                  shrinkWrap: true,
                  padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
                  itemCount: _scannedHistory.length,
                  separatorBuilder: (_, __) => const Divider(),
                  itemBuilder: (context, index) {
                    final sn = _scannedHistory[index];
                    return ListTile(
                      contentPadding: EdgeInsets.zero,
                      leading: const Icon(Icons.qr_code_2, color: Colors.green),
                      title: Text(sn,
                          style: const TextStyle(
                              fontWeight: FontWeight.w600,
                              color: Colors.black)),
                      trailing: const Icon(Icons.check_circle,
                          color: Colors.green, size: 20),
                    );
                  },
                ),
              ),
          ],
        ),
      ),
    );

    try {
      _fssController.resume();
    } catch (e) {
      debugPrint("Error resuming scanner: $e");
    }
  }

  Future<void> _pickAndScanImage() async {
    try {
      final picker = ImagePicker();
      final pickedFile = await picker.pickImage(source: ImageSource.gallery);
      if (pickedFile == null) return;

      final results = await _fssController.scanImage(pickedFile.path);
      if (results.isNotEmpty) {
        final decodedValues = <String>{};
        for (final r in results) {
          final rawValue = _sanitizeBarcode(r.value);
          if (rawValue.isNotEmpty) {
            decodedValues.add(rawValue);
          }
        }

        if (decodedValues.isNotEmpty) {
          if (!mounted) return;

          await showDialog(
            context: context,
            builder: (ctx) => AlertDialog(
              title: Text("Detected Barcodes (${decodedValues.length})",
                  style: const TextStyle(color: Colors.black)),
              backgroundColor: Colors.white,
              content: SizedBox(
                width: double.maxFinite,
                child: ListView.builder(
                  shrinkWrap: true,
                  itemCount: decodedValues.length,
                  itemBuilder: (ctx, index) {
                    final val = decodedValues.elementAt(index);
                    final isAlreadyScanned = _scannedHistory.contains(val);
                    return ListTile(
                      title: Text(val,
                          style: TextStyle(
                            color: isAlreadyScanned ? Colors.red : Colors.black,
                            decoration: isAlreadyScanned
                                ? TextDecoration.lineThrough
                                : null,
                          )),
                      subtitle: isAlreadyScanned
                          ? const Text("Already Scanned",
                              style: TextStyle(color: Colors.red))
                          : null,
                      onTap: isAlreadyScanned
                          ? null
                          : () {
                              Navigator.pop(ctx);
                              setState(() {
                                _scannedHistory.insert(0, val);
                                _currentDetectedBarcodes = [val];
                                _duplicateBarcodes = [];
                                _isBarcodeDetectedInWindow = true;
                                _scanCountdown = 2;
                              });

                              _countdownTimer?.cancel();
                              _countdownTimer = Timer.periodic(
                                  const Duration(seconds: 1), (timer) {
                                if (!mounted) {
                                  timer.cancel();
                                  return;
                                }
                                setState(() {
                                  if (_scanCountdown > 1) {
                                    _scanCountdown--;
                                  } else {
                                    _scanCountdown = 0;
                                    _isCapturing = false;
                                    _currentDetectedBarcodes = [];
                                    _isBarcodeDetectedInWindow = false;
                                    timer.cancel();
                                  }
                                });
                              });
                            },
                    );
                  },
                ),
              ),
              actions: [
                TextButton(
                    onPressed: () => Navigator.pop(ctx),
                    child: const Text("Cancel",
                        style: TextStyle(color: Colors.blueAccent)))
              ],
            ),
          );
        } else {
          _showToast("No text found in barcodes.");
        }
      } else {
        _showToast("No barcodes detected in image.");
      }
    } catch (e) {
      debugPrint("Gallery scan error: $e");
      if (mounted) {
        _showToast("Error scanning image: $e");
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: LayoutBuilder(
        builder: (context, constraints) {
          final screenWidth = constraints.maxWidth;
          final screenHeight = constraints.maxHeight;
          final isBarcodeMode = _scanMode == ScanMode.barcode;

          // Small scan window box
          final boxWidth =
              isBarcodeMode ? screenWidth * 0.70 : screenWidth * 0.50;
          final boxHeight = isBarcodeMode ? 60.0 : screenWidth * 0.50;

          final scanWindowRect = Rect.fromCenter(
            center: Offset(screenWidth / 2, screenHeight / 2),
            width: boxWidth,
            height: boxHeight,
          );

          return Stack(
            children: [
              Positioned.fill(
                child: fss.ScannerView(
                  controller: _fssController,
                  options: fss.ScannerOptions(
                    allowDuplicate: false,
                    duplicateDelay: 2000,
                    enableImageCapture: true,
                    scanMode: _scanMode == ScanMode.qr
                        ? fss.ScanMode.qr
                        : fss.ScanMode.barcode,
                    supportedFormats: _scanMode == ScanMode.qr
                        ? const [fss.BarcodeFormat.qrCode]
                        : const [
                            fss.BarcodeFormat.code128,
                            fss.BarcodeFormat.code39,
                            fss.BarcodeFormat.code93,
                            fss.BarcodeFormat.codaBar,
                            fss.BarcodeFormat.dataMatrix,
                            fss.BarcodeFormat.ean13,
                            fss.BarcodeFormat.ean8,
                            fss.BarcodeFormat.itf,
                            fss.BarcodeFormat.upcA,
                            fss.BarcodeFormat.upcE,
                            fss.BarcodeFormat.pdf417,
                            fss.BarcodeFormat.aztec,
                          ],
                    scanWindow: fss.ScanWindow(
                      widthFactor: isBarcodeMode ? 0.70 : 0.50,
                      heightFactor: isBarcodeMode ? 60.0 / screenHeight : 0.50,
                    ),
                  ),
                ),
              ),
              // Dark Overlay with hole
              CustomPaint(
                size: Size(screenWidth, screenHeight),
                painter: ScannerOverlayPainter(scanWindow: scanWindowRect),
              ),
              // Center Scan Window
              Center(
                child: Container(
                  width: boxWidth,
                  height: boxHeight,
                  decoration: BoxDecoration(
                    border: Border.all(
                      color: _isBarcodeDetectedInWindow
                          ? Colors.greenAccent
                          : Colors.orange,
                      width: 2,
                    ),
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: Center(
                    child: Container(
                      width: boxWidth * 0.3,
                      height: 2,
                      decoration: BoxDecoration(
                        color: _isBarcodeDetectedInWindow
                            ? Colors.greenAccent
                            : Colors.orange,
                        borderRadius: BorderRadius.circular(1),
                        boxShadow: [
                          BoxShadow(
                            color: (_isBarcodeDetectedInWindow
                                    ? Colors.greenAccent
                                    : Colors.orange)
                                .withAlpha(128),
                            blurRadius: 4,
                            spreadRadius: 1,
                          ),
                        ],
                      ),
                    ),
                  ),
                ),
              ),
              // Message and Icon
              Positioned(
                top: (screenHeight / 2) - (boxHeight / 2) - 60,
                left: 20,
                right: 20,
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Icon(
                      Icons.pan_tool,
                      color: _isBarcodeDetectedInWindow
                          ? Colors.greenAccent
                          : Colors.orange,
                      size: 24,
                    ),
                    const SizedBox(width: 8),
                    Flexible(
                      child: Text(
                        "Please keep one hand distance between barcode / QR",
                        textAlign: TextAlign.center,
                        style: TextStyle(
                          color: _isBarcodeDetectedInWindow
                              ? Colors.greenAccent
                              : Colors.orange,
                          fontSize: 14,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                  ],
                ),
              ),
              // Mode Selector (Barcode / QR) & Controls Top Bar
              Positioned(
                top: MediaQuery.of(context).padding.top + 10,
                left: 20,
                right: 20,
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    // Clear History Button
                    CircleAvatar(
                      backgroundColor: Colors.black45,
                      child: IconButton(
                        icon: const Icon(Icons.refresh, color: Colors.white),
                        onPressed: () {
                          setState(() {
                            _scannedHistory.clear();
                          });
                          _showToast("History cleared.");
                        },
                      ),
                    ),
                    // Mode Selector Pill
                    Container(
                      height: 40,
                      decoration: BoxDecoration(
                        color: Colors.black54,
                        borderRadius: BorderRadius.circular(20),
                        border: Border.all(color: Colors.white24),
                      ),
                      padding: const EdgeInsets.all(2),
                      child: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          GestureDetector(
                            onTap: () =>
                                setState(() => _scanMode = ScanMode.barcode),
                            child: Container(
                              padding:
                                  const EdgeInsets.symmetric(horizontal: 16),
                              decoration: BoxDecoration(
                                color: _scanMode == ScanMode.barcode
                                    ? Colors.orange
                                    : Colors.transparent,
                                borderRadius: BorderRadius.circular(20),
                              ),
                              alignment: Alignment.center,
                              child: const Text(
                                "Barcode",
                                style: TextStyle(
                                    color: Colors.white,
                                    fontWeight: FontWeight.bold,
                                    fontSize: 13),
                              ),
                            ),
                          ),
                          GestureDetector(
                            onTap: () =>
                                setState(() => _scanMode = ScanMode.qr),
                            child: Container(
                              padding:
                                  const EdgeInsets.symmetric(horizontal: 16),
                              decoration: BoxDecoration(
                                color: _scanMode == ScanMode.qr
                                    ? Colors.orange
                                    : Colors.transparent,
                                borderRadius: BorderRadius.circular(20),
                              ),
                              alignment: Alignment.center,
                              child: const Text(
                                "QR Code",
                                style: TextStyle(
                                    color: Colors.white,
                                    fontWeight: FontWeight.bold,
                                    fontSize: 13),
                              ),
                            ),
                          ),
                        ],
                      ),
                    ),
                    // Right controls (Flip Camera & Flash)
                    Row(
                      children: [
                        CircleAvatar(
                          backgroundColor: Colors.black45,
                          child: IconButton(
                            icon: const Icon(Icons.flip_camera_ios,
                                color: Colors.white),
                            onPressed: () async {
                              await _fssController.switchCamera();
                              setState(() {});
                            },
                          ),
                        ),
                        const SizedBox(width: 8),
                        CircleAvatar(
                          backgroundColor: Colors.black45,
                          child: IconButton(
                            icon: Icon(
                              _fssController.flashEnabled
                                  ? Icons.flash_on
                                  : Icons.flash_off,
                              color: _fssController.flashEnabled
                                  ? Colors.yellow
                                  : Colors.white,
                            ),
                            onPressed: () async {
                              await _fssController.toggleFlash();
                              setState(() {});
                            },
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
              // Bottom Buttons
              Positioned(
                bottom: 30,
                left: 20,
                child: CircleAvatar(
                  backgroundColor: Colors.black45,
                  radius: 25,
                  child: IconButton(
                    icon: const Icon(Icons.list_alt, color: Colors.white),
                    onPressed: () => _showScannedHistory(context),
                  ),
                ),
              ),
              Positioned(
                bottom: 30,
                right: 20,
                child: CircleAvatar(
                  backgroundColor: Colors.black45,
                  radius: 25,
                  child: IconButton(
                    icon: const Icon(Icons.photo_library, color: Colors.white),
                    onPressed: () => _pickAndScanImage(),
                  ),
                ),
              ),
              // Cooldown Indicator
              if (_scanCountdown > 0)
                Positioned(
                  top: (screenHeight / 2) - (boxHeight / 2) - 140,
                  left: 30,
                  right: 30,
                  child: Center(
                    child: Container(
                      padding: const EdgeInsets.symmetric(
                          horizontal: 20, vertical: 12),
                      decoration: BoxDecoration(
                        color: Colors.black87,
                        borderRadius: BorderRadius.circular(12),
                        border: Border.all(color: Colors.greenAccent, width: 2),
                      ),
                      child: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          const Icon(Icons.check_circle,
                              color: Colors.greenAccent, size: 24),
                          const SizedBox(width: 10),
                          Text(
                            "SCANNED! Next scan in ${_scanCountdown}s",
                            style: const TextStyle(
                                color: Colors.white,
                                fontWeight: FontWeight.bold,
                                fontSize: 14),
                          ),
                        ],
                      ),
                    ),
                  ),
                ),
              // Scan result view
              if (_currentDetectedBarcodes.isNotEmpty ||
                  _duplicateBarcodes.isNotEmpty)
                Positioned(
                  top: (screenHeight / 2) + (boxHeight / 2) + 30,
                  left: 30,
                  right: 30,
                  child: Center(
                    child: Container(
                      padding: const EdgeInsets.symmetric(
                          horizontal: 16, vertical: 10),
                      decoration: BoxDecoration(
                        color: Colors.black87,
                        borderRadius: BorderRadius.circular(10),
                        border: Border.all(
                          color: _duplicateBarcodes.isNotEmpty
                              ? Colors.redAccent
                              : Colors.greenAccent,
                          width: 1.5,
                        ),
                      ),
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          if (_duplicateBarcodes.isNotEmpty) ...[
                            Text(
                              _duplicateBarcodes
                                  .map((s) => "$s\n(ALREADY SCANNED)")
                                  .join('\n'),
                              textAlign: TextAlign.center,
                              style: const TextStyle(
                                color: Colors.redAccent,
                                fontWeight: FontWeight.bold,
                                fontSize: 15,
                              ),
                            ),
                          ],
                          if (_duplicateBarcodes.isNotEmpty &&
                              _currentDetectedBarcodes.isNotEmpty)
                            const SizedBox(height: 6),
                          if (_currentDetectedBarcodes.isNotEmpty) ...[
                            Text(
                              _currentDetectedBarcodes.join('\n'),
                              textAlign: TextAlign.center,
                              style: const TextStyle(
                                color: Colors.greenAccent,
                                fontWeight: FontWeight.bold,
                                fontSize: 15,
                              ),
                            ),
                          ],
                        ],
                      ),
                    ),
                  ),
                ),
            ],
          );
        },
      ),
    );
  }
}

class ScannerOverlayPainter extends CustomPainter {
  final Rect scanWindow;
  final double borderRadius;

  ScannerOverlayPainter({required this.scanWindow, this.borderRadius = 12.0});

  @override
  void paint(Canvas canvas, Size size) {
    final backgroundPath = Path()
      ..addRect(Rect.fromLTWH(0, 0, size.width, size.height));
    final holePath = Path()
      ..addRRect(
          RRect.fromRectAndRadius(scanWindow, Radius.circular(borderRadius)));

    final path =
        Path.combine(PathOperation.difference, backgroundPath, holePath);

    final paint = Paint()
      ..color = Colors.black.withAlpha(128)
      ..style = PaintingStyle.fill;

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(covariant ScannerOverlayPainter oldDelegate) {
    return oldDelegate.scanWindow != scanWindow ||
        oldDelegate.borderRadius != borderRadius;
  }
}

String _sanitizeBarcode(String raw) {
  if (raw.isEmpty) return raw;

  if (raw.startsWith(']')) {
    final match = RegExp(r'^\][a-zA-Z0-9]{1,2}').firstMatch(raw);
    if (match != null) {
      raw = raw.substring(match.end);
    }
  }

  raw = raw.replaceFirst(RegExp(r'^[^a-zA-Z0-9]+'), '');
  return raw;
}