feat: phone-to-phone NFC via HCE with proprietary AID

This commit is contained in:
Forte11Cuba
2026-03-13 12:05:36 -06:00
parent c0f8b166ea
commit 560d6fc683
9 changed files with 568 additions and 94 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ android {
defaultConfig {
applicationId "me.elcaju"
minSdk 21 // Required for FlutterSecureStorage (EncryptedSharedPreferences)
minSdkVersion flutter.minSdkVersion // Required for FlutterSecureStorage (EncryptedSharedPreferences)
targetSdk flutter.targetSdkVersion
versionCode flutter.versionCode
versionName flutter.versionName
+13
View File
@@ -37,6 +37,19 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- HCE service: phone emulates NFC tag for phone-to-phone transfers -->
<service
android:name=".NfcHceService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/hce_nfc" />
</service>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
@@ -1,5 +1,55 @@
package me.elcaju
import android.content.ComponentName
import android.nfc.NfcAdapter
import android.nfc.cardemulation.CardEmulation
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private val CHANNEL = "me.elcaju/nfc_hce"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"setPayload" -> {
val payload = call.argument<ByteArray>("payload")
NfcHceService.ndefPayload = payload
// Force Android to route the NDEF AID to our service
// instead of manufacturer services (Xiaomi Mi Share, etc.)
try {
val adapter = NfcAdapter.getDefaultAdapter(this)
if (adapter != null) {
val cardEmulation = CardEmulation.getInstance(adapter)
cardEmulation.setPreferredService(
this,
ComponentName(this, NfcHceService::class.java)
)
}
} catch (_: Exception) {}
result.success(true)
}
"clearPayload" -> {
NfcHceService.ndefPayload = null
// Release preferred service routing
try {
val adapter = NfcAdapter.getDefaultAdapter(this)
if (adapter != null) {
val cardEmulation = CardEmulation.getInstance(adapter)
cardEmulation.unsetPreferredService(this)
}
} catch (_: Exception) {}
result.success(true)
}
else -> result.notImplemented()
}
}
}
}
@@ -0,0 +1,140 @@
package me.elcaju
import android.nfc.cardemulation.HostApduService
import android.os.Bundle
import android.util.Log
/**
* Host Card Emulation service that makes the phone act as an NFC Forum Type 4 tag.
* Another phone in reader mode can tap and read the NDEF message we serve.
*
* The payload is set from Flutter via MainActivity's MethodChannel.
*/
class NfcHceService : HostApduService() {
companion object {
private const val TAG = "NfcHceService"
// Shared payload - set by Flutter via MethodChannel
@Volatile
var ndefPayload: ByteArray? = null
private val OK = byteArrayOf(0x90.toByte(), 0x00.toByte())
private val NOT_FOUND = byteArrayOf(0x6A.toByte(), 0x82.toByte())
// Capability Container (CC) file - fixed structure
// MLe=0xFF (255), MLc=0xFF (255), max NDEF=0x70FF (28671)
// Compatible with Numo
private val CC_FILE = byteArrayOf(
0x00, 0x0F, // CC length (15)
0x20, // Mapping version 2.0
0x00, 0xFF.toByte(), // MLe: max read 255 bytes
0x00, 0xFF.toByte(), // MLc: max write 255 bytes
0x04, 0x06, // NDEF File Control TLV
0xE1.toByte(), 0x04, // NDEF file ID
0x70, 0xFF.toByte(), // Max NDEF size: 28671 bytes
0x00, // Read access: open
0xFF.toByte() // Write access: denied
)
}
private var selectedFile: String = "none"
private var ndefFileCache: ByteArray? = null
override fun processCommandApdu(commandApdu: ByteArray, extras: Bundle?): ByteArray {
if (commandApdu.size < 4) return NOT_FOUND
val ins = commandApdu[1]
// SELECT command
if (ins == 0xA4.toByte()) {
return handleSelect(commandApdu)
}
// READ BINARY command
if (ins == 0xB0.toByte()) {
return handleRead(commandApdu)
}
return NOT_FOUND
}
private fun handleSelect(apdu: ByteArray): ByteArray {
// Select ElCaju proprietary AID (F04543414A5500)
// Avoids conflicts with Xiaomi Mi Share / Samsung Beam
if (apdu.size >= 12 && apdu[5] == 0xF0.toByte() && apdu[6] == 0x45.toByte()) {
selectedFile = "app"
Log.d(TAG, "Selected ElCaju Application (proprietary AID)")
return OK
}
// Select NDEF Application (AID: D2760000850101)
if (apdu.size >= 12 && apdu[5] == 0xD2.toByte() && apdu[6] == 0x76.toByte()) {
selectedFile = "app"
Log.d(TAG, "Selected NDEF Application")
return OK
}
// Select by file ID
if (apdu.size >= 7) {
val fileId = ((apdu[5].toInt() and 0xFF) shl 8) or (apdu[6].toInt() and 0xFF)
when (fileId) {
0xE103 -> {
selectedFile = "cc"
Log.d(TAG, "Selected CC file")
return OK
}
0xE104 -> {
selectedFile = "ndef"
// Cache the NDEF file when selected
val payload = ndefPayload
if (payload != null) {
val file = ByteArray(2 + payload.size)
file[0] = (payload.size shr 8).toByte()
file[1] = (payload.size and 0xFF).toByte()
System.arraycopy(payload, 0, file, 2, payload.size)
ndefFileCache = file
Log.d(TAG, "Selected NDEF file (${payload.size} bytes payload)")
} else {
ndefFileCache = null
Log.w(TAG, "Selected NDEF file but no payload set")
}
return OK
}
}
}
return NOT_FOUND
}
private fun handleRead(apdu: ByteArray): ByteArray {
if (apdu.size < 5) return NOT_FOUND
val offset = ((apdu[2].toInt() and 0xFF) shl 8) or (apdu[3].toInt() and 0xFF)
// Le=0x00 means 256 bytes in short APDU encoding
var length = apdu[4].toInt() and 0xFF
if (length == 0) length = 256
val data = when (selectedFile) {
"cc" -> CC_FILE
"ndef" -> ndefFileCache ?: return NOT_FOUND
else -> return NOT_FOUND
}
if (offset >= data.size) {
Log.w(TAG, "Read offset $offset beyond data size ${data.size}")
return NOT_FOUND
}
val end = minOf(offset + length, data.size)
val response = data.copyOfRange(offset, end)
Log.d(TAG, "Read $selectedFile: offset=$offset len=$length returned=${response.size} bytes")
return response + OK
}
override fun onDeactivated(reason: Int) {
Log.d(TAG, "Deactivated (reason=$reason)")
selectedFile = "none"
ndefFileCache = null
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="hce_description">ElCaju NFC</string>
</resources>
+12
View File
@@ -0,0 +1,12 @@
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/hce_description"
android:requireDeviceUnlock="false">
<aid-group
android:category="other"
android:description="@string/hce_description">
<!-- ElCaju proprietary AID (avoids Xiaomi/Samsung NFC service conflicts) -->
<aid-filter android:name="F04543414A5500" />
<!-- NDEF Tag Application AID (NFC Forum Type 4) -->
<aid-filter android:name="D2760000850101" />
</aid-group>
</host-apdu-service>
+274 -18
View File
@@ -1,4 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/services.dart';
import 'package:nfc_manager/nfc_manager.dart';
import 'package:nfc_manager/nfc_manager_android.dart';
import 'package:ndef_record/ndef_record.dart';
@@ -20,6 +21,64 @@ enum NfcState {
/// Writes tokens as NDEF Text Records for maximum compatibility
/// with cashu.me and Numo. Reads both Text and URI records.
class NfcService {
static const _hceChannel = MethodChannel('me.elcaju/nfc_hce');
// ─── HCE (phone-to-phone) ───
/// Start emulating an NFC tag with the given text payload.
/// The phone will appear as an NFC tag to other devices.
static Future<void> startEmulating(String text) async {
final ndefMessage = _buildNdefTextMessage(text);
await _hceChannel.invokeMethod('setPayload', {'payload': ndefMessage});
}
/// Stop emulating an NFC tag.
static Future<void> stopEmulating() async {
await _hceChannel.invokeMethod('clearPayload');
}
/// Build an NDEF message with a Text Record.
/// Uses Short Record (SR) for payloads ≤ 255 bytes,
/// Long Record for > 255 bytes (compatible with Numo).
static Uint8List _buildNdefTextMessage(String text) {
final textBytes = Uint8List.fromList(text.codeUnits);
final languageCode = Uint8List.fromList('en'.codeUnits);
// NDEF Text Record payload: [status byte][lang code][text]
final recordPayload = Uint8List(1 + languageCode.length + textBytes.length);
recordPayload[0] = languageCode.length;
recordPayload.setRange(1, 1 + languageCode.length, languageCode);
recordPayload.setRange(1 + languageCode.length, recordPayload.length, textBytes);
final payloadLength = recordPayload.length;
final isShortRecord = payloadLength <= 255;
if (isShortRecord) {
// Short Record: flags(1) + typeLen(1) + payloadLen(1) + type(1) + payload
final record = Uint8List(4 + payloadLength);
record[0] = 0xD1; // MB|ME|SR|TNF=well-known
record[1] = 1; // type length
record[2] = payloadLength;
record[3] = 0x54; // 'T'
record.setRange(4, 4 + payloadLength, recordPayload);
return record;
} else {
// Long Record: flags(1) + typeLen(1) + payloadLen(4) + type(1) + payload
final record = Uint8List(7 + payloadLength);
record[0] = 0xC1; // MB|ME|TNF=well-known (no SR flag)
record[1] = 1; // type length
record[2] = (payloadLength >> 24) & 0xFF;
record[3] = (payloadLength >> 16) & 0xFF;
record[4] = (payloadLength >> 8) & 0xFF;
record[5] = payloadLength & 0xFF;
record[6] = 0x54; // 'T'
record.setRange(7, 7 + payloadLength, recordPayload);
return record;
}
}
// ─── Tag read/write ───
/// Check NFC state on the device.
static Future<NfcState> checkState() async {
try {
@@ -92,6 +151,8 @@ class NfcService {
}
/// Start reading NFC tags for Cashu tokens.
/// Tries IsoDep APDU first (phone-to-phone HCE, bypasses manufacturer
/// NFC services), falls back to NDEF (physical tags).
static Future<void> startRead({
required void Function(String token) onTokenRead,
required void Function(String error) onError,
@@ -99,29 +160,44 @@ class NfcService {
NfcManager.instance.startSession(
pollingOptions: {NfcPollingOption.iso14443},
onDiscovered: (NfcTag tag) async {
final ndef = NdefAndroid.from(tag);
if (ndef == null) {
onError('Tag does not contain NDEF data');
NfcManager.instance.stopSession();
return;
}
try {
final message = ndef.cachedNdefMessage ?? await ndef.getNdefMessage();
if (message == null) {
onError('No NDEF message on tag');
NfcManager.instance.stopSession();
return;
final diagnostics = <String>[];
// 1. Try IsoDep first (HCE phone-to-phone)
final isoDep = IsoDepAndroid.from(tag);
if (isoDep != null) {
final (token, isoInfo) = await _readViaIsoDep(isoDep);
if (token != null) {
onTokenRead(token);
NfcManager.instance.stopSession();
return;
}
diagnostics.add('IsoDep: $isoInfo');
} else {
diagnostics.add('IsoDep: not available');
}
final token = _extractToken(message);
if (token != null) {
onTokenRead(token);
NfcManager.instance.stopSession();
// 2. Fallback: NDEF (physical tags)
final ndef = NdefAndroid.from(tag);
if (ndef != null) {
final message = ndef.cachedNdefMessage ?? await ndef.getNdefMessage();
if (message != null) {
final token = _extractToken(message);
if (token != null) {
onTokenRead(token);
NfcManager.instance.stopSession();
return;
}
diagnostics.add('NDEF: ${message.records.length} records, no Cashu token');
} else {
diagnostics.add('NDEF: no message');
}
} else {
onError('No Cashu token found on tag');
NfcManager.instance.stopSession();
diagnostics.add('NDEF: not available');
}
onError('No Cashu token found [${diagnostics.join('; ')}]');
NfcManager.instance.stopSession();
} catch (e) {
onError(e.toString());
NfcManager.instance.stopSession();
@@ -130,6 +206,186 @@ class NfcService {
);
}
/// Read NDEF from HCE via IsoDep APDU commands (like Numo).
/// Bypasses manufacturer NFC services (Xiaomi, Samsung, etc).
/// Returns (token, diagnosticInfo) tuple.
static Future<(String?, String)> _readViaIsoDep(IsoDepAndroid isoDep) async {
String _hex(Uint8List bytes) =>
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
try {
// Set longer timeout (5s) to avoid TagLostException
await isoDep.setTimeout(5000);
// Try ElCaju proprietary AID first (avoids Xiaomi/Samsung conflicts)
final selectElCaju = await isoDep.transceive(Uint8List.fromList([
0x00, 0xA4, 0x04, 0x00, 0x07,
0xF0, 0x45, 0x43, 0x41, 0x4A, 0x55, 0x00,
0x00,
]));
final bool elCajuSelected = _isOk(selectElCaju);
// Fallback: standard NDEF Application AID
if (!elCajuSelected) {
final selectApp = await isoDep.transceive(Uint8List.fromList([
0x00, 0xA4, 0x04, 0x00, 0x07,
0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01,
0x00,
]));
if (!_isOk(selectApp)) {
return (null, 'SELECT APP failed (both AIDs): elcaju=${_hex(selectElCaju)}, ndef=${_hex(selectApp)}');
}
}
// SELECT NDEF file (E104)
final selectNdef = await isoDep.transceive(Uint8List.fromList([
0x00, 0xA4, 0x00, 0x0C, 0x02,
0xE1, 0x04,
]));
if (!_isOk(selectNdef)) {
return (null, 'SELECT NDEF failed: ${_hex(selectNdef)}');
}
// READ NLEN (first 2 bytes)
final nlenResponse = await isoDep.transceive(Uint8List.fromList([
0x00, 0xB0, 0x00, 0x00, 0x02,
]));
if (nlenResponse.length < 4 || !_isOk(nlenResponse)) {
return (null, 'READ NLEN failed: ${_hex(nlenResponse)}');
}
final ndefLen = (nlenResponse[0] << 8) | nlenResponse[1];
if (ndefLen == 0) {
return (null, 'NLEN=0 (no payload set on emitter)');
}
// READ NDEF message in chunks (max 255 bytes per read)
final ndefBytes = BytesBuilder();
var offset = 2; // skip NLEN
var remaining = ndefLen;
while (remaining > 0) {
final chunkSize = remaining > 255 ? 255 : remaining;
final readCmd = Uint8List.fromList([
0x00, 0xB0,
(offset >> 8) & 0xFF,
offset & 0xFF,
chunkSize,
]);
final chunk = await isoDep.transceive(readCmd);
if (chunk.length < 2 || !_isOk(chunk)) {
return (null, 'READ chunk at offset=$offset failed: ${_hex(chunk)}');
}
// Response = data + SW1 SW2 (last 2 bytes are status)
ndefBytes.add(chunk.sublist(0, chunk.length - 2));
final bytesRead = chunk.length - 2;
offset += bytesRead;
remaining -= bytesRead;
}
// Parse NDEF message from raw bytes
final raw = ndefBytes.toBytes();
if (raw.isEmpty) {
return (null, 'read ${ndefLen}B but empty after parsing');
}
final token = _parseNdefAndExtractToken(Uint8List.fromList(raw));
if (token != null) {
return (token, 'OK');
}
return (null, 'NDEF parsed (${raw.length}B) but no Cashu token, hex: ${_hex(Uint8List.fromList(raw.length > 40 ? raw.sublist(0, 40) : raw))}...');
} catch (e) {
final msg = e.toString();
if (msg.contains('TagLostException')) {
return (null, 'connection lost (hold phones together longer)');
}
return (null, 'error: $msg');
}
}
/// Check if APDU response ends with 90 00 (OK).
static bool _isOk(Uint8List response) {
if (response.length < 2) return false;
return response[response.length - 2] == 0x90 &&
response[response.length - 1] == 0x00;
}
/// Parse raw NDEF bytes and extract Cashu token.
static String? _parseNdefAndExtractToken(Uint8List raw) {
if (raw.isEmpty) return null;
var pos = 0;
while (pos < raw.length) {
if (pos + 3 > raw.length) break;
final flags = raw[pos];
final tnf = flags & 0x07;
final isShortRecord = (flags & 0x10) != 0;
final hasIdLength = (flags & 0x08) != 0;
pos++;
final typeLength = raw[pos]; pos++;
int payloadLength;
if (isShortRecord) {
payloadLength = raw[pos]; pos++;
} else {
if (pos + 4 > raw.length) break;
payloadLength = (raw[pos] << 24) | (raw[pos+1] << 16) |
(raw[pos+2] << 8) | raw[pos+3];
pos += 4;
}
int idLength = 0;
if (hasIdLength) {
idLength = raw[pos]; pos++;
}
// Type
if (pos + typeLength > raw.length) break;
final type = raw.sublist(pos, pos + typeLength); pos += typeLength;
// ID (skip)
pos += idLength;
// Payload
if (pos + payloadLength > raw.length) break;
final payload = raw.sublist(pos, pos + payloadLength);
pos += payloadLength;
// Check: Text Record (TNF=0x01, type='T')
if (tnf == 0x01 && typeLength == 1 && type[0] == 0x54) {
if (payload.isNotEmpty) {
final langLen = payload[0] & 0x3F;
if (payload.length > 1 + langLen) {
final text = String.fromCharCodes(payload.sublist(1 + langLen));
if (_isCashuToken(text)) return text;
}
}
}
// Check: URI Record (TNF=0x01, type='U')
if (tnf == 0x01 && typeLength == 1 && type[0] == 0x55) {
if (payload.isNotEmpty) {
const prefixes = ['', 'http://www.', 'https://www.', 'http://', 'https://'];
final prefixIdx = payload[0];
final prefix = prefixIdx < prefixes.length ? prefixes[prefixIdx] : '';
final rest = String.fromCharCodes(payload.sublist(1));
final uri = '$prefix$rest';
final token = _extractTokenFromUri(uri);
if (token != null) return token;
}
}
// If ME (Message End) flag set, stop
if ((flags & 0x40) != 0) break;
}
return null;
}
/// Stop any active NFC read session.
static void stopRead() {
NfcManager.instance.stopSession();
+43 -27
View File
@@ -353,6 +353,14 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
}
void _toggleNfcRead() async {
// Cancel active session first (before state checks, in case NFC was
// disabled while a session was running)
if (_nfcReading) {
NfcService.stopRead();
setState(() => _nfcReading = false);
return;
}
// Re-check state in case user toggled NFC in settings
final currentState = await NfcService.checkState();
if (mounted) setState(() => _nfcState = currentState);
@@ -381,12 +389,6 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
return;
}
if (_nfcReading) {
NfcService.stopRead();
setState(() => _nfcReading = false);
return;
}
setState(() => _nfcReading = true);
ScaffoldMessenger.of(context).showSnackBar(
@@ -398,27 +400,41 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
),
);
NfcService.startRead(
onTokenRead: (token) {
if (!mounted) return;
setState(() => _nfcReading = false);
_tokenController.text = token;
_onTokenChanged(token);
},
onError: (error) {
if (!mounted) return;
setState(() => _nfcReading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcReadError(error)),
backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
);
},
);
try {
await NfcService.startRead(
onTokenRead: (token) {
if (!mounted) return;
setState(() => _nfcReading = false);
_tokenController.text = token;
_onTokenChanged(token);
},
onError: (error) {
if (!mounted) return;
setState(() => _nfcReading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcReadError(error)),
backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
);
},
);
} catch (e) {
if (!mounted) return;
setState(() => _nfcReading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcReadError(e.toString())),
backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
);
}
}
Widget _buildScanButton() {
+30 -47
View File
@@ -60,8 +60,7 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
@override
void dispose() {
_animationTimer?.cancel();
if (_nfcWriting) NfcService.stopWrite();
if (_nfcWriting) NfcService.stopEmulating();
super.dispose();
}
@@ -612,6 +611,13 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
}
void _writeNfc(BuildContext context) async {
// Toggle off: stop emulating
if (_nfcWriting) {
await NfcService.stopEmulating();
setState(() => _nfcWriting = false);
return;
}
// Re-check state in case user toggled NFC in settings
final currentState = await NfcService.checkState();
if (mounted) setState(() => _nfcState = currentState);
@@ -640,53 +646,30 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
return;
}
if (_nfcWriting) {
// Cancel active session
NfcService.stopWrite();
try {
await NfcService.startEmulating(widget.token);
setState(() => _nfcWriting = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcHoldNear),
backgroundColor: AppColors.primaryAction,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
} catch (e) {
if (!mounted) return;
setState(() => _nfcWriting = false);
return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcWriteError(e.toString())),
backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
);
}
setState(() => _nfcWriting = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcHoldNear),
backgroundColor: AppColors.primaryAction,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
NfcService.startWrite(
token: widget.token,
onSuccess: () {
if (!mounted) return;
setState(() => _nfcWriting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcWriteSuccess),
backgroundColor: AppColors.success,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
);
},
onError: (error) {
if (!mounted) return;
setState(() => _nfcWriting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.nfcWriteError(error)),
backgroundColor: AppColors.error,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
);
},
);
}
void _goToHome(BuildContext context) {