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

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

View File

@@ -1,24 +1,76 @@
part of 'api_service.dart';
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(

View File

@@ -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(

View File

@@ -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);
},
),

View File

@@ -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, {

View File

@@ -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);
},
);
},
),
],
),
);
}
}

View File

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

View File

@@ -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 {

View File

@@ -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,