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_VIDEO"/>
|
||||
<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
|
||||
android:label="Komet"
|
||||
android:name="${applicationName}"
|
||||
@@ -67,6 +69,17 @@
|
||||
android:name=".UploadForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
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
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
@@ -8,15 +8,20 @@ import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.nfc.NfcAdapter
|
||||
import android.nfc.Tag
|
||||
import android.nfc.tech.IsoDep
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.net.NetworkInterface
|
||||
import java.util.Collections
|
||||
import java.util.Random
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
@@ -24,8 +29,22 @@ class MainActivity : FlutterActivity() {
|
||||
private val channelName = "ru.komet.app/vpn_bypass"
|
||||
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 {
|
||||
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) {
|
||||
@@ -50,6 +69,49 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: 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(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
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 =
|
||||
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 '../api.dart';
|
||||
import 'messages.dart';
|
||||
|
||||
class CachedContact {
|
||||
@@ -51,6 +55,49 @@ class CachedContact {
|
||||
}
|
||||
|
||||
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(
|
||||
Map<dynamic, dynamic> data,
|
||||
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/sheet_helpers.dart';
|
||||
import 'contact_profile_screen.dart';
|
||||
import 'nfc_exchange_sheet.dart';
|
||||
|
||||
class ContactsTab extends StatefulWidget {
|
||||
const ContactsTab({super.key});
|
||||
@@ -25,6 +26,24 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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 {
|
||||
@@ -188,7 +207,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.person_add, color: cs.onSurface),
|
||||
onPressed: () {},
|
||||
onPressed: _openNfcExchange,
|
||||
),
|
||||
IconButton(
|
||||
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