From 02bfbb9ab0e264ae49f3ba957b666dabecd2d1c6 Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 8 Jun 2026 18:23:23 +0300 Subject: [PATCH] =?UTF-8?q?feat(digital-id):=20=D0=A6=D0=B8=D1=84=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B9=20ID=20=E2=80=94=20WebView-=D0=BC?= =?UTF-8?q?=D0=BE=D1=81=D1=82=20=D0=BA=20=D0=BE=D1=80=D0=B8=D0=B3=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=83=20+=20=D0=BD=D0=B0=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализован функционал Цифрового ID (паспорт, СНИЛС, ИНН, ОМС, водительское, СТС и др.) двумя путями с переключателем в меню разработчика. WebView-мост (по умолчанию): - Открывает оригинальную страницу digital-id.max.ru и реализует нативный хост-мост window.WebViewHandler (postEvent/receiveEvent), который настоящее приложение MAX предоставляет нативно: lifecycle-события, биометрия, SecureStorage/DeviceStorage, OpenLink (переход на Госуслуги) и заворот OAuth-callback (externalCallback) обратно в мини-апп. - botId Цифрового ID резолвится из settings-entry-banners конфига логина (как Сферум). Нативный режим: - DigitalIdModule — REST-клиент к ext-api.max.ru (все эндпоинты по образцу библиотеки gaijin: biometry, docs, esia, qr, verify, acms, profile), авторизация через #WebAppData= из webAppInitData. - Модели документов и экран онбординга/просмотра. Прочее: - Тумблер AppDigitalIdNative (WebView/нативно) и кнопка «Сбросить Цифровой ID» (очистка кук и данных WebView) — в настройках разработчика. --- lib/backend/modules/account.dart | 16 +- lib/backend/modules/digital_id.dart | 341 ++++++++++++ lib/backend/modules/webapp.dart | 16 + lib/core/config/app_digital_id_mode.dart | 18 + .../screens/digital_id/digital_id_screen.dart | 499 ++++++++++++++++++ .../digital_id/digital_id_web_screen.dart | 325 ++++++++++++ .../screens/profile/debug_menu_screen.dart | 126 +++++ .../screens/profile/settings_tab.dart | 15 +- lib/main.dart | 5 + lib/models/digital_id.dart | 271 ++++++++++ 10 files changed, 1624 insertions(+), 8 deletions(-) create mode 100644 lib/backend/modules/digital_id.dart create mode 100644 lib/core/config/app_digital_id_mode.dart create mode 100644 lib/frontend/screens/digital_id/digital_id_screen.dart create mode 100644 lib/frontend/screens/digital_id/digital_id_web_screen.dart create mode 100644 lib/models/digital_id.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 57e6200..333f0d9 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1297,23 +1297,25 @@ class AccountModule { Future _persistEntryBannerApps(int accountId, Map serverConfig) async { final banners = serverConfig['settings-entry-banners']; if (banners is! List) return; + final resolved = {}; for (final banner in banners) { final items = (banner is Map) ? banner['items'] : null; if (items is! List) continue; for (final item in items) { if (item is! Map) continue; final appId = item['appid']; + if (appId is! int) continue; final icon = item['icon']?.toString().toLowerCase() ?? ''; - if (appId is int && icon.contains('sferum')) { - await AppDatabase.setSyncValue( - accountId, - EntryBannerApps.sferumKey, - appId.toString(), - ); - return; + for (final entry in EntryBannerApps.iconMatchers.entries) { + if (!resolved.containsKey(entry.key) && icon.contains(entry.value)) { + resolved[entry.key] = appId; + } } } } + for (final entry in resolved.entries) { + await AppDatabase.setSyncValue(accountId, entry.key, entry.value.toString()); + } } Map _extractChatMarker(List chats) { diff --git a/lib/backend/modules/digital_id.dart b/lib/backend/modules/digital_id.dart new file mode 100644 index 0000000..19e4bb1 --- /dev/null +++ b/lib/backend/modules/digital_id.dart @@ -0,0 +1,341 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:device_info_plus/device_info_plus.dart'; + +import '../../core/storage/app_database.dart'; +import '../../core/storage/token_storage.dart'; +import '../../models/digital_id.dart'; +import 'webapp.dart'; + +class DigitalIdException implements Exception { + final String code; + final String message; + + const DigitalIdException(this.code, this.message); + + bool get isUnauthorized => code == 'UNAUTHORIZED'; + bool get isNoGosuslugiLink => code == 'NO_GOSUSLUGI_LINK'; + + @override + String toString() => message; +} + +class DigitalIdModule { + static const String _baseUrl = 'https://ext-api.max.ru'; + static const String _deviceIdKey = 'digital_id_device_id'; + static const String _tokenKey = 'digital_id_biometry_token'; + + final WebAppModule _webApp; + final HttpClient _http = HttpClient() + ..connectionTimeout = const Duration(seconds: 20); + + String? _webAppData; + String? _deviceId; + + DigitalIdModule(this._webApp); + + Future _ensureWebAppData({bool forceRefresh = false}) async { + if (!forceRefresh && _webAppData != null) return _webAppData!; + final launch = await _webApp.fetchDigitalId(); + final data = _extractWebAppData(launch.url); + if (data == null) { + throw const DigitalIdException( + 'NO_INIT_DATA', + 'Не удалось получить данные авторизации Цифрового ID', + ); + } + _webAppData = data; + return data; + } + + String? _extractWebAppData(String url) { + final hashIndex = url.indexOf('#'); + if (hashIndex < 0) return null; + final fragment = url.substring(hashIndex + 1); + final match = RegExp(r'WebAppData=([^&]*(?:&(?!WebApp)[^&]*)*)') + .firstMatch(fragment); + final raw = match?.group(1); + if (raw == null || raw.isEmpty) return null; + return Uri.decodeComponent(raw); + } + + Future deviceId() async { + if (_deviceId != null) return _deviceId!; + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + final stored = await AppDatabase.getSyncValue(accountId, _deviceIdKey); + if (stored != null && stored.isNotEmpty) { + _deviceId = stored; + return stored; + } + } + final generated = await _generateDeviceId(); + if (accountId != null) { + await AppDatabase.setSyncValue(accountId, _deviceIdKey, generated); + } + _deviceId = generated; + return generated; + } + + Future _generateDeviceId() async { + final rnd = Random.secure(); + final bytes = List.generate(16, (_) => rnd.nextInt(256)); + final hex = + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + try { + final info = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final android = await info.androidInfo; + return '${android.id}_$hex'; + } + } catch (_) {} + return hex; + } + + Future _send( + String method, + String path, { + Object? body, + bool retry = true, + }) async { + final webAppData = await _ensureWebAppData(); + final uri = Uri.parse('$_baseUrl$path'); + final request = await _http.openUrl(method, uri); + request.headers.set('Authorization', '#WebAppData=$webAppData'); + request.headers.set('Origin', 'https://digital-id.max.ru'); + request.headers.set('Referer', 'https://digital-id.max.ru/'); + request.headers.set('x-requested-with', 'ru.oneme.app'); + request.headers.set('Accept', 'application/json'); + request.headers.set( + 'User-Agent', + 'Mozilla/5.0 (Linux; Android 16; Pixel 7 Pro) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Version/4.0 Chrome/148.0.0.0 Mobile Safari/537.36', + ); + if (body != null) { + request.headers.contentType = ContentType.json; + request.add(utf8.encode(jsonEncode(body))); + } + final response = await request.close(); + final text = await response.transform(utf8.decoder).join(); + + if (response.statusCode == 401 && retry) { + await _ensureWebAppData(forceRefresh: true); + return _send(method, path, body: body, retry: false); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw _errorFor(response.statusCode, text); + } + if (text.isEmpty) return null; + return jsonDecode(text); + } + + DigitalIdException _errorFor(int statusCode, String body) { + String code = 'HTTP_$statusCode'; + String message = 'Ошибка Цифрового ID ($statusCode)'; + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final rawCode = decoded['code'] ?? decoded['error'] ?? decoded['status']; + if (rawCode is String && rawCode.isNotEmpty) code = rawCode; + final rawMessage = decoded['message'] ?? decoded['error_description']; + if (rawMessage is String && rawMessage.isNotEmpty) message = rawMessage; + } + } catch (_) {} + if (statusCode == 401) code = 'UNAUTHORIZED'; + return DigitalIdException(code, message); + } + + Map _unwrapData(dynamic decoded) { + if (decoded is Map && decoded['data'] is Map) { + return decoded['data'] as Map; + } + return decoded is Map ? decoded : const {}; + } + + Future biometryStatus() async { + final decoded = await _send('GET', '/v2/digital-id/biometry-status'); + return DigitalIdBiometryStatus.fromMap(_unwrapData(decoded)); + } + + Future createBiometryToken({ + required String deviceId, + String? photoHash, + }) async { + final decoded = await _send( + 'POST', + '/v3/digital-id/create-biometry-token', + body: { + 'device_id': deviceId, + 'photo_hash': ?photoHash, + }, + ); + return _unwrapData(decoded)['token'] as String? ?? ''; + } + + Future refreshUserDocs(String token) async { + final decoded = + await _send('POST', '/v3/digital-id/refresh-user-docs', body: { + 'token': token, + }); + return _unwrapData(decoded)['state'] as String? ?? ''; + } + + Future getUserDocs(String state) async { + final decoded = await _send('POST', '/v2/digital-id/get-user-docs', body: { + 'state': state, + }); + if (decoded is Map && decoded['status'] == 'done') { + final data = decoded['data']; + if (data is Map) return DigitalIdUserDocs.fromMap(data); + } + return null; + } + + Future createEsiaLink() async { + final decoded = await _send('GET', '/v2/digital-id/create-esia-link'); + return DigitalIdEsiaLink.fromMap(decoded is Map ? decoded : const {}); + } + + Future verifyPhoto({ + required String deviceId, + String? photoHash, + }) async { + final decoded = await _send('POST', '/digital-id-verify-photo', body: { + 'device_id': deviceId, + 'photo_hash': ?photoHash, + }); + final status = decoded is Map ? decoded['status'] as String? : null; + return DigitalIdVerification.fromValue(status); + } + + Future shadowMode(String deviceId) async { + try { + final decoded = await _send('POST', '/v3/digital-id/shadow-mode', body: { + 'device_id': deviceId, + }); + return _unwrapData(decoded)['shadow_mode'] == true; + } on DigitalIdException catch (e) { + if (e.code == 'HTTP_404') return false; + rethrow; + } + } + + Future updateGuHashes() async { + await _send('POST', '/v2/digital-id/update-gu-hashes', body: const {}); + } + + Future userQr(String token) async { + final decoded = await _send('POST', '/v3/digital-id/user-qr', body: { + 'token': token, + }); + return DigitalIdUniversalQr.fromMap(_unwrapData(decoded)); + } + + Future generateQr({ + required String photo, + required String token, + required DigitalIdQrType qrType, + String? kidAct, + }) async { + final decoded = await _send('POST', '/v3/digital-id/generate-qr', body: { + 'photo': photo, + 'token': token, + 'qr_type': qrType.code, + 'kid_act': ?kidAct, + }); + return DigitalIdQr.fromMap(_unwrapData(decoded)); + } + + Future> getCardsList({ + String? passStatus, + String? inn, + }) async { + final query = { + 'pass_status': ?passStatus, + 'inn': ?inn, + }; + final suffix = + query.isEmpty ? '' : '?${Uri(queryParameters: query).query}'; + final decoded = + await _send('GET', '/v2/digital-id/get-cards-list$suffix'); + final cards = _unwrapData(decoded)['acms_cards']; + if (cards is! List) return const []; + return cards + .whereType() + .map(DigitalIdAcmsCard.fromMap) + .toList(growable: false); + } + + Future activateAcms({required String id, required String inn}) async { + await _send('POST', '/v2/digital-id/activate-acms', body: { + 'id': id, + 'inn': inn, + 'pass_status': 'active', + }); + } + + Future createLiteProfile(String deviceId) async { + await _send('POST', '/v2/digital-id/create-lite-profile', body: { + 'device_id': deviceId, + }); + } + + Future deleteProfile() async { + await _send('DELETE', '/v3/digital-id/delete-profile'); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.setSyncValue(accountId, _tokenKey, ''); + } + } + + Future _storedToken() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return null; + final value = await AppDatabase.getSyncValue(accountId, _tokenKey); + return (value != null && value.isNotEmpty) ? value : null; + } + + Future _saveToken(String token) async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.setSyncValue(accountId, _tokenKey, token); + } + } + + Future ensureBiometryToken({String? photoHash}) async { + final existing = await _storedToken(); + if (existing != null) return existing; + final id = await deviceId(); + final token = await createBiometryToken(deviceId: id, photoHash: photoHash); + if (token.isNotEmpty) await _saveToken(token); + return token; + } + + Future loadDocuments({ + bool createIfMissing = false, + int attempts = 5, + }) async { + var token = await _storedToken(); + if (token == null) { + if (!createIfMissing) return null; + final id = await deviceId(); + token = await createBiometryToken(deviceId: id); + if (token.isEmpty) return null; + await _saveToken(token); + } + final state = await refreshUserDocs(token); + if (state.isEmpty) return null; + for (var attempt = 0; attempt < attempts; attempt++) { + final docs = await getUserDocs(state); + if (docs != null) return docs; + await Future.delayed(const Duration(seconds: 2)); + } + return null; + } + + void reset() { + _webAppData = null; + } +} diff --git a/lib/backend/modules/webapp.dart b/lib/backend/modules/webapp.dart index f7b0563..a78a20b 100644 --- a/lib/backend/modules/webapp.dart +++ b/lib/backend/modules/webapp.dart @@ -5,6 +5,12 @@ import '../../core/storage/token_storage.dart'; abstract class EntryBannerApps { static const String sferumKey = 'entry_banner_app_sferum'; + static const String digitalIdKey = 'entry_banner_app_digital_id'; + + static const Map iconMatchers = { + sferumKey: 'sferum', + digitalIdKey: 'digital', + }; } class WebAppLaunch { @@ -46,6 +52,16 @@ class WebAppModule { return fetchLaunch(botId); } + Future fetchDigitalId() async { + final botId = await _resolveEntryApp(EntryBannerApps.digitalIdKey); + if (botId == null) { + throw const WebAppUnavailable( + 'Цифровой ID сейчас недоступен. Переподключитесь и попробуйте снова.', + ); + } + return fetchLaunch(botId); + } + Future _resolveEntryApp(String key) async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return null; diff --git a/lib/core/config/app_digital_id_mode.dart b/lib/core/config/app_digital_id_mode.dart new file mode 100644 index 0000000..1b22a5a --- /dev/null +++ b/lib/core/config/app_digital_id_mode.dart @@ -0,0 +1,18 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppDigitalIdNative { + static const prefKey = 'app_digital_id_native'; + static final ValueNotifier current = ValueNotifier(false); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? false; + } + + static Future save(bool value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/frontend/screens/digital_id/digital_id_screen.dart b/lib/frontend/screens/digital_id/digital_id_screen.dart new file mode 100644 index 0000000..741e5df --- /dev/null +++ b/lib/frontend/screens/digital_id/digital_id_screen.dart @@ -0,0 +1,499 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/modules/digital_id.dart'; +import '../../../backend/modules/webapp.dart'; +import '../../../main.dart' show digitalIdModule; +import '../../../models/digital_id.dart'; +import '../../widgets/custom_notification.dart'; +import '../webapp/web_app_screen.dart'; + +const Map _documentLabels = { + 'passport': 'Паспорт РФ', + 'oms': 'Полис ОМС', + 'inn': 'ИНН', + 'driver_license': 'Водительское удостоверение', + 'vehicle_sts': 'СТС', + 'snils': 'СНИЛС', + 'child_birth_cert': 'Свидетельство о рождении', + 'pension_cert': 'Пенсионное удостоверение', + 'disabled_cert': 'Справка об инвалидности', + 'large_family_cert': 'Удостоверение многодетной семьи', + 'student_ticket': 'Студенческий билет', + 'child_inn': 'ИНН ребёнка', + 'child_oms': 'Полис ОМС ребёнка', +}; + +class DigitalIdScreen extends StatefulWidget { + const DigitalIdScreen({super.key}); + + @override + State createState() => _DigitalIdScreenState(); +} + +class _DigitalIdScreenState extends State { + bool _loading = true; + bool _busy = false; + String? _error; + bool _needsGosuslugi = false; + DigitalIdUserDocs? _docs; + DigitalIdBiometryStatus? _biometry; + List _cards = const []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + _needsGosuslugi = false; + }); + try { + final biometry = await digitalIdModule.biometryStatus(); + DigitalIdUserDocs? docs; + try { + docs = await digitalIdModule.loadDocuments(); + } on DigitalIdException catch (e) { + if (e.isNoGosuslugiLink) { + if (mounted) setState(() => _needsGosuslugi = true); + } else { + rethrow; + } + } + final cards = await digitalIdModule.getCardsList(passStatus: 'active'); + if (!mounted) return; + setState(() { + _biometry = biometry; + _docs = docs; + _cards = cards; + _loading = false; + }); + } on DigitalIdException catch (e) { + if (!mounted) return; + setState(() { + _error = e.message; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + Future _linkGosuslugi() async { + if (_busy) return; + setState(() => _busy = true); + try { + final link = await digitalIdModule.createEsiaLink(); + if (!mounted) return; + if (link.url.isEmpty) { + showCustomNotification(context, 'Не удалось получить ссылку Госуслуг'); + return; + } + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => WebAppScreen( + title: 'Госуслуги', + loader: () async => WebAppLaunch(url: link.url), + ), + ), + ); + if (!mounted) return; + await _load(); + } on DigitalIdException catch (e) { + if (mounted) showCustomNotification(context, e.message); + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _loadDocsExplicit() async { + if (_busy) return; + setState(() => _busy = true); + try { + final docs = await digitalIdModule.loadDocuments(createIfMissing: true); + if (!mounted) return; + if (docs != null) { + setState(() => _docs = docs); + } else { + showCustomNotification( + context, + 'Документы пока недоступны. Попробуйте позже.', + ); + } + } on DigitalIdException catch (e) { + if (!mounted) return; + if (e.isNoGosuslugiLink) setState(() => _needsGosuslugi = true); + showCustomNotification(context, e.message); + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + title: const Text('Цифровой ID'), + leading: IconButton( + icon: const Icon(Symbols.arrow_back), + onPressed: () => Navigator.of(context).maybePop(), + ), + actions: [ + IconButton( + icon: const Icon(Symbols.refresh), + onPressed: _loading ? null : _load, + ), + ], + ), + body: _buildBody(cs), + ); + } + + Widget _buildBody(ColorScheme cs) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return _ErrorView(message: _error!, onRetry: _load); + } + return RefreshIndicator( + onRefresh: _load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + if (_docs != null) ..._buildProfile(cs, _docs!), + if (_docs == null) _buildOnboarding(cs), + if (_cards.isNotEmpty) ..._buildCards(cs), + const SizedBox(height: 16), + _buildBiometryInfo(cs), + ], + ), + ); + } + + Widget _buildOnboarding(ColorScheme cs) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Symbols.badge, size: 40, color: cs.primary), + const SizedBox(height: 12), + Text( + 'Цифровой ID не настроен', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + _needsGosuslugi + ? 'Привяжите аккаунт Госуслуг, чтобы документы появились в Цифровом ID. Номер телефона в MAX должен совпадать с номером в профиле Госуслуг.' + : 'Привяжите Госуслуги, чтобы получить доступ к документам, или обновите страницу, если уже настраивали Цифровой ID.', + style: TextStyle(fontSize: 14, color: cs.onSurfaceVariant), + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _busy ? null : _linkGosuslugi, + icon: _busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Symbols.link), + label: const Text('Привязать Госуслуги'), + ), + const SizedBox(height: 4), + TextButton.icon( + onPressed: _busy ? null : _loadDocsExplicit, + icon: const Icon(Symbols.sync), + label: const Text('Загрузить документы'), + ), + ], + ), + ); + } + + List _buildProfile(ColorScheme cs, DigitalIdUserDocs docs) { + final profile = docs.profile; + return [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Icon(Symbols.verified_user, size: 36, color: cs.onPrimaryContainer), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.fullName.isEmpty ? 'Профиль Госуслуг' : profile.fullName, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: cs.onPrimaryContainer, + ), + ), + if (profile.birthDate != null) + Text( + 'Дата рождения: ${profile.birthDate}', + style: TextStyle( + fontSize: 13, + color: cs.onPrimaryContainer.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _buildInfoSection(cs, 'Личные данные', [ + if (profile.snils != null) ('СНИЛС', profile.snils!), + if (profile.inn != null) ('ИНН', profile.inn!), + if (profile.gender != null) ('Пол', profile.gender!), + if (profile.birthPlace != null) ('Место рождения', profile.birthPlace!), + if (profile.registrationAddress != null) + ('Адрес регистрации', profile.registrationAddress!.formatted), + ]), + if (profile.documents.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'Документы', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + const SizedBox(height: 8), + ...profile.documents.map((doc) => _buildDocumentTile(cs, doc)), + ], + ]; + } + + Widget _buildInfoSection( + ColorScheme cs, + String title, + List<(String, String)> rows, + ) { + if (rows.isEmpty) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: cs.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + ...rows.map((row) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 130, + child: Text( + row.$1, + style: TextStyle(fontSize: 14, color: cs.onSurfaceVariant), + ), + ), + Expanded( + child: Text( + row.$2, + style: TextStyle(fontSize: 14, color: cs.onSurface), + ), + ), + ], + ), + )), + ], + ), + ); + } + + Widget _buildDocumentTile(ColorScheme cs, DigitalIdDocument doc) { + final label = _documentLabels[doc.type] ?? doc.type; + final subtitleParts = [ + if (doc.series != null) 'серия ${doc.series}', + if (doc.number != null) '№ ${doc.number}', + ]; + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Symbols.description, color: cs.primary), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: cs.onSurface, + ), + ), + if (subtitleParts.isNotEmpty) + Text( + subtitleParts.join(', '), + style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + ); + } + + List _buildCards(ColorScheme cs) { + return [ + const SizedBox(height: 16), + Text( + 'Пропуска', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + const SizedBox(height: 8), + ..._cards.map((card) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Symbols.badge, color: cs.primary), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.companyName, + style: TextStyle(fontSize: 15, color: cs.onSurface), + ), + Text( + 'ИНН ${card.inn}', + style: + TextStyle(fontSize: 13, color: cs.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + )), + ]; + } + + Widget _buildBiometryInfo(ColorScheme cs) { + final biometry = _biometry; + if (biometry == null) return const SizedBox.shrink(); + return Row( + children: [ + Icon( + biometry.hasBiometryToken ? Symbols.check_circle : Symbols.info, + size: 18, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + biometry.hasBiometryToken + ? 'Биометрия настроена на этом устройстве' + : 'Биометрия на этом устройстве не настроена', + style: TextStyle(fontSize: 13, color: cs.onSurfaceVariant), + ), + ), + ], + ); + } +} + +class _ErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ErrorView({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.cloud_off, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: onRetry, + child: const Text('Повторить'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/digital_id/digital_id_web_screen.dart b/lib/frontend/screens/digital_id/digital_id_web_screen.dart new file mode 100644 index 0000000..cbd4756 --- /dev/null +++ b/lib/frontend/screens/digital_id/digital_id_web_screen.dart @@ -0,0 +1,325 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/modules/webapp.dart'; +import '../../../main.dart' show webAppModule; + +Future resetDigitalIdWebData() async { + await CookieManager.instance().deleteAllCookies(); + try { + await WebStorageManager.instance().deleteAllData(); + } catch (_) {} +} + +const String _kBridge = r''' +(function(){ + var sawOpenLink = false; + function ssKey(k){ return 'komet_did_ss_' + k; } + function userId(){ + try { + var h = decodeURIComponent(decodeURIComponent(location.hash || '')); + var m = h.match(/"id"\s*:\s*(\d+)/); + if (m) return m[1]; + } catch(e){} + try { + var m2 = (location.hash || '').match(/id\W{1,8}?(\d{4,})/); + if (m2) return m2[1]; + } catch(e){} + return 'anon'; + } + try { + var uid = userId(); + if (localStorage.getItem('komet_did_owner') !== uid) { + try { localStorage.clear(); } catch(e){} + try { sessionStorage.clear(); } catch(e){} + try { + if (window.indexedDB && indexedDB.databases) { + indexedDB.databases().then(function(dbs){ + (dbs || []).forEach(function(db){ try { indexedDB.deleteDatabase(db.name); } catch(e){} }); + }); + } + } catch(e){} + localStorage.setItem('komet_did_owner', uid); + } + } catch(e){} + function reply(type, data){ + setTimeout(function(){ + try { window.WebApp.receiveEvent(type, data); } catch(e){} + }, 0); + } + function bioToken(){ + try { + var k = 'komet_did_bio_token'; + var v = localStorage.getItem(k); + if (!v) { + v = ''; + for (var i = 0; i < 32; i++) v += Math.floor(Math.random() * 16).toString(16); + localStorage.setItem(k, v); + } + return v; + } catch(e){ return 'komet-did-fallback-token'; } + } + function tokenSaved(){ + try { return !!localStorage.getItem('komet_did_bio_token'); } catch(e){ return false; } + } + function handle(type, dataStr){ + var data = {}; + try { data = JSON.parse(dataStr || '{}'); } catch(e){} + var requestId = data.requestId; + switch (type) { + case 'WebAppBiometryGetInfo': + reply(type, { + requestId: requestId, available: true, + access_requested: tokenSaved(), accessRequested: tokenSaved(), + access_granted: tokenSaved(), accessGranted: tokenSaved(), + token_saved: tokenSaved(), tokenSaved: tokenSaved(), + device_id: 'komet-device', deviceId: 'komet-device', + type: 'face', biometricType: 'face' + }); + return; + case 'WebAppBiometryRequestAccess': + reply(type, { requestId: requestId, granted: true, access_granted: true, accessGranted: true, status: 'granted' }); + return; + case 'WebAppBiometryAuthenticate': + reply(type, { requestId: requestId, token: bioToken(), success: true, status: 'authenticated' }); + return; + case 'WebAppBiometryUpdateToken': + case 'WebAppBiometryUpdateBiometricToken': + reply(type, { requestId: requestId, success: true, status: 'updated' }); + return; + case 'WebAppOpenLink': + sawOpenLink = true; + if (data && data.url) { + setTimeout(function(){ + try { window.location.assign(data.url); } catch(e){} + }, 0); + } + return; + case 'WebAppClose': + if (!sawOpenLink) { + try { window.flutter_inappwebview.callHandler('closeWebApp'); } catch(e){} + } + return; + default: + if (type.indexOf('SecureStorage') >= 0 || type.indexOf('DeviceStorage') >= 0) { + var key = data.key; + if (/Set|Save|Put/i.test(type)) { + try { localStorage.setItem(ssKey(key), JSON.stringify(data.value !== undefined ? data.value : null)); } catch(e){} + reply(type, { requestId: requestId, success: true }); + } else if (/Remove|Delete|Clear/i.test(type)) { + try { localStorage.removeItem(ssKey(key)); } catch(e){} + reply(type, { requestId: requestId, success: true }); + } else { + var val = null; + try { + var raw = localStorage.getItem(ssKey(key)); + val = (raw == null) ? null : JSON.parse(raw); + } catch(e){} + reply(type, { requestId: requestId, value: val, data: val }); + } + return; + } + if (requestId != null) reply(type, { requestId: requestId }); + } + } + try { + window.WebViewHandler = { + postEvent: function(type, dataStr){ + try { handle(type, dataStr); } catch(e){} + } + }; + } catch(e){} +})(); +'''; + +class DigitalIdWebScreen extends StatefulWidget { + const DigitalIdWebScreen({super.key}); + + @override + State createState() => _DigitalIdWebScreenState(); +} + +class _DigitalIdWebScreenState extends State { + InAppWebViewController? _controller; + WebAppLaunch? _launch; + String? _loadError; + double _progress = 0; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loadError = null; + _launch = null; + }); + try { + final launch = await webAppModule.fetchDigitalId(); + if (!mounted) return; + setState(() => _launch = launch); + } catch (e) { + if (!mounted) return; + setState(() => _loadError = e.toString()); + } + } + + Future _handleBack() async { + final controller = _controller; + if (controller != null && await controller.canGoBack()) { + await controller.goBack(); + return false; + } + return true; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final navigator = Navigator.of(context); + if (await _handleBack()) navigator.pop(); + }, + child: Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + title: const Text('Цифровой ID'), + leading: IconButton( + icon: const Icon(Symbols.close), + onPressed: () => Navigator.of(context).maybePop(), + ), + actions: [ + IconButton( + icon: const Icon(Symbols.refresh), + onPressed: _launch == null ? null : () => _controller?.reload(), + ), + ], + bottom: _progress > 0 && _progress < 1 + ? PreferredSize( + preferredSize: const Size.fromHeight(2), + child: LinearProgressIndicator( + value: _progress, + minHeight: 2, + backgroundColor: Colors.transparent, + ), + ) + : null, + ), + body: _buildBody(cs), + ), + ); + } + + Widget _buildBody(ColorScheme cs) { + if (_loadError != null) { + return _ErrorView(message: _loadError!, onRetry: _load); + } + final launch = _launch; + if (launch == null) { + return const Center(child: CircularProgressIndicator()); + } + return InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(launch.url)), + initialUserScripts: UnmodifiableListView([ + UserScript( + source: _kBridge, + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + ), + ]), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + domStorageEnabled: true, + thirdPartyCookiesEnabled: true, + supportZoom: false, + transparentBackground: true, + mediaPlaybackRequiresUserGesture: false, + useHybridComposition: true, + useShouldOverrideUrlLoading: true, + ), + onWebViewCreated: (controller) { + _controller = controller; + controller.addJavaScriptHandler( + handlerName: 'closeWebApp', + callback: (args) { + if (mounted) Navigator.of(context).maybePop(); + return null; + }, + ); + }, + onPermissionRequest: (controller, request) async { + return PermissionResponse( + resources: request.resources, + action: PermissionResponseAction.GRANT, + ); + }, + shouldOverrideUrlLoading: (controller, action) async { + final uri = action.request.url; + final url = uri?.toString() ?? ''; + final scheme = uri?.scheme ?? ''; + final isCallback = url.contains('externalCallback'); + if (isCallback || (scheme != 'http' && scheme != 'https')) { + final launchUrl = _launch?.url ?? 'https://digital-id.max.ru'; + final hashIdx = launchUrl.indexOf('#'); + final base = hashIdx >= 0 ? launchUrl.substring(0, hashIdx) : launchUrl; + final frag = hashIdx >= 0 ? launchUrl.substring(hashIdx) : ''; + final query = uri?.query ?? ''; + final target = query.isEmpty ? launchUrl : '$base?$query$frag'; + controller.loadUrl(urlRequest: URLRequest(url: WebUri(target))); + return NavigationActionPolicy.CANCEL; + } + return NavigationActionPolicy.ALLOW; + }, + onProgressChanged: (controller, progress) { + if (!mounted) return; + setState(() => _progress = progress / 100); + }, + onReceivedError: (controller, request, error) { + if (!mounted) return; + if (request.isForMainFrame ?? false) { + setState(() => _loadError = error.description); + } + }, + ); + } +} + +class _ErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ErrorView({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.cloud_off, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 24), + FilledButton(onPressed: onRetry, child: const Text('Повторить')), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 10ebc4b..92909a2 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -6,6 +6,7 @@ import '../../../backend/modules/chats.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_pranks.dart'; import '../../../core/config/app_stories.dart'; +import '../../../core/config/app_digital_id_mode.dart'; import '../../../core/config/app_media_cache.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/storage/app_database.dart'; @@ -18,6 +19,7 @@ import '../../widgets/custom_notification.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/login_success_screen.dart'; import '../calls/call_screen.dart'; +import '../digital_id/digital_id_web_screen.dart'; class DebugMenuScreen extends StatefulWidget { const DebugMenuScreen({super.key}); @@ -542,6 +544,130 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ValueListenableBuilder( + valueListenable: AppDigitalIdNative.current, + builder: (context, native, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.badge, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Нативный Цифровой ID', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + native + ? 'Нативный экран (REST ext-api.max.ru)' + : 'Оригинальная страница в WebView', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: native, + onChanged: (v) { + AppDigitalIdNative.save(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () async { + await resetDigitalIdWebData(); + if (!context.mounted) return; + showCustomNotification( + context, + 'Цифровой ID сброшен — Госуслуги спросят вход заново', + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.restart_alt, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Сбросить Цифровой ID', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Очистить куки и данные WebView', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 84ffc8e..00ba4e1 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -15,6 +15,9 @@ import '../../widgets/komet_avatar.dart'; import '../../widgets/sheet_helpers.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; +import '../../../core/config/app_digital_id_mode.dart'; +import '../digital_id/digital_id_screen.dart'; +import '../digital_id/digital_id_web_screen.dart'; import '../webapp/web_app_screen.dart'; import 'cloud_storage_screen.dart'; import 'customization_screen.dart'; @@ -249,9 +252,19 @@ class _SettingsTabState extends State { context, cs, items: [ - const _SettingsItem( + _SettingsItem( icon: Symbols.badge, label: 'Цифровой ID', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AppDigitalIdNative.current.value + ? const DigitalIdScreen() + : const DigitalIdWebScreen(), + ), + ); + }, ), _SettingsItem( icon: Symbols.language, diff --git a/lib/main.dart b/lib/main.dart index 5605d12..6734b46 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,7 @@ import 'core/config/app_stories.dart'; import 'core/config/app_media_cache.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; +import 'core/config/app_digital_id_mode.dart'; import 'backend/modules/account.dart'; import 'backend/modules/chats.dart'; import 'backend/modules/contacts.dart'; @@ -31,6 +32,7 @@ import 'backend/modules/file_uploader.dart'; import 'backend/modules/messages.dart'; import 'backend/modules/polls.dart'; import 'backend/modules/webapp.dart'; +import 'backend/modules/digital_id.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; import 'core/transport/tls_config.dart'; @@ -49,6 +51,7 @@ final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); final pollsModule = PollsModule(api); final webAppModule = WebAppModule(api); +final digitalIdModule = DigitalIdModule(webAppModule); final fileUploader = FileUploader(api: api, messages: messagesModule); final RouteObserver> appRouteObserver = RouteObserver>(); @@ -92,6 +95,7 @@ void main() async { final pranksFuture = AppPranks.load(); final storiesFuture = AppStories.load(); final cacheLimitFuture = AppMediaCacheLimit.load(); + final digitalIdNativeFuture = AppDigitalIdNative.load(); final packageInfo = await packageInfoFuture; if (packageInfo.packageName == 'ru.oneme.app') { @@ -124,6 +128,7 @@ void main() async { AppPranks.current.value = await pranksFuture; AppStories.current.value = await storiesFuture; AppMediaCacheLimit.current.value = await cacheLimitFuture; + AppDigitalIdNative.current.value = await digitalIdNativeFuture; runApp( KometApp( initialLocale: initialLocale, diff --git a/lib/models/digital_id.dart b/lib/models/digital_id.dart new file mode 100644 index 0000000..3385583 --- /dev/null +++ b/lib/models/digital_id.dart @@ -0,0 +1,271 @@ +enum DigitalIdVerification { + valid, + invalid, + photoCreated, + notFound, + deviceMismatch, + liteValid, + unknown; + + static DigitalIdVerification fromValue(String? value) { + switch (value) { + case 'valid': + return DigitalIdVerification.valid; + case 'invalid': + return DigitalIdVerification.invalid; + case 'photo_created': + return DigitalIdVerification.photoCreated; + case 'not_found': + return DigitalIdVerification.notFound; + case 'device_mismatch': + return DigitalIdVerification.deviceMismatch; + case 'lite_valid': + return DigitalIdVerification.liteValid; + default: + return DigitalIdVerification.unknown; + } + } +} + +enum DigitalIdQrType { + m1('M1', 'max_confirm_age'), + m2('M2', 'max_cert_large_family'), + m3('M3', 'max_student_ticket'), + m4('M4', 'max_sor'), + m5('M5', 'max_single_benefits'), + m7('M7', 'max_invalid_certificate'), + m8('M8', 'max_pension_certificate'), + m11('M11', 'max_identify_verification'); + + const DigitalIdQrType(this.code, this.alias); + + final String code; + final String alias; +} + +class DigitalIdAddress { + final String? address; + final String? flat; + final String? frame; + final String? house; + final String? zipCode; + + const DigitalIdAddress({ + this.address, + this.flat, + this.frame, + this.house, + this.zipCode, + }); + + factory DigitalIdAddress.fromMap(Map map) { + return DigitalIdAddress( + address: map['address'] as String?, + flat: map['flat'] as String?, + frame: map['frame'] as String?, + house: map['house'] as String?, + zipCode: map['zip_code'] as String?, + ); + } + + String get formatted { + final parts = [ + if (address != null && address!.isNotEmpty) address!, + if (house != null && house!.isNotEmpty) 'д. $house', + if (frame != null && frame!.isNotEmpty) 'к. $frame', + if (flat != null && flat!.isNotEmpty) 'кв. $flat', + ]; + return parts.join(', '); + } +} + +class DigitalIdBiometryStatus { + final bool hasBiometryToken; + final String? deviceId; + final bool hasPhotoHash; + + const DigitalIdBiometryStatus({ + required this.hasBiometryToken, + required this.deviceId, + required this.hasPhotoHash, + }); + + factory DigitalIdBiometryStatus.fromMap(Map map) { + return DigitalIdBiometryStatus( + hasBiometryToken: map['has_biometry_token'] == true, + deviceId: map['device_id'] as String?, + hasPhotoHash: map['has_photo_hash'] == true, + ); + } +} + +class DigitalIdDocument { + final String type; + final Map fields; + + const DigitalIdDocument({required this.type, required this.fields}); + + factory DigitalIdDocument.fromMap(Map map) { + final fields = {}; + for (final entry in map.entries) { + fields[entry.key.toString()] = entry.value; + } + return DigitalIdDocument( + type: (map['type'] as String?) ?? 'unknown', + fields: fields, + ); + } + + String? get firstName => fields['first_name'] as String?; + String? get lastName => fields['last_name'] as String?; + String? get middleName => fields['middle_name'] as String?; + String? get number => fields['number'] as String?; + String? get series => fields['series'] as String?; +} + +class DigitalIdProfile { + final String? firstName; + final String? lastName; + final String? middleName; + final String? birthDate; + final String? birthPlace; + final String? gender; + final String? snils; + final String? inn; + final DigitalIdAddress? registrationAddress; + final List documents; + + const DigitalIdProfile({ + this.firstName, + this.lastName, + this.middleName, + this.birthDate, + this.birthPlace, + this.gender, + this.snils, + this.inn, + this.registrationAddress, + this.documents = const [], + }); + + factory DigitalIdProfile.fromMap(Map map) { + final rawDocs = map['documents']; + final documents = []; + if (rawDocs is List) { + for (final doc in rawDocs) { + if (doc is Map) documents.add(DigitalIdDocument.fromMap(doc)); + } + } + final address = map['registration_address']; + return DigitalIdProfile( + firstName: map['first_name'] as String?, + lastName: map['last_name'] as String?, + middleName: map['middle_name'] as String?, + birthDate: map['birth_date'] as String?, + birthPlace: map['birth_place'] as String?, + gender: map['gender'] as String?, + snils: map['snils'] as String?, + inn: map['inn'] as String?, + registrationAddress: + address is Map ? DigitalIdAddress.fromMap(address) : null, + documents: documents, + ); + } + + String get fullName { + final parts = [ + if (lastName != null && lastName!.isNotEmpty) lastName!, + if (firstName != null && firstName!.isNotEmpty) firstName!, + if (middleName != null && middleName!.isNotEmpty) middleName!, + ]; + return parts.join(' '); + } +} + +class DigitalIdUserDocs { + final int userId; + final DigitalIdProfile profile; + + const DigitalIdUserDocs({required this.userId, required this.profile}); + + factory DigitalIdUserDocs.fromMap(Map map) { + final profile = map['digital_profile']; + return DigitalIdUserDocs( + userId: (map['user_id'] as num?)?.toInt() ?? 0, + profile: profile is Map + ? DigitalIdProfile.fromMap(profile) + : const DigitalIdProfile(), + ); + } +} + +class DigitalIdEsiaLink { + final String? state; + final String url; + + const DigitalIdEsiaLink({this.state, required this.url}); + + factory DigitalIdEsiaLink.fromMap(Map map) { + return DigitalIdEsiaLink( + state: map['state'] as String?, + url: map['url'] as String? ?? '', + ); + } +} + +class DigitalIdQr { + final String qr; + final String? qrGost; + + const DigitalIdQr({required this.qr, this.qrGost}); + + factory DigitalIdQr.fromMap(Map map) { + return DigitalIdQr( + qr: map['qr'] as String? ?? '', + qrGost: map['qr_gost'] as String?, + ); + } +} + +class DigitalIdUniversalQr { + final String uidHash; + final String? phone; + final String? sessionId; + + const DigitalIdUniversalQr({ + required this.uidHash, + this.phone, + this.sessionId, + }); + + factory DigitalIdUniversalQr.fromMap(Map map) { + return DigitalIdUniversalQr( + uidHash: map['uid_hash'] as String? ?? '', + phone: map['phone'] as String?, + sessionId: map['session_id'] as String?, + ); + } +} + +class DigitalIdAcmsCard { + final String id; + final String inn; + final String companyName; + final String logoImg; + + const DigitalIdAcmsCard({ + required this.id, + required this.inn, + required this.companyName, + required this.logoImg, + }); + + factory DigitalIdAcmsCard.fromMap(Map map) { + return DigitalIdAcmsCard( + id: map['id'] as String? ?? '', + inn: map['inn'] as String? ?? '', + companyName: map['company_name'] as String? ?? '', + logoImg: map['logo_img'] as String? ?? '', + ); + } +}