DEV Community

Xiao Ling
Xiao Ling

Posted on • Originally published at dynamsoft.com

Building a Cross-Platform Barcode Scanner for Mobile, Desktop, and Web with Flutter

When discussing cross-platform development with Flutter, many developers immediately think of mobile app development. As a result, most Flutter plugins only support Android and iOS. However, Flutter is capable of much more than just mobile development—it also supports desktop and web platforms. Imagine building a cross-platform barcode scanner app from a single codebase that runs on web, Windows, Linux, macOS, iOS and Android. You've probably never seen anything like it. In this article, we'll help you achieve this goal using Flutter and Dynamsoft Barcode Reader SDK.

Cross-Platform Barcode Scanner for Web, Windows, macOS, Linux, iOS, and Android

Prerequisites

  • Flutter SDK
  • flutter_barcode_sdk: A Flutter barcode plugin that wraps the Dynamsoft Barcode Reader SDK. This plugin supports Windows, Linux, macOS, iOS, Android, and Web platforms. You'll also need to apply for a trial license to use the SDK.
  • camera: The official Flutter plugin for accessing the camera on Android, iOS and web platforms.
  • flutter_lite_camera:A lightweight camera plugin for Flutter, designed to capture camera frames on Windows, Linux, and macOS platforms.

These two Flutter camera plugins provide camera access across different platforms, while the Flutter Barcode SDK offers unified APIs for barcode detection on all supported platforms.

The Essential Steps to Build a Cross-Platform Barcode Scanner with Flutter

In the following paragraphs, we will not cover all the details of building a Flutter project. Instead, we will focus on the key logic. To explore the sample, please download the source code.

How to Construct a Camera Preview for Six Platforms

The camera plugin provides a CameraPreview widget to display camera frames and a CameraController class to manage the camera. The following code snippet shows how to create a camera preview for Android, iOS, and web platforms:

class _CameraAppState extends State<CameraApp> {
  late CameraController controller;

  @override
  void initState() {
    super.initState();
    controller = CameraController(_cameras[0], ResolutionPreset.max);
    controller.initialize().then((_) {
      if (!mounted) {
        return;
      }
      setState(() {});
    }).catchError((Object e) {
    });
  }

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

  @override
  Widget build(BuildContext context) {
    if (!controller.value.isInitialized) {
      return Container();
    }
    return MaterialApp(
      home: CameraPreview(controller),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The flutter_lite_camera plugin, on the other hand, only provides a simple captureFrame() method. Constructing a camera preview with this method is more complex compared to the camera plugin.

  1. Use Future.delayed to continuously trigger the frame capturing.

    Future<void> _captureFrames() async {
        if (!_isCameraOpened || !_shouldCapture || !cbIsMounted()) return;
    
        try {
          Map<String, dynamic> frame =
              await _flutterLiteCameraPlugin.captureFrame();
          if (frame.containsKey('data')) {
            Uint8List rgbBuffer = frame['data'];
            await _convertBufferToImage(rgbBuffer, frame['width'], frame['height']);
          }
        } catch (e) {
        }
    
        if (_shouldCapture) {
          Future.delayed(const Duration(milliseconds: 30), _captureFrames);
        }
      }
    
  2. Convert the RGB buffer to a ui.Image object. This step can be computationally expensive.

    ui.Image? _latestFrame;
    
    Future<void> _convertBufferToImage(
      Uint8List rgbBuffer, int width, int height) async {
        final pixels = Uint8List(width * height * 4); 
    
        for (int i = 0; i < width * height; i++) {
          int r = rgbBuffer[i * 3];
          int g = rgbBuffer[i * 3 + 1];
          int b = rgbBuffer[i * 3 + 2];
    
          pixels[i * 4] = b;
          pixels[i * 4 + 1] = g;
          pixels[i * 4 + 2] = r;
          pixels[i * 4 + 3] = 255; 
        }
    
        final completer = Completer<ui.Image>();
        ui.decodeImageFromPixels(
          pixels,
          width,
          height,
          ui.PixelFormat.rgba8888,
          completer.complete,
        );
    
        final image = await completer.future;
        _latestFrame = image;
      }
    
  3. Display the image in a CustomPaint widget. This acts as a camera preview.

    Widget _buildCameraStream() {
        if (_latestFrame == null) {
          return Image.asset(
            'images/default.png',
          );
        } else {
          return CustomPaint(
            painter: FramePainter(_latestFrame!),
            child: SizedBox(
              width: _width.toDouble(),
              height: _height.toDouble(),
            ),
          );
        }
      }
    

We combine the logic of both plugins to create a unified camera preview for all platforms:

Widget getPreview() {
    if (!kIsWeb &&
        (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
      return _buildCameraStream();
    }

    if (controller == null || !controller!.value.isInitialized || isFinished) {
      return Container(
        child: const Text('No camera available!'),
      );
    }

    return CameraPreview(controller!);
  }
Enter fullscreen mode Exit fullscreen mode

How to Decode Barcodes from Camera Frames on Web, Desktop, and Mobile Platforms

While the camera plugin provides camera previews for Web, Android, and iOS, it only supports frame callbacks on Android and iOS. Additionally, platform-specific code is required to distinguish between Android and iOS. For Web, the takePicture() function is the only way to retrieve camera frames.

Android and iOS:

List<BarcodeResult>? barcodeResults;

Future<void> mobileCamera() async {
    await controller!.startImageStream((CameraImage availableImage) async {
      assert(defaultTargetPlatform == TargetPlatform.android ||
          defaultTargetPlatform == TargetPlatform.iOS);
      if (cbIsMounted() == false || isFinished) return;
      int format = ImagePixelFormat.IPF_NV21.index;

      switch (availableImage.format.group) {
        case ImageFormatGroup.yuv420:
          format = ImagePixelFormat.IPF_NV21.index;
          break;
        case ImageFormatGroup.bgra8888:
          format = ImagePixelFormat.IPF_ARGB_8888.index;
          break;
        default:
          format = ImagePixelFormat.IPF_RGB_888.index;
      }

      if (!_isScanAvailable) {
        return;
      }

      _isScanAvailable = false;

      processId(availableImage.planes[0].bytes, availableImage.width,
          availableImage.height, availableImage.planes[0].bytesPerRow, format);
    });
  }

void processId(
      Uint8List bytes, int width, int height, int stride, int format) {
    barcodeReader
        .decodeImageBuffer(bytes, width, height, stride, format)
        .then((results) {
      if (!cbIsMounted()) {
        return;
      }

      if (MediaQuery.of(context).size.width <
          MediaQuery.of(context).size.height) {
        if (Platform.isAndroid && results.isNotEmpty) {
          results = rotate90barcode(results, previewSize!.height.toInt());
        }
      }

      barcodeResults = results;

      _isScanAvailable = true;
    });
  }
Enter fullscreen mode Exit fullscreen mode

Web:

Future<void> webCamera() async {
    _isWebFrameStarted = true;
    while (!(controller == null || isFinished || cbIsMounted() == false)) {
      XFile? file = await controller?.takePicture();
      if (file != null) {
        var results = await barcodeReader.decodeFile(file.path);
        barcodeResults = results;
      }
    }
    _isWebFrameStarted = false;
  }
Enter fullscreen mode Exit fullscreen mode

In contrast, using the flutter_lite_camera plugin simplifies the process:

Future<void> _captureFrames() async {
    if (!_isCameraOpened || !_shouldCapture || !cbIsMounted()) return;

    try {
      Map<String, dynamic> frame =
          await _flutterLiteCameraPlugin.captureFrame();
      if (frame.containsKey('data')) {
        Uint8List rgbBuffer = frame['data'];
        _decodeFrame(rgbBuffer, frame['width'], frame['height']);
        await _convertBufferToImage(rgbBuffer, frame['width'], frame['height']);
      }
    } catch (e) {
    }

    if (_shouldCapture) {
      Future.delayed(const Duration(milliseconds: 30), _captureFrames);
    }
  }

Future<void> _decodeFrame(Uint8List rgb, int width, int height) async {
    if (isDecoding) return;

    isDecoding = true;
    barcodeResults = await barcodeReader.decodeImageBuffer(
      rgb,
      width,
      height,
      width * 3,
      ImagePixelFormat.IPF_RGB_888.index,
    );

    isDecoding = false;
  }
Enter fullscreen mode Exit fullscreen mode

How to Draw the Overlay with Barcode Detection Results

Visualizing barcode detection results in real-time is crucial for a barcode scanner app. In Flutter, you can create a custom widget that fully covers the camera preview. And then place both widgets inside a FittedBox widget to maintain the aspect ratio automatically.

Positioned(
    top: 0,
    right: 0,
    left: 0,
    bottom: 0,
    child: FittedBox(
        fit: BoxFit.cover,
        child: Stack(
        children: createCameraPreview(),
        ),
    ),
    ),

List<Widget> createCameraPreview() {
    if (!kIsWeb &&
        (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
      return [
        SizedBox(width: 640, height: 480, child: _cameraManager.getPreview()),
        Positioned(
          top: 0.0,
          right: 0.0,
          bottom: 0,
          left: 0.0,
          child: createOverlay(
            _cameraManager.barcodeResults,
          ),
        ),
      ];
    } else {
      if (_cameraManager.controller != null &&
          _cameraManager.previewSize != null) {
        double width = _cameraManager.previewSize!.width;
        double height = _cameraManager.previewSize!.height;
        if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
          if (MediaQuery.of(context).size.width <
              MediaQuery.of(context).size.height) {
            width = _cameraManager.previewSize!.height;
            height = _cameraManager.previewSize!.width;
          }
        }

        return [
          SizedBox(
              width: width, height: height, child: _cameraManager.getPreview()),
          Positioned(
            top: 0.0,
            right: 0.0,
            bottom: 0,
            left: 0.0,
            child: createOverlay(
              _cameraManager.barcodeResults,
            ),
          ),
        ];
      } else {
        return [const CircularProgressIndicator()];
      }
    }
  }

Widget createOverlay(
  List<BarcodeResult>? barcodeResults,
) {
  return CustomPaint(
    painter: OverlayPainter(barcodeResults),
  );
}

class OverlayPainter extends CustomPainter {
  List<BarcodeResult>? barcodeResults;

  OverlayPainter(this.barcodeResults);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.blue
      ..strokeWidth = 5
      ..style = PaintingStyle.stroke;

    if (barcodeResults != null) {
      for (var result in barcodeResults!) {
        double minX = result.x1.toDouble();
        double minY = result.y1.toDouble();
        if (result.x2 < minX) minX = result.x2.toDouble();
        if (result.x3 < minX) minX = result.x3.toDouble();
        if (result.x4 < minX) minX = result.x4.toDouble();
        if (result.y2 < minY) minY = result.y2.toDouble();
        if (result.y3 < minY) minY = result.y3.toDouble();
        if (result.y4 < minY) minY = result.y4.toDouble();

        canvas.drawLine(Offset(result.x1.toDouble(), result.y1.toDouble()),
            Offset(result.x2.toDouble(), result.y2.toDouble()), paint);
        canvas.drawLine(Offset(result.x2.toDouble(), result.y2.toDouble()),
            Offset(result.x3.toDouble(), result.y3.toDouble()), paint);
        canvas.drawLine(Offset(result.x3.toDouble(), result.y3.toDouble()),
            Offset(result.x4.toDouble(), result.y4.toDouble()), paint);
        canvas.drawLine(Offset(result.x4.toDouble(), result.y4.toDouble()),
            Offset(result.x1.toDouble(), result.y1.toDouble()), paint);

        TextPainter textPainter = TextPainter(
          text: TextSpan(
            text: result.text,
            style: const TextStyle(
              color: Colors.yellow,
              fontSize: 22.0,
            ),
          ),
          textAlign: TextAlign.center,
          textDirection: TextDirection.ltr,
        );
        textPainter.layout(minWidth: 0, maxWidth: size.width);
        textPainter.paint(canvas, Offset(minX, minY));
      }
    }
  }

  @override
  bool shouldRepaint(OverlayPainter oldDelegate) => true;
}
Enter fullscreen mode Exit fullscreen mode

Running the Cross-Platform Barcode Scanner for Web, Windows, macOS, Linux, iOS, and Android

You only need a single command to run the Flutter project on different platforms. Below are the commands for each platform:

  • Web

    flutter run -d chrome
    
  • Windows

    flutter run -d windows
    
  • macOS

    flutter run -d macos
    
  • Linux

    flutter run -d linux
    
  • iOS

    flutter run 
    
  • Android

    flutter run 
    

Flutter barcode scanner for web, Windows, macOS, Linux, iOS and Android

Source Code

https://github.com/yushulx/flutter-barcode-mrz-document-scanner/tree/main/examples/flutter_camera

Top comments (0)