Реализован функционал Цифрового 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) — в настройках разработчика.
500 lines
16 KiB
Dart
500 lines
16 KiB
Dart
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('Повторить'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|