diff --git a/lib/api/api_service_media.dart b/lib/api/api_service_media.dart index fc86af1..34e118e 100644 --- a/lib/api/api_service_media.dart +++ b/lib/api/api_service_media.dart @@ -1,24 +1,76 @@ part of 'api_service.dart'; extension ApiServiceMedia on ApiService { - void updateProfileText( + /// Обновляет имя/фамилию/описание профиля через сервер (opcode 16) + /// и возвращает обновленный профиль из ответа. + Future updateProfileText( String firstName, String lastName, String description, - ) { - final payload = { - "firstName": firstName, - "lastName": lastName, - "description": description, - }; - _sendMessage(16, payload); + ) async { + try { + await waitUntilOnline(); + + final Map payload = { + "firstName": firstName, + "lastName": lastName, + }; + if (description.isNotEmpty) { + payload["description"] = description; + } + + final int seq = _sendMessage(16, payload); + _log('➡️ SEND: opcode=16, payload=$payload'); + + // Ждем ответ именно на этот seq с opcode 16 + final response = await messages.firstWhere( + (msg) => msg['seq'] == seq && msg['opcode'] == 16, + ); + + final Map? respPayload = + response['payload'] as Map?; + + if (respPayload == null) { + throw Exception('Пустой ответ сервера на изменение профиля'); + } + + // Обработка ошибок вида { error, localizedMessage, message, title } + if (respPayload.containsKey('error')) { + final humanMessage = respPayload['localizedMessage'] ?? + respPayload['message'] ?? + respPayload['title'] ?? + respPayload['error']; + throw Exception(humanMessage.toString()); + } + + final profileJson = respPayload['profile']; + if (profileJson is Map) { + // Обновляем глобальный снапшот чатов/профиля, + // чтобы все экраны сразу видели новые данные. + _lastChatsPayload ??= { + 'chats': [], + 'contacts': [], + 'profile': null, + 'presence': null, + 'config': null, + }; + _lastChatsPayload!['profile'] = profileJson; + + return Profile.fromJson(profileJson); + } + } catch (e) { + _log('❌ Ошибка при обновлении профиля через opcode 16: $e'); + } + return null; } - Future updateProfilePhoto(String firstName, String lastName) async { + /// Загружает фото и привязывает его к профилю через opcode 80 + 16. + /// Возвращает обновленный профиль из ответа opcode 16. + Future updateProfilePhoto(String firstName, String lastName) async { try { final picker = ImagePicker(); final XFile? image = await picker.pickImage(source: ImageSource.gallery); - if (image == null) return; + if (image == null) return null; print("Запрашиваем URL для загрузки фото..."); final int seq = _sendMessage(80, {"count": 1}); @@ -47,11 +99,123 @@ extension ApiServiceMedia on ApiService { "photoToken": photoToken, "avatarType": "USER_AVATAR", }; - _sendMessage(16, payload); + final int seq16 = _sendMessage(16, payload); print("Запрос на смену аватара отправлен."); + + // Ждем ответ opcode 16 с обновленным профилем + final resp16 = await messages.firstWhere( + (msg) => msg['seq'] == seq16 && msg['opcode'] == 16, + ); + + final Map? respPayload16 = + resp16['payload'] as Map?; + + if (respPayload16 == null) { + throw Exception('Пустой ответ сервера на смену аватара'); + } + + if (respPayload16.containsKey('error')) { + final humanMessage = respPayload16['localizedMessage'] ?? + respPayload16['message'] ?? + respPayload16['title'] ?? + respPayload16['error']; + throw Exception(humanMessage.toString()); + } + + final profileJson = respPayload16['profile']; + if (profileJson is Map) { + _lastChatsPayload ??= { + 'chats': [], + 'contacts': [], + 'profile': null, + 'presence': null, + 'config': null, + }; + _lastChatsPayload!['profile'] = profileJson; + + final profile = Profile.fromJson(profileJson); + await ProfileCacheService().syncWithServerProfile(profile); + return profile; + } } catch (e) { print("!!! Ошибка в процессе смены аватара: $e"); } + return null; + } + + /// Загружает список заготовленных аватаров (opcode 25). + /// Возвращает payload вида: + /// { currentPresetId: int, presetAvatars: [ { name, avatars: [ {url,id}, ...] }, ... ] } + Future> fetchPresetAvatars() async { + await waitUntilOnline(); + + final int seq = _sendMessage(25, {}); + _log('➡️ SEND: opcode=25, payload={}'); + + final resp = await messages.firstWhere( + (msg) => msg['seq'] == seq && msg['opcode'] == 25, + ); + + final payload = resp['payload'] as Map?; + return payload ?? {}; + } + + /// Выбирает один из заготовленных аватаров (PRESET_AVATAR) через opcode 16. + /// firstName / lastName – текущие значения профиля (как в примерах сервера). + Future setPresetAvatar({ + required String firstName, + required String lastName, + required int photoId, + }) async { + try { + await waitUntilOnline(); + + final payload = { + "firstName": firstName, + "lastName": lastName, + "photoId": photoId, + "avatarType": "PRESET_AVATAR", + }; + + final int seq16 = _sendMessage(16, payload); + _log('➡️ SEND: opcode=16 (PRESET_AVATAR), payload=$payload'); + + final resp16 = await messages.firstWhere( + (msg) => msg['seq'] == seq16 && msg['opcode'] == 16, + ); + + final Map? respPayload16 = + resp16['payload'] as Map?; + + if (respPayload16 == null) { + throw Exception('Пустой ответ сервера на установку пресет‑аватара'); + } + + if (respPayload16.containsKey('error')) { + final humanMessage = respPayload16['localizedMessage'] ?? + respPayload16['message'] ?? + respPayload16['title'] ?? + respPayload16['error']; + throw Exception(humanMessage.toString()); + } + + final profileJson = respPayload16['profile']; + if (profileJson is Map) { + _lastChatsPayload ??= { + 'chats': [], + 'contacts': [], + 'profile': null, + 'presence': null, + 'config': null, + }; + _lastChatsPayload!['profile'] = profileJson; + + return Profile.fromJson(profileJson); + } + } catch (e) { + _log('❌ Ошибка при установке пресет‑аватара: $e'); + } + return null; } Future sendPhotoMessage( diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 71037ba..3385840 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -60,7 +60,10 @@ class ChatScreen extends StatefulWidget { final int chatId; final Contact contact; final int myId; + /// Колбэк для мягких обновлений списка чатов (например, после редактирования сообщения). final VoidCallback? onChatUpdated; + /// Колбэк, который вызывается, когда чат нужно убрать из списка (удаление / выход из группы). + final VoidCallback? onChatRemoved; final bool isGroupChat; final bool isChannel; final int? participantCount; @@ -72,6 +75,7 @@ class ChatScreen extends StatefulWidget { required this.contact, required this.myId, this.onChatUpdated, + this.onChatRemoved, this.isGroupChat = false, this.isChannel = false, this.participantCount, @@ -709,9 +713,7 @@ class _ChatScreenState extends State { ? (!readSettings.disabled && readSettings.readOnEnter) : theme.debugReadOnEnter; - if (shouldReadOnEnter && - _messages.isNotEmpty && - widget.onChatUpdated != null) { + if (shouldReadOnEnter && _messages.isNotEmpty) { final lastMessageId = _messages.last.id; ApiService.instance.markMessageAsRead(widget.chatId, lastMessageId); } @@ -1189,8 +1191,6 @@ class _ChatScreenState extends State { setState(() { _replyingToMessage = null; }); - - widget.onChatUpdated?.call(); } } @@ -1835,7 +1835,7 @@ class _ChatScreenState extends State { if (mounted) { Navigator.of(context).pop(); - widget.onChatUpdated?.call(); + widget.onChatRemoved?.call(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -1911,7 +1911,7 @@ class _ChatScreenState extends State { if (mounted) { Navigator.of(context).pop(); - widget.onChatUpdated?.call(); + widget.onChatRemoved?.call(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( diff --git a/lib/screens/chats_screen.dart b/lib/screens/chats_screen.dart index 67c8e9c..073a009 100644 --- a/lib/screens/chats_screen.dart +++ b/lib/screens/chats_screen.dart @@ -2529,7 +2529,7 @@ class _ChatsScreenState extends State isGroupChat: isGroupChat, isChannel: isChannel, participantCount: participantCount, - onChatUpdated: () { + onChatRemoved: () { _removeChatLocally(chat.id); }, ), @@ -2723,6 +2723,9 @@ class _ChatsScreenState extends State onChatUpdated: () { _loadChatsAndContacts(); }, + onChatRemoved: () { + _removeChatLocally(chat.id); + }, ), ), ); @@ -4240,7 +4243,7 @@ class _ChatsScreenState extends State isGroupChat: isGroupChat, isChannel: isChannel, participantCount: participantCount, - onChatUpdated: () { + onChatRemoved: () { _removeChatLocally(chat.id); }, ), diff --git a/lib/screens/manage_account_screen.dart b/lib/screens/manage_account_screen.dart index 911997f..7e78fea 100644 --- a/lib/screens/manage_account_screen.dart +++ b/lib/screens/manage_account_screen.dart @@ -3,10 +3,6 @@ import 'package:flutter/services.dart'; import 'package:gwid/api/api_service.dart'; import 'package:gwid/models/profile.dart'; import 'package:gwid/screens/phone_entry_screen.dart'; -import 'package:gwid/services/profile_cache_service.dart'; -import 'package:gwid/services/local_profile_manager.dart'; -import 'package:image_picker/image_picker.dart'; -import 'dart:io'; class ManageAccountScreen extends StatefulWidget { final Profile? myProfile; @@ -21,11 +17,8 @@ class _ManageAccountScreenState extends State { late final TextEditingController _lastNameController; late final TextEditingController _descriptionController; final GlobalKey _formKey = GlobalKey(); - final ProfileCacheService _profileCache = ProfileCacheService(); - final LocalProfileManager _profileManager = LocalProfileManager(); Profile? _actualProfile; - String? _localAvatarPath; bool _isLoading = false; @override @@ -35,9 +28,8 @@ class _ManageAccountScreenState extends State { } Future _initializeProfileData() async { - await _profileManager.initialize(); - - _actualProfile = await _profileManager.getActualProfile(widget.myProfile); + // Берём только серверный профиль без локальных оверрайдов + _actualProfile = widget.myProfile; _firstNameController = TextEditingController( text: _actualProfile?.firstName ?? '', @@ -48,12 +40,6 @@ class _ManageAccountScreenState extends State { _descriptionController = TextEditingController( text: _actualProfile?.description ?? '', ); - final localPath = await _profileManager.getLocalAvatarPath(); - if (mounted) { - setState(() { - _localAvatarPath = localPath; - }); - } } Future _saveProfile() async { @@ -70,26 +56,21 @@ class _ManageAccountScreenState extends State { final lastName = _lastNameController.text.trim(); final description = _descriptionController.text.trim(); - final userId = _actualProfile?.id ?? widget.myProfile?.id ?? 0; - final photoBaseUrl = - _actualProfile?.photoBaseUrl ?? widget.myProfile?.photoBaseUrl; - final photoId = _actualProfile?.photoId ?? widget.myProfile?.photoId ?? 0; - - await _profileCache.saveProfileData( - userId: userId, - firstName: firstName, - lastName: lastName, - description: description.isEmpty ? null : description, - photoBaseUrl: photoBaseUrl, - photoId: photoId, + // Отправляем изменения сразу на сервер (opcode 16) + final updatedProfile = await ApiService.instance.updateProfileText( + firstName, + lastName, + description, ); - _actualProfile = await _profileManager.getActualProfile(widget.myProfile); + if (updatedProfile != null) { + _actualProfile = updatedProfile; + } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text("Профиль сохранен локально"), + content: Text("Профиль обновлен"), behavior: SnackBarBehavior.floating, duration: Duration(seconds: 2), ), @@ -163,40 +144,24 @@ class _ManageAccountScreenState extends State { Future _pickAndUpdateProfilePhoto() async { try { - final ImagePicker picker = ImagePicker(); - final XFile? image = await picker.pickImage( - source: ImageSource.gallery, - maxWidth: 1024, - maxHeight: 1024, - imageQuality: 85, - ); - - if (image == null) return; - setState(() { _isLoading = true; }); - File imageFile = File(image.path); + final firstName = _firstNameController.text.trim(); + final lastName = _lastNameController.text.trim(); - final userId = _actualProfile?.id ?? widget.myProfile?.id ?? 0; - if (userId != 0) { - final localPath = await _profileCache.saveAvatar(imageFile, userId); + // Полный серверный флоу: opcode 80 (url) + загрузка + opcode 16 (photoToken) + final updatedProfile = + await ApiService.instance.updateProfilePhoto(firstName, lastName); - if (localPath != null && mounted) { - setState(() { - _localAvatarPath = localPath; - }); - _actualProfile = await _profileManager.getActualProfile( - widget.myProfile, - ); - } - } - - if (mounted) { + if (updatedProfile != null && mounted) { + setState(() { + _actualProfile = updatedProfile; + }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text("Фотография профиля сохранена"), + content: Text("Фотография профиля обновлена"), behavior: SnackBarBehavior.floating, ), ); @@ -375,22 +340,15 @@ class _ManageAccountScreenState extends State { Widget _buildAvatarSection(ThemeData theme) { ImageProvider? avatarImage; - if (_localAvatarPath != null) { - avatarImage = FileImage(File(_localAvatarPath!)); - } else if (_actualProfile?.photoBaseUrl != null) { - if (_actualProfile!.photoBaseUrl!.startsWith('file://')) { - final path = _actualProfile!.photoBaseUrl!.replaceFirst('file://', ''); - avatarImage = FileImage(File(path)); - } else { - avatarImage = NetworkImage(_actualProfile!.photoBaseUrl!); - } - } else if (widget.myProfile?.photoBaseUrl != null) { - avatarImage = NetworkImage(widget.myProfile!.photoBaseUrl!); + final photoUrl = + _actualProfile?.photoBaseUrl ?? widget.myProfile?.photoBaseUrl; + if (photoUrl != null) { + avatarImage = NetworkImage(photoUrl); } return Center( child: GestureDetector( - onTap: _pickAndUpdateProfilePhoto, + onTap: _showAvatarOptions, child: Stack( children: [ CircleAvatar( @@ -440,6 +398,262 @@ class _ManageAccountScreenState extends State { ); } + void _showAvatarOptions() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.image_outlined), + title: const Text('Выбрать из заготовленных аватаров'), + onTap: () { + Navigator.of(context).pop(); + _choosePresetAvatar(); + }, + ), + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('Загрузить своё фото'), + onTap: () { + Navigator.of(context).pop(); + _pickAndUpdateProfilePhoto(); + }, + ), + ], + ), + ); + }, + ); + } + + void _choosePresetAvatar() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + final theme = Theme.of(context); + return SafeArea( + child: Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 12, + bottom: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Выбор аватара', + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Выбери картинку из коллекции, потом при желании можно загрузить своё фото.', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 16), + FutureBuilder>( + future: ApiService.instance.fetchPresetAvatars(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: CircularProgressIndicator()), + ); + } + if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text( + 'Не удалось загрузить аватары: ${snapshot.error}', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ); + } + + final data = snapshot.data ?? {}; + final List categories = + data['presetAvatars'] as List? ?? []; + + if (categories.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text('Список заготовленных аватаров пуст.'), + ), + ); + } + + final scrollController = ScrollController(); + + return SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: Scrollbar( + controller: scrollController, + child: ListView.builder( + controller: scrollController, + itemCount: categories.length, + itemBuilder: (context, index) { + final cat = + categories[index] as Map? ?? + {}; + final String name = cat['name']?.toString() ?? ''; + final List avatars = + cat['avatars'] as List? ?? []; + + if (avatars.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (name.isNotEmpty) ...[ + Text( + name, + style: theme.textTheme.bodyMedium + ?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ], + GridView.builder( + shrinkWrap: true, + physics: + const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + ), + itemCount: avatars.length, + itemBuilder: (context, i) { + final a = avatars[i] + as Map? ?? + {}; + final String url = + a['url']?.toString() ?? ''; + final int? photoId = a['id'] as int?; + + if (url.isEmpty || photoId == null) { + return const SizedBox.shrink(); + } + + return InkWell( + borderRadius: + BorderRadius.circular(999), + onTap: () async { + final firstName = + _firstNameController.text.trim(); + final lastName = + _lastNameController.text.trim(); + + try { + setState(() { + _isLoading = true; + }); + final updatedProfile = + await ApiService.instance + .setPresetAvatar( + firstName: firstName, + lastName: lastName, + photoId: photoId, + ); + if (!mounted) return; + + if (updatedProfile != null) { + setState(() { + _actualProfile = + updatedProfile; + }); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + content: Text( + 'Аватар обновлён', + ), + behavior: SnackBarBehavior + .floating, + ), + ); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: Text( + 'Ошибка смены аватара: $e', + ), + behavior: SnackBarBehavior + .floating, + backgroundColor: theme + .colorScheme.error, + ), + ); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + }, + child: CircleAvatar( + backgroundImage: NetworkImage(url), + ), + ); + }, + ), + ], + ), + ); + }, + ), + ), + ); + }, + ), + ], + ), + ), + ); + }, + ); + } + InputDecoration _buildInputDecoration( String label, IconData icon, { diff --git a/lib/screens/settings/bypass_screen.dart b/lib/screens/settings/bypass_screen.dart index 7235884..7f5dfd7 100644 --- a/lib/screens/settings/bypass_screen.dart +++ b/lib/screens/settings/bypass_screen.dart @@ -2,127 +2,369 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:gwid/utils/theme_provider.dart'; -class BypassScreen extends StatelessWidget { +class BypassScreen extends StatefulWidget { final bool isModal; const BypassScreen({super.key, this.isModal = false}); + @override + State createState() => _BypassScreenState(); +} + +class _BypassScreenState extends State { + // 0 – обходы, 1 – фишки + int _selectedTab = 0; + @override Widget build(BuildContext context) { + if (widget.isModal) { + final colors = Theme.of(context).colorScheme; + return _buildModalSettings(context, colors); + } final colors = Theme.of(context).colorScheme; - if (isModal) { - return buildModalContent(context); - } - return Scaffold( - appBar: AppBar(title: const Text("Bypass")), + appBar: AppBar(title: const Text("Специальные возможности и фишки")), body: ListView( padding: const EdgeInsets.all(16), children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colors.primaryContainer.withOpacity(0.3), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + // Переключатель вкладок (как между папками) + LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 480; + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outline.withOpacity(0.2)), + ), + child: Row( children: [ - Icon(Icons.info_outline, color: colors.primary), - const SizedBox(width: 8), - Text( - "Обход блокировки", - style: TextStyle( - fontWeight: FontWeight.bold, - color: colors.primary, + Expanded( + child: GestureDetector( + onTap: () { + if (_selectedTab != 0) { + setState(() => _selectedTab = 0); + } + }, + child: _SegmentButton( + selected: _selectedTab == 0, + label: isNarrow ? 'Bypass' : 'Обходы', + ), + ), + ), + const SizedBox(width: 4), + Expanded( + child: GestureDetector( + onTap: () { + if (_selectedTab != 1) { + setState(() => _selectedTab = 1); + } + }, + child: _SegmentButton( + selected: _selectedTab == 1, + label: isNarrow ? 'Фишки' : 'Фишки (komet.color)', + ), ), ), ], ), - const SizedBox(height: 8), - Text( - "Эта функция позволяет отправлять сообщения заблокированным пользователям. " - "Включите эту опцию, если хотите обойти " - "стандартные ограничения мессенджера.", - style: TextStyle(color: colors.onSurfaceVariant), - ), - ], - ), - ), - - const SizedBox(height: 24), - - Consumer( - builder: (context, themeProvider, child) { - return Card( - child: SwitchListTile( - title: const Text( - "Обход блокировки", - style: TextStyle(fontWeight: FontWeight.w600), - ), - subtitle: const Text( - "Разрешить отправку сообщений заблокированным пользователям", - ), - value: themeProvider.blockBypass, - onChanged: (value) { - themeProvider.setBlockBypass(value); - }, - secondary: Icon( - themeProvider.blockBypass - ? Icons.psychology - : Icons.psychology_outlined, - color: themeProvider.blockBypass - ? colors.primary - : colors.onSurfaceVariant, - ), - ), ); }, ), - const SizedBox(height: 16), - - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colors.outline.withOpacity(0.3)), + if (_selectedTab == 0) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.primaryContainer.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: colors.primary), + const SizedBox(width: 8), + Text( + "Обход блокировки", + style: TextStyle( + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + "Эта функция позволяет отправлять сообщения заблокированным пользователям. " + "Включите эту опцию, если хотите обойти " + "стандартные ограничения мессенджера.", + style: TextStyle(color: colors.onSurfaceVariant), + ), + ], + ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.warning_outlined, - color: colors.primary, - size: 16, + + const SizedBox(height: 24), + + Consumer( + builder: (context, themeProvider, child) { + return Card( + child: SwitchListTile( + title: const Text( + "Обход блокировки", + style: TextStyle(fontWeight: FontWeight.w600), ), - const SizedBox(width: 8), - Text( - "ВНИМНИЕ🚨🚨🚨", - style: TextStyle( - fontWeight: FontWeight.w600, + subtitle: const Text( + "Разрешить отправку сообщений заблокированным пользователям", + ), + value: themeProvider.blockBypass, + onChanged: (value) { + themeProvider.setBlockBypass(value); + }, + secondary: Icon( + themeProvider.blockBypass + ? Icons.psychology + : Icons.psychology_outlined, + color: themeProvider.blockBypass + ? colors.primary + : colors.onSurfaceVariant, + ), + ), + ); + }, + ), + + const SizedBox(height: 16), + + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outline.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.warning_outlined, color: colors.primary, + size: 16, + ), + const SizedBox(width: 8), + Text( + "ВНИМНИЕ🚨🚨🚨", + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + "Используя любую из bypass функций, вас возможно накажут", + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + ), + ), + ], + ), + ), + ] else ...[ + // Новый экран "фишек" (контент пока статичный) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outline.withOpacity(0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.color_lens_outlined, color: colors.primary), + const SizedBox(width: 8), + Text( + 'Фишки (цветные никнеймы, скоро)', + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.primary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + "В будущих версиях можно будет подсвечивать отдельные буквы и слова в нике с помощью простого синтаксиса.", + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + ), + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: colors.outline.withOpacity(0.3), ), ), - ], - ), - const SizedBox(height: 8), - Text( - "Используя любую из bypass функций, вас возможно накажут", - style: TextStyle( - color: colors.onSurfaceVariant, - fontSize: 14, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Простой пример:", + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 8), + SelectableText( + "komet.color_#FF0000'привет'", + style: TextStyle( + fontFamily: 'monospace', + color: colors.onSurface, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + "Отображение: ", + style: TextStyle(color: colors.onSurfaceVariant), + ), + const Text( + "привет", + style: TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFFFF0000), // красный #FF0000 + ), + ), + Text( + "", + style: TextStyle(color: colors.onSurfaceVariant), + ), + ], + ), + const SizedBox(height: 16), + Text( + "Сложный пример:", + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SelectableText( + "komet.color_#FFFFFF'п'", + style: TextStyle(fontFamily: 'monospace'), + ), + SelectableText( + "komet.color_#FF0000'р'", + style: TextStyle(fontFamily: 'monospace'), + ), + SelectableText( + "komet.color_#00FF00'и'", + style: TextStyle(fontFamily: 'monospace'), + ), + SelectableText( + "komet.color_#0000FF'в'", + style: TextStyle(fontFamily: 'monospace'), + ), + SelectableText( + "komet.color_#FFFF00'е'", + style: TextStyle(fontFamily: 'monospace'), + ), + SelectableText( + "komet.color_#FF00FF'т'", + style: TextStyle(fontFamily: 'monospace'), + ), + ], + ), + const SizedBox(height: 4), + Text( + "В сообщении эти куски пишутся подряд без пробелов и переносов строки — здесь они показаны столбиком для наглядности.", + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 12, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + "Отображение: ", + style: TextStyle(color: colors.onSurfaceVariant), + ), + const Text( + "п", + style: TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFFFFFFFF), + ), + ), + const Text( + "р", + style: TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFFFF0000), + ), + ), + const Text( + "и", + style: TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFF00FF00), + ), + ), + const Text( + "в", + style: TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFF0000FF), + ), + ), + const Text( + "е", + style: TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFFFFFF00), + ), + ), + const Text( + "т", + style: TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFFFF00FF), + ), + ), + ], + ), + ], + ), ), - ), - ], + ], + ), ), - ), + ], ], ), ); @@ -178,7 +420,7 @@ class BypassScreen extends StatelessWidget { ), const Expanded( child: Text( - "Bypass", + "Специальные возможности и фишки", style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, @@ -262,60 +504,35 @@ class BypassScreen extends StatelessWidget { ), ); } +} - Widget buildModalContent(BuildContext context) { +class _SegmentButton extends StatelessWidget { + final bool selected; + final String label; + + const _SegmentButton({required this.selected, required this.label}); + + @override + Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colors.primaryContainer.withOpacity(0.3), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colors.outline.withOpacity(0.3)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.info_outline, color: colors.primary, size: 20), - const SizedBox(width: 8), - Text( - "Информация", - style: TextStyle( - fontWeight: FontWeight.bold, - color: colors.primary, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - "Эта функция предназначена для обхода ограничений и блокировок. Используйте с осторожностью. Всю ответственность за ваш аккаунт несете только вы.", - style: TextStyle( - color: colors.onSurface.withOpacity(0.8), - fontSize: 14, - ), - ), - ], + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + color: selected ? colors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: selected ? colors.onPrimary : colors.onSurfaceVariant, ), ), - const SizedBox(height: 20), - Consumer( - builder: (context, themeProvider, child) { - return SwitchListTile( - title: const Text("Обход блокировки"), - subtitle: const Text("Активировать функции обхода ограничений"), - value: themeProvider.blockBypass, - onChanged: (value) { - themeProvider.setBlockBypass(value); - }, - ); - }, - ), - ], + ), ); } } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 05f0566..9619069 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -451,8 +451,8 @@ class _SettingsScreenState extends State { _buildSettingsCategory( context, icon: Icons.psychology_outlined, - title: "Специальные возможности", - subtitle: "Обход ограничений", + title: "Специальные возможности и фишки", + subtitle: "Обход ограничений, эксперименты", screen: const BypassScreen(), ), _buildSettingsCategory( diff --git a/lib/services/local_profile_manager.dart b/lib/services/local_profile_manager.dart index b14a95c..49265af 100644 --- a/lib/services/local_profile_manager.dart +++ b/lib/services/local_profile_manager.dart @@ -16,28 +16,9 @@ class LocalProfileManager { } Future getActualProfile(Profile? serverProfile) async { - await initialize(); - - final localAvatarPath = await _profileCache.getLocalAvatarPath(); - final mergedProfile = await _profileCache.getMergedProfile(serverProfile); - - if (mergedProfile != null && localAvatarPath != null) { - return Profile( - id: mergedProfile.id, - phone: mergedProfile.phone, - firstName: mergedProfile.firstName, - lastName: mergedProfile.lastName, - description: mergedProfile.description, - photoBaseUrl: 'file://$localAvatarPath', - photoId: mergedProfile.photoId, - updateTime: mergedProfile.updateTime, - options: mergedProfile.options, - accountStatus: mergedProfile.accountStatus, - profileOptions: mergedProfile.profileOptions, - ); - } - - return mergedProfile; + // Полностью отключаем локальные оверрайды профиля: + // всегда используем только данные с сервера. + return serverProfile; } Future getLocalAvatarPath() async { diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index a641665..ac97789 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -168,6 +168,13 @@ Color _getUserColor(int userId, BuildContext context) { return color; } +class _KometColoredSegment { + final String text; + final Color? color; + + _KometColoredSegment(this.text, this.color); +} + class ChatMessageBubble extends StatelessWidget { final Message message; final bool isMe; @@ -3753,37 +3760,19 @@ class ChatMessageBubble extends StatelessWidget { if (message.text.isNotEmpty) ...[ if (message.text.contains("welcome.saved.dialog.message")) Linkify( - text:'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.', - style: TextStyle(color: textColor, fontStyle: FontStyle.italic), + text: + 'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.', + style: + TextStyle(color: textColor, fontStyle: FontStyle.italic), linkStyle: linkStyle, onOpen: onOpenLink, options: const LinkifyOptions(humanize: false), textAlign: TextAlign.left, ) - else if (message.text.contains("komet.custom_text")) - Linkify( - style: message.text.contains("komet.custom_text.red") ? - TextStyle(color: Color.from(alpha: 255, red: 255, green: 0, blue: 0)) : - message.text.contains("komet.custom_text.black") ? - TextStyle(color: Color.from(alpha: 255, red: 0, green: 0, blue: 0)) : - message.text.contains("komet.custom_text.green") ? - TextStyle(color: Color.from(alpha: 255, red: 0, green: 255, blue: 0)) : - message.text.contains("komet.custom_text.white") ? - TextStyle(color: Color.from(alpha: 255, red: 255, green: 255, blue: 255)) : defaultTextStyle, - linkStyle: linkStyle, - onOpen: onOpenLink, - options: const LinkifyOptions(humanize: false), - textAlign: message.text.contains("komet.custom_text.right") - ? TextAlign.right - : message.text.contains("komet.custom_text.center") ? TextAlign.center : TextAlign.left, - text: message.text - .replaceAll("komet.custom_text.red", "") - .replaceAll("komet.custom_text.black", "") - .replaceAll("komet.custom_text.green", "") - .replaceAll("komet.custom_text.white", "") - .replaceAll("komet.custom_text.right", "") - .replaceAll("komet.custom_text.center", "") - .replaceAll("komet.custom_text", ""), + else if (message.text.contains("komet.color_")) + _buildKometColorRichText( + message.text, + defaultTextStyle, ) else Linkify( @@ -3912,6 +3901,110 @@ class ChatMessageBubble extends StatelessWidget { ]; } + /// Строит раскрашенный текст на основе синтаксиса komet.color_#HEX'текст'. + /// Если цвет некорректный, используется красный. + Widget _buildKometColorRichText( + String rawText, + TextStyle baseStyle, + ) { + final segments = _parseKometColorSegments(rawText, baseStyle.color); + + return RichText( + textAlign: TextAlign.left, + text: TextSpan( + style: baseStyle, + children: segments + .map( + (seg) => TextSpan( + text: seg.text, + style: seg.color != null + ? baseStyle.copyWith(color: seg.color) + : baseStyle, + ), + ) + .toList(), + ), + ); + } + + List<_KometColoredSegment> _parseKometColorSegments( + String text, + Color? fallbackColor, + ) { + const marker = 'komet.color_'; + final segments = <_KometColoredSegment>[]; + + int index = 0; + while (index < text.length) { + final start = text.indexOf(marker, index); + if (start == -1) { + segments.add( + _KometColoredSegment(text.substring(index), null), + ); + break; + } + + if (start > index) { + segments.add( + _KometColoredSegment(text.substring(index, start), null), + ); + } + + final colorStart = start + marker.length; + final firstQuote = text.indexOf("'", colorStart); + if (firstQuote == -1) { + // Кривой синтаксис — считаем всё остальное обычным текстом. + segments.add( + _KometColoredSegment(text.substring(start), null), + ); + break; + } + + final colorStr = text.substring(colorStart, firstQuote); + final textStart = firstQuote + 1; + final secondQuote = text.indexOf("'", textStart); + if (secondQuote == -1) { + segments.add( + _KometColoredSegment(text.substring(start), null), + ); + break; + } + + final segmentText = text.substring(textStart, secondQuote); + final color = _parseKometHexColor(colorStr, fallbackColor); + + segments.add(_KometColoredSegment(segmentText, color)); + index = secondQuote + 1; + } + + return segments; + } + + Color _parseKometHexColor(String raw, Color? fallbackColor) { + String hex = raw.trim(); + if (hex.startsWith('#')) { + hex = hex.substring(1); + } + + // Ожидаем 6 или 8 символов; всё остальное считаем "херовым" цветом. + final isValidLength = hex.length == 6 || hex.length == 8; + final isValidChars = RegExp(r'^[0-9a-fA-F]+$').hasMatch(hex); + if (!isValidLength || !isValidChars) { + return const Color(0xFFFF0000); // дефолт – красный + } + + if (hex.length == 6) { + hex = 'FF$hex'; // добавляем альфу + } + + try { + final value = int.parse(hex, radix: 16); + return Color(value); + } catch (_) { + return const Color(0xFFFF0000); + } + } + BoxDecoration _createBubbleDecoration( Color bubbleColor, double messageBorderRadius,