Сделал изменение профиля рабочим, возможность загрузить свое фото или выбрать с пресетов; работающие сообщения типа komet.color_#...
This commit is contained in:
@@ -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 {
|
||||||
"firstName": firstName,
|
await waitUntilOnline();
|
||||||
"lastName": lastName,
|
|
||||||
"description": description,
|
final Map<String, dynamic> payload = {
|
||||||
};
|
"firstName": firstName,
|
||||||
_sendMessage(16, payload);
|
"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<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;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateProfilePhoto(String firstName, String lastName) async {
|
/// Загружает фото и привязывает его к профилю через 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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -2,127 +2,369 @@ 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: [
|
||||||
Container(
|
// Переключатель вкладок (как между папками)
|
||||||
padding: const EdgeInsets.all(16),
|
LayoutBuilder(
|
||||||
decoration: BoxDecoration(
|
builder: (context, constraints) {
|
||||||
color: colors.primaryContainer.withOpacity(0.3),
|
final isNarrow = constraints.maxWidth < 480;
|
||||||
borderRadius: BorderRadius.circular(12),
|
return Container(
|
||||||
),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
child: Column(
|
padding: const EdgeInsets.all(4),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
decoration: BoxDecoration(
|
||||||
children: [
|
color: colors.surfaceContainerHighest,
|
||||||
Row(
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: colors.outline.withOpacity(0.2)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.info_outline, color: colors.primary),
|
Expanded(
|
||||||
const SizedBox(width: 8),
|
child: GestureDetector(
|
||||||
Text(
|
onTap: () {
|
||||||
"Обход блокировки",
|
if (_selectedTab != 0) {
|
||||||
style: TextStyle(
|
setState(() => _selectedTab = 0);
|
||||||
fontWeight: FontWeight.bold,
|
}
|
||||||
color: colors.primary,
|
},
|
||||||
|
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<ThemeProvider>(
|
|
||||||
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),
|
if (_selectedTab == 0) ...[
|
||||||
|
Container(
|
||||||
Container(
|
padding: const EdgeInsets.all(16),
|
||||||
padding: const EdgeInsets.all(12),
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: colors.primaryContainer.withOpacity(0.3),
|
||||||
color: colors.surfaceContainerHighest,
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderRadius: BorderRadius.circular(8),
|
),
|
||||||
border: Border.all(color: colors.outline.withOpacity(0.3)),
|
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,
|
const SizedBox(height: 24),
|
||||||
children: [
|
|
||||||
Row(
|
Consumer<ThemeProvider>(
|
||||||
children: [
|
builder: (context, themeProvider, child) {
|
||||||
Icon(
|
return Card(
|
||||||
Icons.warning_outlined,
|
child: SwitchListTile(
|
||||||
color: colors.primary,
|
title: const Text(
|
||||||
size: 16,
|
"Обход блокировки",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
subtitle: const Text(
|
||||||
Text(
|
"Разрешить отправку сообщений заблокированным пользователям",
|
||||||
"ВНИМНИЕ🚨🚨🚨",
|
),
|
||||||
style: TextStyle(
|
value: themeProvider.blockBypass,
|
||||||
fontWeight: FontWeight.w600,
|
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,
|
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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(height: 8),
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Используя любую из bypass функций, вас возможно накажут",
|
"Простой пример:",
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colors.onSurfaceVariant,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 14,
|
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: selected ? colors.primary : Colors.transparent,
|
||||||
color: colors.primaryContainer.withOpacity(0.3),
|
borderRadius: BorderRadius.circular(10),
|
||||||
borderRadius: BorderRadius.circular(12),
|
),
|
||||||
border: Border.all(color: colors.outline.withOpacity(0.3)),
|
child: Center(
|
||||||
),
|
child: Text(
|
||||||
child: Column(
|
label,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
style: TextStyle(
|
||||||
children: [
|
fontSize: 14,
|
||||||
Row(
|
fontWeight: FontWeight.w600,
|
||||||
children: [
|
color: selected ? colors.onPrimary : colors.onSurfaceVariant,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user