From f9be6abd4a4777e1924b781699e6c483dcd56aae Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Mon, 6 Apr 2026 22:04:22 +0700 Subject: [PATCH] =?UTF-8?q?=D0=B1=D0=BB=D1=8F=20=D0=B0=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BA=20=D0=BC=D0=BD=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=83=20=D0=BC=D0=B5=D0=BD=D1=8F=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D0=BF=D0=B1=D0=BB=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 476 ++++++ lib/core/storage/app_database.dart | 63 +- lib/core/transport/sender.dart | 2 - .../profile/password_entry_screen.dart | 1488 +++++++++++++++++ .../screens/profile/security_screen.dart | 898 +++++++++- pubspec.lock | 16 +- 6 files changed, 2843 insertions(+), 100 deletions(-) create mode 100644 lib/frontend/screens/profile/password_entry_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index bb4f03d..52fe39d 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; @@ -9,6 +10,184 @@ import 'chats.dart'; import 'contacts.dart'; import 'folders.dart'; +class PrivacyConfig { + final String searchByPhone; + final String incomingCall; + final bool doubleTapReactionDisabled; + final bool safeModeNoPin; + final String? doubleTapReactionValue; + final String familyProtection; + final bool pushDetails; + final bool hidden; + final String chatsInvite; + final bool pushNewContacts; + final bool unsafeFiles; + final String inactiveTtl; + final bool showReadMark; + final bool altKeyboard; + final bool contentLevelAccess; + final String stickersSuggest; + final bool safeMode; + final bool audioTranscriptionEnabled; + final String hash; + + const PrivacyConfig({ + required this.searchByPhone, + required this.incomingCall, + required this.doubleTapReactionDisabled, + required this.safeModeNoPin, + this.doubleTapReactionValue, + required this.familyProtection, + required this.pushDetails, + required this.hidden, + required this.chatsInvite, + required this.pushNewContacts, + required this.unsafeFiles, + required this.inactiveTtl, + required this.showReadMark, + required this.altKeyboard, + required this.contentLevelAccess, + required this.stickersSuggest, + required this.safeMode, + required this.audioTranscriptionEnabled, + required this.hash, + }); + + factory PrivacyConfig.fromMap(Map map) { + return PrivacyConfig( + searchByPhone: map['SEARCH_BY_PHONE']?.toString() ?? 'ALL', + incomingCall: map['INCOMING_CALL']?.toString() ?? 'CONTACTS', + doubleTapReactionDisabled: map['DOUBLE_TAP_REACTION_DISABLED'] ?? false, + safeModeNoPin: map['SAFE_MODE_NO_PIN'] ?? false, + doubleTapReactionValue: map['DOUBLE_TAP_REACTION_VALUE']?.toString(), + familyProtection: map['FAMILY_PROTECTION']?.toString() ?? 'OFF', + pushDetails: map['PUSH_DETAILS'] ?? false, + hidden: map['HIDDEN'] ?? true, + chatsInvite: map['CHATS_INVITE']?.toString() ?? 'CONTACTS', + pushNewContacts: map['PUSH_NEW_CONTACTS'] ?? false, + unsafeFiles: map['UNSAFE_FILES'] ?? true, + inactiveTtl: map['INACTIVE_TTL']?.toString() ?? '6M', + showReadMark: map['SHOW_READ_MARK'] ?? true, + altKeyboard: map['ALT_KEYBOARD'] ?? false, + contentLevelAccess: map['CONTENT_LEVEL_ACCESS'] ?? false, + stickersSuggest: map['STICKERS_SUGGEST']?.toString() ?? 'ON', + safeMode: map['SAFE_MODE'] ?? false, + audioTranscriptionEnabled: map['AUDIO_TRANSCRIPTION_ENABLED'] ?? true, + hash: map['hash']?.toString() ?? '', + ); + } + + String toJson() => jsonEncode({ + 'SEARCH_BY_PHONE': searchByPhone, + 'INCOMING_CALL': incomingCall, + 'DOUBLE_TAP_REACTION_DISABLED': doubleTapReactionDisabled, + 'SAFE_MODE_NO_PIN': safeModeNoPin, + 'DOUBLE_TAP_REACTION_VALUE': doubleTapReactionValue, + 'FAMILY_PROTECTION': familyProtection, + 'PUSH_DETAILS': pushDetails, + 'HIDDEN': hidden, + 'CHATS_INVITE': chatsInvite, + 'PUSH_NEW_CONTACTS': pushNewContacts, + 'UNSAFE_FILES': unsafeFiles, + 'INACTIVE_TTL': inactiveTtl, + 'SHOW_READ_MARK': showReadMark, + 'ALT_KEYBOARD': altKeyboard, + 'CONTENT_LEVEL_ACCESS': contentLevelAccess, + 'STICKERS_SUGGEST': stickersSuggest, + 'SAFE_MODE': safeMode, + 'AUDIO_TRANSCRIPTION_ENABLED': audioTranscriptionEnabled, + 'hash': hash, + }); + + factory PrivacyConfig.fromJson(String json) { + try { + final map = jsonDecode(json) as Map; + return PrivacyConfig.fromMap(map); + } catch (_) { + return PrivacyConfig.empty(); + } + } + + static PrivacyConfig empty() { + return const PrivacyConfig( + searchByPhone: 'ALL', + incomingCall: 'CONTACTS', + doubleTapReactionDisabled: false, + safeModeNoPin: false, + familyProtection: 'OFF', + pushDetails: false, + hidden: true, + chatsInvite: 'CONTACTS', + pushNewContacts: false, + unsafeFiles: true, + inactiveTtl: '6M', + showReadMark: true, + altKeyboard: false, + contentLevelAccess: false, + stickersSuggest: 'ON', + safeMode: false, + audioTranscriptionEnabled: true, + hash: '', + ); + } +} + +class BlockedContact { + final int id; + final String? firstName; + final String? lastName; + final String? baseUrl; + final int? photoId; + final String status; + final int registrationTime; + final int updateTime; + + const BlockedContact({ + required this.id, + this.firstName, + this.lastName, + this.baseUrl, + this.photoId, + required this.status, + required this.registrationTime, + required this.updateTime, + }); + + factory BlockedContact.fromMap(Map map) { + String? firstName; + String? lastName; + final names = map['names'] as List?; + if (names != null && names.isNotEmpty) { + for (final n in names) { + if (n is Map) { + firstName = n['firstName'] as String?; + lastName = n['lastName'] as String?; + if (n['type'] == 'ONEME') break; + } + } + } + + return BlockedContact( + id: map['id'] as int? ?? 0, + firstName: firstName, + lastName: lastName, + baseUrl: map['baseUrl'] as String?, + photoId: map['photoId'] as int?, + status: map['status']?.toString() ?? 'BLOCKED', + registrationTime: map['registrationTime'] as int? ?? 0, + updateTime: map['updateTime'] as int? ?? 0, + ); + } +} + +class TwoFactorDetails { + final bool enabled; + final String? email; + final String? hint; + + const TwoFactorDetails({required this.enabled, this.email, this.hint}); +} + enum AuthRequestType { startAuth('START_AUTH'), resend('RESEND'), @@ -182,6 +361,303 @@ class AccountModule { Stream get loginStatusStream => _loginStatusController.stream; + Future getPrivacyConfig() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + final saved = await AppDatabase.getPrivacyConfig(accountId); + if (saved != null) { + return PrivacyConfig.fromJson(saved); + } + } + return PrivacyConfig.empty(); + } + + Future> getBlockedContacts() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.contactList, { + 'status': 'BLOCKED', + 'count': 100, + 'from': 0, + }); + _checkPacketError(packet, 'getBlockedContacts'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'getBlockedContacts: неожиданный тип payload: ${data.runtimeType}', + ); + } + final contacts = data['contacts'] as List?; + if (contacts == null) return []; + return contacts + .whereType() + .map((c) => BlockedContact.fromMap(c.cast())) + .toList(); + } + + Future updatePrivacyConfig( + Map settings, + ) async { + _ensureOnline(); + final payload = { + 'settings': {'user': settings}, + }; + final packet = await _api.sendRequest(Opcode.config, payload); + _checkPacketError(packet, 'updatePrivacyConfig'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'updatePrivacyConfig: неожиданный тип payload: ${data.runtimeType}', + ); + } + final user = data['user']; + if (user is! Map) { + throw Exception('updatePrivacyConfig: отсутствует user в payload'); + } + final config = PrivacyConfig.fromMap(user.cast()); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.savePrivacyConfig(accountId, config.toJson()); + } + return config; + } + + // 2FA Creation (when not set) + Future create2faTrack() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authCreateTrack, {'type': 0}); + _checkPacketError(packet, 'create2faTrack'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'create2faTrack: неожиданный тип payload: ${data.runtimeType}', + ); + } + final trackId = data['trackId'] as String?; + if (trackId == null) { + throw Exception('create2faTrack: отсутствует trackId'); + } + return trackId; + } + + Future set2faPassword(String trackId, String password) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authValidatePassword, { + 'trackId': trackId, + 'password': password, + }); + _checkPacketError(packet, 'set2faPassword'); + if (packet.payload != null && packet.payload is! Map) { + throw Exception('set2faPassword: неожиданный ответ'); + } + } + + Future set2faHint(String trackId, String hint) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authValidateHint, { + 'trackId': trackId, + 'hint': hint, + }); + _checkPacketError(packet, 'set2faHint'); + if (packet.payload != null && packet.payload is! Map) { + throw Exception('set2faHint: неожиданный ответ'); + } + } + + Future verify2faEmail(String trackId, String email) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authVerifyEmail, { + 'trackId': trackId, + 'email': email, + }); + _checkPacketError(packet, 'verify2faEmail'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'verify2faEmail: неожиданный тип payload: ${data.runtimeType}', + ); + } + final blockingDuration = data['blockingDuration'] as int? ?? 60; + return blockingDuration; + } + + Future verify2faCode(String trackId, String code) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authCheckEmail, { + 'trackId': trackId, + 'verifyCode': code, + }); + _checkPacketError(packet, 'verify2faCode'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'verify2faCode: неожиданный тип payload: ${data.runtimeType}', + ); + } + final email = data['email'] as String? ?? ''; + return email; + } + + Future confirm2fa({ + required String trackId, + required String password, + String? hint, + }) async { + _ensureOnline(); + final payload = { + 'expectedCapabilities': [0, 3, 4], + 'trackId': trackId, + 'password': password, + }; + if (hint != null) payload['hint'] = hint; + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'confirm2fa'); + return _processProfileUpdate(packet); + } + + // 2FA Management (when already set) + Future enter2faPanel() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authCreateTrack, {'type': 0}); + _checkPacketError(packet, 'enter2faPanel'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'enter2faPanel: неожиданный тип payload: ${data.runtimeType}', + ); + } + final trackId = data['trackId'] as String?; + if (trackId == null) { + throw Exception('enter2faPanel: отсутствует trackId'); + } + return trackId; + } + + Future get2faDetails(String trackId) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.auth2faDetails, { + 'trackId': trackId, + }); + _checkPacketError(packet, 'get2faDetails'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'get2faDetails: неожиданный тип payload: ${data.runtimeType}', + ); + } + final password = data['password'] as Map?; + return TwoFactorDetails( + enabled: password?['enabled'] ?? false, + email: password?['email'] as String?, + hint: password?['hint'] as String?, + ); + } + + Future check2faPassword(String trackId, String password) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, { + 'trackId': trackId, + 'password': password, + }); + _checkPacketError(packet, 'check2faPassword'); + final data = packet.payload; + if (data is Map && data['error'] != null) { + throw Exception('Неверный пароль'); + } + } + + Future update2faPassword({ + required String trackId, + required String newPassword, + String? hint, + }) async { + _ensureOnline(); + final validatePacket = await _api.sendRequest(Opcode.authValidatePassword, { + 'trackId': trackId, + 'password': newPassword, + }); + _checkPacketError(validatePacket, 'update2faPassword: validate'); + if (validatePacket.payload != null && validatePacket.payload is! Map) { + throw Exception('update2faPassword: неожиданный ответ при валидации'); + } + + if (hint != null) { + final hintPacket = await _api.sendRequest(Opcode.authValidateHint, { + 'trackId': trackId, + 'hint': hint, + }); + _checkPacketError(hintPacket, 'update2faPassword: hint'); + } + + final payload = { + 'expectedCapabilities': [1, 3], + 'trackId': trackId, + 'password': newPassword, + }; + if (hint != null) payload['hint'] = hint; + + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'update2faPassword'); + return _processProfileUpdate(packet); + } + + Future update2faEmail({ + required String trackId, + required String email, + required String code, + }) async { + _ensureOnline(); + final verifyPacket = await _api.sendRequest(Opcode.authVerifyEmail, { + 'trackId': trackId, + 'email': email, + }); + _checkPacketError(verifyPacket, 'update2faEmail: verify'); + + final codePacket = await _api.sendRequest(Opcode.authCheckEmail, { + 'trackId': trackId, + 'verifyCode': code, + }); + _checkPacketError(codePacket, 'update2faEmail: code'); + + final payload = { + 'expectedCapabilities': [4], + 'trackId': trackId, + }; + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'update2faEmail'); + return _processProfileUpdate(packet); + } + + Future remove2fa(String trackId) async { + _ensureOnline(); + final payload = { + 'expectedCapabilities': [5], + 'trackId': trackId, + 'remove2fa': true, + }; + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'remove2fa'); + return _processProfileUpdate(packet); + } + + Future _processProfileUpdate(Packet packet) async { + _api.registerPushHandler(Opcode.notifProfile, (p) {}); + await for (final push in _api.pushStream.where( + (p) => p.opcode == Opcode.notifProfile, + )) { + final payload = push.payload; + if (payload is Map) { + final profile = payload['profile']; + if (profile is Map) { + final contact = profile['contact']; + if (contact is Map) { + return ProfileData.fromServerMap(contact.cast()); + } + } + } + } + throw Exception('Не удалось получить обновлённый профиль'); + } + Future requestCode( String phone, { String language = 'ru', diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index ccd6191..f9bb88e 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -14,6 +14,7 @@ class ProfileData { final String country; final int accountStatus; final int updateTime; + final List? profileOptions; ProfileData({ required this.id, @@ -26,6 +27,7 @@ class ProfileData { required this.country, required this.accountStatus, required this.updateTime, + this.profileOptions, }); factory ProfileData.fromServerMap(Map contact) { @@ -44,6 +46,12 @@ class ProfileData { lastName = name['lastName'] as String?; } + final profileOptionsRaw = contact['profileOptions']; + List? profileOptions; + if (profileOptionsRaw is List) { + profileOptions = profileOptionsRaw.map((e) => e as int).toList(); + } + return ProfileData( id: contact['id'] as int, firstName: firstName, @@ -55,21 +63,31 @@ class ProfileData { country: (contact['country'] as String?) ?? '', accountStatus: (contact['accountStatus'] as int?) ?? 0, updateTime: (contact['updateTime'] as int?) ?? 0, + profileOptions: profileOptions, ); } factory ProfileData.fromDbRow(Map row) { + final profileOptionsStr = row['profile_options'] as String?; + List? profileOptions; + if (profileOptionsStr != null && profileOptionsStr.isNotEmpty) { + profileOptions = profileOptionsStr + .split(',') + .map((e) => int.parse(e.trim())) + .toList(); + } return ProfileData( id: row['id'] as int, - firstName: row['first_name'] as String, + firstName: (row['first_name'] as String?) ?? '', lastName: row['last_name'] as String?, - phone: row['phone'] as int, + phone: (row['phone'] as int?) ?? 0, photoId: row['photo_id'] as int?, baseUrl: row['base_url'] as String?, baseRawUrl: row['base_raw_url'] as String?, - country: row['country'] as String, - accountStatus: row['account_status'] as int, - updateTime: row['update_time'] as int, + country: (row['country'] as String?) ?? '', + accountStatus: (row['account_status'] as int?) ?? 0, + updateTime: (row['update_time'] as int?) ?? 0, + profileOptions: profileOptions, ); } @@ -84,6 +102,7 @@ class ProfileData { 'country': country, 'account_status': accountStatus, 'update_time': updateTime, + 'profile_options': profileOptions?.join(','), }; } @@ -119,7 +138,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 6, + version: 7, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -143,6 +162,11 @@ class AppDatabase { if (oldVersion < 6) { await db.execute(_messagesSchema); } + if (oldVersion < 7) { + await db.execute( + 'ALTER TABLE profile ADD COLUMN profile_options TEXT', + ); + } }, ); } @@ -160,7 +184,8 @@ class AppDatabase { country TEXT NOT NULL DEFAULT '', account_status INTEGER NOT NULL DEFAULT 0, update_time INTEGER NOT NULL DEFAULT 0, - is_active INTEGER NOT NULL DEFAULT 0 + is_active INTEGER NOT NULL DEFAULT 0, + profile_options TEXT ) '''); await db.execute(_syncStateSchema); @@ -318,6 +343,30 @@ class AppDatabase { }; } + static Future savePrivacyConfig( + int accountId, + String jsonConfig, + ) async { + final db = await _instance; + await db.insert('sync_state', { + 'account_id': accountId, + 'key': 'privacy_config', + 'value': jsonConfig, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future getPrivacyConfig(int accountId) async { + final db = await _instance; + final rows = await db.query( + 'sync_state', + where: 'account_id = ? AND key = ?', + whereArgs: [accountId, 'privacy_config'], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first['value'] as String; + } + static Future close() async { await _db?.close(); _db = null; diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart index 8ef642f..69e8c5d 100644 --- a/lib/core/transport/sender.dart +++ b/lib/core/transport/sender.dart @@ -2,7 +2,6 @@ import '../protocol/packet.dart'; import '../utils/logger.dart'; import 'connection.dart'; -/// Упаковывает и отправляет пакеты, ведёт счётчик seq. class PacketSender { int _seq = 0; @@ -13,7 +12,6 @@ class PacketSender { return _seq; } - /// Отправляет пакет, возвращает присвоенный seq. int send(Connection connection, int opcode, Map payload) { final seq = _nextSeq(); final data = packPacket(opcode, payload, seq: seq); diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart new file mode 100644 index 0000000..dca05e4 --- /dev/null +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -0,0 +1,1488 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show accountModule; +import '../../../backend/modules/account.dart' show TwoFactorDetails; +import '../../../core/storage/app_database.dart'; +import '../../widgets/custom_notification.dart'; + +class PasswordEntryScreen extends StatefulWidget { + const PasswordEntryScreen({super.key}); + + @override + State createState() => _PasswordEntryScreenState(); +} + +class _PasswordEntryScreenState extends State { + bool _isLoading = true; + bool _is2faEnabled = false; + String? _email; + String? _hint; + + @override + void initState() { + super.initState(); + _check2faStatus(); + } + + Future _check2faStatus() async { + try { + final profile = await AppDatabase.loadActiveProfile(); + if (mounted) { + setState(() { + _is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_isLoading) { + return Scaffold( + backgroundColor: cs.surface, + body: Center(child: CircularProgressIndicator(color: cs.primary)), + ); + } + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildAppBar(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildMainSection(cs), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Text( + 'Пароль для входа', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ], + ), + ); + } + + Widget _buildMainSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _is2faEnabled ? Symbols.lock : Symbols.lock_open, + color: cs.primary, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _is2faEnabled + ? 'Пароль установлен' + : 'Пароль не установлен', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + _is2faEnabled + ? 'Используется для дополнительной защиты' + : 'Двухфакторная аутентификация', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Symbols.settings, + label: _is2faEnabled ? 'Изменить пароль' : 'Установить пароль', + isLast: _is2faEnabled, + onTap: () => _navigateToPasswordSetup(context, cs), + ), + if (_is2faEnabled) ...[ + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Icons.email_outlined, + label: 'Изменить почту', + isLast: false, + onTap: () => _navigateToEmailChange(context, cs), + ), + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Icons.delete_outline, + label: 'Удалить пароль', + isLast: true, + textColor: cs.error, + onTap: () => _showRemoveConfirmation(context, cs), + ), + ], + ], + ), + ); + } + + Widget _buildActionRow( + ColorScheme cs, { + required IconData icon, + required String label, + required bool isLast, + required VoidCallback onTap, + Color? textColor, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + icon, + color: textColor ?? cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: textColor ?? cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + ), + ], + ); + } + + void _navigateToPasswordSetup(BuildContext context, ColorScheme cs) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const TwoFactorSetupScreen()), + ); + } + + void _navigateToEmailChange(BuildContext context, ColorScheme cs) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorEmailChangeScreen(), + ), + ); + } + + void _showRemoveConfirmation(BuildContext context, ColorScheme cs) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text('Удалить пароль?', style: TextStyle(color: cs.onSurface)), + content: Text( + 'Вы уверены, что хотите удалить пароль для входа? Это ослабит защиту вашего аккаунта.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorRemoveScreen(), + ), + ); + }, + child: Text('Удалить', style: TextStyle(color: cs.error)), + ), + ], + ), + ); + } +} + +class TwoFactorSetupScreen extends StatefulWidget { + const TwoFactorSetupScreen({super.key}); + + @override + State createState() => _TwoFactorSetupScreenState(); +} + +class _TwoFactorSetupScreenState extends State { + final _passwordController = TextEditingController(); + final _hintController = TextEditingController(); + final _emailController = TextEditingController(); + final _codeController = TextEditingController(); + + int _step = 0; + bool _isLoading = false; + String? _trackId; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + _hintController.dispose(); + _emailController.dispose(); + _codeController.dispose(); + super.dispose(); + } + + Future _nextStep() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + switch (_step) { + case 0: + final trackId = await accountModule.create2faTrack(); + setState(() { + _trackId = trackId; + _step = 1; + }); + break; + case 1: + if (_passwordController.text.length < 6) { + setState( + () => _errorMessage = 'Пароль должен быть минимум 6 символов', + ); + break; + } + await accountModule.set2faPassword( + _trackId!, + _passwordController.text, + ); + setState(() => _step = 2); + break; + case 2: + if (_hintController.text.isNotEmpty) { + await accountModule.set2faHint(_trackId!, _hintController.text); + } + setState(() => _step = 3); + break; + case 3: + if (!_emailController.text.contains('@')) { + setState(() => _errorMessage = 'Введите корректный email'); + break; + } + await accountModule.verify2faEmail(_trackId!, _emailController.text); + setState(() => _step = 4); + break; + case 4: + if (_codeController.text.length != 6) { + setState(() => _errorMessage = 'Введите 6-значный код'); + break; + } + await accountModule.verify2faCode(_trackId!, _codeController.text); + await accountModule.confirm2fa( + trackId: _trackId!, + password: _passwordController.text, + hint: _hintController.text.isEmpty ? null : _hintController.text, + ); + if (mounted) { + showCustomNotification(context, 'Пароль установлен'); + Navigator.popUntil( + context, + (route) => + route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + break; + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Установка пароля', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: _buildStepContent(cs), + ); + } + + Widget _buildStepContent(ColorScheme cs) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStepIndicator(cs), + const SizedBox(height: 24), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Symbols.error, color: cs.error, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: cs.onErrorContainer, + fontSize: 14, + ), + ), + ), + ], + ), + ), + _buildCurrentStep(cs), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _nextStep, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 4 ? 'Установить пароль' : 'Продолжить'), + ), + ), + ], + ), + ); + } + + Widget _buildStepIndicator(ColorScheme cs) { + final steps = ['Пароль', 'Подсказка', 'Почта', 'Код', 'Готово']; + return Row( + children: List.generate(steps.length, (index) { + final isActive = index <= _step; + final isCurrent = index == _step; + return Expanded( + child: Column( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive ? cs.primary : cs.surfaceContainerHighest, + ), + child: Center( + child: isActive + ? Icon(Symbols.check, color: cs.onPrimary, size: 16) + : Text( + '${index + 1}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + steps[index], + style: TextStyle( + color: isCurrent ? cs.primary : cs.onSurfaceVariant, + fontSize: 11, + fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ); + }), + ); + } + + Widget _buildCurrentStep(ColorScheme cs) { + switch (_step) { + case 0: + return _buildPasswordField(cs); + case 1: + return _buildPasswordConfirmField(cs); + case 2: + return _buildHintField(cs); + case 3: + return _buildEmailField(cs); + case 4: + return _buildCodeField(cs); + default: + return const SizedBox(); + } + } + + Widget _buildPasswordField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Придумайте пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Минимум 6 символов', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Введите пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildPasswordConfirmField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Подтвердите пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Введите пароль ещё раз', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Повторите пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildHintField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Подсказка для пароля', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Необязательно', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _hintController, + decoration: InputDecoration( + hintText: 'Введите подсказку (необязательно)', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildEmailField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Привяжите email', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Для восстановления пароля', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'example@mail.ru', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildCodeField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Введите код', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Код отправлен на ${_emailController.text}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration( + hintText: '000000', + counterText: '', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } +} + +class TwoFactorManageScreen extends StatefulWidget { + const TwoFactorManageScreen({super.key}); + + @override + State createState() => _TwoFactorManageScreenState(); +} + +class _TwoFactorManageScreenState extends State { + final _passwordController = TextEditingController(); + bool _isLoading = false; + bool _isAuthenticated = false; + String? _trackId; + TwoFactorDetails? _details; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _authenticate() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + _trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(_trackId!, _passwordController.text); + final details = await accountModule.get2faDetails(_trackId!); + setState(() { + _isAuthenticated = true; + _details = details; + }); + } catch (e) { + setState(() => _errorMessage = 'Неверный пароль'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Управление паролем', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: _isAuthenticated ? _buildManageContent(cs) : _buildAuthContent(cs), + ); + } + + Widget _buildAuthContent(ColorScheme cs) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Введите текущий пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _authenticate, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Продолжить'), + ), + ), + ], + ), + ); + } + + Widget _buildManageContent(ColorScheme cs) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Icon(Symbols.lock, color: cs.primary, size: 48), + const SizedBox(height: 12), + Text( + 'Пароль установлен', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + if (_details?.email != null) ...[ + const SizedBox(height: 4), + Text( + _details!.email!, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ], + if (_details?.hint != null && _details!.hint!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Подсказка: ${_details!.hint}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorPasswordChangeScreen(), + ), + ); + }, + icon: const Icon(Symbols.edit), + label: const Text('Изменить пароль'), + style: OutlinedButton.styleFrom( + foregroundColor: cs.primary, + side: BorderSide(color: cs.outline), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ); + } +} + +class TwoFactorPasswordChangeScreen extends StatefulWidget { + const TwoFactorPasswordChangeScreen({super.key}); + + @override + State createState() => + _TwoFactorPasswordChangeScreenState(); +} + +class _TwoFactorPasswordChangeScreenState + extends State { + final _passwordController = TextEditingController(); + final _hintController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + _hintController.dispose(); + super.dispose(); + } + + Future _changePassword() async { + if (_passwordController.text.length < 6) { + setState(() => _errorMessage = 'Пароль должен быть минимум 6 символов'); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(trackId, _passwordController.text); + await accountModule.update2faPassword( + trackId: trackId, + newPassword: _passwordController.text, + hint: _hintController.text.isEmpty ? null : _hintController.text, + ); + if (mounted) { + showCustomNotification(context, 'Пароль изменён'); + Navigator.popUntil( + context, + (route) => route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Изменить пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Новый пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Введите новый пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + Text( + 'Подсказка', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _hintController, + decoration: InputDecoration( + hintText: 'Введите подсказку (необязательно)', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _changePassword, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Сохранить'), + ), + ), + ], + ), + ), + ); + } +} + +class TwoFactorEmailChangeScreen extends StatefulWidget { + const TwoFactorEmailChangeScreen({super.key}); + + @override + State createState() => + _TwoFactorEmailChangeScreenState(); +} + +class _TwoFactorEmailChangeScreenState + extends State { + final _passwordController = TextEditingController(); + final _emailController = TextEditingController(); + final _codeController = TextEditingController(); + int _step = 0; + bool _isLoading = false; + String? _trackId; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + _emailController.dispose(); + _codeController.dispose(); + super.dispose(); + } + + Future _nextStep() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + switch (_step) { + case 0: + _trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword( + _trackId!, + _passwordController.text, + ); + setState(() => _step = 1); + break; + case 1: + if (!_emailController.text.contains('@')) { + setState(() => _errorMessage = 'Введите корректный email'); + break; + } + await accountModule.verify2faEmail(_trackId!, _emailController.text); + setState(() => _step = 2); + break; + case 2: + if (_codeController.text.length != 6) { + setState(() => _errorMessage = 'Введите 6-значный код'); + break; + } + await accountModule.verify2faCode(_trackId!, _codeController.text); + await accountModule.update2faEmail( + trackId: _trackId!, + email: _emailController.text, + code: _codeController.text, + ); + if (mounted) { + showCustomNotification(context, 'Почта изменена'); + Navigator.popUntil( + context, + (route) => + route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + break; + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Изменить почту', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_step == 0) ...[ + Text( + 'Введите текущий пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ] else ...[ + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + if (_step == 1) ...[ + Text( + 'Новая почта', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'example@mail.ru', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ] else ...[ + Text( + 'Введите код', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Код отправлен на ${_emailController.text}', + style: TextStyle(color: cs.onSurfaceVariant), + ), + const SizedBox(height: 16), + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration( + hintText: '000000', + counterText: '', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ], + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _nextStep, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 2 ? 'Сохранить' : 'Продолжить'), + ), + ), + ], + ), + ), + ); + } +} + +class TwoFactorRemoveScreen extends StatefulWidget { + const TwoFactorRemoveScreen({super.key}); + + @override + State createState() => _TwoFactorRemoveScreenState(); +} + +class _TwoFactorRemoveScreenState extends State { + final _passwordController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _remove2fa() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(trackId, _passwordController.text); + await accountModule.remove2fa(trackId); + if (mounted) { + showCustomNotification(context, 'Пароль удалён'); + Navigator.popUntil( + context, + (route) => route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Удаление пароля', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.errorContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Symbols.warning, color: cs.error), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Внимание! После удаления пароля защита вашего аккаунта ослабнет.', + style: TextStyle(color: cs.onSurface), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + 'Введите пароль для подтверждения', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _isLoading ? null : _remove2fa, + style: FilledButton.styleFrom( + backgroundColor: cs.error, + foregroundColor: cs.onError, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onError, + ), + ) + : const Text('Удалить пароль'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index a2c84d0..676396e 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -1,6 +1,12 @@ +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show accountModule; +import '../../../backend/modules/account.dart' + show PrivacyConfig, BlockedContact; +import '../../../core/storage/app_database.dart'; import '../../widgets/custom_notification.dart'; +import 'password_entry_screen.dart'; class SecurityScreen extends StatefulWidget { const SecurityScreen({super.key}); @@ -9,8 +15,73 @@ class SecurityScreen extends StatefulWidget { State createState() => _SecurityScreenState(); } -class _SecurityScreenState extends State { - bool _safeMode = false; +class _SecurityScreenState extends State + with SingleTickerProviderStateMixin { + bool _isLoading = true; + bool _isSaving = false; + bool _is2faEnabled = false; + PrivacyConfig? _privacyConfig; + List _blockedContacts = []; + late AnimationController _shimmerController; + + @override + void initState() { + super.initState(); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _loadData(); + } + + @override + void dispose() { + _shimmerController.dispose(); + super.dispose(); + } + + Future _loadData() async { + try { + final results = await Future.wait([ + accountModule.getPrivacyConfig(), + accountModule.getBlockedContacts(), + AppDatabase.loadActiveProfile(), + ]); + if (mounted) { + setState(() { + _privacyConfig = results[0] as PrivacyConfig; + _blockedContacts = results[1] as List; + final profile = results[2] as ProfileData?; + _is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка загрузки: $e'); + setState(() => _isLoading = false); + } + } + } + + Future _updateSetting(String key, dynamic value) async { + if (_isSaving) return; + setState(() => _isSaving = true); + try { + final newConfig = await accountModule.updatePrivacyConfig({key: value}); + if (mounted) { + setState(() => _privacyConfig = newConfig); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка сохранения: $e'); + } + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } @override Widget build(BuildContext context) { @@ -20,53 +91,100 @@ class _SecurityScreenState extends State { backgroundColor: cs.surface, body: SafeArea( bottom: false, - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - SliverToBoxAdapter(child: _buildAppBar(context, cs)), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _buildTopSection(cs), + child: _isLoading + ? _buildShimmer(cs) + : CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildAppBar(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildTopSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildPrivacySettings(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + child: _buildInfoLabel(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildConfidentialSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + child: _buildBlacklistSection(cs), + ), + ), + ], ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: _buildSafeModeSection(cs), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), - child: _buildInfoLabel(cs), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _buildOnlineSection(cs), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), - child: _buildBlacklistSection(cs), - ), - ), - ], - ), ), ); } + Widget _buildShimmer(ColorScheme cs) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + children: [ + _buildAppBar(context, cs), + _buildShimmerSection(cs, height: 104), + const SizedBox(height: 12), + _buildShimmerSection(cs, height: 280), + const SizedBox(height: 20), + _buildShimmerSection(cs, height: 220), + const SizedBox(height: 12), + _buildShimmerSection(cs, height: 120), + ], + ), + ); + } + + Widget _buildShimmerSection(ColorScheme cs, {required double height}) { + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + 0.2 * sin(_shimmerController.value * pi * 2); + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + height: height, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ); + }, + ); + } + Widget _buildAppBar(BuildContext context, ColorScheme cs) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Row( children: [ IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface, size: 24, weight: 400), + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), onPressed: () => Navigator.pop(context), ), const SizedBox(width: 4), @@ -79,11 +197,37 @@ class _SecurityScreenState extends State { fontFamily: 'Outfit', ), ), + const Spacer(), + if (_isSaving) + Padding( + padding: const EdgeInsets.only(right: 16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), + ), + ), ], ), ); } + String _getPrivacyLabel(String value) { + switch (value) { + case 'ALL': + return 'Все'; + case 'CONTACTS': + return 'Мои контакты'; + case 'NONE': + return 'Никто'; + default: + return value; + } + } + Widget _buildTopSection(ColorScheme cs) { return Container( decoration: BoxDecoration( @@ -92,19 +236,14 @@ class _SecurityScreenState extends State { ), child: Column( children: [ - _buildNavRow( - cs, - icon: Symbols.key, - label: 'Пароль для входа', - subtitle: 'Отключён', - trailing: _buildWarningBadge(cs), - isLast: false, - ), + _buildPasswordRow(cs), _buildNavRow( cs, icon: Symbols.shield, label: 'Семейная защита', - subtitle: 'Отключена', + subtitle: _privacyConfig?.familyProtection == 'ON' + ? 'Включена' + : 'Отключена', isLast: true, ), ], @@ -112,7 +251,82 @@ class _SecurityScreenState extends State { ); } - Widget _buildSafeModeSection(ColorScheme cs) { + Widget _buildPasswordRow(ColorScheme cs) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PasswordEntryScreen(), + ), + ); + }, + borderRadius: BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + Symbols.key, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Пароль для входа', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + _is2faEnabled ? 'Включён' : 'Отключён', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + _buildWarningBadge(cs), + const SizedBox(width: 4), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + + Widget _buildPrivacySettings(ColorScheme cs) { + final isSafeMode = _privacyConfig?.safeMode ?? false; return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, @@ -124,7 +338,12 @@ class _SecurityScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), child: Row( children: [ - Icon(Symbols.lock, color: cs.onSurfaceVariant, size: 22, weight: 400), + Icon( + Symbols.lock, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), const SizedBox(width: 16), Expanded( child: Column( @@ -140,7 +359,7 @@ class _SecurityScreenState extends State { ), const SizedBox(height: 2), Text( - 'Доступно только в мобильном приложении', + 'Скрывает личную информацию', style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -150,32 +369,366 @@ class _SecurityScreenState extends State { ), ), Switch( - value: _safeMode, - onChanged: (v) => setState(() => _safeMode = v), + value: isSafeMode, + onChanged: (v) => showCustomNotification( + context, + 'Изменение настроек пока недоступно', + ), ), ], ), ), - if (_safeMode) ...[ + if (isSafeMode) ...[ Padding( padding: const EdgeInsets.only(left: 58), - child: Divider(height: 1, thickness: 1, color: cs.outlineVariant.withValues(alpha: 0.35)), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + _buildSubRow( + cs, + label: 'Найти меня по номеру', + value: _getPrivacyLabel(_privacyConfig?.searchByPhone ?? 'ALL'), + isLast: false, + ), + _buildSubRow( + cs, + label: 'Кто может мне звонить', + value: _getPrivacyLabel( + _privacyConfig?.incomingCall ?? 'CONTACTS', + ), + isLast: false, + ), + _buildSubRow( + cs, + label: 'Кто может приглашать в чаты', + value: _getPrivacyLabel( + _privacyConfig?.chatsInvite ?? 'CONTACTS', + ), + isLast: false, + ), + _buildSubRow( + cs, + label: 'Показывать контакт', + value: _privacyConfig?.contentLevelAccess == true + ? 'Безопасный' + : 'Весь', + isLast: true, + ), + ], + if (!isSafeMode) ...[ + Padding( + padding: const EdgeInsets.only(left: 20), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + _buildOptionRow( + cs, + icon: Symbols.phone, + label: 'Кто может мне звонить', + value: _getPrivacyLabel( + _privacyConfig?.incomingCall ?? 'CONTACTS', + ), + isLast: false, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Кто может мне звонить', + currentValue: _privacyConfig?.incomingCall ?? 'CONTACTS', + options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + onSelect: (value) => _updateSetting('INCOMING_CALL', value), + ), + ), + _buildOptionRow( + cs, + icon: Symbols.group, + label: 'Кто может приглашать в чаты', + value: _getPrivacyLabel( + _privacyConfig?.chatsInvite ?? 'CONTACTS', + ), + isLast: false, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Кто может приглашать в чаты', + currentValue: _privacyConfig?.chatsInvite ?? 'CONTACTS', + options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + onSelect: (value) => _updateSetting('CHATS_INVITE', value), + ), + ), + _buildOptionRow( + cs, + icon: Symbols.contact_phone, + label: 'Найти меня по номеру', + value: _getPrivacyLabel(_privacyConfig?.searchByPhone ?? 'ALL'), + isLast: false, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Найти меня по номеру', + currentValue: _privacyConfig?.searchByPhone ?? 'ALL', + options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + onSelect: (value) => _updateSetting('SEARCH_BY_PHONE', value), + ), + ), + _buildOptionRow( + cs, + icon: Icons.visibility_off_outlined, + label: 'Видеть статус «в сети»', + value: _privacyConfig?.hidden == true ? 'Никто' : 'Мои контакты', + isLast: true, + onTap: () => _showHiddenStatusSheet(context, cs), ), - _buildSubRow(cs, label: 'Найти меня по номеру', value: 'Могут все', isLast: false), - _buildSubRow(cs, label: 'Позвонить', value: 'Могут все', isLast: false), - _buildSubRow(cs, label: 'Пригласить в чат', value: 'Могут контакты', isLast: false), - _buildSubRow(cs, label: 'Показывать контент', value: 'Весь', isLast: true), ], ], ), ); } + void _showOptionSheet( + BuildContext context, + ColorScheme cs, { + required String title, + required String currentValue, + required List<(String, String)> options, + required void Function(String) onSelect, + }) { + showModalBottomSheet( + context: context, + backgroundColor: cs.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ...options.map((option) { + final isSelected = option.$1 == currentValue; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.pop(context); + onSelect(option.$1); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + child: Row( + children: [ + Expanded( + child: Text( + option.$2, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + ), + ), + ), + if (isSelected) + Icon(Symbols.check, color: cs.primary, size: 20), + ], + ), + ), + ), + ); + }), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + void _showHiddenStatusSheet(BuildContext context, ColorScheme cs) { + final currentValue = _privacyConfig?.hidden == true ? 'NONE' : 'CONTACTS'; + + if (currentValue == 'NONE') { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), + content: Text( + 'Вы не сможете видеть статусы посещения других пользователей.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _updateSetting('HIDDEN', false); + }, + child: Text('Да', style: TextStyle(color: cs.primary)), + ), + ], + ), + ); + return; + } + + showModalBottomSheet( + context: context, + backgroundColor: cs.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + 'Видеть статус «в сети»', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _buildOptionSheetItem( + cs, + 'Мои контакты', + currentValue == 'CONTACTS', + () { + Navigator.pop(context); + _updateSetting('HIDDEN', false); + }, + ), + _buildOptionSheetItem(cs, 'Никто', currentValue == 'NONE', () { + Navigator.pop(context); + _showHiddenStatusConfirmDialog(context, cs); + }, isLast: true), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + void _showHiddenStatusConfirmDialog(BuildContext context, ColorScheme cs) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), + content: Text( + 'Вы не сможете видеть статусы посещения других пользователей.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _updateSetting('HIDDEN', true); + }, + child: Text('Да', style: TextStyle(color: cs.primary)), + ), + ], + ), + ); + } + + Widget _buildOptionSheetItem( + ColorScheme cs, + String label, + bool isSelected, + VoidCallback onTap, { + bool isLast = false, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + ), + if (isSelected) + Icon(Symbols.check, color: cs.primary, size: 20), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 20), + child: Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + ), + ], + ); + } + Widget _buildInfoLabel(ColorScheme cs) { return Padding( padding: const EdgeInsets.only(left: 4, bottom: 0), child: Text( - 'ИНФОРМАЦИЯ', + 'КОНФИДЕНЦИАЛЬНОСТЬ', style: TextStyle( color: cs.onSurfaceVariant.withValues(alpha: 0.6), fontSize: 12, @@ -186,24 +739,53 @@ class _SecurityScreenState extends State { ); } - Widget _buildOnlineSection(ColorScheme cs) { + Widget _buildConfidentialSection(ColorScheme cs) { return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(20), ), - child: _buildNavRow( - cs, - icon: null, - label: 'Видеть статус «в сети»', - value: 'Никто', - isLast: true, - noIcon: true, + child: Column( + children: [ + _buildSwitchRow( + cs, + icon: Symbols.description, + label: 'Галочки «Прочитано»', + value: _privacyConfig?.showReadMark ?? true, + isLast: false, + onChanged: (v) => _updateSetting('SHOW_READ_MARK', v), + ), + _buildSwitchRow( + cs, + icon: Symbols.keyboard_alt, + label: 'Альтернативная клавиатура', + value: _privacyConfig?.altKeyboard ?? false, + isLast: false, + onChanged: (v) => _updateSetting('ALT_KEYBOARD', v), + ), + _buildSwitchRow( + cs, + icon: Symbols.warning, + label: 'Принимать опасные файлы', + value: _privacyConfig?.unsafeFiles ?? true, + isLast: false, + onChanged: (v) => _updateSetting('UNSAFE_FILES', v), + ), + _buildSwitchRow( + cs, + icon: Icons.mic_none_outlined, + label: 'Транскрибация аудио', + value: _privacyConfig?.audioTranscriptionEnabled ?? true, + isLast: true, + onChanged: (v) => _updateSetting('AUDIO_TRANSCRIPTION_ENABLED', v), + ), + ], ), ); } Widget _buildBlacklistSection(ColorScheme cs) { + final count = _blockedContacts.length; return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, @@ -212,12 +794,22 @@ class _SecurityScreenState extends State { child: Material( color: Colors.transparent, child: InkWell( - onTap: () => showCustomNotification(context, 'Чёрный список'), + onTap: () => showCustomNotification( + context, + 'Чёрный список: $count контактов', + ), borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), child: Row( children: [ + Icon( + Symbols.block, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -232,7 +824,7 @@ class _SecurityScreenState extends State { ), const SizedBox(height: 2), Text( - 'Список тех, кто не может вам писать, звонить и добавлять в чаты', + '$count ${_getBlockedCountText(count)}', style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -242,7 +834,12 @@ class _SecurityScreenState extends State { ), ), const SizedBox(width: 8), - Icon(Symbols.chevron_right, color: cs.outline, size: 20, weight: 400), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), ], ), ), @@ -251,15 +848,22 @@ class _SecurityScreenState extends State { ); } + String _getBlockedCountText(int count) { + if (count == 0) return 'контактов'; + final mod = count % 10; + if (mod == 1 && count != 11) return 'контакт'; + if (mod >= 2 && mod <= 4 && (count < 10 || count > 20)) return 'контакта'; + return 'контактов'; + } + Widget _buildNavRow( ColorScheme cs, { - required IconData? icon, + required IconData icon, required String label, String? subtitle, String? value, Widget? trailing, required bool isLast, - bool noIcon = false, }) { return Column( children: [ @@ -274,10 +878,8 @@ class _SecurityScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), child: Row( children: [ - if (!noIcon) ...[ - Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), - const SizedBox(width: 16), - ], + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), Expanded( child: subtitle != null ? Column( @@ -320,7 +922,12 @@ class _SecurityScreenState extends State { ), if (trailing != null) trailing, const SizedBox(width: 4), - Icon(Symbols.chevron_right, color: cs.outline, size: 20, weight: 400), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), ], ), ), @@ -329,7 +936,73 @@ class _SecurityScreenState extends State { if (!isLast) Padding( padding: const EdgeInsets.only(left: 58), - child: Divider(height: 1, thickness: 1, color: cs.outlineVariant.withValues(alpha: 0.35)), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + + Widget _buildOptionRow( + ColorScheme cs, { + required IconData icon, + required String label, + required String value, + required bool isLast, + required VoidCallback onTap, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + value, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(width: 4), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), ), ], ); @@ -365,7 +1038,12 @@ class _SecurityScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(width: 4), - Icon(Symbols.chevron_right, color: cs.outline, size: 18, weight: 400), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 18, + weight: 400, + ), ], ), ), @@ -383,15 +1061,69 @@ class _SecurityScreenState extends State { ); } + Widget _buildSwitchRow( + ColorScheme cs, { + required IconData icon, + required String label, + required bool value, + required bool isLast, + required void Function(bool) onChanged, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onChanged(!value), + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Switch(value: value, onChanged: onChanged), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + Widget _buildWarningBadge(ColorScheme cs) { return Container( width: 22, height: 22, - decoration: BoxDecoration( - color: cs.error, - shape: BoxShape.circle, + decoration: BoxDecoration(color: cs.error, shape: BoxShape.circle), + child: Icon( + Symbols.priority_high, + color: cs.onError, + size: 14, + weight: 700, ), - child: Icon(Symbols.priority_high, color: cs.onError, size: 14, weight: 700), ); } } diff --git a/pubspec.lock b/pubspec.lock index 66aac21..62a257f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -638,10 +638,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: "direct main" description: