добавлены анимации на экране сообщений, добавлено редактирование профиля (локально), изменена панель сообщений

добавлен баг с незагрузкой аватарок в чатах
This commit is contained in:
needle10
2025-11-27 20:06:11 +03:00
parent ad943e0936
commit 9745370613
26 changed files with 3782 additions and 1008 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -18,6 +18,7 @@ import 'package:gwid/services/account_manager.dart';
import 'package:gwid/services/avatar_cache_service.dart'; import 'package:gwid/services/avatar_cache_service.dart';
import 'package:gwid/services/cache_service.dart'; import 'package:gwid/services/cache_service.dart';
import 'package:gwid/services/chat_cache_service.dart'; import 'package:gwid/services/chat_cache_service.dart';
import 'package:gwid/services/profile_cache_service.dart';
import 'package:gwid/spoofing_service.dart'; import 'package:gwid/spoofing_service.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';

View File

@@ -69,6 +69,37 @@ extension ApiServiceChats on ApiService {
_sendInitialSetupRequests(); _sendInitialSetupRequests();
} }
if (profile != null && authToken != null) {
try {
final accountManager = AccountManager();
await accountManager.initialize();
final currentAccount = accountManager.currentAccount;
if (currentAccount != null && currentAccount.token == authToken) {
final profileObj = Profile.fromJson(profile);
await accountManager.updateAccountProfile(
currentAccount.id,
profileObj,
);
try {
final profileCache = ProfileCacheService();
await profileCache.initialize();
await profileCache.syncWithServerProfile(profileObj);
} catch (e) {
print('[ProfileCache] Ошибка синхронизации профиля: $e');
}
print(
'[_sendAuthRequestAfterHandshake] ✅ Профиль сохранен в AccountManager',
);
}
} catch (e) {
print(
'[_sendAuthRequestAfterHandshake] Ошибка сохранения профиля в AccountManager: $e',
);
}
}
if (_onlineCompleter != null && !_onlineCompleter!.isCompleted) { if (_onlineCompleter != null && !_onlineCompleter!.isCompleted) {
_onlineCompleter!.complete(); _onlineCompleter!.complete();
} }
@@ -95,9 +126,10 @@ extension ApiServiceChats on ApiService {
}; };
_lastChatsPayload = result; _lastChatsPayload = result;
final contacts = contactListJson final contacts = (contactListJson as List)
.map((json) => Contact.fromJson(json)) .map((json) => Contact.fromJson(json as Map<String, dynamic>))
.toList(); .toList()
.cast<Contact>();
updateContactCache(contacts); updateContactCache(contacts);
_lastChatsAt = DateTime.now(); _lastChatsAt = DateTime.now();
_preloadContactAvatars(contacts); _preloadContactAvatars(contacts);
@@ -241,7 +273,11 @@ extension ApiServiceChats on ApiService {
} }
try { try {
final payload = {"chatsCount": 100}; // Используем opcode 48 для запроса конкретных чатов
// chatIds:[0] - это "Избранное" (Saved Messages)
final payload = {
"chatIds": [0],
};
final int chatSeq = _sendMessage(48, payload); final int chatSeq = _sendMessage(48, payload);
final chatResponse = await messages.firstWhere( final chatResponse = await messages.firstWhere(
@@ -265,15 +301,16 @@ extension ApiServiceChats on ApiService {
contactIds.addAll(participants.keys.map((id) => int.parse(id))); contactIds.addAll(participants.keys.map((id) => int.parse(id)));
} }
final int contactSeq = _sendMessage(32, { List<dynamic> contactListJson = [];
"contactIds": contactIds.toList(), if (contactIds.isNotEmpty) {
}); final int contactSeq = _sendMessage(32, {
final contactResponse = await messages.firstWhere( "contactIds": contactIds.toList(),
(msg) => msg['seq'] == contactSeq, });
); final contactResponse = await messages.firstWhere(
(msg) => msg['seq'] == contactSeq,
final List<dynamic> contactListJson = );
contactResponse['payload']?['contacts'] ?? []; contactListJson = contactResponse['payload']?['contacts'] ?? [];
}
final result = { final result = {
'chats': chatListJson, 'chats': chatListJson,
@@ -283,8 +320,8 @@ extension ApiServiceChats on ApiService {
}; };
_lastChatsPayload = result; _lastChatsPayload = result;
final contacts = contactListJson final List<Contact> contacts = contactListJson
.map((json) => Contact.fromJson(json)) .map((json) => Contact.fromJson(json as Map<String, dynamic>))
.toList(); .toList();
updateContactCache(contacts); updateContactCache(contacts);
_lastChatsAt = DateTime.now(); _lastChatsAt = DateTime.now();
@@ -295,7 +332,7 @@ extension ApiServiceChats on ApiService {
unawaited(_chatCacheService.cacheContacts(contacts)); unawaited(_chatCacheService.cacheContacts(contacts));
return result; return result;
} catch (e) { } catch (e) {
print('Ошибка получения чатов: $e'); print('Ошибка получения чатов через opcode 48: $e');
rethrow; rethrow;
} }
} }
@@ -445,6 +482,14 @@ extension ApiServiceChats on ApiService {
currentAccount.id, currentAccount.id,
profileObj, profileObj,
); );
try {
final profileCache = ProfileCacheService();
await profileCache.initialize();
await profileCache.syncWithServerProfile(profileObj);
} catch (e) {
print('[ProfileCache] Ошибка синхронизации профиля: $e');
}
} }
} catch (e) { } catch (e) {
print('Ошибка сохранения профиля в AccountManager: $e'); print('Ошибка сохранения профиля в AccountManager: $e');
@@ -510,8 +555,8 @@ extension ApiServiceChats on ApiService {
}; };
_lastChatsPayload = result; _lastChatsPayload = result;
final contacts = contactListJson final List<Contact> contacts = contactListJson
.map((json) => Contact.fromJson(json)) .map((json) => Contact.fromJson(json as Map<String, dynamic>))
.toList(); .toList();
updateContactCache(contacts); updateContactCache(contacts);
_lastChatsAt = DateTime.now(); _lastChatsAt = DateTime.now();

View File

@@ -679,6 +679,7 @@ extension ApiServiceConnection on ApiService {
_reconnectTimer?.cancel(); _reconnectTimer?.cancel();
_isSessionOnline = false; _isSessionOnline = false;
_isSessionReady = false; _isSessionReady = false;
_handshakeSent = false;
_onlineCompleter = Completer<void>(); _onlineCompleter = Completer<void>();
_chatsFetchedInThisSession = false; _chatsFetchedInThisSession = false;

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,10 @@ import 'package:gwid/user_id_lookup_screen.dart';
import 'package:gwid/screens/music_library_screen.dart'; import 'package:gwid/screens/music_library_screen.dart';
import 'package:gwid/widgets/message_preview_dialog.dart'; import 'package:gwid/widgets/message_preview_dialog.dart';
import 'package:gwid/services/chat_read_settings_service.dart'; import 'package:gwid/services/chat_read_settings_service.dart';
import 'package:gwid/services/local_profile_manager.dart';
import 'package:gwid/widgets/contact_name_widget.dart';
import 'package:gwid/widgets/contact_avatar_widget.dart';
import 'package:gwid/services/contact_local_names_service.dart';
import 'package:gwid/services/account_manager.dart'; import 'package:gwid/services/account_manager.dart';
import 'package:gwid/models/account.dart'; import 'package:gwid/models/account.dart';
@@ -163,25 +167,48 @@ class _ChatsScreenState extends State<ChatsScreen>
_isProfileLoading = true; _isProfileLoading = true;
}); });
Profile? serverProfile;
try { try {
final accountManager = AccountManager(); final accountManager = AccountManager();
await accountManager.initialize(); await accountManager.initialize();
final currentAccount = accountManager.currentAccount; final currentAccount = accountManager.currentAccount;
if (currentAccount?.profile != null && mounted) { if (currentAccount?.profile != null) {
setState(() { serverProfile = currentAccount!.profile;
_myProfile = currentAccount!.profile;
_isProfileLoading = false;
});
return;
} }
} catch (e) { } catch (e) {
print('Ошибка загрузки профиля из AccountManager: $e'); print('Ошибка загрузки профиля из AccountManager: $e');
} }
final cachedProfileData = ApiService.instance.lastChatsPayload?['profile']; if (serverProfile == null) {
if (cachedProfileData != null && mounted) { final cachedProfileData =
ApiService.instance.lastChatsPayload?['profile'];
if (cachedProfileData != null) {
serverProfile = Profile.fromJson(cachedProfileData);
}
}
try {
final profileManager = LocalProfileManager();
await profileManager.initialize();
final actualProfile = await profileManager.getActualProfile(
serverProfile,
);
if (mounted && actualProfile != null) {
setState(() {
_myProfile = actualProfile;
_isProfileLoading = false;
});
return;
}
} catch (e) {
print('Ошибка загрузки локального профиля: $e');
}
if (mounted && serverProfile != null) {
setState(() { setState(() {
_myProfile = Profile.fromJson(cachedProfileData); _myProfile = serverProfile;
_isProfileLoading = false; _isProfileLoading = false;
}); });
return; return;
@@ -277,6 +304,20 @@ class _ChatsScreenState extends State<ChatsScreen>
final opcode = message['opcode']; final opcode = message['opcode'];
final cmd = message['cmd']; final cmd = message['cmd'];
final payload = message['payload']; final payload = message['payload'];
if (opcode == 19 && cmd == 1 && payload != null) {
final profileData = payload['profile'];
if (profileData != null) {
print('🔄 ChatsScreen: Получен профиль из opcode 19, обновляем UI');
if (mounted) {
setState(() {
_myProfile = Profile.fromJson(profileData);
_isProfileLoading = false;
});
}
}
}
if (payload == null) return; if (payload == null) return;
final chatIdValue = payload['chatId']; final chatIdValue = payload['chatId'];
final int? chatId = chatIdValue != null ? chatIdValue as int? : null; final int? chatId = chatIdValue != null ? chatIdValue as int? : null;
@@ -640,6 +681,7 @@ class _ChatsScreenState extends State<ChatsScreen>
if (mounted) { if (mounted) {
final chats = data['chats'] as List<dynamic>; final chats = data['chats'] as List<dynamic>;
final contacts = data['contacts'] as List<dynamic>; final contacts = data['contacts'] as List<dynamic>;
final profileData = data['profile'];
_allChats = chats _allChats = chats
.where((json) => json != null) .where((json) => json != null)
@@ -650,6 +692,14 @@ class _ChatsScreenState extends State<ChatsScreen>
final contact = Contact.fromJson(contactJson); final contact = Contact.fromJson(contactJson);
_contacts[contact.id] = contact; _contacts[contact.id] = contact;
} }
setState(() {
if (profileData != null) {
_myProfile = Profile.fromJson(profileData);
_isProfileLoading = false;
}
});
_filterChats(); _filterChats();
} }
}); });
@@ -1061,7 +1111,14 @@ class _ChatsScreenState extends State<ChatsScreen>
final isSelected = selectedContacts.contains(contact.id); final isSelected = selectedContacts.contains(contact.id);
return CheckboxListTile( return CheckboxListTile(
title: Text(contact.name), title: Text(
getContactDisplayName(
contactId: contact.id,
originalName: contact.name,
originalFirstName: contact.firstName,
originalLastName: contact.lastName,
),
),
subtitle: Text( subtitle: Text(
contact.firstName.isNotEmpty && contact.firstName.isNotEmpty &&
contact.lastName.isNotEmpty contact.lastName.isNotEmpty
@@ -1387,12 +1444,20 @@ class _ChatsScreenState extends State<ChatsScreen>
if (contact == null) continue; if (contact == null) continue;
if (contact.name.toLowerCase().contains(query)) { final displayName = getContactDisplayName(
contactId: contact.id,
originalName: contact.name,
originalFirstName: contact.firstName,
originalLastName: contact.lastName,
);
if (displayName.toLowerCase().contains(query) ||
contact.name.toLowerCase().contains(query)) {
results.add( results.add(
SearchResult( SearchResult(
chat: chat, chat: chat,
contact: contact, contact: contact,
matchedText: contact.name, matchedText: displayName,
matchType: 'name', matchType: 'name',
), ),
); );
@@ -1465,6 +1530,7 @@ class _ChatsScreenState extends State<ChatsScreen>
if (mounted) { if (mounted) {
final chats = data['chats'] as List; final chats = data['chats'] as List;
final contacts = data['contacts'] as List; final contacts = data['contacts'] as List;
final profileData = data['profile'];
_allChats = chats _allChats = chats
.where((json) => json != null) .where((json) => json != null)
@@ -1477,6 +1543,13 @@ class _ChatsScreenState extends State<ChatsScreen>
_contacts[contact.id] = contact; _contacts[contact.id] = contact;
} }
if (profileData != null) {
setState(() {
_myProfile = Profile.fromJson(profileData);
_isProfileLoading = false;
});
}
_filterChats(); _filterChats();
} }
}); });
@@ -1710,7 +1783,9 @@ class _ChatsScreenState extends State<ChatsScreen>
ApiService.instance.getBlockedContacts(); ApiService.instance.getBlockedContacts();
} }
_loadFolders(snapshot.data!); WidgetsBinding.instance.addPostFrameCallback((_) {
_loadFolders(snapshot.data!);
});
_loadChatOrder().then((_) { _loadChatOrder().then((_) {
setState(() { setState(() {
@@ -2285,7 +2360,15 @@ class _ChatsScreenState extends State<ChatsScreen>
) )
: null, : null,
), ),
title: _buildHighlightedText(contact.name, result.matchedText), title: _buildHighlightedText(
getContactDisplayName(
contactId: contact.id,
originalName: contact.name,
originalFirstName: contact.firstName,
originalLastName: contact.lastName,
),
result.matchedText,
),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -2394,7 +2477,12 @@ class _ChatsScreenState extends State<ChatsScreen>
} else if (isSavedMessages) { } else if (isSavedMessages) {
title = "Избранное"; title = "Избранное";
} else if (contact != null) { } else if (contact != null) {
title = contact.name; title = getContactDisplayName(
contactId: contact.id,
originalName: contact.name,
originalFirstName: contact.firstName,
originalLastName: contact.lastName,
);
} else if (chat.title?.isNotEmpty == true) { } else if (chat.title?.isNotEmpty == true) {
title = chat.title!; title = chat.title!;
} else { } else {
@@ -2453,39 +2541,41 @@ class _ChatsScreenState extends State<ChatsScreen>
children: [ children: [
Stack( Stack(
children: [ children: [
CircleAvatar( isSavedMessages || isGroupChat
radius: 28, ? CircleAvatar(
backgroundColor: colors.primaryContainer, radius: 28,
backgroundImage: backgroundColor: colors.primaryContainer,
!isSavedMessages && backgroundImage:
!isGroupChat && isGroupChat && chat.baseIconUrl != null
contact?.photoBaseUrl != null ? NetworkImage(chat.baseIconUrl ?? '')
? NetworkImage(contact?.photoBaseUrl ?? '') : null,
: (isGroupChat && chat.baseIconUrl != null) child:
? NetworkImage(chat.baseIconUrl ?? '') isSavedMessages ||
: null, (isGroupChat && chat.baseIconUrl == null)
child: ? Icon(
isSavedMessages || isSavedMessages
(isGroupChat && chat.baseIconUrl == null) ? Icons.bookmark
? Icon( : Icons.group,
isSavedMessages ? Icons.bookmark : Icons.group, color: colors.onPrimaryContainer,
color: colors.onPrimaryContainer, size: 20,
size: 20,
)
: (contact?.photoBaseUrl == null
? Text(
(contact != null &&
contact.name.isNotEmpty)
? contact.name[0].toUpperCase()
: '?',
style: TextStyle(
color: colors.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
) )
: null), : null,
), )
: contact != null
? ContactAvatarWidget(
contactId: contact.id,
originalAvatarUrl: contact.photoBaseUrl,
radius: 28,
fallbackText: contact.name.isNotEmpty
? contact.name[0].toUpperCase()
: '?',
backgroundColor: colors.primaryContainer,
)
: CircleAvatar(
radius: 28,
backgroundColor: colors.primaryContainer,
child: const Text('?'),
),
if (chat.newMessages > 0) if (chat.newMessages > 0)
Positioned( Positioned(
@@ -3823,7 +3913,12 @@ class _ChatsScreenState extends State<ChatsScreen>
contact = _contacts[otherParticipantId]; contact = _contacts[otherParticipantId];
if (contact != null) { if (contact != null) {
title = contact.name; title = getContactDisplayName(
contactId: contact.id,
originalName: contact.name,
originalFirstName: contact.firstName,
originalLastName: contact.lastName,
);
} else if (chat.title?.isNotEmpty == true) { } else if (chat.title?.isNotEmpty == true) {
title = chat.title!; title = chat.title!;
} else { } else {
@@ -4531,7 +4626,12 @@ class _AddChatsToFolderDialogState extends State<_AddChatsToFolderDialog> {
contact = widget.contacts[otherParticipantId]; contact = widget.contacts[otherParticipantId];
if (contact != null) { if (contact != null) {
title = contact.name; title = getContactDisplayName(
contactId: contact.id,
originalName: contact.name,
originalFirstName: contact.firstName,
originalLastName: contact.lastName,
);
} else if (chat.title?.isNotEmpty == true) { } else if (chat.title?.isNotEmpty == true) {
title = chat.title!; title = chat.title!;
} else { } else {

View File

@@ -14,6 +14,7 @@ import 'connection_lifecycle_manager.dart';
import 'services/cache_service.dart'; import 'services/cache_service.dart';
import 'services/avatar_cache_service.dart'; import 'services/avatar_cache_service.dart';
import 'services/chat_cache_service.dart'; import 'services/chat_cache_service.dart';
import 'services/contact_local_names_service.dart';
import 'services/version_checker.dart'; import 'services/version_checker.dart';
import 'services/account_manager.dart'; import 'services/account_manager.dart';
import 'services/music_player_service.dart'; import 'services/music_player_service.dart';
@@ -28,6 +29,7 @@ Future<void> main() async {
await CacheService().initialize(); await CacheService().initialize();
await AvatarCacheService().initialize(); await AvatarCacheService().initialize();
await ChatCacheService().initialize(); await ChatCacheService().initialize();
await ContactLocalNamesService().initialize();
print("Сервисы кеширования инициализированы"); print("Сервисы кеширования инициализированы");
print("Инициализируем AccountManager..."); print("Инициализируем AccountManager...");

View File

@@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; 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/phone_entry_screen.dart'; import 'package:gwid/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 'package:image_picker/image_picker.dart';
import 'dart:io'; import 'dart:io';
@@ -20,39 +21,97 @@ 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;
String? _localAvatarPath;
bool _isLoading = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_firstNameController = TextEditingController( _initializeProfileData();
text: widget.myProfile?.firstName ?? '',
);
_lastNameController = TextEditingController(
text: widget.myProfile?.lastName ?? '',
);
_descriptionController = TextEditingController(
text: widget.myProfile?.description ?? '',
);
} }
void _saveProfile() { Future<void> _initializeProfileData() async {
await _profileManager.initialize();
_actualProfile = await _profileManager.getActualProfile(widget.myProfile);
_firstNameController = TextEditingController(
text: _actualProfile?.firstName ?? '',
);
_lastNameController = TextEditingController(
text: _actualProfile?.lastName ?? '',
);
_descriptionController = TextEditingController(
text: _actualProfile?.description ?? '',
);
final localPath = await _profileManager.getLocalAvatarPath();
if (mounted) {
setState(() {
_localAvatarPath = localPath;
});
}
}
Future<void> _saveProfile() async {
if (!_formKey.currentState!.validate()) { if (!_formKey.currentState!.validate()) {
return; return;
} }
ApiService.instance.updateProfileText( setState(() {
_firstNameController.text.trim(), _isLoading = true;
_lastNameController.text.trim(), });
_descriptionController.text.trim(),
);
ScaffoldMessenger.of(context).showSnackBar( try {
const SnackBar( final firstName = _firstNameController.text.trim();
content: Text("Профиль успешно сохранен"), final lastName = _lastNameController.text.trim();
behavior: SnackBarBehavior.floating, final description = _descriptionController.text.trim();
duration: Duration(seconds: 2),
), 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,
);
_actualProfile = await _profileManager.getActualProfile(widget.myProfile);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Профиль сохранен локально"),
behavior: SnackBarBehavior.floating,
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Ошибка сохранения: $e"),
behavior: SnackBarBehavior.floating,
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
} }
void _logout() async { void _logout() async {
@@ -102,27 +161,62 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
} }
} }
void _pickAndUpdateProfilePhoto() async { Future<void> _pickAndUpdateProfilePhoto() async {
final ImagePicker picker = ImagePicker(); try {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1024,
maxHeight: 1024,
imageQuality: 85,
);
final XFile? image = await picker.pickImage(source: ImageSource.gallery); if (image == null) return;
setState(() {
if (image != null) { _isLoading = true;
});
File imageFile = File(image.path); File imageFile = File(image.path);
final userId = _actualProfile?.id ?? widget.myProfile?.id ?? 0;
if (userId != 0) {
final localPath = await _profileCache.saveAvatar(imageFile, userId);
if (localPath != null && mounted) {
setState(() {
_localAvatarPath = localPath;
});
_actualProfile = await _profileManager.getActualProfile(
widget.myProfile,
);
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
ScaffoldMessenger.of(context).showSnackBar( content: Text("Фотография профиля сохранена"),
const SnackBar( behavior: SnackBarBehavior.floating,
content: Text("Фотография профиля обновляется..."), ),
behavior: SnackBarBehavior.floating, );
), }
); } catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Ошибка загрузки фото: $e"),
behavior: SnackBarBehavior.floating,
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
} }
} }
@@ -155,7 +249,6 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
_buildAvatarSection(theme), _buildAvatarSection(theme),
const SizedBox(height: 32), const SizedBox(height: 32),
Card( Card(
elevation: 2, elevation: 2,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -199,7 +292,6 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Card( Card(
elevation: 2, elevation: 2,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -234,7 +326,6 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
if (widget.myProfile != null) if (widget.myProfile != null)
Card( Card(
elevation: 2, elevation: 2,
@@ -281,22 +372,32 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
); );
} }
Widget _buildAvatarSection(ThemeData theme) { 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!);
}
return Center( return Center(
child: GestureDetector( child: GestureDetector(
onTap: _pickAndUpdateProfilePhoto,
onTap: _pickAndUpdateProfilePhoto, // 2. Вызываем метод при нажатии
child: Stack( child: Stack(
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 60, radius: 60,
backgroundColor: theme.colorScheme.secondaryContainer, backgroundColor: theme.colorScheme.secondaryContainer,
backgroundImage: widget.myProfile?.photoBaseUrl != null backgroundImage: avatarImage,
? NetworkImage(widget.myProfile!.photoBaseUrl!) child: avatarImage == null
: null,
child: widget.myProfile?.photoBaseUrl == null
? Icon( ? Icon(
Icons.person, Icons.person,
size: 60, size: 60,
@@ -304,6 +405,21 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
) )
: null, : null,
), ),
if (_isLoading)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
),
),
),
Positioned( Positioned(
bottom: 4, bottom: 4,
right: 4, right: 4,
@@ -329,7 +445,6 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
IconData icon, { IconData icon, {
bool alignLabel = false, bool alignLabel = false,
}) { }) {
final prefixIcon = (label == "О себе") final prefixIcon = (label == "О себе")
? Padding( ? Padding(
padding: const EdgeInsets.only(bottom: 60), // Смещаем иконку вверх padding: const EdgeInsets.only(bottom: 60), // Смещаем иконку вверх

View File

@@ -6,8 +6,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:gwid/api/api_service.dart'; import 'package:gwid/api/api_service.dart';
import 'package:gwid/otp_screen.dart'; import 'package:gwid/otp_screen.dart';
import 'package:gwid/proxy_service.dart'; import 'package:gwid/proxy_service.dart';
import 'package:gwid/screens/settings/proxy_settings_screen.dart'; import 'package:gwid/screens/settings/auth_settings_screen.dart';
import 'package:gwid/screens/settings/session_spoofing_screen.dart';
import 'package:gwid/token_auth_screen.dart'; import 'package:gwid/token_auth_screen.dart';
import 'package:gwid/tos_screen.dart'; // Импорт экрана ToS import 'package:gwid/tos_screen.dart'; // Импорт экрана ToS
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
@@ -219,10 +218,6 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
} }
} }
void refreshProxySettings() {
_checkProxySettings();
}
void _requestOtp() async { void _requestOtp() async {
if (!_isButtonEnabled || _isLoading || !_isTosAccepted) return; if (!_isButtonEnabled || _isLoading || !_isTosAccepted) return;
setState(() => _isLoading = true); setState(() => _isLoading = true);
@@ -429,9 +424,14 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
_AnonymityCard(isConfigured: _hasCustomAnonymity), _SettingsButton(
const SizedBox(height: 16), hasCustomAnonymity: _hasCustomAnonymity,
_ProxyCard(isConfigured: _hasProxyConfigured), hasProxyConfigured: _hasProxyConfigured,
onRefresh: () {
_checkAnonymitySettings();
_checkProxySettings();
},
),
const SizedBox(height: 24), const SizedBox(height: 24),
Text.rich( Text.rich(
textAlign: TextAlign.center, textAlign: TextAlign.center,
@@ -600,172 +600,159 @@ class _CountryPicker extends StatelessWidget {
} }
} }
class _AnonymityCard extends StatelessWidget { class _SettingsButton extends StatelessWidget {
final bool isConfigured; final bool hasCustomAnonymity;
const _AnonymityCard({required this.isConfigured}); final bool hasProxyConfigured;
final VoidCallback onRefresh;
const _SettingsButton({
required this.hasCustomAnonymity,
required this.hasProxyConfigured,
required this.onRefresh,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final Color cardColor = isConfigured final hasAnySettings = hasCustomAnonymity || hasProxyConfigured;
? colors.secondaryContainer
: colors.surfaceContainerHighest.withOpacity(0.5);
final Color onCardColor = isConfigured
? colors.onSecondaryContainer
: colors.onSurfaceVariant;
final IconData icon = isConfigured
? Icons.verified_user_outlined
: Icons.visibility_outlined;
return Card( return Card(
elevation: 0, elevation: 0,
color: cardColor, color: hasAnySettings
? colors.primaryContainer.withOpacity(0.3)
: colors.surfaceContainerHighest.withOpacity(0.5),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outline.withOpacity(0.5)), side: BorderSide(
color: hasAnySettings
? colors.primary.withOpacity(0.3)
: colors.outline.withOpacity(0.3),
width: hasAnySettings ? 2 : 1,
),
), ),
child: Padding( child: InkWell(
padding: const EdgeInsets.all(16.0), borderRadius: BorderRadius.circular(16),
child: Column( onTap: () async {
children: [ await Navigator.of(context).push(
Row( MaterialPageRoute(builder: (context) => const AuthSettingsScreen()),
children: [ );
Icon(icon, color: onCardColor, size: 20), onRefresh();
const SizedBox(width: 12), },
Expanded( child: Padding(
child: Text( padding: const EdgeInsets.all(20.0),
isConfigured child: Column(
? 'Активны кастомные настройки анонимности' children: [
: 'Настройте анонимность для скрытия данных', Row(
style: GoogleFonts.manrope( children: [
textStyle: textTheme.bodyMedium, Container(
color: onCardColor, padding: const EdgeInsets.all(10),
fontWeight: FontWeight.w500, decoration: BoxDecoration(
color: hasAnySettings
? colors.primary.withOpacity(0.15)
: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
), ),
child: Icon(
Icons.tune_outlined,
color: hasAnySettings
? colors.primary
: colors.onSurfaceVariant,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Настройки',
style: GoogleFonts.manrope(
textStyle: textTheme.titleMedium,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
hasAnySettings
? 'Настроены дополнительные параметры'
: 'Прокси и анонимность',
style: GoogleFonts.manrope(
textStyle: textTheme.bodySmall,
color: colors.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
color: colors.onSurfaceVariant,
size: 16,
),
],
),
if (hasAnySettings) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasCustomAnonymity) ...[
Icon(
Icons.verified_user,
size: 16,
color: colors.primary,
),
const SizedBox(width: 6),
Text(
'Анонимность',
style: GoogleFonts.manrope(
textStyle: textTheme.labelSmall,
color: colors.primary,
fontWeight: FontWeight.bold,
),
),
],
if (hasCustomAnonymity && hasProxyConfigured) ...[
const SizedBox(width: 12),
Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: colors.primary,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
],
if (hasProxyConfigured) ...[
Icon(Icons.vpn_key, size: 16, color: colors.primary),
const SizedBox(width: 6),
Text(
'Прокси',
style: GoogleFonts.manrope(
textStyle: textTheme.labelSmall,
color: colors.primary,
fontWeight: FontWeight.bold,
),
),
],
],
), ),
), ),
], ],
), ],
const SizedBox(height: 12), ),
SizedBox(
width: double.infinity,
child: isConfigured
? FilledButton.tonalIcon(
onPressed: _navigateToSpoofingScreen(context),
icon: const Icon(Icons.settings, size: 18),
label: Text(
'Изменить настройки',
style: GoogleFonts.manrope(fontWeight: FontWeight.bold),
),
)
: FilledButton.icon(
onPressed: _navigateToSpoofingScreen(context),
icon: const Icon(Icons.visibility_off, size: 18),
label: Text(
'Настроить анонимность',
style: GoogleFonts.manrope(fontWeight: FontWeight.bold),
),
),
),
],
), ),
), ),
); );
} }
VoidCallback _navigateToSpoofingScreen(BuildContext context) {
return () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const SessionSpoofingScreen()),
);
};
}
}
class _ProxyCard extends StatelessWidget {
final bool isConfigured;
const _ProxyCard({required this.isConfigured});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final Color cardColor = isConfigured
? colors.secondaryContainer
: colors.surfaceContainerHighest.withOpacity(0.5);
final Color onCardColor = isConfigured
? colors.onSecondaryContainer
: colors.onSurfaceVariant;
final IconData icon = isConfigured ? Icons.vpn_key : Icons.vpn_key_outlined;
return Card(
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outline.withOpacity(0.5)),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
Icon(icon, color: onCardColor, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
isConfigured
? 'Прокси-сервер настроен и активен'
: 'Настройте прокси-сервер для подключения',
style: GoogleFonts.manrope(
textStyle: textTheme.bodyMedium,
color: onCardColor,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: isConfigured
? FilledButton.tonalIcon(
onPressed: _navigateToProxyScreen(context),
icon: const Icon(Icons.settings, size: 18),
label: Text(
'Изменить настройки',
style: GoogleFonts.manrope(fontWeight: FontWeight.bold),
),
)
: FilledButton.icon(
onPressed: _navigateToProxyScreen(context),
icon: const Icon(Icons.vpn_key, size: 18),
label: Text(
'Настроить прокси',
style: GoogleFonts.manrope(fontWeight: FontWeight.bold),
),
),
),
],
),
),
);
}
VoidCallback _navigateToProxyScreen(BuildContext context) {
return () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ProxySettingsScreen()),
);
if (context.mounted) {
final state = context.findAncestorStateOfType<_PhoneEntryScreenState>();
state?.refreshProxySettings();
}
};
}
} }

View File

@@ -0,0 +1,610 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:gwid/services/contact_local_names_service.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import 'dart:convert';
class EditContactScreen extends StatefulWidget {
final int contactId;
final String? originalFirstName;
final String? originalLastName;
final String? originalDescription;
final String? originalAvatarUrl;
const EditContactScreen({
super.key,
required this.contactId,
this.originalFirstName,
this.originalLastName,
this.originalDescription,
this.originalAvatarUrl,
});
@override
State<EditContactScreen> createState() => _EditContactScreenState();
}
class _EditContactScreenState extends State<EditContactScreen> {
late final TextEditingController _firstNameController;
late final TextEditingController _lastNameController;
late final TextEditingController _notesController;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
bool _isLoading = true;
String? _localAvatarPath;
bool _isLoadingAvatar = false;
@override
void initState() {
super.initState();
_firstNameController = TextEditingController(
text: widget.originalFirstName ?? '',
);
_lastNameController = TextEditingController(
text: widget.originalLastName ?? '',
);
_notesController = TextEditingController();
_loadContactData();
}
Future<void> _loadContactData() async {
try {
final prefs = await SharedPreferences.getInstance();
final key = 'contact_${widget.contactId}';
final savedData = prefs.getString(key);
if (savedData != null) {
final data = jsonDecode(savedData) as Map<String, dynamic>;
_firstNameController.text =
data['firstName'] ?? widget.originalFirstName ?? '';
_lastNameController.text =
data['lastName'] ?? widget.originalLastName ?? '';
_notesController.text = data['notes'] ?? '';
final avatarPath = data['avatarPath'] as String?;
if (avatarPath != null) {
final file = File(avatarPath);
if (await file.exists()) {
if (mounted) {
setState(() {
_localAvatarPath = avatarPath;
});
}
}
}
}
if (_localAvatarPath == null && mounted) {
final cachedPath = ContactLocalNamesService().getContactAvatarPath(
widget.contactId,
);
if (cachedPath != null) {
final file = File(cachedPath);
if (await file.exists()) {
if (mounted) {
setState(() {
_localAvatarPath = cachedPath;
});
}
}
}
}
} catch (e) {
print('Ошибка загрузки локальных данных контакта: $e');
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _saveContactData() async {
if (_isLoading || !_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
final data = {
'firstName': _firstNameController.text.trim(),
'lastName': _lastNameController.text.trim(),
'notes': _notesController.text.trim(),
'updatedAt': DateTime.now().toIso8601String(),
};
if (_localAvatarPath != null) {
data['avatarPath'] = _localAvatarPath!;
}
await ContactLocalNamesService().saveContactData(widget.contactId, data);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Данные контакта сохранены'),
behavior: SnackBarBehavior.floating,
),
);
Navigator.of(context).pop(true);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка сохранения: $e'),
behavior: SnackBarBehavior.floating,
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _clearContactData() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Очистить данные?'),
content: const Text(
'Будут восстановлены оригинальные данные контакта с сервера.',
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Colors.red.shade400,
foregroundColor: Colors.white,
),
child: const Text('Очистить'),
),
],
),
);
if (confirmed == true) {
try {
await ContactLocalNamesService().clearContactData(widget.contactId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Данные контакта очищены'),
behavior: SnackBarBehavior.floating,
),
);
Navigator.of(context).pop(true);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка: $e'),
behavior: SnackBarBehavior.floating,
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
}
ImageProvider? _getAvatarImage() {
if (_localAvatarPath != null) {
return FileImage(File(_localAvatarPath!));
} else if (widget.originalAvatarUrl != null) {
return NetworkImage(widget.originalAvatarUrl!);
}
return null;
}
Future<void> _pickAvatar() 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(() {
_isLoadingAvatar = true;
});
File imageFile = File(image.path);
final localPath = await ContactLocalNamesService().saveContactAvatar(
imageFile,
widget.contactId,
);
if (localPath != null && mounted) {
setState(() {
_localAvatarPath = localPath;
_isLoadingAvatar = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Аватар сохранен'),
behavior: SnackBarBehavior.floating,
),
);
} else {
if (mounted) {
setState(() {
_isLoadingAvatar = false;
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_isLoadingAvatar = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка загрузки аватара: $e'),
behavior: SnackBarBehavior.floating,
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
Future<void> _removeAvatar() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Удалить аватар?'),
content: const Text(
'Локальный аватар будет удален, будет показан оригинальный.',
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Colors.red.shade400,
foregroundColor: Colors.white,
),
child: const Text('Удалить'),
),
],
),
);
if (confirmed == true) {
try {
await ContactLocalNamesService().removeContactAvatar(widget.contactId);
if (mounted) {
setState(() {
_localAvatarPath = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Аватар удален'),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка удаления: $e'),
behavior: SnackBarBehavior.floating,
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Редактировать контакт'),
centerTitle: true,
scrolledUnderElevation: 0,
actions: [
TextButton(
onPressed: _isLoading ? null : _saveContactData,
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text(
'Сохранить',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Эти данные сохраняются только локально',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Text(
'Аватар',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
GestureDetector(
onTap: _pickAvatar,
child: Stack(
children: [
CircleAvatar(
radius: 60,
backgroundColor:
theme.colorScheme.secondaryContainer,
backgroundImage: _getAvatarImage(),
child: _getAvatarImage() == null
? Icon(
Icons.person,
size: 60,
color: theme
.colorScheme
.onSecondaryContainer,
)
: null,
),
if (_isLoadingAvatar)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
),
),
),
Positioned(
bottom: 4,
right: 4,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(
Icons.camera_alt,
color: Colors.white,
size: 20,
),
),
),
),
],
),
),
if (_localAvatarPath != null) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _removeAvatar,
icon: const Icon(Icons.delete_outline),
label: const Text('Удалить аватар'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(color: theme.colorScheme.error),
),
),
],
],
),
),
),
const SizedBox(height: 16),
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Локальное имя',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _firstNameController,
maxLength: 60,
decoration: InputDecoration(
labelText: 'Имя',
hintText: widget.originalFirstName ?? 'Имя',
prefixIcon: const Icon(Icons.person_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
counterText: '',
),
),
const SizedBox(height: 12),
TextFormField(
controller: _lastNameController,
maxLength: 60,
decoration: InputDecoration(
labelText: 'Фамилия',
hintText: widget.originalLastName ?? 'Фамилия',
prefixIcon: const Icon(Icons.person_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
counterText: '',
),
),
],
),
),
),
const SizedBox(height: 16),
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Заметки',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _notesController,
maxLines: 4,
maxLength: 400,
decoration: InputDecoration(
labelText: 'Заметки о контакте',
hintText: 'Добавьте заметки...',
prefixIcon: const Padding(
padding: EdgeInsets.only(bottom: 60),
child: Icon(Icons.note_outlined),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
alignLabelWithHint: true,
),
),
],
),
),
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: _clearContactData,
icon: const Icon(Icons.restore),
label: const Text('Восстановить оригинальные данные'),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(color: theme.colorScheme.error),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
],
),
),
),
);
}
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_notesController.dispose();
super.dispose();
}
}
class ContactLocalDataHelper {
static Future<Map<String, dynamic>?> getContactData(int contactId) async {
return ContactLocalNamesService().getContactData(contactId);
}
static Future<void> clearContactData(int contactId) async {
await ContactLocalNamesService().clearContactData(contactId);
}
}

View File

@@ -0,0 +1,410 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:gwid/proxy_service.dart';
import 'package:gwid/screens/settings/proxy_settings_screen.dart';
import 'package:gwid/screens/settings/session_spoofing_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AuthSettingsScreen extends StatefulWidget {
const AuthSettingsScreen({super.key});
@override
State<AuthSettingsScreen> createState() => _AuthSettingsScreenState();
}
class _AuthSettingsScreenState extends State<AuthSettingsScreen>
with SingleTickerProviderStateMixin {
bool _hasCustomAnonymity = false;
bool _hasProxyConfigured = false;
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOut,
);
_slideAnimation =
Tween<Offset>(begin: const Offset(0, 0.1), end: Offset.zero).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
),
);
_checkSettings();
_animationController.forward();
}
Future<void> _checkSettings() async {
await Future.wait([_checkAnonymitySettings(), _checkProxySettings()]);
}
Future<void> _checkAnonymitySettings() async {
final prefs = await SharedPreferences.getInstance();
final anonymityEnabled = prefs.getBool('anonymity_enabled') ?? false;
if (mounted) {
setState(() => _hasCustomAnonymity = anonymityEnabled);
}
}
Future<void> _checkProxySettings() async {
final settings = await ProxyService.instance.loadProxySettings();
if (mounted) {
setState(() {
_hasProxyConfigured = settings.isEnabled && settings.host.isNotEmpty;
});
}
}
Future<void> _navigateToAnonymitySettings() async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const SessionSpoofingScreen()),
);
_checkAnonymitySettings();
}
Future<void> _navigateToProxySettings() async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ProxySettingsScreen()),
);
_checkProxySettings();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color.lerp(colors.surface, colors.primary, 0.05)!,
colors.surface,
Color.lerp(colors.surface, colors.tertiary, 0.05)!,
],
),
),
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
style: IconButton.styleFrom(
backgroundColor: colors.surfaceContainerHighest,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Настройки',
style: GoogleFonts.manrope(
textStyle: textTheme.headlineSmall,
fontWeight: FontWeight.bold,
),
),
Text(
'Безопасность и конфиденциальность',
style: GoogleFonts.manrope(
textStyle: textTheme.bodyMedium,
color: colors.onSurfaceVariant,
),
),
],
),
),
],
),
),
Expanded(
child: FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: ListView(
padding: const EdgeInsets.all(24.0),
children: [
_SettingsCard(
icon: _hasCustomAnonymity
? Icons.verified_user
: Icons.visibility_outlined,
title: 'Настройки анонимности',
description: _hasCustomAnonymity
? 'Активны кастомные настройки анонимности'
: 'Настройте анонимность для скрытия данных устройства',
isConfigured: _hasCustomAnonymity,
onTap: _navigateToAnonymitySettings,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: _hasCustomAnonymity
? [
Color.lerp(
colors.primaryContainer,
colors.primary,
0.2,
)!,
colors.primaryContainer,
]
: [
colors.surfaceContainerHighest,
colors.surfaceContainer,
],
),
),
const SizedBox(height: 16),
_SettingsCard(
icon: _hasProxyConfigured
? Icons.vpn_key
: Icons.vpn_key_outlined,
title: 'Настройки прокси',
description: _hasProxyConfigured
? 'Прокси-сервер настроен и активен'
: 'Настройте прокси-сервер для безопасного подключения',
isConfigured: _hasProxyConfigured,
onTap: _navigateToProxySettings,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: _hasProxyConfigured
? [
Color.lerp(
colors.tertiaryContainer,
colors.tertiary,
0.2,
)!,
colors.tertiaryContainer,
]
: [
colors.surfaceContainerHighest,
colors.surfaceContainer,
],
),
),
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withOpacity(
0.5,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colors.outline.withOpacity(0.2),
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: colors.primary,
size: 24,
),
const SizedBox(width: 16),
Expanded(
child: Text(
'Эти настройки помогут вам безопасно и анонимно использовать приложение',
style: GoogleFonts.manrope(
textStyle: textTheme.bodyMedium,
color: colors.onSurfaceVariant,
height: 1.4,
),
),
),
],
),
),
],
),
),
),
),
],
),
),
),
);
}
}
class _SettingsCard extends StatelessWidget {
final IconData icon;
final String title;
final String description;
final bool isConfigured;
final VoidCallback onTap;
final Gradient gradient;
const _SettingsCard({
required this.icon,
required this.title,
required this.description,
required this.isConfigured,
required this.onTap,
required this.gradient,
});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: BoxDecoration(
gradient: gradient,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isConfigured
? colors.primary.withOpacity(0.3)
: colors.outline.withOpacity(0.2),
width: isConfigured ? 2 : 1,
),
boxShadow: isConfigured
? [
BoxShadow(
color: colors.primary.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 4),
),
]
: null,
),
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isConfigured
? colors.primary.withOpacity(0.15)
: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: isConfigured
? colors.primary
: colors.onSurfaceVariant,
size: 28,
),
),
const Spacer(),
if (isConfigured)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colors.primary.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle,
color: colors.primary,
size: 16,
),
const SizedBox(width: 4),
Text(
'Активно',
style: GoogleFonts.manrope(
textStyle: textTheme.labelSmall,
color: colors.primary,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Text(
title,
style: GoogleFonts.manrope(
textStyle: textTheme.titleLarge,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
description,
style: GoogleFonts.manrope(
textStyle: textTheme.bodyMedium,
color: colors.onSurfaceVariant,
height: 1.4,
),
),
const SizedBox(height: 16),
Row(
children: [
Text(
isConfigured ? 'Изменить настройки' : 'Настроить',
style: GoogleFonts.manrope(
textStyle: textTheme.labelLarge,
color: isConfigured
? colors.primary
: colors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward,
color: isConfigured
? colors.primary
: colors.onSurfaceVariant,
size: 18,
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,243 @@
import 'dart:async';
import 'dart:io';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:convert';
class ContactLocalNamesService {
static final ContactLocalNamesService _instance =
ContactLocalNamesService._internal();
factory ContactLocalNamesService() => _instance;
ContactLocalNamesService._internal();
final Map<int, Map<String, dynamic>> _cache = {};
final _changesController = StreamController<int>.broadcast();
Stream<int> get changes => _changesController.stream;
bool _initialized = false;
Future<void> initialize() async {
if (_initialized) return;
try {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys();
for (final key in keys) {
if (key.startsWith('contact_')) {
final contactIdStr = key.replaceFirst('contact_', '');
final contactId = int.tryParse(contactIdStr);
if (contactId != null) {
final data = prefs.getString(key);
if (data != null) {
try {
final decoded = jsonDecode(data) as Map<String, dynamic>;
final avatarPath = decoded['avatarPath'] as String?;
if (avatarPath != null) {
final file = File(avatarPath);
if (!await file.exists()) {
decoded.remove('avatarPath');
}
}
_cache[contactId] = decoded;
} catch (e) {
print(
'Ошибка парсинга локальных данных для контакта $contactId: $e',
);
}
}
}
}
}
_initialized = true;
print(
'✅ ContactLocalNamesService: загружено ${_cache.length} локальных имен',
);
} catch (e) {
print('❌ Ошибка инициализации ContactLocalNamesService: $e');
}
}
Map<String, dynamic>? getContactData(int contactId) {
return _cache[contactId];
}
String getDisplayName({
required int contactId,
String? originalName,
String? originalFirstName,
String? originalLastName,
}) {
final localData = _cache[contactId];
if (localData != null) {
final firstName = localData['firstName'] as String?;
final lastName = localData['lastName'] as String?;
if (firstName != null && firstName.isNotEmpty ||
lastName != null && lastName.isNotEmpty) {
final fullName = '${firstName ?? ''} ${lastName ?? ''}'.trim();
if (fullName.isNotEmpty) {
return fullName;
}
}
}
if (originalFirstName != null || originalLastName != null) {
final fullName = '${originalFirstName ?? ''} ${originalLastName ?? ''}'
.trim();
if (fullName.isNotEmpty) {
return fullName;
}
}
return originalName ?? 'ID $contactId';
}
String? getDisplayDescription({
required int contactId,
String? originalDescription,
}) {
final localData = _cache[contactId];
if (localData != null) {
final notes = localData['notes'] as String?;
if (notes != null && notes.isNotEmpty) {
return notes;
}
}
return originalDescription;
}
Future<String?> saveContactAvatar(File imageFile, int contactId) async {
try {
final directory = await getApplicationDocumentsDirectory();
final avatarDir = Directory('${directory.path}/contact_avatars');
if (!await avatarDir.exists()) {
await avatarDir.create(recursive: true);
}
final fileName = 'contact_$contactId.jpg';
final savePath = '${avatarDir.path}/$fileName';
await imageFile.copy(savePath);
final localData = _cache[contactId] ?? {};
localData['avatarPath'] = savePath;
_cache[contactId] = localData;
final prefs = await SharedPreferences.getInstance();
final key = 'contact_$contactId';
await prefs.setString(key, jsonEncode(localData));
_changesController.add(contactId);
print('✅ Локальный аватар контакта сохранен: $savePath');
return savePath;
} catch (e) {
print('❌ Ошибка сохранения локального аватара контакта: $e');
return null;
}
}
String? getContactAvatarPath(int contactId) {
final localData = _cache[contactId];
if (localData != null) {
return localData['avatarPath'] as String?;
}
return null;
}
String? getDisplayAvatar({
required int contactId,
String? originalAvatarUrl,
}) {
final localAvatarPath = getContactAvatarPath(contactId);
if (localAvatarPath != null) {
final file = File(localAvatarPath);
if (file.existsSync()) {
return 'file://$localAvatarPath';
} else {
final localData = _cache[contactId];
if (localData != null) {
localData.remove('avatarPath');
_cache[contactId] = localData;
}
}
}
return originalAvatarUrl;
}
Future<void> removeContactAvatar(int contactId) async {
try {
final localData = _cache[contactId];
if (localData != null) {
final avatarPath = localData['avatarPath'] as String?;
if (avatarPath != null) {
final file = File(avatarPath);
if (await file.exists()) {
await file.delete();
}
}
localData.remove('avatarPath');
_cache[contactId] = localData;
final prefs = await SharedPreferences.getInstance();
final key = 'contact_$contactId';
await prefs.setString(key, jsonEncode(localData));
_changesController.add(contactId);
print('✅ Локальный аватар контакта удален');
}
} catch (e) {
print('❌ Ошибка удаления локального аватара контакта: $e');
}
}
Future<void> saveContactData(int contactId, Map<String, dynamic> data) async {
try {
final prefs = await SharedPreferences.getInstance();
final key = 'contact_$contactId';
await prefs.setString(key, jsonEncode(data));
_cache[contactId] = data;
_changesController.add(contactId);
print('✅ Сохранены локальные данные для контакта $contactId');
} catch (e) {
print('❌ Ошибка сохранения локальных данных контакта: $e');
}
}
Future<void> clearContactData(int contactId) async {
try {
final prefs = await SharedPreferences.getInstance();
final key = 'contact_$contactId';
await prefs.remove(key);
_cache.remove(contactId);
_changesController.add(contactId);
print('✅ Очищены локальные данные для контакта $contactId');
} catch (e) {
print('❌ Ошибка очистки локальных данных контакта: $e');
}
}
void clearCache() {
_cache.clear();
}
void dispose() {
_changesController.close();
}
}

View File

@@ -0,0 +1,57 @@
import 'package:gwid/models/profile.dart';
import 'package:gwid/services/profile_cache_service.dart';
class LocalProfileManager {
static final LocalProfileManager _instance = LocalProfileManager._internal();
factory LocalProfileManager() => _instance;
LocalProfileManager._internal();
final ProfileCacheService _profileCache = ProfileCacheService();
bool _initialized = false;
Future<void> initialize() async {
if (_initialized) return;
await _profileCache.initialize();
_initialized = true;
}
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;
}
Future<String?> getLocalAvatarPath() async {
await initialize();
return await _profileCache.getLocalAvatarPath();
}
Future<bool> hasLocalChanges() async {
await initialize();
return await _profileCache.hasLocalChanges();
}
Future<void> clearLocalChanges() async {
await initialize();
await _profileCache.clearProfileCache();
}
}

View File

@@ -0,0 +1,241 @@
import 'dart:io';
import 'package:gwid/services/cache_service.dart';
import 'package:gwid/models/profile.dart';
import 'package:path_provider/path_provider.dart';
class ProfileCacheService {
static final ProfileCacheService _instance = ProfileCacheService._internal();
factory ProfileCacheService() => _instance;
ProfileCacheService._internal();
final CacheService _cacheService = CacheService();
static const String _profileKey = 'my_profile_data';
static const String _profileAvatarKey = 'my_profile_avatar';
static const Duration _profileTTL = Duration(days: 30);
bool _initialized = false;
Future<void> initialize() async {
if (_initialized) return;
await _cacheService.initialize();
_initialized = true;
print('✅ ProfileCacheService инициализирован');
}
Future<void> saveProfileData({
required int userId,
required String firstName,
required String lastName,
String? description,
String? photoBaseUrl,
int? photoId,
}) async {
try {
final profileData = {
'userId': userId,
'firstName': firstName,
'lastName': lastName,
'description': description,
'photoBaseUrl': photoBaseUrl,
'photoId': photoId,
'updatedAt': DateTime.now().toIso8601String(),
};
await _cacheService.set(_profileKey, profileData, ttl: _profileTTL);
print('✅ Данные профиля сохранены в кэш: $firstName $lastName');
} catch (e) {
print('❌ Ошибка сохранения профиля в кэш: $e');
}
}
Future<Map<String, dynamic>?> getProfileData() async {
try {
final cached = await _cacheService.get<Map<String, dynamic>>(
_profileKey,
ttl: _profileTTL,
);
if (cached != null) {
print('✅ Данные профиля загружены из кэша');
return cached;
}
} catch (e) {
print('❌ Ошибка загрузки профиля из кэша: $e');
}
return null;
}
Future<String?> saveAvatar(File imageFile, int userId) async {
try {
final directory = await getApplicationDocumentsDirectory();
final avatarDir = Directory('${directory.path}/avatars');
if (!await avatarDir.exists()) {
await avatarDir.create(recursive: true);
}
final fileName = 'profile_$userId.jpg';
final savePath = '${avatarDir.path}/$fileName';
await imageFile.copy(savePath);
await _cacheService.set(_profileAvatarKey, savePath, ttl: _profileTTL);
print('✅ Аватар сохранен локально: $savePath');
return savePath;
} catch (e) {
print('❌ Ошибка сохранения аватара: $e');
return null;
}
}
Future<String?> getLocalAvatarPath() async {
try {
final path = await _cacheService.get<String>(
_profileAvatarKey,
ttl: _profileTTL,
);
if (path != null) {
final file = File(path);
if (await file.exists()) {
print('✅ Локальный аватар найден: $path');
return path;
} else {
await _cacheService.remove(_profileAvatarKey);
}
}
} catch (e) {
print('❌ Ошибка загрузки локального аватара: $e');
}
return null;
}
Future<void> updateProfileFields({
String? firstName,
String? lastName,
String? description,
String? photoBaseUrl,
}) async {
try {
final currentData = await getProfileData();
if (currentData == null) {
print('⚠️ Нет сохраненных данных профиля для обновления');
return;
}
if (firstName != null) currentData['firstName'] = firstName;
if (lastName != null) currentData['lastName'] = lastName;
if (description != null) currentData['description'] = description;
if (photoBaseUrl != null) currentData['photoBaseUrl'] = photoBaseUrl;
currentData['updatedAt'] = DateTime.now().toIso8601String();
await _cacheService.set(_profileKey, currentData, ttl: _profileTTL);
print('✅ Поля профиля обновлены в кэше');
} catch (e) {
print('❌ Ошибка обновления полей профиля: $e');
}
}
Future<void> clearProfileCache() async {
try {
await _cacheService.remove(_profileKey);
await _cacheService.remove(_profileAvatarKey);
final avatarPath = await getLocalAvatarPath();
if (avatarPath != null) {
final file = File(avatarPath);
if (await file.exists()) {
await file.delete();
}
}
print('✅ Кэш профиля очищен');
} catch (e) {
print('❌ Ошибка очистки кэша профиля: $e');
}
}
Future<void> syncWithServerProfile(Profile serverProfile) async {
try {
final cachedData = await getProfileData();
if (cachedData != null) {
print(
'⚠️ Локальные данные профиля уже существуют, пропускаем синхронизацию',
);
return;
}
await saveProfileData(
userId: serverProfile.id,
firstName: serverProfile.firstName,
lastName: serverProfile.lastName,
description: serverProfile.description,
photoBaseUrl: serverProfile.photoBaseUrl,
photoId: serverProfile.photoId,
);
print('✅ Профиль инициализирован с сервера');
} catch (e) {
print('❌ Ошибка синхронизации профиля: $e');
}
}
Future<Profile?> getMergedProfile(Profile? serverProfile) async {
try {
final cachedData = await getProfileData();
if (cachedData == null && serverProfile == null) {
return null;
}
if (cachedData == null && serverProfile != null) {
return serverProfile;
}
if (cachedData != null && serverProfile == null) {
return Profile(
id: cachedData['userId'] ?? 0,
phone: '',
firstName: cachedData['firstName'] ?? '',
lastName: cachedData['lastName'] ?? '',
description: cachedData['description'],
photoBaseUrl: cachedData['photoBaseUrl'],
photoId: cachedData['photoId'] ?? 0,
updateTime: 0,
options: [],
accountStatus: 0,
profileOptions: [],
);
}
return Profile(
id: serverProfile!.id,
phone: serverProfile.phone,
firstName: cachedData!['firstName'] ?? serverProfile.firstName,
lastName: cachedData['lastName'] ?? serverProfile.lastName,
description: cachedData['description'] ?? serverProfile.description,
photoBaseUrl: cachedData['photoBaseUrl'] ?? serverProfile.photoBaseUrl,
photoId: cachedData['photoId'] ?? serverProfile.photoId,
updateTime: serverProfile.updateTime,
options: serverProfile.options,
accountStatus: serverProfile.accountStatus,
profileOptions: serverProfile.profileOptions,
);
} catch (e) {
print('❌ Ошибка получения объединенного профиля: $e');
return serverProfile;
}
}
Future<bool> hasLocalChanges() async {
try {
final cachedData = await getProfileData();
return cachedData != null;
} catch (e) {
return false;
}
}
}

View File

@@ -0,0 +1,139 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:gwid/services/contact_local_names_service.dart';
class ContactAvatarWidget extends StatefulWidget {
final int contactId;
final String? originalAvatarUrl;
final double radius;
final String? fallbackText;
final Color? backgroundColor;
final Color? textColor;
const ContactAvatarWidget({
super.key,
required this.contactId,
this.originalAvatarUrl,
this.radius = 24,
this.fallbackText,
this.backgroundColor,
this.textColor,
});
@override
State<ContactAvatarWidget> createState() => _ContactAvatarWidgetState();
}
class _ContactAvatarWidgetState extends State<ContactAvatarWidget> {
String? _localAvatarPath;
StreamSubscription? _subscription;
@override
void initState() {
super.initState();
_loadLocalAvatar();
_subscription = ContactLocalNamesService().changes.listen((contactId) {
if (contactId == widget.contactId && mounted) {
_loadLocalAvatar();
}
});
}
@override
void didUpdateWidget(ContactAvatarWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.contactId != widget.contactId ||
oldWidget.originalAvatarUrl != widget.originalAvatarUrl) {
_loadLocalAvatar();
}
}
void _loadLocalAvatar() {
final localPath = ContactLocalNamesService().getContactAvatarPath(
widget.contactId,
);
if (localPath != null) {
final file = File(localPath);
if (file.existsSync()) {
setState(() {
_localAvatarPath = localPath;
});
return;
}
}
if (mounted) {
setState(() {
_localAvatarPath = null;
});
}
}
ImageProvider? _getAvatarImage() {
if (_localAvatarPath != null) {
return FileImage(File(_localAvatarPath!));
} else if (widget.originalAvatarUrl != null) {
if (widget.originalAvatarUrl!.startsWith('file://')) {
final path = widget.originalAvatarUrl!.replaceFirst('file://', '');
return FileImage(File(path));
}
return NetworkImage(widget.originalAvatarUrl!);
}
return null;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final avatarImage = _getAvatarImage();
return CircleAvatar(
radius: widget.radius,
backgroundColor:
widget.backgroundColor ?? theme.colorScheme.secondaryContainer,
backgroundImage: avatarImage,
child: avatarImage == null
? Text(
widget.fallbackText ?? '?',
style: TextStyle(
color:
widget.textColor ?? theme.colorScheme.onSecondaryContainer,
fontSize: widget.radius * 0.8,
),
)
: null,
);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
ImageProvider? getContactAvatarImage({
required int contactId,
String? originalAvatarUrl,
}) {
final localPath = ContactLocalNamesService().getContactAvatarPath(contactId);
if (localPath != null) {
final file = File(localPath);
if (file.existsSync()) {
return FileImage(file);
}
}
if (originalAvatarUrl != null) {
if (originalAvatarUrl.startsWith('file://')) {
final path = originalAvatarUrl.replaceFirst('file://', '');
return FileImage(File(path));
}
return NetworkImage(originalAvatarUrl);
}
return null;
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:gwid/screens/edit_contact_screen.dart';
class ContactDisplayName extends StatefulWidget {
final int contactId;
final String? originalFirstName;
final String? originalLastName;
final String? fallbackName;
final TextStyle? style;
final int? maxLines;
final TextOverflow? overflow;
const ContactDisplayName({
super.key,
required this.contactId,
this.originalFirstName,
this.originalLastName,
this.fallbackName,
this.style,
this.maxLines,
this.overflow,
});
@override
State<ContactDisplayName> createState() => _ContactDisplayNameState();
}
class _ContactDisplayNameState extends State<ContactDisplayName> {
String? _localFirstName;
String? _localLastName;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadLocalData();
}
@override
void didUpdateWidget(ContactDisplayName oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.contactId != widget.contactId) {
_loadLocalData();
}
}
Future<void> _loadLocalData() async {
setState(() {
_isLoading = true;
});
final localData = await ContactLocalDataHelper.getContactData(
widget.contactId,
);
if (mounted) {
setState(() {
_localFirstName = localData?['firstName'] as String?;
_localLastName = localData?['lastName'] as String?;
_isLoading = false;
});
}
}
String get _displayName {
final firstName = _localFirstName ?? widget.originalFirstName ?? '';
final lastName = _localLastName ?? widget.originalLastName ?? '';
final fullName = '$firstName $lastName'.trim();
if (fullName.isNotEmpty) {
return fullName;
}
return widget.fallbackName ?? 'ID ${widget.contactId}';
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Text(
widget.fallbackName ?? '...',
style: widget.style,
maxLines: widget.maxLines,
overflow: widget.overflow,
);
}
return Text(
_displayName,
style: widget.style,
maxLines: widget.maxLines,
overflow: widget.overflow,
);
}
}

View File

@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:gwid/services/contact_local_names_service.dart';
class ContactNameWidget extends StatefulWidget {
final int contactId;
final String? originalName;
final String? originalFirstName;
final String? originalLastName;
final TextStyle? style;
final int? maxLines;
final TextOverflow? overflow;
const ContactNameWidget({
super.key,
required this.contactId,
this.originalName,
this.originalFirstName,
this.originalLastName,
this.style,
this.maxLines,
this.overflow,
});
@override
State<ContactNameWidget> createState() => _ContactNameWidgetState();
}
class _ContactNameWidgetState extends State<ContactNameWidget> {
late String _displayName;
StreamSubscription? _subscription;
@override
void initState() {
super.initState();
_updateDisplayName();
_subscription = ContactLocalNamesService().changes.listen((contactId) {
if (contactId == widget.contactId && mounted) {
_updateDisplayName();
}
});
}
@override
void didUpdateWidget(ContactNameWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.contactId != widget.contactId ||
oldWidget.originalName != widget.originalName ||
oldWidget.originalFirstName != widget.originalFirstName ||
oldWidget.originalLastName != widget.originalLastName) {
_updateDisplayName();
}
}
void _updateDisplayName() {
setState(() {
_displayName = ContactLocalNamesService().getDisplayName(
contactId: widget.contactId,
originalName: widget.originalName,
originalFirstName: widget.originalFirstName,
originalLastName: widget.originalLastName,
);
});
}
@override
Widget build(BuildContext context) {
return Text(
_displayName,
style: widget.style,
maxLines: widget.maxLines,
overflow: widget.overflow,
);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
String getContactDisplayName({
required int contactId,
String? originalName,
String? originalFirstName,
String? originalLastName,
}) {
return ContactLocalNamesService().getDisplayName(
contactId: contactId,
originalName: originalName,
originalFirstName: originalFirstName,
originalLastName: originalLastName,
);
}

View File

@@ -6,6 +6,7 @@ import 'package:gwid/models/contact.dart';
import 'package:gwid/models/profile.dart'; import 'package:gwid/models/profile.dart';
import 'package:gwid/api/api_service.dart'; import 'package:gwid/api/api_service.dart';
import 'package:gwid/widgets/chat_message_bubble.dart'; import 'package:gwid/widgets/chat_message_bubble.dart';
import 'package:gwid/widgets/contact_name_widget.dart';
import 'package:gwid/chat_screen.dart'; import 'package:gwid/chat_screen.dart';
class ControlMessageChip extends StatelessWidget { class ControlMessageChip extends StatelessWidget {
@@ -26,8 +27,15 @@ class ControlMessageChip extends StatelessWidget {
); );
final eventType = controlAttach['event']; final eventType = controlAttach['event'];
final senderName = final senderContact = contacts[message.senderId];
contacts[message.senderId]?.name ?? 'ID ${message.senderId}'; final senderName = senderContact != null
? getContactDisplayName(
contactId: senderContact.id,
originalName: senderContact.name,
originalFirstName: senderContact.firstName,
originalLastName: senderContact.lastName,
)
: 'ID ${message.senderId}';
final isMe = message.senderId == myId; final isMe = message.senderId == myId;
final senderDisplayName = isMe ? 'Вы' : senderName; final senderDisplayName = isMe ? 'Вы' : senderName;
@@ -40,7 +48,16 @@ class ControlMessageChip extends StatelessWidget {
if (id == myId) { if (id == myId) {
return 'Вы'; return 'Вы';
} }
return contacts[id]?.name ?? 'участник с ID $id'; final contact = contacts[id];
if (contact != null) {
return getContactDisplayName(
contactId: contact.id,
originalName: contact.name,
originalFirstName: contact.firstName,
originalLastName: contact.lastName,
);
}
return 'участник с ID $id';
}) })
.where((name) => name.isNotEmpty) .where((name) => name.isNotEmpty)
.join(', '); .join(', ');

View File

@@ -1,5 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gwid/services/avatar_cache_service.dart'; import 'package:gwid/services/avatar_cache_service.dart';
import 'package:gwid/widgets/contact_name_widget.dart';
import 'package:gwid/widgets/contact_avatar_widget.dart';
import 'package:gwid/services/contact_local_names_service.dart';
class UserProfilePanel extends StatefulWidget { class UserProfilePanel extends StatefulWidget {
final int userId; final int userId;
@@ -33,27 +37,62 @@ class UserProfilePanel extends StatefulWidget {
class _UserProfilePanelState extends State<UserProfilePanel> { class _UserProfilePanelState extends State<UserProfilePanel> {
final ScrollController _nameScrollController = ScrollController(); final ScrollController _nameScrollController = ScrollController();
String? _localDescription;
StreamSubscription? _changesSubscription;
String get _displayName { String get _displayName {
if (widget.firstName != null || widget.lastName != null) { final displayName = getContactDisplayName(
final firstName = widget.firstName ?? ''; contactId: widget.userId,
final lastName = widget.lastName ?? ''; originalName: widget.name,
final fullName = '$firstName $lastName'.trim(); originalFirstName: widget.firstName,
return fullName.isNotEmpty originalLastName: widget.lastName,
? fullName );
: (widget.name ?? 'ID ${widget.userId}'); return displayName;
}
String? get _displayDescription {
if (_localDescription != null && _localDescription!.isNotEmpty) {
return _localDescription;
} }
return widget.name ?? 'ID ${widget.userId}'; return widget.description;
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadLocalDescription();
_changesSubscription = ContactLocalNamesService().changes.listen((
contactId,
) {
if (contactId == widget.userId && mounted) {
_loadLocalDescription();
}
});
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkNameLength(); _checkNameLength();
}); });
} }
Future<void> _loadLocalDescription() async {
final localData = await ContactLocalNamesService().getContactData(
widget.userId,
);
if (mounted) {
setState(() {
_localDescription = localData?['notes'] as String?;
});
}
}
@override
void dispose() {
_changesSubscription?.cancel();
_nameScrollController.dispose();
super.dispose();
}
void _checkNameLength() { void _checkNameLength() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_nameScrollController.hasClients) { if (_nameScrollController.hasClients) {
@@ -99,12 +138,6 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
}); });
} }
@override
void dispose() {
_nameScrollController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
@@ -130,11 +163,13 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
children: [ children: [
AvatarCacheService().getAvatarWidget( ContactAvatarWidget(
widget.avatarUrl, contactId: widget.userId,
userId: widget.userId, originalAvatarUrl: widget.avatarUrl,
size: 80, radius: 40,
fallbackText: _displayName, fallbackText: _displayName.isNotEmpty
? _displayName[0].toUpperCase()
: '?',
backgroundColor: colors.primaryContainer, backgroundColor: colors.primaryContainer,
textColor: colors.onPrimaryContainer, textColor: colors.onPrimaryContainer,
), ),
@@ -213,11 +248,11 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
), ),
], ],
), ),
if (widget.description != null && if (_displayDescription != null &&
widget.description!.isNotEmpty) ...[ _displayDescription!.isNotEmpty) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
widget.description!, _displayDescription!,
style: TextStyle( style: TextStyle(
color: colors.onSurfaceVariant, color: colors.onSurfaceVariant,
fontSize: 14, fontSize: 14,