From 2fe852be9957c959cb22771870d9a1544c5340fb Mon Sep 17 00:00:00 2001 From: Nathan Moeller Date: Wed, 29 Apr 2026 22:57:52 -0700 Subject: [PATCH] feat: Add nfc support to read invoice --- android/app/src/main/AndroidManifest.xml | 2 + lib/l10n/app_en.arb | 12 ++ lib/l10n/app_es.arb | 4 + lib/scan.dart | 161 ++++++++++++++++++++--- pubspec.lock | 26 ++-- pubspec.yaml | 1 + 6 files changed, 179 insertions(+), 27 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 298c7125..d169f2a3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + { bool _scanned = false; bool _isPasting = false; + bool _isReadingNfc = false; + bool _nfcAvailable = false; bool _permissionDenied = false; _QrLoopSession? _currentSession; @@ -61,6 +64,7 @@ class _ScanQRPageState extends State { void initState() { super.initState(); _requestCameraPermission(); + _checkNfcAvailability(); } Future _requestCameraPermission() async { @@ -87,9 +91,20 @@ class _ScanQRPageState extends State { _qrController?.resumeCamera(); } + Future _checkNfcAvailability() async { + if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) return; + final available = await NfcManager.instance.isAvailable(); + if (mounted) { + setState(() => _nfcAvailable = available); + } + } + @override void dispose() { _qrController?.dispose(); + if (_isReadingNfc) { + NfcManager.instance.stopSession(); + } super.dispose(); } @@ -613,6 +628,85 @@ class _ScanQRPageState extends State { setState(() => _isPasting = false); } + Future _readNfcTag() async { + setState(() => _isReadingNfc = true); + + try { + await NfcManager.instance.startSession( + onDiscovered: (NfcTag tag) async { + try { + final ndef = Ndef.from(tag); + if (ndef == null || ndef.cachedMessage == null) { + ToastService().show( + message: context.l10n.nfcNoTextRecord, + duration: const Duration(seconds: 5), + onTap: () {}, + icon: Icon(Icons.warning), + ); + await NfcManager.instance.stopSession(); + if (mounted) setState(() => _isReadingNfc = false); + return; + } + + String? payload; + for (final record in ndef.cachedMessage!.records) { + if (record.typeNameFormat == NdefTypeNameFormat.nfcWellknown && + String.fromCharCodes(record.type) == 'T') { + final languageCodeLength = record.payload.first & 0x3F; + payload = String.fromCharCodes( + record.payload.sublist(1 + languageCodeLength), + ); + break; + } + if (record.typeNameFormat == NdefTypeNameFormat.nfcWellknown && + String.fromCharCodes(record.type) == 'U') { + payload = String.fromCharCodes(record.payload.sublist(1)); + break; + } + } + + await NfcManager.instance.stopSession(); + + if (payload == null || payload.isEmpty) { + ToastService().show( + message: context.l10n.nfcNoTextRecord, + duration: const Duration(seconds: 5), + onTap: () {}, + icon: Icon(Icons.warning), + ); + if (mounted) setState(() => _isReadingNfc = false); + return; + } + + final parsed = await _handleText(payload); + if (!parsed) { + AppLogger.instance.warn("NFC payload cannot be parsed: $payload"); + ToastService().show( + message: context.l10n.cannotBeParsed, + duration: const Duration(seconds: 5), + onTap: () {}, + icon: Icon(Icons.error), + ); + } + } catch (e) { + AppLogger.instance.warn("Error reading NFC tag: $e"); + await NfcManager.instance.stopSession(errorMessage: e.toString()); + } + if (mounted) setState(() => _isReadingNfc = false); + }, + ); + } catch (e) { + AppLogger.instance.warn("Failed to start NFC session: $e"); + ToastService().show( + message: context.l10n.nfcNotAvailable, + duration: const Duration(seconds: 5), + onTap: () {}, + icon: Icon(Icons.error), + ); + if (mounted) setState(() => _isReadingNfc = false); + } + } + double? get _progress { final session = _currentSession; if (session == null || session.totalFrames <= 1) return null; @@ -760,24 +854,55 @@ class _ScanQRPageState extends State { alignment: Alignment.bottomCenter, child: Padding( padding: const EdgeInsets.all(24.0), - child: ElevatedButton.icon( - onPressed: _isPasting ? null : _pasteFromClipboard, - icon: - _isPasting - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.0, - ), - ) - : const Icon(Icons.paste), - label: Text( - _isPasting - ? context.l10n.pasting - : context.l10n.pasteFromClipboard, - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _isPasting ? null : _pasteFromClipboard, + icon: + _isPasting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.0, + ), + ) + : const Icon(Icons.paste), + label: Text( + _isPasting + ? context.l10n.pasting + : context.l10n.pasteFromClipboard, + ), + ), + ), + if (_nfcAvailable) ...[ + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: _isReadingNfc ? null : _readNfcTag, + icon: + _isReadingNfc + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.0, + ), + ) + : const Icon(Icons.nfc), + label: Text( + _isReadingNfc + ? context.l10n.readingNfc + : context.l10n.readNfc, + ), + ), + ), + ], + ], ), ), ), diff --git a/pubspec.lock b/pubspec.lock index d87a8e6a..54bdf616 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -497,18 +497,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -541,6 +541,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nfc_manager: + dependency: "direct main" + description: + name: nfc_manager + sha256: "071faa6e43317f9feeef316067456ae6e61a40f7748ed6e93470a812e103c326" + url: "https://pub.dev" + source: hosted + version: "3.5.1" numpad_layout: dependency: "direct main" description: @@ -926,10 +934,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timing: dependency: transitive description: @@ -1099,5 +1107,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0-0 <4.0.0" + dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index b37beb17..d2025cef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: flutter_foreground_task: ^8.13.0 permission_handler: ^11.3.1 app_links: ^6.3.3 + nfc_manager: ^3.5.0 dev_dependencies: flutter_test: