feat(contacts): добавление контакта через NFC
This commit is contained in:
@@ -15,6 +15,8 @@
|
|||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
|
||||||
|
<uses-permission android:name="android.permission.NFC"/>
|
||||||
|
<uses-feature android:name="android.hardware.nfc.hce" android:required="false"/>
|
||||||
<application
|
<application
|
||||||
android:label="Komet"
|
android:label="Komet"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -67,6 +69,17 @@
|
|||||||
android:name=".UploadForegroundService"
|
android:name=".UploadForegroundService"
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
<service
|
||||||
|
android:name=".NfcHostApduService"
|
||||||
|
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/komet_nfc_apdu"/>
|
||||||
|
</service>
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|||||||
@@ -8,15 +8,20 @@ import android.net.ConnectivityManager
|
|||||||
import android.net.Network
|
import android.net.Network
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.NetworkRequest
|
import android.net.NetworkRequest
|
||||||
|
import android.nfc.NfcAdapter
|
||||||
|
import android.nfc.Tag
|
||||||
|
import android.nfc.tech.IsoDep
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import java.net.NetworkInterface
|
import java.net.NetworkInterface
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
|
import java.util.Random
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
@@ -24,8 +29,22 @@ class MainActivity : FlutterActivity() {
|
|||||||
private val channelName = "ru.komet.app/vpn_bypass"
|
private val channelName = "ru.komet.app/vpn_bypass"
|
||||||
private val iconAliases = listOf("DefaultIcon", "MinimalIcon")
|
private val iconAliases = listOf("DefaultIcon", "MinimalIcon")
|
||||||
|
|
||||||
|
private var nfcAdapter: NfcAdapter? = null
|
||||||
|
private var nfcEvents: EventChannel.EventSink? = null
|
||||||
|
private val nfcHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val nfcJitter = Random()
|
||||||
|
private val seenPeers = HashSet<Long>()
|
||||||
|
@Volatile private var nfcCycling = false
|
||||||
|
private val nfcReaderCallback = NfcAdapter.ReaderCallback { tag -> onNfcTagDiscovered(tag) }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val LOG_TAG = "VpnBypass"
|
const val LOG_TAG = "VpnBypass"
|
||||||
|
const val NFC_TAG = "NfcExchange"
|
||||||
|
const val NFC_PHASE_MIN_MS = 350L
|
||||||
|
const val NFC_PHASE_JITTER_MS = 400
|
||||||
|
val NFC_READER_FLAGS = NfcAdapter.FLAG_READER_NFC_A or
|
||||||
|
NfcAdapter.FLAG_READER_NFC_B or
|
||||||
|
NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyIcon(name: String) {
|
private fun applyIcon(name: String) {
|
||||||
@@ -50,6 +69,49 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
|
||||||
|
|
||||||
|
MethodChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
"ru.komet.app/nfc",
|
||||||
|
).setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"status" -> result.success(nfcStatus())
|
||||||
|
"start" -> {
|
||||||
|
val selfId = when (val v = call.argument<Any>("selfId")) {
|
||||||
|
is Int -> v.toLong()
|
||||||
|
is Long -> v
|
||||||
|
else -> 0L
|
||||||
|
}
|
||||||
|
if (selfId <= 0L) {
|
||||||
|
result.error("INVALID_ID", "selfId must be positive", null)
|
||||||
|
} else {
|
||||||
|
startNfcExchange(selfId)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"stop" -> {
|
||||||
|
stopNfcExchange()
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EventChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
"ru.komet.app/nfc_events",
|
||||||
|
).setStreamHandler(object : EventChannel.StreamHandler {
|
||||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||||
|
nfcEvents = events
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(arguments: Any?) {
|
||||||
|
nfcEvents = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
MethodChannel(
|
MethodChannel(
|
||||||
flutterEngine.dartExecutor.binaryMessenger,
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
channelName,
|
channelName,
|
||||||
@@ -127,6 +189,94 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun nfcStatus(): Map<String, Any> {
|
||||||
|
val adapter = nfcAdapter
|
||||||
|
return mapOf(
|
||||||
|
"supported" to (adapter != null),
|
||||||
|
"enabled" to (adapter?.isEnabled == true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startNfcExchange(selfId: Long) {
|
||||||
|
NfcExchange.selfId = selfId
|
||||||
|
NfcExchange.active = true
|
||||||
|
seenPeers.clear()
|
||||||
|
nfcCycling = true
|
||||||
|
nfcHandler.removeCallbacksAndMessages(null)
|
||||||
|
nfcReaderOn()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopNfcExchange() {
|
||||||
|
nfcCycling = false
|
||||||
|
NfcExchange.active = false
|
||||||
|
NfcExchange.selfId = 0L
|
||||||
|
nfcHandler.removeCallbacksAndMessages(null)
|
||||||
|
nfcReaderDisable()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nfcReaderOn() {
|
||||||
|
if (!nfcCycling) return
|
||||||
|
nfcReaderEnable()
|
||||||
|
nfcHandler.postDelayed({ nfcReaderOff() }, nfcPhaseDuration())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nfcReaderOff() {
|
||||||
|
if (!nfcCycling) return
|
||||||
|
nfcReaderDisable()
|
||||||
|
nfcHandler.postDelayed({ nfcReaderOn() }, nfcPhaseDuration())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nfcPhaseDuration(): Long =
|
||||||
|
NFC_PHASE_MIN_MS + nfcJitter.nextInt(NFC_PHASE_JITTER_MS)
|
||||||
|
|
||||||
|
private fun nfcReaderEnable() {
|
||||||
|
val adapter = nfcAdapter ?: return
|
||||||
|
try {
|
||||||
|
adapter.enableReaderMode(this, nfcReaderCallback, NFC_READER_FLAGS, null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(NFC_TAG, "enableReaderMode failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nfcReaderDisable() {
|
||||||
|
try {
|
||||||
|
nfcAdapter?.disableReaderMode(this)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(NFC_TAG, "disableReaderMode failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onNfcTagDiscovered(tag: Tag) {
|
||||||
|
val isoDep = IsoDep.get(tag) ?: return
|
||||||
|
val peer = try {
|
||||||
|
isoDep.connect()
|
||||||
|
NfcExchange.parsePeerId(isoDep.transceive(NfcExchange.buildSelectCommand()))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(NFC_TAG, "transceive failed: ${e.message}")
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
isoDep.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (peer == null || peer <= 0L) return
|
||||||
|
nfcHandler.post {
|
||||||
|
if (peer == NfcExchange.selfId || !seenPeers.add(peer)) return@post
|
||||||
|
nfcCycling = false
|
||||||
|
nfcReaderDisable()
|
||||||
|
nfcEvents?.success(mapOf("event" to "received", "id" to peer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
if (NfcExchange.active) {
|
||||||
|
stopNfcExchange()
|
||||||
|
nfcEvents?.success(mapOf("event" to "cancelled"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun connectivityManager(): ConnectivityManager =
|
private fun connectivityManager(): ConnectivityManager =
|
||||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package ru.komet.app
|
||||||
|
|
||||||
|
object NfcExchange {
|
||||||
|
|
||||||
|
const val AID = "F04B4F4D455431"
|
||||||
|
|
||||||
|
private const val PREFIX = "KMT1:"
|
||||||
|
private val STATUS_OK = byteArrayOf(0x90.toByte(), 0x00)
|
||||||
|
private val STATUS_NOT_FOUND = byteArrayOf(0x6A, 0x82.toByte())
|
||||||
|
|
||||||
|
@Volatile var active: Boolean = false
|
||||||
|
@Volatile var selfId: Long = 0L
|
||||||
|
|
||||||
|
fun buildSelectResponse(): ByteArray {
|
||||||
|
val id = selfId
|
||||||
|
if (!active || id <= 0L) return STATUS_NOT_FOUND
|
||||||
|
return (PREFIX + id).toByteArray(Charsets.UTF_8) + STATUS_OK
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSelectCommand(): ByteArray {
|
||||||
|
val aid = hexToBytes(AID)
|
||||||
|
return byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, aid.size.toByte()) +
|
||||||
|
aid + byteArrayOf(0x00)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parsePeerId(response: ByteArray?): Long? {
|
||||||
|
if (response == null || response.size < 2) return null
|
||||||
|
val sw1 = response[response.size - 2]
|
||||||
|
val sw2 = response[response.size - 1]
|
||||||
|
if (sw1 != 0x90.toByte() || sw2.toInt() != 0x00) return null
|
||||||
|
val text = String(response.copyOfRange(0, response.size - 2), Charsets.UTF_8)
|
||||||
|
if (!text.startsWith(PREFIX)) return null
|
||||||
|
return text.substring(PREFIX.length).toLongOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hexToBytes(hex: String): ByteArray {
|
||||||
|
val out = ByteArray(hex.length / 2)
|
||||||
|
for (i in out.indices) {
|
||||||
|
out[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package ru.komet.app
|
||||||
|
|
||||||
|
import android.nfc.cardemulation.HostApduService
|
||||||
|
import android.os.Bundle
|
||||||
|
|
||||||
|
class NfcHostApduService : HostApduService() {
|
||||||
|
|
||||||
|
private val selectHeader = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00)
|
||||||
|
private val statusNotFound = byteArrayOf(0x6A, 0x82.toByte())
|
||||||
|
|
||||||
|
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
|
||||||
|
if (commandApdu == null || !isSelectApdu(commandApdu)) return statusNotFound
|
||||||
|
return NfcExchange.buildSelectResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDeactivated(reason: Int) {}
|
||||||
|
|
||||||
|
private fun isSelectApdu(apdu: ByteArray): Boolean {
|
||||||
|
if (apdu.size < selectHeader.size) return false
|
||||||
|
for (i in selectHeader.indices) {
|
||||||
|
if (apdu[i] != selectHeader[i]) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="nfc_service_description">Komet contact exchange</string>
|
||||||
|
<string name="nfc_aid_group_description">Komet contact exchange</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:description="@string/nfc_service_description"
|
||||||
|
android:requireDeviceUnlock="false">
|
||||||
|
<aid-group
|
||||||
|
android:description="@string/nfc_aid_group_description"
|
||||||
|
android:category="other">
|
||||||
|
<aid-filter android:name="F04B4F4D455431"/>
|
||||||
|
</aid-group>
|
||||||
|
</host-apdu-service>
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../core/protocol/opcode_map.dart';
|
||||||
import '../../core/storage/app_database.dart';
|
import '../../core/storage/app_database.dart';
|
||||||
|
import '../api.dart';
|
||||||
import 'messages.dart';
|
import 'messages.dart';
|
||||||
|
|
||||||
class CachedContact {
|
class CachedContact {
|
||||||
@@ -51,6 +55,49 @@ class CachedContact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ContactsModule {
|
class ContactsModule {
|
||||||
|
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
||||||
|
|
||||||
|
static Future<CachedContact?> addContact(
|
||||||
|
Api api,
|
||||||
|
int id,
|
||||||
|
String firstName,
|
||||||
|
) async {
|
||||||
|
final resp = await api.sendRequest(Opcode.contactUpdate, {
|
||||||
|
'action': 'ADD',
|
||||||
|
'contactId': id,
|
||||||
|
'firstName': firstName,
|
||||||
|
});
|
||||||
|
|
||||||
|
final profile = await AppDatabase.loadActiveProfile();
|
||||||
|
if (profile == null) return null;
|
||||||
|
|
||||||
|
final data = resp.payload;
|
||||||
|
final contact = (data is Map && data['contact'] is Map)
|
||||||
|
? (data['contact'] as Map).cast<dynamic, dynamic>()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final row = contact != null
|
||||||
|
? _parseContact(contact, profile.id)
|
||||||
|
: {
|
||||||
|
'id': id,
|
||||||
|
'account_id': profile.id,
|
||||||
|
'first_name': firstName,
|
||||||
|
'last_name': null,
|
||||||
|
'phone': 0,
|
||||||
|
'photo_id': null,
|
||||||
|
'base_url': null,
|
||||||
|
'base_raw_url': null,
|
||||||
|
'update_time': 0,
|
||||||
|
'options': null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (row == null) return null;
|
||||||
|
await AppDatabase.saveContacts([row]);
|
||||||
|
if (contact != null) _primeContactCache(contact);
|
||||||
|
revision.value++;
|
||||||
|
return CachedContact.fromDbRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> syncFromLoginPayload(
|
static Future<void> syncFromLoginPayload(
|
||||||
Map<dynamic, dynamic> data,
|
Map<dynamic, dynamic> data,
|
||||||
int accountId,
|
int accountId,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
enum NfcEventType { received, cancelled }
|
||||||
|
|
||||||
|
class NfcEvent {
|
||||||
|
final NfcEventType type;
|
||||||
|
final int? id;
|
||||||
|
|
||||||
|
const NfcEvent(this.type, this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
class NfcStatus {
|
||||||
|
final bool supported;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
const NfcStatus({required this.supported, required this.enabled});
|
||||||
|
|
||||||
|
bool get ready => supported && enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NfcExchangeService {
|
||||||
|
NfcExchangeService._();
|
||||||
|
static final NfcExchangeService instance = NfcExchangeService._();
|
||||||
|
|
||||||
|
static const MethodChannel _method = MethodChannel('ru.komet.app/nfc');
|
||||||
|
static const EventChannel _events = EventChannel('ru.komet.app/nfc_events');
|
||||||
|
|
||||||
|
bool get _supported => Platform.isAndroid;
|
||||||
|
|
||||||
|
Future<NfcStatus> status() async {
|
||||||
|
if (!_supported) return const NfcStatus(supported: false, enabled: false);
|
||||||
|
try {
|
||||||
|
final res = await _method.invokeMapMethod<String, dynamic>('status');
|
||||||
|
return NfcStatus(
|
||||||
|
supported: res?['supported'] == true,
|
||||||
|
enabled: res?['enabled'] == true,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return const NfcStatus(supported: false, enabled: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<NfcEvent> get events =>
|
||||||
|
_events.receiveBroadcastStream().map(_decodeEvent);
|
||||||
|
|
||||||
|
Future<void> start(int selfId) =>
|
||||||
|
_method.invokeMethod('start', {'selfId': selfId});
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
if (!_supported) return;
|
||||||
|
try {
|
||||||
|
await _method.invokeMethod('stop');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
NfcEvent _decodeEvent(dynamic raw) {
|
||||||
|
final map = raw is Map ? raw : const {};
|
||||||
|
final id = map['id'];
|
||||||
|
final type = map['event'] == 'received'
|
||||||
|
? NfcEventType.received
|
||||||
|
: NfcEventType.cancelled;
|
||||||
|
return NfcEvent(type, id is int ? id : (id is num ? id.toInt() : null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import '../../widgets/komet_avatar.dart';
|
|||||||
import '../../widgets/connection_status.dart';
|
import '../../widgets/connection_status.dart';
|
||||||
import '../../widgets/sheet_helpers.dart';
|
import '../../widgets/sheet_helpers.dart';
|
||||||
import 'contact_profile_screen.dart';
|
import 'contact_profile_screen.dart';
|
||||||
|
import 'nfc_exchange_sheet.dart';
|
||||||
|
|
||||||
class ContactsTab extends StatefulWidget {
|
class ContactsTab extends StatefulWidget {
|
||||||
const ContactsTab({super.key});
|
const ContactsTab({super.key});
|
||||||
@@ -25,6 +26,24 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadContacts();
|
_loadContacts();
|
||||||
|
ContactsModule.revision.addListener(_loadContacts);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
ContactsModule.revision.removeListener(_loadContacts);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openNfcExchange() async {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: cs.surfaceContainerHigh,
|
||||||
|
shape: kSheetShape,
|
||||||
|
builder: (_) => const NfcExchangeSheet(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openSearchById() async {
|
Future<void> _openSearchById() async {
|
||||||
@@ -188,7 +207,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Symbols.person_add, color: cs.onSurface),
|
icon: Icon(Symbols.person_add, color: cs.onSurface),
|
||||||
onPressed: () {},
|
onPressed: _openNfcExchange,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Symbols.search, color: cs.onSurface),
|
icon: Icon(Symbols.search, color: cs.onSurface),
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
import '../../../backend/modules/contacts.dart';
|
||||||
|
import '../../../core/cache/info_cache.dart';
|
||||||
|
import '../../../core/nfc/nfc_exchange_service.dart';
|
||||||
|
import '../../../core/storage/app_database.dart';
|
||||||
|
import '../../../main.dart';
|
||||||
|
import '../../widgets/custom_notification.dart';
|
||||||
|
import '../../widgets/komet_avatar.dart';
|
||||||
|
|
||||||
|
enum _Stage { checking, unsupported, disabled, scanning, found, adding, added }
|
||||||
|
|
||||||
|
class NfcExchangeSheet extends StatefulWidget {
|
||||||
|
const NfcExchangeSheet({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NfcExchangeSheet> createState() => _NfcExchangeSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NfcExchangeSheetState extends State<NfcExchangeSheet>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
final _nfc = NfcExchangeService.instance;
|
||||||
|
late final AnimationController _pulse;
|
||||||
|
StreamSubscription<NfcEvent>? _sub;
|
||||||
|
|
||||||
|
_Stage _stage = _Stage.checking;
|
||||||
|
int? _peerId;
|
||||||
|
Map<String, dynamic>? _peerInfo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_pulse = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1800),
|
||||||
|
)..repeat();
|
||||||
|
_begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sub?.cancel();
|
||||||
|
_nfc.stop();
|
||||||
|
_pulse.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _begin() async {
|
||||||
|
final status = await _nfc.status();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!status.supported) {
|
||||||
|
setState(() => _stage = _Stage.unsupported);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!status.enabled) {
|
||||||
|
setState(() => _stage = _Stage.disabled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final profile = await AppDatabase.loadActiveProfile();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (profile == null) {
|
||||||
|
setState(() => _stage = _Stage.unsupported);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_sub = _nfc.events.listen(_onEvent);
|
||||||
|
await _nfc.start(profile.id);
|
||||||
|
if (mounted) setState(() => _stage = _Stage.scanning);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onEvent(NfcEvent event) async {
|
||||||
|
if (event.type == NfcEventType.cancelled) {
|
||||||
|
if (mounted && _stage == _Stage.scanning) {
|
||||||
|
setState(() => _stage = _Stage.disabled);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final id = event.id;
|
||||||
|
if (id == null || _peerId != null) return;
|
||||||
|
_peerId = id;
|
||||||
|
setState(() => _stage = _Stage.found);
|
||||||
|
final info = await ContactInfoFetch.get(id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _peerInfo = info);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _peerName() {
|
||||||
|
final info = _peerInfo;
|
||||||
|
if (info != null) {
|
||||||
|
final names = info['names'];
|
||||||
|
if (names is List && names.isNotEmpty) {
|
||||||
|
for (final n in names) {
|
||||||
|
if (n is! Map) continue;
|
||||||
|
final full = n['name']?.toString();
|
||||||
|
if (full != null && full.isNotEmpty) return full;
|
||||||
|
final first = n['firstName']?.toString() ?? '';
|
||||||
|
final last = n['lastName']?.toString() ?? '';
|
||||||
|
final combined = '$first $last'.trim();
|
||||||
|
if (combined.isNotEmpty) return combined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Контакт #${_peerId ?? ''}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _firstNameForAdd() {
|
||||||
|
final info = _peerInfo;
|
||||||
|
if (info != null) {
|
||||||
|
final names = info['names'];
|
||||||
|
if (names is List && names.isNotEmpty) {
|
||||||
|
for (final n in names) {
|
||||||
|
if (n is! Map) continue;
|
||||||
|
final first = n['firstName']?.toString();
|
||||||
|
if (first != null && first.isNotEmpty) return first;
|
||||||
|
final full = n['name']?.toString();
|
||||||
|
if (full != null && full.isNotEmpty) return full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Контакт';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _add() async {
|
||||||
|
final id = _peerId;
|
||||||
|
if (id == null) return;
|
||||||
|
setState(() => _stage = _Stage.adding);
|
||||||
|
try {
|
||||||
|
await ContactsModule.addContact(api, id, _firstNameForAdd());
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _stage = _Stage.added);
|
||||||
|
showCustomNotification(context, 'Контакт добавлен');
|
||||||
|
await Future.delayed(const Duration(milliseconds: 700));
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _stage = _Stage.found);
|
||||||
|
showCustomNotification(context, 'Не удалось добавить: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Обмен контактом',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
icon: Icon(Symbols.close, color: cs.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildContent(cs),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(ColorScheme cs) {
|
||||||
|
switch (_stage) {
|
||||||
|
case _Stage.checking:
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 40),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
case _Stage.unsupported:
|
||||||
|
return _message(cs, Symbols.nfc, 'NFC недоступен на этом устройстве');
|
||||||
|
case _Stage.disabled:
|
||||||
|
return _message(
|
||||||
|
cs,
|
||||||
|
Symbols.nfc,
|
||||||
|
'Включите NFC в настройках телефона и попробуйте снова',
|
||||||
|
);
|
||||||
|
case _Stage.scanning:
|
||||||
|
return _scanning(cs);
|
||||||
|
case _Stage.found:
|
||||||
|
case _Stage.adding:
|
||||||
|
case _Stage.added:
|
||||||
|
return _foundCard(cs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _message(ColorScheme cs, IconData icon, String text) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 28),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: cs.onSurfaceVariant, size: 44),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _scanning(ColorScheme cs) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _pulse,
|
||||||
|
builder: (context, child) => CustomPaint(
|
||||||
|
painter: _RadarPainter(_pulse.value, cs.primary),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Icon(Symbols.nfc, color: cs.primary, size: 48),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'Поднесите телефоны друг к другу',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Оба устройства должны держать этот экран открытым',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _foundCard(ColorScheme cs) {
|
||||||
|
final loading = _peerInfo == null && _stage == _Stage.found;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
KometAvatar(
|
||||||
|
name: _peerName(),
|
||||||
|
imageUrl: _peerInfo?['baseUrl'] as String?,
|
||||||
|
size: 88,
|
||||||
|
fontSize: 34,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
_peerName(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'ID ${_peerId ?? ''}',
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: (_stage == _Stage.adding || loading) ? null : _add,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
child: _stage == _Stage.adding
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Text(_stage == _Stage.added ? 'Добавлено' : 'Добавить контакт'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RadarPainter extends CustomPainter {
|
||||||
|
final double progress;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
_RadarPainter(this.progress, this.color);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final center = Offset(size.width / 2, size.height / 2);
|
||||||
|
final maxRadius = size.width / 2;
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
final t = (progress + i / 3) % 1.0;
|
||||||
|
final radius = maxRadius * t;
|
||||||
|
final opacity = (1.0 - t) * 0.35;
|
||||||
|
if (opacity <= 0) continue;
|
||||||
|
final paint = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2
|
||||||
|
..color = color.withValues(alpha: opacity);
|
||||||
|
canvas.drawCircle(center, radius, paint);
|
||||||
|
}
|
||||||
|
final corePaint = Paint()
|
||||||
|
..color = color.withValues(alpha: 0.10 + 0.05 * math.sin(progress * 2 * math.pi));
|
||||||
|
canvas.drawCircle(center, maxRadius * 0.32, corePaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_RadarPainter oldDelegate) =>
|
||||||
|
oldDelegate.progress != progress || oldDelegate.color != color;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user