diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index a81c2de..685e442 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -44,7 +44,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.markosyan.flutter_zxing_example" - minSdkVersion 21 + minSdkVersion 23 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 87f9028..b6a2958 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2,27 +2,51 @@ PODS: - camera (0.0.1): - Flutter - Flutter (1.0.0) + - flutter_beep (0.0.1): + - Flutter - flutter_zxing (0.0.1): - Flutter + - path_provider_ios (0.0.1): + - Flutter + - share_plus (0.0.1): + - Flutter + - url_launcher_ios (0.0.1): + - Flutter DEPENDENCIES: - camera (from `.symlinks/plugins/camera/ios`) - Flutter (from `Flutter`) + - flutter_beep (from `.symlinks/plugins/flutter_beep/ios`) - flutter_zxing (from `.symlinks/plugins/flutter_zxing/ios`) + - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) EXTERNAL SOURCES: camera: :path: ".symlinks/plugins/camera/ios" Flutter: :path: Flutter + flutter_beep: + :path: ".symlinks/plugins/flutter_beep/ios" flutter_zxing: :path: ".symlinks/plugins/flutter_zxing/ios" + path_provider_ios: + :path: ".symlinks/plugins/path_provider_ios/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: camera: 9993f92f2c793e87b65e35f3a23c70582afb05b1 Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a + flutter_beep: 54fb393b22dfa0f0e4573c81b1c74dd71c4e5af8 flutter_zxing: 19a866d17c8a87ee1026d68521c69d2f008635f6 + path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 + share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 + url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c -COCOAPODS: 1.11.2 +COCOAPODS: 1.11.3 diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 38df93f..7d4f7c0 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -43,5 +43,7 @@ UIViewControllerBasedStatusBarAppearance + NSCameraUsageDescription + $(APP_DISPLAY_NAME) needs camera access to scan barcodes diff --git a/example/lib/main.dart b/example/lib/main.dart index 3ca34ce..53582a0 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; -import 'dart:async'; -import 'package:flutter/services.dart'; -import 'package:flutter_zxing/flutter_zxing.dart'; +import 'package:flutter_zxing_example/zxing_page.dart'; void main() { runApp(const MyApp()); @@ -16,47 +14,15 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { - String _platformVersion = 'Unknown'; - @override void initState() { super.initState(); - initPlatformState(); - } - - // Platform messages are asynchronous, so we initialize in an async method. - Future initPlatformState() async { - String platformVersion; - // Platform messages may fail, so we use a try/catch PlatformException. - // We also handle the message potentially returning null. - try { - platformVersion = - await FlutterZxing.platformVersion ?? 'Unknown platform version'; - } on PlatformException { - platformVersion = 'Failed to get platform version.'; - } - - // If the widget was removed from the tree while the asynchronous platform - // message was in flight, we want to discard the reply rather than calling - // setState to update our non-existent appearance. - if (!mounted) return; - - setState(() { - _platformVersion = platformVersion; - }); } @override Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - appBar: AppBar( - title: const Text('Plugin example app'), - ), - body: Center( - child: Text('Running on: $_platformVersion\n'), - ), - ), + return const MaterialApp( + home: ZxingPage(), ); } } diff --git a/example/lib/zxing_page.dart b/example/lib/zxing_page.dart new file mode 100644 index 0000000..8cc2a67 --- /dev/null +++ b/example/lib/zxing_page.dart @@ -0,0 +1,199 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_zxing/flutter_zxing.dart'; +import 'package:flutter_zxing/generated_bindings.dart'; +import 'package:flutter_zxing/zxing_reader_widget.dart'; +import 'package:flutter_zxing/zxing_writer_widget.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +late Directory tempDir; +String get tempPath => '${tempDir.path}/zxing.jpg'; + +class ZxingPage extends StatefulWidget { + const ZxingPage({ + Key? key, + }) : super(key: key); + + @override + State createState() => _ZxingPageState(); +} + +class _ZxingPageState extends State with TickerProviderStateMixin { + CameraController? controller; + TabController? _tabController; + + bool isAndroid() => Theme.of(context).platform == TargetPlatform.android; + + // Scan result queue + final _resultQueue = []; + + // Write result + Uint8List? writeResult; + + // true when the camera is active + bool _isScanning = true; + + @override + void initState() { + super.initState(); + + initStateAsync(); + } + + void initStateAsync() async { + _tabController = TabController(length: 3, vsync: this); + _tabController?.addListener(() { + _isScanning = _tabController?.index == 0; + if (_isScanning) { + controller?.resumePreview(); + } else { + controller?.pausePreview(); + } + }); + getTemporaryDirectory().then((value) { + tempDir = value; + }); + } + + @override + void dispose() { + super.dispose(); + controller?.dispose(); + } + + void showInSnackBar(String message) {} + + void logError(String code, String? message) { + if (message != null) { + debugPrint('Error: $code\nError Message: $message'); + } else { + debugPrint('Error: $code'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Flutter Scanner'), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Scanner'), + Tab(text: 'Result'), + Tab(text: 'Writer'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + // Scanner + ZxingReaderWidget(onScan: (result) async { + _resultQueue.insert(0, result); + _tabController?.index = 1; + await Future.delayed(const Duration(milliseconds: 500)); + setState(() {}); + }), + // Result + _buildResultList(), + // Writer + SingleChildScrollView( + child: Column( + children: [ + ZxingWriterWidget( + onSuccess: (result) { + setState(() { + writeResult = result; + }); + }, + onError: (error) { + setState(() { + writeResult = null; + }); + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error, + textAlign: TextAlign.center, + ), + ), + ); + }, + ), + if (writeResult != null) buildWriteResult(), + ], + ), + ), + ], + ), + ); + } + + Column buildWriteResult() { + return Column( + children: [ + // Barcode image + Image.memory(writeResult ?? Uint8List(0)), + // Share button + ElevatedButton( + onPressed: () { + // Save image to device + final file = File(tempPath); + file.writeAsBytesSync(writeResult ?? Uint8List(0)); + final path = file.path; + // Share image + Share.shareFiles([path]); + }, + child: const Text('Share'), + ), + ], + ); + } + + _buildResultList() { + return _resultQueue.isEmpty + ? const Center( + child: Text( + 'No Results', + style: TextStyle(fontSize: 24), + )) + : ListView.builder( + itemCount: _resultQueue.length, + itemBuilder: (context, index) { + final result = _resultQueue[index]; + return ListTile( + title: Text(result.textString), + subtitle: Text(result.formatString), + trailing: ButtonBar( + mainAxisSize: MainAxisSize.min, + children: [ + // Copy button + TextButton( + child: const Text('Copy'), + onPressed: () { + Clipboard.setData( + ClipboardData(text: result.textString)); + }, + ), + // Remove button + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () { + _resultQueue.removeAt(index); + setState(() {}); + }, + ), + ], + ), + ); + }, + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index fc4b6f4..1fd1c8d 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -1,6 +1,13 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.2" async: dependency: transitive description: @@ -71,6 +78,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.3.2" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" cupertino_icons: dependency: "direct main" description: @@ -92,11 +106,25 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.2" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.2" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_beep: + dependency: transitive + description: + name: flutter_beep + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" flutter_lints: dependency: "direct dev" description: @@ -128,6 +156,13 @@ packages: relative: true source: path version: "0.0.1" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.3" js: dependency: transitive description: @@ -163,6 +198,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.7.0" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" path: dependency: transitive description: @@ -170,6 +212,69 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.9" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.12" + path_provider_ios: + dependency: transitive + description: + name: path_provider_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.8" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.5" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "4.4.0" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" plugin_platform_interface: dependency: transitive description: @@ -177,6 +282,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.2" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.4" quiver: dependency: transitive description: @@ -184,6 +296,48 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1+1" + share_plus: + dependency: "direct main" + description: + name: share_plus + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + share_plus_linux: + dependency: transitive + description: + name: share_plus_linux + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + share_plus_macos: + dependency: transitive + description: + name: share_plus_macos + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + share_plus_web: + dependency: transitive + description: + name: share_plus_web + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + share_plus_windows: + dependency: transitive + description: + name: share_plus_windows + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -245,6 +399,62 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.20" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.15" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.15" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.9" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" vector_math: dependency: transitive description: @@ -252,6 +462,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0+1" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "5.3.1" sdks: dart: ">=2.16.1 <3.0.0" - flutter: ">=2.5.0" + flutter: ">=2.10.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index aff5ff5..41773e4 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,32 +1,24 @@ name: flutter_zxing_example description: Demonstrates how to use the flutter_zxing plugin. - -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +version: 0.0.1 +publish_to: "none" # Remove this line if you wish to publish to pub.dev environment: sdk: ">=2.16.1 <3.0.0" dependencies: + cupertino_icons: ^1.0.2 flutter: sdk: flutter - flutter_zxing: path: ../ - - cupertino_icons: ^1.0.2 + path_provider: ^2.0.9 + share_plus: ^4.0.1 dev_dependencies: + flutter_lints: ^1.0.0 flutter_test: sdk: flutter - flutter_lints: ^1.0.0 -# The following section is specific to Flutter. flutter: - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - diff --git a/lib/flutter_zxing.dart b/lib/flutter_zxing.dart index dd357c6..72824f4 100644 --- a/lib/flutter_zxing.dart +++ b/lib/flutter_zxing.dart @@ -56,3 +56,65 @@ extension Uint8ListBlobConversion on Uint8List { return blob; } } + +extension Encode on EncodeResult { + bool get isValidBool => isValid == 1; + Uint32List get bytes => data.asTypedList(length); + String get errorMessage => error.cast().toDartString(); +} + +extension Code on CodeResult { + bool get isValidBool => isValid == 1; + String get textString => text.cast().toDartString(); + + String get formatString { + return CodeFormat.formatName(format); + } +} + +extension CodeFormat on Format { + static String formatName(int format) => formatNames[format] ?? 'Unknown'; + String get name => formatNames[this] ?? 'Unknown'; + + static final formatNames = { + Format.None: 'None', + Format.Aztec: 'Aztec', + Format.Codabar: 'CodaBar', + Format.Code39: 'Code39', + Format.Code93: 'Code93', + Format.Code128: 'Code128', + Format.DataBar: 'DataBar', + Format.DataBarExpanded: 'DataBarExpanded', + Format.DataMatrix: 'DataMatrix', + Format.EAN8: 'EAN8', + Format.EAN13: 'EAN13', + Format.ITF: 'ITF', + Format.MaxiCode: 'MaxiCode', + Format.PDF417: 'PDF417', + Format.QRCode: 'QR Code', + Format.UPCA: 'UPCA', + Format.UPCE: 'UPCE', + Format.OneDCodes: 'OneD', + Format.TwoDCodes: 'TwoD', + Format.Any: 'Any', + }; + + static final writerFormats = [ + Format.QRCode, + Format.DataMatrix, + Format.Aztec, + Format.PDF417, + Format.Codabar, + Format.Code39, + Format.Code93, + Format.Code128, + Format.EAN8, + Format.EAN13, + Format.ITF, + Format.UPCA, + Format.UPCE, + // Format.DataBar, + // Format.DataBarExpanded, + // Format.MaxiCode, + ]; +} diff --git a/lib/image_converter.dart b/lib/image_converter.dart new file mode 100644 index 0000000..51e8b88 --- /dev/null +++ b/lib/image_converter.dart @@ -0,0 +1,57 @@ +import 'dart:typed_data'; + +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:image/image.dart' as imglib; + +// https://gist.github.com/Alby-o/fe87e35bc21d534c8220aed7df028e03 + +Future convertImage(CameraImage image) async { + try { + late imglib.Image img; + if (image.format.group == ImageFormatGroup.yuv420) { + // img = convertYUV420(image); + return image.planes.first.bytes; + } else if (image.format.group == ImageFormatGroup.bgra8888) { + img = convertBGRA8888(image); + } + return img.getBytes(format: imglib.Format.luminance); + } catch (e) { + debugPrint(">>>>>>>>>>>> ERROR:" + e.toString()); + } + return Uint8List(0); +} + +imglib.Image convertBGRA8888(CameraImage image) { + return imglib.Image.fromBytes( + image.width, + image.height, + image.planes[0].bytes, + format: imglib.Format.bgra, + ); +} + +// ignore: unused_element +imglib.Image convertYUV420(CameraImage image) { + var img = imglib.Image(image.width, image.height); // Create Image buffer + + Plane plane = image.planes[0]; + const int shift = (0xFF << 24); + + // Fill image buffer with plane[0] from YUV420_888 + for (int x = 0; x < image.width; x++) { + for (int planeOffset = 0; + planeOffset < image.height * image.width; + planeOffset += image.width) { + final pixelColor = plane.bytes[planeOffset + x]; + // color: 0x FF FF FF FF + // A B G R + // Calculate pixel color + var newVal = shift | (pixelColor << 16) | (pixelColor << 8) | pixelColor; + + img.data[planeOffset + x] = newVal; + } + } + + return img; +} diff --git a/lib/isolate_utils.dart b/lib/isolate_utils.dart new file mode 100644 index 0000000..8cbdb21 --- /dev/null +++ b/lib/isolate_utils.dart @@ -0,0 +1,69 @@ +import 'dart:isolate'; +import 'dart:math'; + +import 'package:camera/camera.dart'; + +import 'flutter_zxing.dart'; +import 'image_converter.dart'; + +// Inspired from https://github.com/am15h/object_detection_flutter + +/// Bundles data to pass between Isolate +class IsolateData { + CameraImage cameraImage; + double cropPercent; + + SendPort? responsePort; + + IsolateData( + this.cameraImage, + this.cropPercent, + ); +} + +/// Manages separate Isolate instance for inference +class IsolateUtils { + static const String kDebugName = "ZxingIsolate"; + + // ignore: unused_field + Isolate? _isolate; + final _receivePort = ReceivePort(); + SendPort? _sendPort; + + SendPort? get sendPort => _sendPort; + + Future start() async { + _isolate = await Isolate.spawn( + entryPoint, + _receivePort.sendPort, + debugName: kDebugName, + ); + + _sendPort = await _receivePort.first; + } + + void stop() { + _isolate?.kill(priority: Isolate.immediate); + _isolate = null; + _sendPort = null; + } + + static void entryPoint(SendPort sendPort) async { + final port = ReceivePort(); + sendPort.send(port.sendPort); + + await for (final IsolateData? isolateData in port) { + if (isolateData != null) { + final image = isolateData.cameraImage; + final cropPercent = isolateData.cropPercent; + final bytes = await convertImage(image); + final cropSize = (min(image.width, image.height) * cropPercent).round(); + + final result = + FlutterZxing.zxingRead(bytes, image.width, image.height, cropSize); + + isolateData.responsePort?.send(result); + } + } + } +} diff --git a/lib/scanner_overlay.dart b/lib/scanner_overlay.dart new file mode 100644 index 0000000..4eefc29 --- /dev/null +++ b/lib/scanner_overlay.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; + +class ScannerOverlay extends ShapeBorder { + const ScannerOverlay({ + this.borderColor = Colors.red, + this.borderWidth = 3.0, + this.overlayColor = const Color.fromRGBO(0, 0, 0, 40), + this.borderRadius = 0, + this.borderLength = 40, + this.cutOutSize = 250, + }) : assert(borderLength <= cutOutSize / 2 + borderWidth * 2, + "Border can't be larger than ${cutOutSize / 2 + borderWidth * 2}"); + + final Color borderColor; + final double borderWidth; + final Color overlayColor; + final double borderRadius; + final double borderLength; + final double cutOutSize; + + @override + EdgeInsetsGeometry get dimensions => const EdgeInsets.all(10); + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + return Path() + ..fillType = PathFillType.evenOdd + ..addPath(getOuterPath(rect), Offset.zero); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + Path _getLeftTopPath(Rect rect) { + return Path() + ..moveTo(rect.left, rect.bottom) + ..lineTo(rect.left, rect.top) + ..lineTo(rect.right, rect.top); + } + + return _getLeftTopPath(rect) + ..lineTo( + rect.right, + rect.bottom, + ) + ..lineTo( + rect.left, + rect.bottom, + ) + ..lineTo( + rect.left, + rect.top, + ); + } + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { + final width = rect.width; + final borderWidthSize = width / 2; + final height = rect.height; + final borderOffset = borderWidth / 2; + final _borderLength = borderLength > cutOutSize / 2 + borderWidth * 2 + ? borderWidthSize / 2 + : borderLength; + final _cutOutSize = cutOutSize < width ? cutOutSize : width - borderOffset; + + final backgroundPaint = Paint() + ..color = overlayColor + ..style = PaintingStyle.fill; + + final borderPaint = Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth; + + final boxPaint = Paint() + ..color = borderColor + ..style = PaintingStyle.fill + ..blendMode = BlendMode.dstOut; + + final cutOutRect = Rect.fromLTWH( + rect.left + width / 2 - _cutOutSize / 2 + borderOffset, + rect.top + height / 2 - _cutOutSize / 2 + borderOffset, + _cutOutSize - borderOffset * 2, + _cutOutSize - borderOffset * 2, + ); + + canvas + ..saveLayer( + rect, + backgroundPaint, + ) + ..drawRect( + rect, + backgroundPaint, + ) + // Draw top right corner + ..drawRRect( + RRect.fromLTRBAndCorners( + cutOutRect.right - _borderLength, + cutOutRect.top, + cutOutRect.right, + cutOutRect.top + _borderLength, + topRight: Radius.circular(borderRadius), + ), + borderPaint, + ) + // Draw top left corner + ..drawRRect( + RRect.fromLTRBAndCorners( + cutOutRect.left, + cutOutRect.top, + cutOutRect.left + _borderLength, + cutOutRect.top + _borderLength, + topLeft: Radius.circular(borderRadius), + ), + borderPaint, + ) + // Draw bottom right corner + ..drawRRect( + RRect.fromLTRBAndCorners( + cutOutRect.right - _borderLength, + cutOutRect.bottom - _borderLength, + cutOutRect.right, + cutOutRect.bottom, + bottomRight: Radius.circular(borderRadius), + ), + borderPaint, + ) + // Draw bottom left corner + ..drawRRect( + RRect.fromLTRBAndCorners( + cutOutRect.left, + cutOutRect.bottom - _borderLength, + cutOutRect.left + _borderLength, + cutOutRect.bottom, + bottomLeft: Radius.circular(borderRadius), + ), + borderPaint, + ) + ..drawRRect( + RRect.fromRectAndRadius( + cutOutRect, + Radius.circular(borderRadius), + ), + boxPaint, + ) + ..restore(); + } + + @override + ShapeBorder scale(double t) { + return ScannerOverlay( + borderColor: borderColor, + borderWidth: borderWidth, + overlayColor: overlayColor, + ); + } +} diff --git a/lib/zxing_reader_widget.dart b/lib/zxing_reader_widget.dart new file mode 100644 index 0000000..0e4f693 --- /dev/null +++ b/lib/zxing_reader_widget.dart @@ -0,0 +1,238 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:math'; + +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_beep/flutter_beep.dart'; +import 'flutter_zxing.dart'; +import 'generated_bindings.dart'; + +import 'isolate_utils.dart'; +import 'scanner_overlay.dart'; + +class ZxingReaderWidget extends StatefulWidget { + const ZxingReaderWidget({ + Key? key, + required this.onScan, + this.onControllerCreated, + this.beep = true, + this.showCroppingRect = true, + this.scanDelay = const Duration(milliseconds: 500), // 500ms delay + this.cropPercent = 0.5, // 50% of the screen + this.resolution = ResolutionPreset.high, + }) : super(key: key); + + final Function(CodeResult) onScan; + final Function(CameraController?)? onControllerCreated; + final bool beep; + final bool showCroppingRect; + final Duration scanDelay; + final double cropPercent; + final ResolutionPreset resolution; + + @override + State createState() => _ZxingReaderWidgetState(); +} + +class _ZxingReaderWidgetState extends State + with TickerProviderStateMixin { + late List cameras; + CameraController? controller; + + bool isAndroid() => Theme.of(context).platform == TargetPlatform.android; + + // true when code detecting is ongoing + bool _isProcessing = false; + + /// Instance of [IsolateUtils] + IsolateUtils? isolateUtils; + + @override + void initState() { + super.initState(); + + initStateAsync(); + } + + void initStateAsync() async { + // Spawn a new isolate + isolateUtils = IsolateUtils(); + await isolateUtils?.start(); + + availableCameras().then((cameras) { + setState(() { + this.cameras = cameras; + onNewCameraSelected(cameras.first); + }); + }); + + SystemChannels.lifecycle.setMessageHandler((message) async { + debugPrint(message); + final CameraController? cameraController = controller; + if (cameraController == null || !cameraController.value.isInitialized) { + return; + } + if (mounted) { + if (message == AppLifecycleState.paused.toString()) { + cameraController.dispose(); + } + if (message == AppLifecycleState.resumed.toString()) { + onNewCameraSelected(cameraController.description); + } + } + return null; + }); + } + + @override + void dispose() { + controller?.dispose(); + isolateUtils?.stop(); + super.dispose(); + } + + void onNewCameraSelected(CameraDescription cameraDescription) async { + if (controller != null) { + await controller!.dispose(); + } + + controller = CameraController( + cameraDescription, + widget.resolution, + enableAudio: false, + imageFormatGroup: + isAndroid() ? ImageFormatGroup.yuv420 : ImageFormatGroup.bgra8888, + ); + + try { + await controller?.initialize(); + controller?.startImageStream(processCameraImage); + } on CameraException catch (e) { + _showCameraException(e); + } + + controller?.addListener(() { + if (mounted) setState(() {}); + }); + + if (mounted) { + setState(() {}); + } + + widget.onControllerCreated?.call(controller); + } + + void _showCameraException(CameraException e) { + logError(e.code, e.description); + showInSnackBar('Error: ${e.code}\n${e.description}'); + } + + void showInSnackBar(String message) {} + + void logError(String code, String? message) { + if (message != null) { + debugPrint('Error: $code\nError Message: $message'); + } else { + debugPrint('Error: $code'); + } + } + + processCameraImage(CameraImage image) async { + if (!_isProcessing) { + _isProcessing = true; + try { + var isolateData = IsolateData(image, widget.cropPercent); + + /// perform inference in separate isolate + CodeResult result = await inference(isolateData); + if (result.isValidBool) { + if (widget.beep) { + FlutterBeep.beep(); + } + widget.onScan(result); + setState(() {}); + await Future.delayed(const Duration(seconds: 1)); + } + } on FileSystemException catch (e) { + debugPrint(e.message); + } catch (e) { + debugPrint(e.toString()); + } + await Future.delayed(widget.scanDelay); + _isProcessing = false; + } + + return null; + } + + /// Runs inference in another isolate + Future inference(IsolateData isolateData) async { + ReceivePort responsePort = ReceivePort(); + isolateUtils?.sendPort + ?.send(isolateData..responsePort = responsePort.sendPort); + var results = await responsePort.first; + return results; + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final cropSize = min(size.width, size.height) * widget.cropPercent; + return Stack( + children: [ + // Camera preview + Center(child: _cameraPreviewWidget(cropSize)), + ], + ); + } + + // Display the preview from the camera. + Widget _cameraPreviewWidget(double cropSize) { + final CameraController? cameraController = controller; + if (cameraController == null || !cameraController.value.isInitialized) { + return const CircularProgressIndicator(); + } else { + final size = MediaQuery.of(context).size; + var cameraMaxSize = max(size.width, size.height); + return Stack( + children: [ + SizedBox( + width: cameraMaxSize, + height: cameraMaxSize, + child: ClipRRect( + child: OverflowBox( + alignment: Alignment.center, + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: cameraMaxSize, + child: CameraPreview( + cameraController, + ), + ), + ), + ), + ), + ), + widget.showCroppingRect + ? Container( + decoration: ShapeDecoration( + shape: ScannerOverlay( + borderColor: Theme.of(context).primaryColor, + overlayColor: const Color.fromRGBO(0, 0, 0, 0.5), + borderRadius: 1, + borderLength: 16, + borderWidth: 8, + cutOutSize: cropSize, + ), + ), + ) + : Container() + ], + ); + } + } +} diff --git a/lib/zxing_writer_widget.dart b/lib/zxing_writer_widget.dart new file mode 100644 index 0000000..1523bb7 --- /dev/null +++ b/lib/zxing_writer_widget.dart @@ -0,0 +1,119 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:image/image.dart' as imglib; + +import 'flutter_zxing.dart'; +import 'generated_bindings.dart'; + +class ZxingWriterWidget extends StatefulWidget { + const ZxingWriterWidget({ + Key? key, + this.onSuccess, + this.onError, + }) : super(key: key); + + final Function(Uint8List)? onSuccess; + final Function(String)? onError; + + @override + State createState() => _ZxingWriterWidgetState(); +} + +class _ZxingWriterWidgetState extends State + with TickerProviderStateMixin { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _textController = TextEditingController(); + + bool isAndroid() => Theme.of(context).platform == TargetPlatform.android; + + final _maxTextLength = 2000; + final _supportedFormats = CodeFormat.writerFormats; + var _codeFormat = Format.QRCode; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const SizedBox(height: 20), + // Format DropDown button + DropdownButtonFormField( + value: _codeFormat, + items: _supportedFormats + .map((format) => DropdownMenuItem( + value: format, + child: Text(CodeFormat.formatName(format)), + )) + .toList(), + onChanged: (format) { + setState(() { + _codeFormat = format ?? Format.QRCode; + }); + }, + ), + const SizedBox(height: 20), + // Input multiline text + TextFormField( + controller: _textController, + keyboardType: TextInputType.multiline, + maxLines: null, + maxLength: _maxTextLength, + onChanged: (value) { + setState(() {}); + }, + decoration: InputDecoration( + border: const OutlineInputBorder(), + filled: true, + hintText: 'Enter text to encode', + counterText: + '${_textController.value.text.length} / $_maxTextLength', + ), + ), + // Write button + ElevatedButton( + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + _formKey.currentState?.save(); + FocusScope.of(context).unfocus(); + final text = _textController.value.text; + const width = 300; + const height = 300; + final result = FlutterZxing.zxingEncode( + text, width, height, _codeFormat, 5, 0); + String? error; + if (result.isValidBool) { + try { + final img = + imglib.Image.fromBytes(width, height, result.bytes); + final resultBytes = + Uint8List.fromList(imglib.encodeJpg(img)); + widget.onSuccess?.call(resultBytes); + } on Exception catch (e) { + error = e.toString(); + } + } else { + error = result.errorMessage; + } + if (error != null) { + debugPrint(error); + widget.onError?.call(error); + } + } + }, + child: const Text('Encode'), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index eb4559c..60b259e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: ffi: ^1.1.2 flutter: sdk: flutter + flutter_beep: ^1.0.0 + image: ^3.1.3 dev_dependencies: ffigen: ^4.1.3 # dart run ffigen @@ -30,7 +32,7 @@ flutter: ffigen: name: GeneratedBindings description: Bindings to `native_verokit.h`. - output: 'lib/generated_bindings.dart' + output: "lib/generated_bindings.dart" headers: entry-points: - - 'ios/Classes/src/native_zxing.h' \ No newline at end of file + - "ios/Classes/src/native_zxing.h"