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:
@@ -1297,23 +1297,25 @@ class AccountModule {
|
||||
Future<void> _persistEntryBannerApps(int accountId, Map serverConfig) async {
|
||||
final banners = serverConfig['settings-entry-banners'];
|
||||
if (banners is! List) return;
|
||||
final resolved = <String, int>{};
|
||||
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<String, dynamic> _extractChatMarker(List<Map> chats) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> iconMatchers = {
|
||||
sferumKey: 'sferum',
|
||||
digitalIdKey: 'digital',
|
||||
};
|
||||
}
|
||||
|
||||
class WebAppLaunch {
|
||||
@@ -46,6 +52,16 @@ class WebAppModule {
|
||||
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 {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return null;
|
||||
|
||||
@@ -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_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<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(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
|
||||
@@ -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<SettingsTab> {
|
||||
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,
|
||||
|
||||
@@ -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<PageRoute<dynamic>> appRouteObserver =
|
||||
RouteObserver<PageRoute<dynamic>>();
|
||||
@@ -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,
|
||||
|
||||
@@ -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? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user