diff --git a/android/gradle.properties b/android/gradle.properties index 84044a9..d636853 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,5 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true + android.enableJetifier=false +dev.steenbakker.mobile_scanner.useUnbundled=true diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5827274..7b98ca5 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -50,5 +50,9 @@ UIApplicationSupportsIndirectInputEvents + NSCameraUsageDescription + This app needs camera access to scan QR codes + NSPhotoLibraryUsageDescription + This app needs photos access to get QR code from photo library diff --git a/lib/view/barcode_scanner_screen.dart b/lib/view/barcode_scanner_screen.dart new file mode 100644 index 0000000..8298808 --- /dev/null +++ b/lib/view/barcode_scanner_screen.dart @@ -0,0 +1,454 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:mobile_scanner/mobile_scanner.dart' as scanner; +import 'package:barcode_widget/barcode_widget.dart'; +import 'package:magicepaperapp/constants/color_constants.dart'; +import 'package:image/image.dart' as img; + +class BarcodeScannerScreen extends StatefulWidget { + final int width; + final int height; + + const BarcodeScannerScreen({ + super.key, + required this.width, + required this.height, + }); + + @override + State createState() => _BarcodeScannerScreenState(); +} + +class _BarcodeScannerScreenState extends State { + final GlobalKey _barcodeKey = GlobalKey(); + final TextEditingController _textController = TextEditingController(); + String _barcodeData = ''; + bool _hasError = false; + Barcode _selectedBarcode = Barcode.qrCode(); + bool _showScanner = false; + scanner.MobileScannerController? _scannerController; + + @override + void initState() { + super.initState(); + _textController.addListener(() { + setState(() { + _barcodeData = _textController.text; + _hasError = false; + }); + }); + } + + final Map barcodeFormatToSupportedChars = { + 'Aztec': 'All', + 'CODABAR': '0-9 \$ - . / : +', + 'CODE 128': 'All', + 'CODE 39': '0-9 A-Z - . \$ / + % , ', + 'CODE 93': '0-9 A-Z - . \$ / + % , ', + 'Data Matrix': 'All', + 'EAN 13': '0-9', + 'EAN 2': '0-9', + 'EAN 5': '0-9', + 'EAN 8': '0-9', + 'GS1 128': 'All', + 'ISBN': '0-9', + 'ITF': '0-9', + 'ITF 14': '0-9', + 'ITF 16': '0-9', + 'PDF417': 'All', + 'QR Code': 'All', + 'RM4SCC': '0-9 A-Z', + 'Telepen': 'All', + 'UPC A': '0-9', + 'UPC E': '0-9', + }; + + String? _validateBarcodeData(String data, Barcode barcode) { + if (data.isEmpty) { + return null; + } + final allowedChars = barcode.charSet.toSet(); + for (final rune in data.runes) { + if (!allowedChars.contains(rune)) { + final char = String.fromCharCode(rune); + final rules = barcodeFormatToSupportedChars[_selectedBarcode.name]; + return "Invalid character '$char' \nSupported characters are ${rules ?? 'Please check the barcode rules.'}"; + } + } + if (data.length < barcode.minLength) { + return 'Data is too short. Minimum length for ${barcode.name} is ${barcode.minLength}.'; + } + if (barcode.maxLength < 10000 && data.length > barcode.maxLength) { + return 'Data is too long. Maximum length for ${barcode.name} is ${barcode.maxLength}.'; + } + return null; + } + + void _handleBarcode(scanner.BarcodeCapture barcodes) { + if (mounted && barcodes.barcodes.isNotEmpty) { + final barcode = barcodes.barcodes.first; + final value = barcode.displayValue ?? barcode.rawValue ?? ''; + + setState(() { + _textController.text = value; + _barcodeData = value; + _selectedBarcode = _getBarcodeType(barcode.format); + _showScanner = false; + }); + + _scannerController?.dispose(); + _scannerController = null; + } + } + + @override + void dispose() { + _textController.dispose(); + + _scannerController?.dispose(); + + super.dispose(); + } + + void _startScanning() { + setState(() { + _showScanner = true; + _scannerController = scanner.MobileScannerController(); + }); + } + + void _stopScanning() { + setState(() { + _showScanner = false; + }); + _scannerController?.dispose(); + _scannerController = null; + } + + Barcode _getBarcodeType(scanner.BarcodeFormat? format) { + switch (format) { + case scanner.BarcodeFormat.qrCode: + return Barcode.qrCode(); + case scanner.BarcodeFormat.code128: + return Barcode.code128(); + case scanner.BarcodeFormat.code39: + return Barcode.code39(); + case scanner.BarcodeFormat.code93: + return Barcode.code93(); + case scanner.BarcodeFormat.ean13: + return Barcode.ean13(); + case scanner.BarcodeFormat.ean8: + return Barcode.ean8(); + case scanner.BarcodeFormat.upcA: + return Barcode.upcA(); + case scanner.BarcodeFormat.upcE: + return Barcode.upcE(); + case scanner.BarcodeFormat.dataMatrix: + return Barcode.dataMatrix(); + case scanner.BarcodeFormat.pdf417: + return Barcode.pdf417(); + case scanner.BarcodeFormat.aztec: + return Barcode.aztec(); + case scanner.BarcodeFormat.codabar: + return Barcode.codabar(); + case scanner.BarcodeFormat.itf: + return Barcode.itf(); + default: + return Barcode.code128(); + } + } + + Future _generateBarcodeImage() async { + RenderRepaintBoundary boundary = + _barcodeKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + ui.Image image = await boundary.toImage(pixelRatio: 3.0); + ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); + Uint8List pngBytes = byteData!.buffer.asUint8List(); + + img.Image? barcodeImage = img.decodeImage(pngBytes); + if (barcodeImage == null) return; + + img.Image resizedImage; + if (barcodeImage.width > widget.width) { + resizedImage = img.copyRotate(barcodeImage, angle: 270); + } else { + resizedImage = img.copyResize(barcodeImage, + width: widget.width, height: widget.height); + } + final resultBytes = Uint8List.fromList(img.encodePng(resizedImage)); + Navigator.of(context).pop(resultBytes); + } + + Widget _buildFormatSelector() { + final Map availableFormats = { + 'QR Code': Barcode.qrCode(), + 'Data Matrix': Barcode.dataMatrix(), + 'Aztec': Barcode.aztec(), + 'PDF417': Barcode.pdf417(), + 'Code 128': Barcode.code128(), + 'Code 93': Barcode.code93(), + 'Code 39': Barcode.code39(), + 'Codabar': Barcode.codabar(), + 'EAN-13': Barcode.ean13(), + 'EAN-8': Barcode.ean8(), + 'EAN-5': Barcode.ean5(), + 'EAN-2': Barcode.ean2(), + 'GS1 128': Barcode.gs128(), + 'ISBN': Barcode.isbn(), + 'ITF': Barcode.itf(), + 'ITF-16': Barcode.itf16(), + 'ITF-14': Barcode.itf14(), + 'RM4SCC': Barcode.rm4scc(), + 'Telepen': Barcode.telepen(), + 'UPC-A': Barcode.upcA(), + 'UPC-E': Barcode.upcE(), + }; + + return DropdownButtonFormField( + value: _selectedBarcode.name, + decoration: const InputDecoration( + labelText: 'Barcode Format', + border: OutlineInputBorder( + borderSide: BorderSide(color: Colors.red), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.red, width: 2), + ), + ), + items: availableFormats.entries + .map((entry) => DropdownMenuItem( + value: entry.value.name, + child: Text(entry.key), + )) + .toList(), + onChanged: (newBarcodeName) { + if (newBarcodeName != null) { + setState(() { + _selectedBarcode = availableFormats.values.firstWhere( + (barcode) => barcode.name == newBarcodeName, + orElse: () => Barcode.qrCode(), + ); + _hasError = false; + }); + } + }, + ); + } + + Widget _buildBarcodePreview() { + if (_barcodeData.isEmpty) { + return Container( + width: 240, + height: 120, + decoration: BoxDecoration( + color: Colors.grey[200], + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: Text( + 'Enter or scan barcode data', + style: TextStyle(color: Colors.grey), + ), + ), + ); + } + + return BarcodeWidget( + errorBuilder: (context, error) { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _hasError = true; + }); + }); + + final validationError = + _validateBarcodeData(_barcodeData, _selectedBarcode); + if (validationError != null) { + error = validationError; + } + + return Container( + width: double.infinity, + height: 250, + decoration: BoxDecoration( + color: Colors.red[50], + border: Border.all(color: Colors.red[400]!, width: 2), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + color: Colors.red[600], + size: 45, + ), + const SizedBox(height: 8), + Text( + 'Invalid Barcode', + style: TextStyle( + color: Colors.red[700], + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Expanded( + child: SingleChildScrollView( + child: Text( + error.toString(), + style: TextStyle( + color: Colors.red[600], + fontSize: 18, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ), + ); + }, + style: const TextStyle(color: colorBlack), + padding: const EdgeInsets.all(10), + backgroundColor: colorWhite, + barcode: _selectedBarcode, + data: _barcodeData, + ); + } + + Widget _buildScannerView() { + return Scaffold( + appBar: AppBar( + title: const Text('Scan Barcode'), + backgroundColor: colorAccent, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: _stopScanning, + ), + ), + backgroundColor: colorBlack, + body: Stack( + children: [ + scanner.MobileScanner( + controller: _scannerController, + onDetect: _handleBarcode, + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + alignment: Alignment.bottomCenter, + height: 100, + color: const Color.fromRGBO(0, 0, 0, 0.4), + child: const Center( + child: Text( + 'Point camera at barcode to scan', + style: TextStyle(color: colorWhite, fontSize: 16), + ), + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_showScanner) { + return _buildScannerView(); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Barcode Generator'), + titleTextStyle: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + backgroundColor: colorAccent, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 20), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _textController, + decoration: const InputDecoration( + labelText: 'Barcode Data', + hintText: 'Enter barcode data or scan', + prefixIcon: Icon(Icons.qr_code_2_rounded), + border: OutlineInputBorder( + borderSide: BorderSide(color: Colors.red), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.red, width: 2), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0, left: 4.0), + child: Text( + 'Characters: ${_barcodeData.length}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _startScanning, + icon: const Icon(Icons.qr_code_scanner), + label: const Text('Scan Barcode'), + style: ElevatedButton.styleFrom( + backgroundColor: colorAccent, + foregroundColor: colorWhite, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(height: 40), + if (_barcodeData.isNotEmpty) _buildFormatSelector(), + RepaintBoundary( + key: _barcodeKey, + child: Center(child: _buildBarcodePreview()), + ), + const SizedBox(height: 20), + if (_barcodeData.isNotEmpty && !_hasError) + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _generateBarcodeImage, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: colorWhite, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Generate Image'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/view/image_editor.dart b/lib/view/image_editor.dart index 4ffbb7e..7a430c9 100644 --- a/lib/view/image_editor.dart +++ b/lib/view/image_editor.dart @@ -12,6 +12,8 @@ import 'package:magicepaperapp/util/epd/driver/waveform.dart'; import 'package:magicepaperapp/util/image_editor_utils.dart'; import 'package:magicepaperapp/util/xbm_encoder.dart'; import 'package:magicepaperapp/view/widget/image_list.dart'; +import 'package:magicepaperapp/view/barcode_scanner_screen.dart'; + import 'package:pro_image_editor/pro_image_editor.dart'; import 'package:provider/provider.dart'; import 'package:image/image.dart' as img; @@ -511,6 +513,30 @@ class BottomActionMenu extends StatelessWidget { await imageSaveHandler?.navigateToImageLibrary(); }, ), + _buildActionButton( + context: context, + icon: Icons.qr_code_scanner, + label: 'Barcode', + onTap: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => BarcodeScannerScreen( + width: epd.width, + height: epd.height, + ), + ), + ); + + if (result is Uint8List) { + await imgLoader.updateImage( + bytes: result, + width: epd.width, + height: epd.height, + ); + await imgLoader.saveFinalizedImageBytes(result); + } + }, + ), _buildActionButton( context: context, icon: Icons.dashboard_customize_outlined, @@ -532,6 +558,7 @@ class BottomActionMenu extends StatelessWidget { height: epd.height, ); await imgLoader.saveFinalizedImageBytes(result); + onSourceChanged?.call('template'); } }, diff --git a/pubspec.yaml b/pubspec.yaml index a9a864b..c711538 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,12 +39,13 @@ dependencies: file_picker: ^10.1.2 flutter_colorpicker: ^1.1.0 mime: ^2.0.0 + mobile_scanner: ^7.0.1 + barcode_widget: ^2.0.4 intl: ^0.20.2 path_provider: ^2.0.15 get_it: ^8.0.3 path: ^1.9.1 permission_handler: ^11.0.1 - barcode_widget: ^2.0.4 ndef: ^0.3.1 dev_dependencies: