458 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Dart
		
	
	
			
		
		
	
	
			458 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Dart
		
	
	
| // lib/services/nfc_service.dart
 | ||
| //
 | ||
| // NFC 封装服务(包含读取和写入方法,均提供回调与 Future 两种使用方式)
 | ||
| // 请将此文件放到你的项目中(例如 lib/services/nfc_service.dart)并直接使用。
 | ||
| 
 | ||
| import 'dart:async';
 | ||
| import 'dart:convert';
 | ||
| import 'dart:io';
 | ||
| import 'dart:typed_data';
 | ||
| import 'package:flutter/foundation.dart';
 | ||
| import 'package:nfc_manager/nfc_manager.dart';
 | ||
| import 'package:nfc_manager/ndef_record.dart';
 | ||
| import 'package:nfc_manager/nfc_manager_android.dart';
 | ||
| import 'package:nfc_manager/nfc_manager_ios.dart';
 | ||
| import 'package:nfc_manager_ndef/nfc_manager_ndef.dart';
 | ||
| 
 | ||
| /// NfcService 单例:提供 NFC 的读取与写入基础能力
 | ||
| ///
 | ||
| /// - startScanOnceWithCallback(...):回调式的一次性读取(发现标签即回调)
 | ||
| /// - readOnceText(...):Future 式读取,返回解析出的文本(若失败会抛异常)
 | ||
| /// - writeText(...):写入文本记录到标签(回调/返回写入结果)
 | ||
| /// - stopSession():停止当前会话
 | ||
| ///
 | ||
| /// 注:iOS 必须在真机测试,并在 Xcode 中打开 NFC 权限;Android 需设备支持 NFC。
 | ||
| class NfcService {
 | ||
|   NfcService._internal();
 | ||
| 
 | ||
|   static final NfcService instance = NfcService._internal();
 | ||
| 
 | ||
|   /// 是否正在进行 NFC 会话(扫描或写入)
 | ||
|   final ValueNotifier<bool> scanning = ValueNotifier(false);
 | ||
| 
 | ||
|   /// 日志广播流(方便 UI 订阅)
 | ||
|   final StreamController<String> _logController = StreamController.broadcast();
 | ||
| 
 | ||
|   Stream<String> get logs => _logController.stream;
 | ||
| 
 | ||
|   /// 检查设备是否支持 NFC
 | ||
|   Future<bool> isAvailable() => NfcManager.instance.isAvailable();
 | ||
| 
 | ||
|   /// 停止会话(若正在运行)
 | ||
|   Future<void> stopSession() async {
 | ||
|     if (scanning.value) {
 | ||
|       try {
 | ||
|         await NfcManager.instance.stopSession();
 | ||
|       } catch (_) {}
 | ||
|       scanning.value = false;
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   // ---------------- 辅助方法 ----------------
 | ||
| 
 | ||
|   /// bytes -> "AA:BB:CC" 形式的大写十六进制字符串
 | ||
|   String _bytesToHex(Uint8List bytes) {
 | ||
|     return bytes
 | ||
|         .map((b) => b.toRadixString(16).padLeft(2, '0'))
 | ||
|         .join(':')
 | ||
|         .toUpperCase();
 | ||
|   }
 | ||
| 
 | ||
|   /// 递归在 Map/List 中查找第一个可用的字节数组 (List<int> / Uint8List)
 | ||
|   List<int>? _findFirstByteArray(dynamic value) {
 | ||
|     if (value == null) return null;
 | ||
|     if (value is Uint8List) return value.toList();
 | ||
|     if (value is List<int>) return value;
 | ||
|     if (value is List) {
 | ||
|       for (var item in value) {
 | ||
|         final found = _findFirstByteArray(item);
 | ||
|         if (found != null) return found;
 | ||
|       }
 | ||
|       return null;
 | ||
|     }
 | ||
|     if (value is Map) {
 | ||
|       for (final entry in value.entries) {
 | ||
|         final found = _findFirstByteArray(entry.value);
 | ||
|         if (found != null) return found;
 | ||
|       }
 | ||
|     }
 | ||
|     return null;
 | ||
|   }
 | ||
| 
 | ||
|   /// 从 tag(NfcTag / Map)中提取 UID,兼容多种实现
 | ||
|   String extractUidFromTag(dynamic tag) {
 | ||
|     try {
 | ||
|       dynamic raw = tag;
 | ||
|       // 有些实现把数据放 data 字段
 | ||
|       if (tag is Map && tag.containsKey('data')) raw = tag['data'];
 | ||
| 
 | ||
|       final bytes = _findFirstByteArray(raw);
 | ||
|       if (bytes != null && bytes.isNotEmpty) {
 | ||
|         return _bytesToHex(Uint8List.fromList(bytes));
 | ||
|       }
 | ||
| 
 | ||
|       // 再尝试一些常见的键名
 | ||
|       if (tag is Map) {
 | ||
|         final possible = ['id', 'identifier', 'Id', 'ID'];
 | ||
|         for (final k in possible) {
 | ||
|           if (tag[k] != null) {
 | ||
|             final b = _findFirstByteArray(tag[k]);
 | ||
|             if (b != null) return _bytesToHex(Uint8List.fromList(b));
 | ||
|           }
 | ||
|         }
 | ||
|       }
 | ||
| 
 | ||
|       // 最后一招:平台特定 API (如果存在)
 | ||
|       if (Platform.isAndroid) {
 | ||
|         try {
 | ||
|           final nfcA = NfcAAndroid.from(tag);
 | ||
|           if (nfcA != null && nfcA.tag.id != null)
 | ||
|             return _bytesToHex(Uint8List.fromList(nfcA.tag.id));
 | ||
|         } catch (_) {}
 | ||
|       } else if (Platform.isIOS) {
 | ||
|         try {
 | ||
|           final mifare = MiFareIos.from(tag);
 | ||
|           if (mifare != null && mifare.identifier != null)
 | ||
|             return _bytesToHex(Uint8List.fromList(mifare.identifier));
 | ||
|         } catch (_) {}
 | ||
|       }
 | ||
|     } catch (e) {
 | ||
|       debugPrint('extractUidFromTag error: $e');
 | ||
|     }
 | ||
|     return 'UNKNOWN';
 | ||
|   }
 | ||
| 
 | ||
|   /// 从 NDEF record 的 payload 解析文本(Text Record)并返回字符串
 | ||
|   String parseTextFromPayload(Uint8List payload) {
 | ||
|     if (payload.isEmpty) return '';
 | ||
|     final status = payload[0];
 | ||
|     final langLen = status & 0x3F;
 | ||
|     final textBytes = payload.sublist(1 + langLen);
 | ||
|     try {
 | ||
|       return utf8.decode(textBytes);
 | ||
|     } catch (e) {
 | ||
|       return String.fromCharCodes(textBytes);
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   String _formatMessageToString(dynamic msg) {
 | ||
|     if (msg == null) return '<empty>';
 | ||
|     try {
 | ||
|       final records =
 | ||
|           (msg is Map && msg['records'] != null)
 | ||
|               ? List<dynamic>.from(msg['records'])
 | ||
|               : (msg is dynamic ? (msg.records as List<dynamic>?) : null);
 | ||
|       if (records == null || records.isEmpty) return '<empty>';
 | ||
|       final sb = StringBuffer();
 | ||
|       for (var i = 0; i < records.length; i++) {
 | ||
|         final r = records[i];
 | ||
|         sb.writeln('Record $i: ${r.toString()}');
 | ||
|         try {
 | ||
|           // 解析 payload
 | ||
|           Uint8List? p;
 | ||
|           if (r is Map && r['payload'] != null) {
 | ||
|             final ptmp = r['payload'];
 | ||
|             if (ptmp is Uint8List)
 | ||
|               p = ptmp;
 | ||
|             else if (ptmp is List<int>)
 | ||
|               p = Uint8List.fromList(ptmp);
 | ||
|           } else {
 | ||
|             final pr = (r as dynamic).payload;
 | ||
|             if (pr is Uint8List)
 | ||
|               p = pr;
 | ||
|             else if (pr is List<int>)
 | ||
|               p = Uint8List.fromList(pr);
 | ||
|           }
 | ||
|           if (p != null) {
 | ||
|             final txt = parseTextFromPayload(p);
 | ||
|             sb.writeln('  text: $txt');
 | ||
|           }
 | ||
|         } catch (_) {}
 | ||
|       }
 | ||
|       return sb.toString();
 | ||
|     } catch (e) {
 | ||
|       return msg.toString();
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   // ---------------- 读取 API ----------------
 | ||
| 
 | ||
|   /// 回调式的一次性扫描(发现标签后立即回调)
 | ||
|   ///
 | ||
|   /// onResult(uid, parsedText, rawMessage) - 当发现标签时回调
 | ||
|   /// onError(error) - 出错时回调
 | ||
|   /// timeout - 超时时间(可选)
 | ||
|   Future<void> startScanOnceWithCallback({
 | ||
|     required void Function(String uid, String parsedText, dynamic rawMessage)
 | ||
|     onResult,
 | ||
|     void Function(Object error)? onError,
 | ||
|     Duration? timeout,
 | ||
|   }) async {
 | ||
|     final available = await isAvailable();
 | ||
|     if (!available) {
 | ||
|       onError?.call('NFC not available');
 | ||
|       return;
 | ||
|     }
 | ||
|     if (scanning.value) {
 | ||
|       onError?.call('Another session running');
 | ||
|       return;
 | ||
|     }
 | ||
| 
 | ||
|     scanning.value = true;
 | ||
|     _logController.add('NFC scanning started');
 | ||
| 
 | ||
|     Timer? timer;
 | ||
|     if (timeout != null) {
 | ||
|       timer = Timer(timeout, () async {
 | ||
|         await stopSession();
 | ||
|         _logController.add('NFC scanning timeout');
 | ||
|         onError?.call('timeout');
 | ||
|       });
 | ||
|     }
 | ||
| 
 | ||
|     try {
 | ||
|       await NfcManager.instance.startSession(
 | ||
|         onDiscovered: (dynamic tag) async {
 | ||
|           try {
 | ||
|             final uid = extractUidFromTag(tag);
 | ||
|             dynamic rawMsg;
 | ||
|             String parsedText = '';
 | ||
| 
 | ||
|             // 尝试用 Ndef.from 获取消息
 | ||
|             try {
 | ||
|               final ndef = Ndef.from(tag);
 | ||
|               if (ndef != null) {
 | ||
|                 rawMsg = ndef.cachedMessage;
 | ||
|                 if (rawMsg != null) {
 | ||
|                   // 解析第一条文本记录(若存在)
 | ||
|                   if ((rawMsg.records as List).isNotEmpty) {
 | ||
|                     final first = rawMsg.records.first;
 | ||
|                     final payload = first.payload;
 | ||
|                     if (payload is Uint8List)
 | ||
|                       parsedText = parseTextFromPayload(payload);
 | ||
|                     else if (payload is List<int>)
 | ||
|                       parsedText = parseTextFromPayload(
 | ||
|                         Uint8List.fromList(payload),
 | ||
|                       );
 | ||
|                   }
 | ||
|                 }
 | ||
|               } else {
 | ||
|                 rawMsg = null;
 | ||
|               }
 | ||
|             } catch (_) {
 | ||
|               // 回退:尝试从 tag map 中嗅探 cachedMessage / records
 | ||
|               try {
 | ||
|                 if (tag is Map) {
 | ||
|                   rawMsg =
 | ||
|                       tag['cachedMessage'] ??
 | ||
|                       tag['ndef']?['cachedMessage'] ??
 | ||
|                       tag['message'];
 | ||
|                   if (rawMsg != null) {
 | ||
|                     final recs =
 | ||
|                         (rawMsg['records'] ?? (rawMsg as dynamic).records)
 | ||
|                             as dynamic;
 | ||
|                     if (recs != null && recs is List && recs.isNotEmpty) {
 | ||
|                       final r = recs.first;
 | ||
|                       final p =
 | ||
|                           (r is Map) ? r['payload'] : (r as dynamic).payload;
 | ||
|                       if (p is Uint8List)
 | ||
|                         parsedText = parseTextFromPayload(p);
 | ||
|                       else if (p is List<int>)
 | ||
|                         parsedText = parseTextFromPayload(
 | ||
|                           Uint8List.fromList(p),
 | ||
|                         );
 | ||
|                     }
 | ||
|                   }
 | ||
|                 }
 | ||
|               } catch (_) {}
 | ||
|             }
 | ||
| 
 | ||
|             // 回调结果
 | ||
|             onResult(uid, parsedText, rawMsg);
 | ||
|             _logController.add(
 | ||
|               'UID: $uid\nmessage: ${_formatMessageToString(rawMsg)}',
 | ||
|             );
 | ||
| 
 | ||
|             await stopSession();
 | ||
|           } catch (e) {
 | ||
|             onError?.call(e);
 | ||
|             await stopSession();
 | ||
|           } finally {
 | ||
|             timer?.cancel();
 | ||
|             scanning.value = false;
 | ||
|           }
 | ||
|         },
 | ||
|         // 尽量多协议尝试以提升兼容性
 | ||
|         pollingOptions: {NfcPollingOption.iso14443},
 | ||
|       );
 | ||
|     } catch (e) {
 | ||
|       scanning.value = false;
 | ||
|       timer?.cancel();
 | ||
|       onError?.call(e);
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   /// Future 式读取:解析第一条文本记录并返回字符串(格式:'UID\n文本')
 | ||
|   Future<String> readOnceText({
 | ||
|     Duration timeout = const Duration(seconds: 10),
 | ||
|   }) async {
 | ||
|     final completer = Completer<String>();
 | ||
|     await startScanOnceWithCallback(
 | ||
|       onResult: (uid, parsedText, rawMsg) {
 | ||
|         if (parsedText.isEmpty) {
 | ||
|           completer.completeError('No text record found (UID: $uid)');
 | ||
|         } else {
 | ||
|           completer.complete('UID: $uid\n$parsedText');
 | ||
|         }
 | ||
|       },
 | ||
|       onError: (err) {
 | ||
|         if (!completer.isCompleted) completer.completeError(err);
 | ||
|       },
 | ||
|       timeout: timeout,
 | ||
|     );
 | ||
|     return completer.future;
 | ||
|   }
 | ||
| 
 | ||
|   // ---------------- 写入 API ----------------
 | ||
| 
 | ||
|   /// 构造 NDEF Text payload(status byte + lang + utf8 bytes)
 | ||
|   Uint8List _buildTextPayload(String text, {String lang = 'en'}) {
 | ||
|     final textBytes = utf8.encode(text);
 | ||
|     final langBytes = utf8.encode(lang);
 | ||
|     final status = langBytes.length & 0x3F;
 | ||
|     final payload = <int>[status, ...langBytes, ...textBytes];
 | ||
|     return Uint8List.fromList(payload);
 | ||
|   }
 | ||
| 
 | ||
|   /// 写入文本到标签(单条 Text record)
 | ||
|   ///
 | ||
|   /// - text: 要写入的文本
 | ||
|   /// - timeout: 超时时间(可选)
 | ||
|   /// - onComplete: 回调 (ok, err)
 | ||
|   ///
 | ||
|   /// 返回 true 表示写入成功(同时 onComplete 也会被触发)
 | ||
|   Future<bool> writeText(
 | ||
|     String text, {
 | ||
|     Duration? timeout,
 | ||
|     void Function(bool ok, Object? err)? onComplete,
 | ||
|   }) async {
 | ||
|     debugPrint('writeText called - scanning=${scanning.value}');
 | ||
|     final available = await isAvailable();
 | ||
|     debugPrint('writeText: isAvailable=$available');
 | ||
|     if (!available) {
 | ||
|       onComplete?.call(false, 'NFC not available');
 | ||
|       return false;
 | ||
|     }
 | ||
|     if (scanning.value) {
 | ||
|       onComplete?.call(false, 'Another session running');
 | ||
|       return false;
 | ||
|     }
 | ||
| 
 | ||
|     try {
 | ||
|       await NfcManager.instance.stopSession();
 | ||
|     } catch (e) {
 | ||
|       debugPrint('stopSession ignore: $e');
 | ||
|     }
 | ||
| 
 | ||
|     scanning.value = true;
 | ||
|     Timer? timer;
 | ||
|     if (timeout != null) {
 | ||
|       timer = Timer(timeout, () async {
 | ||
|         await stopSession();
 | ||
|         scanning.value = false;
 | ||
|         onComplete?.call(false, 'timeout');
 | ||
|       });
 | ||
|     }
 | ||
| 
 | ||
|     bool success = false;
 | ||
|     try {
 | ||
|       // 修改了 pollingOptions 配置
 | ||
|       final polling =
 | ||
|           Platform.isIOS
 | ||
|               ? {NfcPollingOption.iso14443} // iOS 使用更具体的配置
 | ||
|               : {
 | ||
|                 NfcPollingOption.iso14443,
 | ||
|                 NfcPollingOption.iso15693,
 | ||
|                 NfcPollingOption.iso18092,
 | ||
|               };
 | ||
| 
 | ||
|       await NfcManager.instance.startSession(
 | ||
|         pollingOptions: polling,
 | ||
|         onDiscovered: (dynamic tag) async {
 | ||
|           timer?.cancel();
 | ||
|           try {
 | ||
|             final ndef = Ndef.from(tag);
 | ||
|             if (ndef == null) {
 | ||
|               onComplete?.call(false, 'Tag not NDEF');
 | ||
|               await stopSession();
 | ||
|               scanning.value = false;
 | ||
|               return;
 | ||
|             }
 | ||
| 
 | ||
|             // 检查是否可写
 | ||
|             if (Platform.isIOS) {
 | ||
|               final miFare = MiFareIos.from(tag);
 | ||
|               if (miFare == null) {
 | ||
|                 onComplete?.call(false, 'Unsupported tag type on iOS');
 | ||
|                 await stopSession();
 | ||
|                 scanning.value = false;
 | ||
|                 return;
 | ||
|               }
 | ||
|             }
 | ||
| 
 | ||
|             final payload = _buildTextPayload(text, lang: 'en');
 | ||
|             final record = NdefRecord(
 | ||
|               typeNameFormat: TypeNameFormat.wellKnown,
 | ||
|               type: Uint8List.fromList('T'.codeUnits),
 | ||
|               identifier: Uint8List(0),
 | ||
|               payload: payload,
 | ||
|             );
 | ||
|             final message = NdefMessage(records: [record]);
 | ||
| 
 | ||
|             await ndef.write(message: message);
 | ||
|             // 取出 UID (优先取 nfca.identifier,如果没有就取顶层 id)
 | ||
|             String? uid;
 | ||
|             if (Platform.isAndroid) {
 | ||
|               try {
 | ||
|                 final nfcA = NfcAAndroid.from(tag);
 | ||
|                 if (nfcA != null && nfcA.tag.id != null) {
 | ||
|                   uid = _bytesToHex(Uint8List.fromList(nfcA.tag.id));
 | ||
|                 }
 | ||
|               } catch (_) {}
 | ||
|             } else if (Platform.isIOS) {
 | ||
|               try {
 | ||
|                 final mifare = MiFareIos.from(tag);
 | ||
|                 if (mifare != null && mifare.identifier != null) {
 | ||
|                   uid = _bytesToHex(Uint8List.fromList(mifare.identifier));
 | ||
|                 }
 | ||
|               } catch (_) {}
 | ||
|             }
 | ||
|             success = true;
 | ||
|             onComplete?.call(true, uid); // ✅ 把 UID 返回出去
 | ||
|           } catch (e, st) {
 | ||
|             debugPrint('iOS NFC Write Error: $e');
 | ||
|             debugPrint('Stack trace: $st');
 | ||
|             onComplete?.call(false, e);
 | ||
|           } finally {
 | ||
|             await stopSession();
 | ||
|             scanning.value = false;
 | ||
|           }
 | ||
|         },
 | ||
|       );
 | ||
|     } catch (e, st) {
 | ||
|       debugPrint('startSession exception: $e\n$st');
 | ||
|       timer?.cancel();
 | ||
|       scanning.value = false;
 | ||
|       onComplete?.call(false, e);
 | ||
|     }
 | ||
|     return success;
 | ||
|   }
 | ||
| 
 | ||
|   /// 释放资源
 | ||
|   void dispose() {
 | ||
|     try {
 | ||
|       _logController.close();
 | ||
|     } catch (_) {}
 | ||
|   }
 | ||
| }
 |