Сделал изменение профиля рабочим, возможность загрузить свое фото или выбрать с пресетов; работающие сообщения типа komet.color_#...

This commit is contained in:
jganenok
2025-12-01 21:16:09 +07:00
parent f367eb9824
commit ba1a954c5b
8 changed files with 954 additions and 282 deletions

View File

@@ -1,24 +1,76 @@
part of 'api_service.dart'; part of 'api_service.dart';
extension ApiServiceMedia on ApiService { extension ApiServiceMedia on ApiService {
void updateProfileText( /// Обновляет имя/фамилию/описание профиля через сервер (opcode 16)
/// и возвращает обновленный профиль из ответа.
Future<Profile?> updateProfileText(
String firstName, String firstName,
String lastName, String lastName,
String description, String description,
) { ) async {
final payload = { try {
await waitUntilOnline();
final Map<String, dynamic> payload = {
"firstName": firstName, "firstName": firstName,
"lastName": lastName, "lastName": lastName,
"description": description,
}; };
_sendMessage(16, payload); if (description.isNotEmpty) {
payload["description"] = description;
} }
Future<void> updateProfilePhoto(String firstName, String lastName) async { 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<String, dynamic>? respPayload =
response['payload'] as Map<String, dynamic>?;
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<String, dynamic>) {
// Обновляем глобальный снапшот чатов/профиля,
// чтобы все экраны сразу видели новые данные.
_lastChatsPayload ??= {
'chats': <dynamic>[],
'contacts': <dynamic>[],
'profile': null,
'presence': null,
'config': null,
};
_lastChatsPayload!['profile'] = profileJson;
return Profile.fromJson(profileJson);
}
} catch (e) {
_log('❌ Ошибка при обновлении профиля через opcode 16: $e');
}
return null;
}
/// Загружает фото и привязывает его к профилю через opcode 80 + 16.
/// Возвращает обновленный профиль из ответа opcode 16.
Future<Profile?> updateProfilePhoto(String firstName, String lastName) async {
try { try {
final picker = ImagePicker(); final picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery); final XFile? image = await picker.pickImage(source: ImageSource.gallery);
if (image == null) return; if (image == null) return null;
print("Запрашиваем URL для загрузки фото..."); print("Запрашиваем URL для загрузки фото...");
final int seq = _sendMessage(80, {"count": 1}); final int seq = _sendMessage(80, {"count": 1});
@@ -47,11 +99,123 @@ extension ApiServiceMedia on ApiService {
"photoToken": photoToken, "photoToken": photoToken,
"avatarType": "USER_AVATAR", "avatarType": "USER_AVATAR",
}; };
_sendMessage(16, payload); final int seq16 = _sendMessage(16, payload);
print("Запрос на смену аватара отправлен."); print("Запрос на смену аватара отправлен.");
// Ждем ответ opcode 16 с обновленным профилем
final resp16 = await messages.firstWhere(
(msg) => msg['seq'] == seq16 && msg['opcode'] == 16,
);
final Map<String, dynamic>? respPayload16 =
resp16['payload'] as Map<String, dynamic>?;
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<String, dynamic>) {
_lastChatsPayload ??= {
'chats': <dynamic>[],
'contacts': <dynamic>[],
'profile': null,
'presence': null,
'config': null,
};
_lastChatsPayload!['profile'] = profileJson;
final profile = Profile.fromJson(profileJson);
await ProfileCacheService().syncWithServerProfile(profile);
return profile;
}
} catch (e) { } catch (e) {
print("!!! Ошибка в процессе смены аватара: $e"); print("!!! Ошибка в процессе смены аватара: $e");
} }
return null;
}
/// Загружает список заготовленных аватаров (opcode 25).
/// Возвращает payload вида:
/// { currentPresetId: int, presetAvatars: [ { name, avatars: [ {url,id}, ...] }, ... ] }
Future<Map<String, dynamic>> 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<String, dynamic>?;
return payload ?? <String, dynamic>{};
}
/// Выбирает один из заготовленных аватаров (PRESET_AVATAR) через opcode 16.
/// firstName / lastName текущие значения профиля (как в примерах сервера).
Future<Profile?> 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<String, dynamic>? respPayload16 =
resp16['payload'] as Map<String, dynamic>?;
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<String, dynamic>) {
_lastChatsPayload ??= {
'chats': <dynamic>[],
'contacts': <dynamic>[],
'profile': null,
'presence': null,
'config': null,
};
_lastChatsPayload!['profile'] = profileJson;
return Profile.fromJson(profileJson);
}
} catch (e) {
_log('❌ Ошибка при установке пресет‑аватара: $e');
}
return null;
} }
Future<void> sendPhotoMessage( Future<void> sendPhotoMessage(

View File

@@ -60,7 +60,10 @@ class ChatScreen extends StatefulWidget {
final int chatId; final int chatId;
final Contact contact; final Contact contact;
final int myId; final int myId;
/// Колбэк для мягких обновлений списка чатов (например, после редактирования сообщения).
final VoidCallback? onChatUpdated; final VoidCallback? onChatUpdated;
/// Колбэк, который вызывается, когда чат нужно убрать из списка (удаление / выход из группы).
final VoidCallback? onChatRemoved;
final bool isGroupChat; final bool isGroupChat;
final bool isChannel; final bool isChannel;
final int? participantCount; final int? participantCount;
@@ -72,6 +75,7 @@ class ChatScreen extends StatefulWidget {
required this.contact, required this.contact,
required this.myId, required this.myId,
this.onChatUpdated, this.onChatUpdated,
this.onChatRemoved,
this.isGroupChat = false, this.isGroupChat = false,
this.isChannel = false, this.isChannel = false,
this.participantCount, this.participantCount,
@@ -709,9 +713,7 @@ class _ChatScreenState extends State<ChatScreen> {
? (!readSettings.disabled && readSettings.readOnEnter) ? (!readSettings.disabled && readSettings.readOnEnter)
: theme.debugReadOnEnter; : theme.debugReadOnEnter;
if (shouldReadOnEnter && if (shouldReadOnEnter && _messages.isNotEmpty) {
_messages.isNotEmpty &&
widget.onChatUpdated != null) {
final lastMessageId = _messages.last.id; final lastMessageId = _messages.last.id;
ApiService.instance.markMessageAsRead(widget.chatId, lastMessageId); ApiService.instance.markMessageAsRead(widget.chatId, lastMessageId);
} }
@@ -1189,8 +1191,6 @@ class _ChatScreenState extends State<ChatScreen> {
setState(() { setState(() {
_replyingToMessage = null; _replyingToMessage = null;
}); });
widget.onChatUpdated?.call();
} }
} }
@@ -1835,7 +1835,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (mounted) { if (mounted) {
Navigator.of(context).pop(); Navigator.of(context).pop();
widget.onChatUpdated?.call(); widget.onChatRemoved?.call();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -1911,7 +1911,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (mounted) { if (mounted) {
Navigator.of(context).pop(); Navigator.of(context).pop();
widget.onChatUpdated?.call(); widget.onChatRemoved?.call();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(

View File

@@ -2529,7 +2529,7 @@ class _ChatsScreenState extends State<ChatsScreen>
isGroupChat: isGroupChat, isGroupChat: isGroupChat,
isChannel: isChannel, isChannel: isChannel,
participantCount: participantCount, participantCount: participantCount,
onChatUpdated: () { onChatRemoved: () {
_removeChatLocally(chat.id); _removeChatLocally(chat.id);
}, },
), ),
@@ -2723,6 +2723,9 @@ class _ChatsScreenState extends State<ChatsScreen>
onChatUpdated: () { onChatUpdated: () {
_loadChatsAndContacts(); _loadChatsAndContacts();
}, },
onChatRemoved: () {
_removeChatLocally(chat.id);
},
), ),
), ),
); );
@@ -4240,7 +4243,7 @@ class _ChatsScreenState extends State<ChatsScreen>
isGroupChat: isGroupChat, isGroupChat: isGroupChat,
isChannel: isChannel, isChannel: isChannel,
participantCount: participantCount, participantCount: participantCount,
onChatUpdated: () { onChatRemoved: () {
_removeChatLocally(chat.id); _removeChatLocally(chat.id);
}, },
), ),

View File

@@ -3,10 +3,6 @@ import 'package:flutter/services.dart';
import 'package:gwid/api/api_service.dart'; import 'package:gwid/api/api_service.dart';
import 'package:gwid/models/profile.dart'; import 'package:gwid/models/profile.dart';
import 'package:gwid/screens/phone_entry_screen.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 { class ManageAccountScreen extends StatefulWidget {
final Profile? myProfile; final Profile? myProfile;
@@ -21,11 +17,8 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
late final TextEditingController _lastNameController; late final TextEditingController _lastNameController;
late final TextEditingController _descriptionController; late final TextEditingController _descriptionController;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final ProfileCacheService _profileCache = ProfileCacheService();
final LocalProfileManager _profileManager = LocalProfileManager();
Profile? _actualProfile; Profile? _actualProfile;
String? _localAvatarPath;
bool _isLoading = false; bool _isLoading = false;
@override @override
@@ -35,9 +28,8 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
} }
Future<void> _initializeProfileData() async { Future<void> _initializeProfileData() async {
await _profileManager.initialize(); // Берём только серверный профиль без локальных оверрайдов
_actualProfile = widget.myProfile;
_actualProfile = await _profileManager.getActualProfile(widget.myProfile);
_firstNameController = TextEditingController( _firstNameController = TextEditingController(
text: _actualProfile?.firstName ?? '', text: _actualProfile?.firstName ?? '',
@@ -48,12 +40,6 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
_descriptionController = TextEditingController( _descriptionController = TextEditingController(
text: _actualProfile?.description ?? '', text: _actualProfile?.description ?? '',
); );
final localPath = await _profileManager.getLocalAvatarPath();
if (mounted) {
setState(() {
_localAvatarPath = localPath;
});
}
} }
Future<void> _saveProfile() async { Future<void> _saveProfile() async {
@@ -70,26 +56,21 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
final lastName = _lastNameController.text.trim(); final lastName = _lastNameController.text.trim();
final description = _descriptionController.text.trim(); final description = _descriptionController.text.trim();
final userId = _actualProfile?.id ?? widget.myProfile?.id ?? 0; // Отправляем изменения сразу на сервер (opcode 16)
final photoBaseUrl = final updatedProfile = await ApiService.instance.updateProfileText(
_actualProfile?.photoBaseUrl ?? widget.myProfile?.photoBaseUrl; firstName,
final photoId = _actualProfile?.photoId ?? widget.myProfile?.photoId ?? 0; lastName,
description,
await _profileCache.saveProfileData(
userId: userId,
firstName: firstName,
lastName: lastName,
description: description.isEmpty ? null : description,
photoBaseUrl: photoBaseUrl,
photoId: photoId,
); );
_actualProfile = await _profileManager.getActualProfile(widget.myProfile); if (updatedProfile != null) {
_actualProfile = updatedProfile;
}
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text("Профиль сохранен локально"), content: Text("Профиль обновлен"),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
duration: Duration(seconds: 2), duration: Duration(seconds: 2),
), ),
@@ -163,40 +144,24 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
Future<void> _pickAndUpdateProfilePhoto() async { Future<void> _pickAndUpdateProfilePhoto() async {
try { 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(() { setState(() {
_isLoading = true; _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; // Полный серверный флоу: opcode 80 (url) + загрузка + opcode 16 (photoToken)
if (userId != 0) { final updatedProfile =
final localPath = await _profileCache.saveAvatar(imageFile, userId); await ApiService.instance.updateProfilePhoto(firstName, lastName);
if (localPath != null && mounted) { if (updatedProfile != null && mounted) {
setState(() { setState(() {
_localAvatarPath = localPath; _actualProfile = updatedProfile;
}); });
_actualProfile = await _profileManager.getActualProfile(
widget.myProfile,
);
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text("Фотография профиля сохранена"), content: Text("Фотография профиля обновлена"),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
@@ -375,22 +340,15 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
Widget _buildAvatarSection(ThemeData theme) { Widget _buildAvatarSection(ThemeData theme) {
ImageProvider? avatarImage; ImageProvider? avatarImage;
if (_localAvatarPath != null) { final photoUrl =
avatarImage = FileImage(File(_localAvatarPath!)); _actualProfile?.photoBaseUrl ?? widget.myProfile?.photoBaseUrl;
} else if (_actualProfile?.photoBaseUrl != null) { if (photoUrl != null) {
if (_actualProfile!.photoBaseUrl!.startsWith('file://')) { avatarImage = NetworkImage(photoUrl);
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!);
} }
return Center( return Center(
child: GestureDetector( child: GestureDetector(
onTap: _pickAndUpdateProfilePhoto, onTap: _showAvatarOptions,
child: Stack( child: Stack(
children: [ children: [
CircleAvatar( CircleAvatar(
@@ -440,6 +398,262 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
); );
} }
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<Map<String, dynamic>>(
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<dynamic> categories =
data['presetAvatars'] as List<dynamic>? ?? [];
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<String, dynamic>? ??
{};
final String name = cat['name']?.toString() ?? '';
final List<dynamic> avatars =
cat['avatars'] as List<dynamic>? ?? [];
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<String, dynamic>? ??
{};
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( InputDecoration _buildInputDecoration(
String label, String label,
IconData icon, { IconData icon, {

View File

@@ -2,24 +2,80 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:gwid/utils/theme_provider.dart'; import 'package:gwid/utils/theme_provider.dart';
class BypassScreen extends StatelessWidget { class BypassScreen extends StatefulWidget {
final bool isModal; final bool isModal;
const BypassScreen({super.key, this.isModal = false}); const BypassScreen({super.key, this.isModal = false});
@override
State<BypassScreen> createState() => _BypassScreenState();
}
class _BypassScreenState extends State<BypassScreen> {
// 0 обходы, 1 фишки
int _selectedTab = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.isModal) {
final colors = Theme.of(context).colorScheme;
return _buildModalSettings(context, colors);
}
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
if (isModal) {
return buildModalContent(context);
}
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text("Bypass")), appBar: AppBar(title: const Text("Специальные возможности и фишки")),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
// Переключатель вкладок (как между папками)
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: [
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)',
),
),
),
],
),
);
},
),
if (_selectedTab == 0) ...[
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -123,6 +179,192 @@ class BypassScreen extends StatelessWidget {
], ],
), ),
), ),
] 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),
),
),
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( const Expanded(
child: Text( child: Text(
"Bypass", "Специальные возможности и фишки",
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, 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; final colors = Theme.of(context).colorScheme;
return ListView( return AnimatedContainer(
padding: const EdgeInsets.all(16), duration: const Duration(milliseconds: 180),
children: [ curve: Curves.easeOut,
Container( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.primaryContainer.withOpacity(0.3), color: selected ? colors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outline.withOpacity(0.3)),
), ),
child: Column( child: Center(
crossAxisAlignment: CrossAxisAlignment.start, child: Text(
children: [ label,
Row(
children: [
Icon(Icons.info_outline, color: colors.primary, size: 20),
const SizedBox(width: 8),
Text(
"Информация",
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold,
color: colors.primary,
),
),
],
),
const SizedBox(height: 8),
Text(
"Эта функция предназначена для обхода ограничений и блокировок. Используйте с осторожностью. Всю ответственность за ваш аккаунт несете только вы.",
style: TextStyle(
color: colors.onSurface.withOpacity(0.8),
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600,
color: selected ? colors.onPrimary : colors.onSurfaceVariant,
), ),
), ),
],
), ),
),
const SizedBox(height: 20),
Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return SwitchListTile(
title: const Text("Обход блокировки"),
subtitle: const Text("Активировать функции обхода ограничений"),
value: themeProvider.blockBypass,
onChanged: (value) {
themeProvider.setBlockBypass(value);
},
);
},
),
],
); );
} }
} }

View File

@@ -451,8 +451,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSettingsCategory( _buildSettingsCategory(
context, context,
icon: Icons.psychology_outlined, icon: Icons.psychology_outlined,
title: "Специальные возможности", title: "Специальные возможности и фишки",
subtitle: "Обход ограничений", subtitle: "Обход ограничений, эксперименты",
screen: const BypassScreen(), screen: const BypassScreen(),
), ),
_buildSettingsCategory( _buildSettingsCategory(

View File

@@ -16,28 +16,9 @@ class LocalProfileManager {
} }
Future<Profile?> getActualProfile(Profile? serverProfile) async { Future<Profile?> getActualProfile(Profile? serverProfile) async {
await initialize(); // Полностью отключаем локальные оверрайды профиля:
// всегда используем только данные с сервера.
final localAvatarPath = await _profileCache.getLocalAvatarPath(); return serverProfile;
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;
} }
Future<String?> getLocalAvatarPath() async { Future<String?> getLocalAvatarPath() async {

View File

@@ -168,6 +168,13 @@ Color _getUserColor(int userId, BuildContext context) {
return color; return color;
} }
class _KometColoredSegment {
final String text;
final Color? color;
_KometColoredSegment(this.text, this.color);
}
class ChatMessageBubble extends StatelessWidget { class ChatMessageBubble extends StatelessWidget {
final Message message; final Message message;
final bool isMe; final bool isMe;
@@ -3753,37 +3760,19 @@ class ChatMessageBubble extends StatelessWidget {
if (message.text.isNotEmpty) ...[ if (message.text.isNotEmpty) ...[
if (message.text.contains("welcome.saved.dialog.message")) if (message.text.contains("welcome.saved.dialog.message"))
Linkify( Linkify(
text:'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.', text:
style: TextStyle(color: textColor, fontStyle: FontStyle.italic), 'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.',
style:
TextStyle(color: textColor, fontStyle: FontStyle.italic),
linkStyle: linkStyle, linkStyle: linkStyle,
onOpen: onOpenLink, onOpen: onOpenLink,
options: const LinkifyOptions(humanize: false), options: const LinkifyOptions(humanize: false),
textAlign: TextAlign.left, textAlign: TextAlign.left,
) )
else if (message.text.contains("komet.custom_text")) else if (message.text.contains("komet.color_"))
Linkify( _buildKometColorRichText(
style: message.text.contains("komet.custom_text.red") ? message.text,
TextStyle(color: Color.from(alpha: 255, red: 255, green: 0, blue: 0)) : defaultTextStyle,
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 else
Linkify( 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( BoxDecoration _createBubbleDecoration(
Color bubbleColor, Color bubbleColor,
double messageBorderRadius, double messageBorderRadius,