Сделал изменение профиля рабочим, возможность загрузить свое фото или выбрать с пресетов; работающие сообщения типа komet.color_#...
This commit is contained in:
@@ -1,24 +1,76 @@
|
||||
part of 'api_service.dart';
|
||||
|
||||
extension ApiServiceMedia on ApiService {
|
||||
void updateProfileText(
|
||||
/// Обновляет имя/фамилию/описание профиля через сервер (opcode 16)
|
||||
/// и возвращает обновленный профиль из ответа.
|
||||
Future<Profile?> updateProfileText(
|
||||
String firstName,
|
||||
String lastName,
|
||||
String description,
|
||||
) {
|
||||
final payload = {
|
||||
"firstName": firstName,
|
||||
"lastName": lastName,
|
||||
"description": description,
|
||||
};
|
||||
_sendMessage(16, payload);
|
||||
) async {
|
||||
try {
|
||||
await waitUntilOnline();
|
||||
|
||||
final Map<String, dynamic> 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<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 {
|
||||
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<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) {
|
||||
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(
|
||||
|
||||
@@ -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<ChatScreen> {
|
||||
? (!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<ChatScreen> {
|
||||
setState(() {
|
||||
_replyingToMessage = null;
|
||||
});
|
||||
|
||||
widget.onChatUpdated?.call();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1835,7 +1835,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
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<ChatScreen> {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
widget.onChatUpdated?.call();
|
||||
widget.onChatRemoved?.call();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
|
||||
@@ -2529,7 +2529,7 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
isGroupChat: isGroupChat,
|
||||
isChannel: isChannel,
|
||||
participantCount: participantCount,
|
||||
onChatUpdated: () {
|
||||
onChatRemoved: () {
|
||||
_removeChatLocally(chat.id);
|
||||
},
|
||||
),
|
||||
@@ -2723,6 +2723,9 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
onChatUpdated: () {
|
||||
_loadChatsAndContacts();
|
||||
},
|
||||
onChatRemoved: () {
|
||||
_removeChatLocally(chat.id);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -4240,7 +4243,7 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
isGroupChat: isGroupChat,
|
||||
isChannel: isChannel,
|
||||
participantCount: participantCount,
|
||||
onChatUpdated: () {
|
||||
onChatRemoved: () {
|
||||
_removeChatLocally(chat.id);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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<ManageAccountScreen> {
|
||||
late final TextEditingController _lastNameController;
|
||||
late final TextEditingController _descriptionController;
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
final ProfileCacheService _profileCache = ProfileCacheService();
|
||||
final LocalProfileManager _profileManager = LocalProfileManager();
|
||||
|
||||
Profile? _actualProfile;
|
||||
String? _localAvatarPath;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
@@ -35,9 +28,8 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
|
||||
}
|
||||
|
||||
Future<void> _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<ManageAccountScreen> {
|
||||
_descriptionController = TextEditingController(
|
||||
text: _actualProfile?.description ?? '',
|
||||
);
|
||||
final localPath = await _profileManager.getLocalAvatarPath();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_localAvatarPath = localPath;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveProfile() async {
|
||||
@@ -70,26 +56,21 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
|
||||
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<ManageAccountScreen> {
|
||||
|
||||
Future<void> _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<ManageAccountScreen> {
|
||||
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<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(
|
||||
String label,
|
||||
IconData icon, {
|
||||
|
||||
@@ -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<BypassScreen> createState() => _BypassScreenState();
|
||||
}
|
||||
|
||||
class _BypassScreenState extends State<BypassScreen> {
|
||||
// 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<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),
|
||||
|
||||
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<ThemeProvider>(
|
||||
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<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(
|
||||
context,
|
||||
icon: Icons.psychology_outlined,
|
||||
title: "Специальные возможности",
|
||||
subtitle: "Обход ограничений",
|
||||
title: "Специальные возможности и фишки",
|
||||
subtitle: "Обход ограничений, эксперименты",
|
||||
screen: const BypassScreen(),
|
||||
),
|
||||
_buildSettingsCategory(
|
||||
|
||||
@@ -16,28 +16,9 @@ class LocalProfileManager {
|
||||
}
|
||||
|
||||
Future<Profile?> 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<String?> getLocalAvatarPath() async {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user