feat(digital-id): Цифровой ID — WebView-мост к оригиналу + нативный режим

Реализован функционал Цифрового 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) — в настройках разработчика.
This commit is contained in:
klockky
2026-06-08 18:23:23 +03:00
parent 7ad258f6c0
commit 06b5720638
10 changed files with 1624 additions and 8 deletions
+9 -7
View File
@@ -1297,23 +1297,25 @@ class AccountModule {
Future<void> _persistEntryBannerApps(int accountId, Map serverConfig) async { Future<void> _persistEntryBannerApps(int accountId, Map serverConfig) async {
final banners = serverConfig['settings-entry-banners']; final banners = serverConfig['settings-entry-banners'];
if (banners is! List) return; if (banners is! List) return;
final resolved = <String, int>{};
for (final banner in banners) { for (final banner in banners) {
final items = (banner is Map) ? banner['items'] : null; final items = (banner is Map) ? banner['items'] : null;
if (items is! List) continue; if (items is! List) continue;
for (final item in items) { for (final item in items) {
if (item is! Map) continue; if (item is! Map) continue;
final appId = item['appid']; final appId = item['appid'];
if (appId is! int) continue;
final icon = item['icon']?.toString().toLowerCase() ?? ''; final icon = item['icon']?.toString().toLowerCase() ?? '';
if (appId is int && icon.contains('sferum')) { for (final entry in EntryBannerApps.iconMatchers.entries) {
await AppDatabase.setSyncValue( if (!resolved.containsKey(entry.key) && icon.contains(entry.value)) {
accountId, resolved[entry.key] = appId;
EntryBannerApps.sferumKey, }
appId.toString(),
);
return;
} }
} }
} }
for (final entry in resolved.entries) {
await AppDatabase.setSyncValue(accountId, entry.key, entry.value.toString());
}
} }
Map<String, dynamic> _extractChatMarker(List<Map> chats) { Map<String, dynamic> _extractChatMarker(List<Map> chats) {
+341
View File
@@ -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<String> _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<String> 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<String> _generateDeviceId() async {
final rnd = Random.secure();
final bytes = List<int>.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<dynamic> _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<DigitalIdBiometryStatus> biometryStatus() async {
final decoded = await _send('GET', '/v2/digital-id/biometry-status');
return DigitalIdBiometryStatus.fromMap(_unwrapData(decoded));
}
Future<String> 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<String> 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<DigitalIdUserDocs?> 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<DigitalIdEsiaLink> createEsiaLink() async {
final decoded = await _send('GET', '/v2/digital-id/create-esia-link');
return DigitalIdEsiaLink.fromMap(decoded is Map ? decoded : const {});
}
Future<DigitalIdVerification> 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<bool> 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<void> updateGuHashes() async {
await _send('POST', '/v2/digital-id/update-gu-hashes', body: const {});
}
Future<DigitalIdUniversalQr> userQr(String token) async {
final decoded = await _send('POST', '/v3/digital-id/user-qr', body: {
'token': token,
});
return DigitalIdUniversalQr.fromMap(_unwrapData(decoded));
}
Future<DigitalIdQr> 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<List<DigitalIdAcmsCard>> getCardsList({
String? passStatus,
String? inn,
}) async {
final query = <String, String>{
'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>()
.map(DigitalIdAcmsCard.fromMap)
.toList(growable: false);
}
Future<void> 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<void> createLiteProfile(String deviceId) async {
await _send('POST', '/v2/digital-id/create-lite-profile', body: {
'device_id': deviceId,
});
}
Future<void> deleteProfile() async {
await _send('DELETE', '/v3/digital-id/delete-profile');
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await AppDatabase.setSyncValue(accountId, _tokenKey, '');
}
}
Future<String?> _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<void> _saveToken(String token) async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await AppDatabase.setSyncValue(accountId, _tokenKey, token);
}
}
Future<String> 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<DigitalIdUserDocs?> 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;
}
}
+16
View File
@@ -5,6 +5,12 @@ import '../../core/storage/token_storage.dart';
abstract class EntryBannerApps { abstract class EntryBannerApps {
static const String sferumKey = 'entry_banner_app_sferum'; static const String sferumKey = 'entry_banner_app_sferum';
static const String digitalIdKey = 'entry_banner_app_digital_id';
static const Map<String, String> iconMatchers = {
sferumKey: 'sferum',
digitalIdKey: 'digital',
};
} }
class WebAppLaunch { class WebAppLaunch {
@@ -46,6 +52,16 @@ class WebAppModule {
return fetchLaunch(botId); return fetchLaunch(botId);
} }
Future<WebAppLaunch> fetchDigitalId() async {
final botId = await _resolveEntryApp(EntryBannerApps.digitalIdKey);
if (botId == null) {
throw const WebAppUnavailable(
'Цифровой ID сейчас недоступен. Переподключитесь и попробуйте снова.',
);
}
return fetchLaunch(botId);
}
Future<int?> _resolveEntryApp(String key) async { Future<int?> _resolveEntryApp(String key) async {
final accountId = await TokenStorage.getActiveAccountId(); final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return null; if (accountId == null) return null;
+18
View File
@@ -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<bool> current = ValueNotifier(false);
static Future<bool> load() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(prefKey) ?? false;
}
static Future<void> save(bool value) async {
current.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(prefKey, value);
}
}
@@ -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<String, String> _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<DigitalIdScreen> createState() => _DigitalIdScreenState();
}
class _DigitalIdScreenState extends State<DigitalIdScreen> {
bool _loading = true;
bool _busy = false;
String? _error;
bool _needsGosuslugi = false;
DigitalIdUserDocs? _docs;
DigitalIdBiometryStatus? _biometry;
List<DigitalIdAcmsCard> _cards = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<void> _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<void> _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<Widget> _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 = <String>[
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<Widget> _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('Повторить'),
),
],
),
),
);
}
}
@@ -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<void> 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<DigitalIdWebScreen> createState() => _DigitalIdWebScreenState();
}
class _DigitalIdWebScreenState extends State<DigitalIdWebScreen> {
InAppWebViewController? _controller;
WebAppLaunch? _launch;
String? _loadError;
double _progress = 0;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<bool> _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>([
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('Повторить')),
],
),
),
);
}
}
@@ -6,6 +6,7 @@ import '../../../backend/modules/chats.dart';
import '../../../core/config/app_swipe_back_desktop.dart'; import '../../../core/config/app_swipe_back_desktop.dart';
import '../../../core/config/app_pranks.dart'; import '../../../core/config/app_pranks.dart';
import '../../../core/config/app_stories.dart'; import '../../../core/config/app_stories.dart';
import '../../../core/config/app_digital_id_mode.dart';
import '../../../core/config/app_media_cache.dart'; import '../../../core/config/app_media_cache.dart';
import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/opcode_map.dart';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
@@ -18,6 +19,7 @@ import '../../widgets/custom_notification.dart';
import '../../widgets/sheet_helpers.dart'; import '../../widgets/sheet_helpers.dart';
import '../../widgets/login_success_screen.dart'; import '../../widgets/login_success_screen.dart';
import '../calls/call_screen.dart'; import '../calls/call_screen.dart';
import '../digital_id/digital_id_web_screen.dart';
class DebugMenuScreen extends StatefulWidget { class DebugMenuScreen extends StatefulWidget {
const DebugMenuScreen({super.key}); const DebugMenuScreen({super.key});
@@ -542,6 +544,130 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
), ),
), ),
), ),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: ValueListenableBuilder<bool>(
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( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
+14 -1
View File
@@ -15,6 +15,9 @@ import '../../widgets/komet_avatar.dart';
import '../../widgets/sheet_helpers.dart'; import '../../widgets/sheet_helpers.dart';
import '../auth/login_screen.dart'; import '../auth/login_screen.dart';
import '../auth/proxy_settings_sheet.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 '../webapp/web_app_screen.dart';
import 'cloud_storage_screen.dart'; import 'cloud_storage_screen.dart';
import 'customization_screen.dart'; import 'customization_screen.dart';
@@ -249,9 +252,19 @@ class _SettingsTabState extends State<SettingsTab> {
context, context,
cs, cs,
items: [ items: [
const _SettingsItem( _SettingsItem(
icon: Symbols.badge, icon: Symbols.badge,
label: 'Цифровой ID', label: 'Цифровой ID',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AppDigitalIdNative.current.value
? const DigitalIdScreen()
: const DigitalIdWebScreen(),
),
);
},
), ),
_SettingsItem( _SettingsItem(
icon: Symbols.language, icon: Symbols.language,
+5
View File
@@ -24,6 +24,7 @@ import 'core/config/app_stories.dart';
import 'core/config/app_media_cache.dart'; import 'core/config/app_media_cache.dart';
import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_mode.dart';
import 'core/config/app_theme_schedule.dart'; import 'core/config/app_theme_schedule.dart';
import 'core/config/app_digital_id_mode.dart';
import 'backend/modules/account.dart'; import 'backend/modules/account.dart';
import 'backend/modules/chats.dart'; import 'backend/modules/chats.dart';
import 'backend/modules/contacts.dart'; import 'backend/modules/contacts.dart';
@@ -31,6 +32,7 @@ import 'backend/modules/file_uploader.dart';
import 'backend/modules/messages.dart'; import 'backend/modules/messages.dart';
import 'backend/modules/polls.dart'; import 'backend/modules/polls.dart';
import 'backend/modules/webapp.dart'; import 'backend/modules/webapp.dart';
import 'backend/modules/digital_id.dart';
import 'core/push/push_service.dart'; import 'core/push/push_service.dart';
import 'core/storage/app_database.dart'; import 'core/storage/app_database.dart';
import 'core/transport/tls_config.dart'; import 'core/transport/tls_config.dart';
@@ -49,6 +51,7 @@ final accountModule = AccountModule(api);
final messagesModule = MessagesModule(api); final messagesModule = MessagesModule(api);
final pollsModule = PollsModule(api); final pollsModule = PollsModule(api);
final webAppModule = WebAppModule(api); final webAppModule = WebAppModule(api);
final digitalIdModule = DigitalIdModule(webAppModule);
final fileUploader = FileUploader(api: api, messages: messagesModule); final fileUploader = FileUploader(api: api, messages: messagesModule);
final RouteObserver<PageRoute<dynamic>> appRouteObserver = final RouteObserver<PageRoute<dynamic>> appRouteObserver =
RouteObserver<PageRoute<dynamic>>(); RouteObserver<PageRoute<dynamic>>();
@@ -92,6 +95,7 @@ void main() async {
final pranksFuture = AppPranks.load(); final pranksFuture = AppPranks.load();
final storiesFuture = AppStories.load(); final storiesFuture = AppStories.load();
final cacheLimitFuture = AppMediaCacheLimit.load(); final cacheLimitFuture = AppMediaCacheLimit.load();
final digitalIdNativeFuture = AppDigitalIdNative.load();
final packageInfo = await packageInfoFuture; final packageInfo = await packageInfoFuture;
if (packageInfo.packageName == 'ru.oneme.app') { if (packageInfo.packageName == 'ru.oneme.app') {
@@ -124,6 +128,7 @@ void main() async {
AppPranks.current.value = await pranksFuture; AppPranks.current.value = await pranksFuture;
AppStories.current.value = await storiesFuture; AppStories.current.value = await storiesFuture;
AppMediaCacheLimit.current.value = await cacheLimitFuture; AppMediaCacheLimit.current.value = await cacheLimitFuture;
AppDigitalIdNative.current.value = await digitalIdNativeFuture;
runApp( runApp(
KometApp( KometApp(
initialLocale: initialLocale, initialLocale: initialLocale,
+271
View File
@@ -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 = <String>[
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<String, dynamic> fields;
const DigitalIdDocument({required this.type, required this.fields});
factory DigitalIdDocument.fromMap(Map map) {
final fields = <String, dynamic>{};
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<DigitalIdDocument> 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 = <DigitalIdDocument>[];
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 = <String>[
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? ?? '',
);
}
}