добавлены анимации на экране сообщений, добавлено редактирование профиля (локально), изменена панель сообщений
добавлен баг с незагрузкой аватарок в чатах
This commit is contained in:
BIN
android/app/src/main/jniLibs/arm64-v8a/libeslz4-android64.so
Normal file
BIN
android/app/src/main/jniLibs/arm64-v8a/libeslz4-android64.so
Normal file
Binary file not shown.
BIN
android/app/src/main/jniLibs/arm64-v8a/liblz4.so
Normal file
BIN
android/app/src/main/jniLibs/arm64-v8a/liblz4.so
Normal file
Binary file not shown.
BIN
android/app/src/main/jniLibs/armeabi-v7a/libeslz4-android32.so
Normal file
BIN
android/app/src/main/jniLibs/armeabi-v7a/libeslz4-android32.so
Normal file
Binary file not shown.
BIN
android/app/src/main/jniLibs/armeabi-v7a/liblz4.so
Normal file
BIN
android/app/src/main/jniLibs/armeabi-v7a/liblz4.so
Normal file
Binary file not shown.
BIN
android/app/src/main/jniLibs/x86/libeslz4-android32.so
Normal file
BIN
android/app/src/main/jniLibs/x86/libeslz4-android32.so
Normal file
Binary file not shown.
BIN
android/app/src/main/jniLibs/x86/liblz4.so
Normal file
BIN
android/app/src/main/jniLibs/x86/liblz4.so
Normal file
Binary file not shown.
BIN
android/app/src/main/jniLibs/x86_64/libeslz4-android64.so
Normal file
BIN
android/app/src/main/jniLibs/x86_64/libeslz4-android64.so
Normal file
Binary file not shown.
BIN
android/app/src/main/jniLibs/x86_64/liblz4.so
Normal file
BIN
android/app/src/main/jniLibs/x86_64/liblz4.so
Normal file
Binary file not shown.
@@ -18,6 +18,7 @@ import 'package:gwid/services/account_manager.dart';
|
||||
import 'package:gwid/services/avatar_cache_service.dart';
|
||||
import 'package:gwid/services/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:http/http.dart' as http;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
@@ -69,6 +69,37 @@ extension ApiServiceChats on ApiService {
|
||||
_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) {
|
||||
_onlineCompleter!.complete();
|
||||
}
|
||||
@@ -95,9 +126,10 @@ extension ApiServiceChats on ApiService {
|
||||
};
|
||||
_lastChatsPayload = result;
|
||||
|
||||
final contacts = contactListJson
|
||||
.map((json) => Contact.fromJson(json))
|
||||
.toList();
|
||||
final contacts = (contactListJson as List)
|
||||
.map((json) => Contact.fromJson(json as Map<String, dynamic>))
|
||||
.toList()
|
||||
.cast<Contact>();
|
||||
updateContactCache(contacts);
|
||||
_lastChatsAt = DateTime.now();
|
||||
_preloadContactAvatars(contacts);
|
||||
@@ -241,7 +273,11 @@ extension ApiServiceChats on ApiService {
|
||||
}
|
||||
|
||||
try {
|
||||
final payload = {"chatsCount": 100};
|
||||
// Используем opcode 48 для запроса конкретных чатов
|
||||
// chatIds:[0] - это "Избранное" (Saved Messages)
|
||||
final payload = {
|
||||
"chatIds": [0],
|
||||
};
|
||||
|
||||
final int chatSeq = _sendMessage(48, payload);
|
||||
final chatResponse = await messages.firstWhere(
|
||||
@@ -265,15 +301,16 @@ extension ApiServiceChats on ApiService {
|
||||
contactIds.addAll(participants.keys.map((id) => int.parse(id)));
|
||||
}
|
||||
|
||||
List<dynamic> contactListJson = [];
|
||||
if (contactIds.isNotEmpty) {
|
||||
final int contactSeq = _sendMessage(32, {
|
||||
"contactIds": contactIds.toList(),
|
||||
});
|
||||
final contactResponse = await messages.firstWhere(
|
||||
(msg) => msg['seq'] == contactSeq,
|
||||
);
|
||||
|
||||
final List<dynamic> contactListJson =
|
||||
contactResponse['payload']?['contacts'] ?? [];
|
||||
contactListJson = contactResponse['payload']?['contacts'] ?? [];
|
||||
}
|
||||
|
||||
final result = {
|
||||
'chats': chatListJson,
|
||||
@@ -283,8 +320,8 @@ extension ApiServiceChats on ApiService {
|
||||
};
|
||||
_lastChatsPayload = result;
|
||||
|
||||
final contacts = contactListJson
|
||||
.map((json) => Contact.fromJson(json))
|
||||
final List<Contact> contacts = contactListJson
|
||||
.map((json) => Contact.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
updateContactCache(contacts);
|
||||
_lastChatsAt = DateTime.now();
|
||||
@@ -295,7 +332,7 @@ extension ApiServiceChats on ApiService {
|
||||
unawaited(_chatCacheService.cacheContacts(contacts));
|
||||
return result;
|
||||
} catch (e) {
|
||||
print('Ошибка получения чатов: $e');
|
||||
print('Ошибка получения чатов через opcode 48: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -445,6 +482,14 @@ extension ApiServiceChats on ApiService {
|
||||
currentAccount.id,
|
||||
profileObj,
|
||||
);
|
||||
|
||||
try {
|
||||
final profileCache = ProfileCacheService();
|
||||
await profileCache.initialize();
|
||||
await profileCache.syncWithServerProfile(profileObj);
|
||||
} catch (e) {
|
||||
print('[ProfileCache] Ошибка синхронизации профиля: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Ошибка сохранения профиля в AccountManager: $e');
|
||||
@@ -510,8 +555,8 @@ extension ApiServiceChats on ApiService {
|
||||
};
|
||||
_lastChatsPayload = result;
|
||||
|
||||
final contacts = contactListJson
|
||||
.map((json) => Contact.fromJson(json))
|
||||
final List<Contact> contacts = contactListJson
|
||||
.map((json) => Contact.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
updateContactCache(contacts);
|
||||
_lastChatsAt = DateTime.now();
|
||||
|
||||
@@ -679,6 +679,7 @@ extension ApiServiceConnection on ApiService {
|
||||
_reconnectTimer?.cancel();
|
||||
_isSessionOnline = false;
|
||||
_isSessionReady = false;
|
||||
_handshakeSent = false;
|
||||
_onlineCompleter = Completer<void>();
|
||||
_chatsFetchedInThisSession = false;
|
||||
|
||||
|
||||
1070
lib/chat_screen.dart
1070
lib/chat_screen.dart
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,10 @@ import 'package:gwid/user_id_lookup_screen.dart';
|
||||
import 'package:gwid/screens/music_library_screen.dart';
|
||||
import 'package:gwid/widgets/message_preview_dialog.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/models/account.dart';
|
||||
|
||||
@@ -163,25 +167,48 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
_isProfileLoading = true;
|
||||
});
|
||||
|
||||
Profile? serverProfile;
|
||||
|
||||
try {
|
||||
final accountManager = AccountManager();
|
||||
await accountManager.initialize();
|
||||
final currentAccount = accountManager.currentAccount;
|
||||
if (currentAccount?.profile != null && mounted) {
|
||||
setState(() {
|
||||
_myProfile = currentAccount!.profile;
|
||||
_isProfileLoading = false;
|
||||
});
|
||||
return;
|
||||
if (currentAccount?.profile != null) {
|
||||
serverProfile = currentAccount!.profile;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Ошибка загрузки профиля из AccountManager: $e');
|
||||
}
|
||||
|
||||
final cachedProfileData = ApiService.instance.lastChatsPayload?['profile'];
|
||||
if (cachedProfileData != null && mounted) {
|
||||
if (serverProfile == null) {
|
||||
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 = Profile.fromJson(cachedProfileData);
|
||||
_myProfile = actualProfile;
|
||||
_isProfileLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Ошибка загрузки локального профиля: $e');
|
||||
}
|
||||
|
||||
if (mounted && serverProfile != null) {
|
||||
setState(() {
|
||||
_myProfile = serverProfile;
|
||||
_isProfileLoading = false;
|
||||
});
|
||||
return;
|
||||
@@ -277,6 +304,20 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
final opcode = message['opcode'];
|
||||
final cmd = message['cmd'];
|
||||
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;
|
||||
final chatIdValue = payload['chatId'];
|
||||
final int? chatId = chatIdValue != null ? chatIdValue as int? : null;
|
||||
@@ -640,6 +681,7 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
if (mounted) {
|
||||
final chats = data['chats'] as List<dynamic>;
|
||||
final contacts = data['contacts'] as List<dynamic>;
|
||||
final profileData = data['profile'];
|
||||
|
||||
_allChats = chats
|
||||
.where((json) => json != null)
|
||||
@@ -650,6 +692,14 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
final contact = Contact.fromJson(contactJson);
|
||||
_contacts[contact.id] = contact;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
if (profileData != null) {
|
||||
_myProfile = Profile.fromJson(profileData);
|
||||
_isProfileLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
_filterChats();
|
||||
}
|
||||
});
|
||||
@@ -1061,7 +1111,14 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
final isSelected = selectedContacts.contains(contact.id);
|
||||
|
||||
return CheckboxListTile(
|
||||
title: Text(contact.name),
|
||||
title: Text(
|
||||
getContactDisplayName(
|
||||
contactId: contact.id,
|
||||
originalName: contact.name,
|
||||
originalFirstName: contact.firstName,
|
||||
originalLastName: contact.lastName,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
contact.firstName.isNotEmpty &&
|
||||
contact.lastName.isNotEmpty
|
||||
@@ -1387,12 +1444,20 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
|
||||
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(
|
||||
SearchResult(
|
||||
chat: chat,
|
||||
contact: contact,
|
||||
matchedText: contact.name,
|
||||
matchedText: displayName,
|
||||
matchType: 'name',
|
||||
),
|
||||
);
|
||||
@@ -1465,6 +1530,7 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
if (mounted) {
|
||||
final chats = data['chats'] as List;
|
||||
final contacts = data['contacts'] as List;
|
||||
final profileData = data['profile'];
|
||||
|
||||
_allChats = chats
|
||||
.where((json) => json != null)
|
||||
@@ -1477,6 +1543,13 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
_contacts[contact.id] = contact;
|
||||
}
|
||||
|
||||
if (profileData != null) {
|
||||
setState(() {
|
||||
_myProfile = Profile.fromJson(profileData);
|
||||
_isProfileLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
_filterChats();
|
||||
}
|
||||
});
|
||||
@@ -1710,7 +1783,9 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
ApiService.instance.getBlockedContacts();
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadFolders(snapshot.data!);
|
||||
});
|
||||
|
||||
_loadChatOrder().then((_) {
|
||||
setState(() {
|
||||
@@ -2285,7 +2360,15 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
)
|
||||
: 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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -2394,7 +2477,12 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
} else if (isSavedMessages) {
|
||||
title = "Избранное";
|
||||
} 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) {
|
||||
title = chat.title!;
|
||||
} else {
|
||||
@@ -2453,38 +2541,40 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
isSavedMessages || isGroupChat
|
||||
? CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: colors.primaryContainer,
|
||||
backgroundImage:
|
||||
!isSavedMessages &&
|
||||
!isGroupChat &&
|
||||
contact?.photoBaseUrl != null
|
||||
? NetworkImage(contact?.photoBaseUrl ?? '')
|
||||
: (isGroupChat && chat.baseIconUrl != null)
|
||||
isGroupChat && chat.baseIconUrl != null
|
||||
? NetworkImage(chat.baseIconUrl ?? '')
|
||||
: null,
|
||||
child:
|
||||
isSavedMessages ||
|
||||
(isGroupChat && chat.baseIconUrl == null)
|
||||
? Icon(
|
||||
isSavedMessages ? Icons.bookmark : Icons.group,
|
||||
isSavedMessages
|
||||
? Icons.bookmark
|
||||
: Icons.group,
|
||||
color: colors.onPrimaryContainer,
|
||||
size: 20,
|
||||
)
|
||||
: (contact?.photoBaseUrl == null
|
||||
? Text(
|
||||
(contact != null &&
|
||||
contact.name.isNotEmpty)
|
||||
: null,
|
||||
)
|
||||
: contact != null
|
||||
? ContactAvatarWidget(
|
||||
contactId: contact.id,
|
||||
originalAvatarUrl: contact.photoBaseUrl,
|
||||
radius: 28,
|
||||
fallbackText: contact.name.isNotEmpty
|
||||
? contact.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: colors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
backgroundColor: colors.primaryContainer,
|
||||
)
|
||||
: null),
|
||||
: CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: colors.primaryContainer,
|
||||
child: const Text('?'),
|
||||
),
|
||||
|
||||
if (chat.newMessages > 0)
|
||||
@@ -3823,7 +3913,12 @@ class _ChatsScreenState extends State<ChatsScreen>
|
||||
contact = _contacts[otherParticipantId];
|
||||
|
||||
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) {
|
||||
title = chat.title!;
|
||||
} else {
|
||||
@@ -4531,7 +4626,12 @@ class _AddChatsToFolderDialogState extends State<_AddChatsToFolderDialog> {
|
||||
contact = widget.contacts[otherParticipantId];
|
||||
|
||||
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) {
|
||||
title = chat.title!;
|
||||
} else {
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'connection_lifecycle_manager.dart';
|
||||
import 'services/cache_service.dart';
|
||||
import 'services/avatar_cache_service.dart';
|
||||
import 'services/chat_cache_service.dart';
|
||||
import 'services/contact_local_names_service.dart';
|
||||
import 'services/version_checker.dart';
|
||||
import 'services/account_manager.dart';
|
||||
import 'services/music_player_service.dart';
|
||||
@@ -28,6 +29,7 @@ Future<void> main() async {
|
||||
await CacheService().initialize();
|
||||
await AvatarCacheService().initialize();
|
||||
await ChatCacheService().initialize();
|
||||
await ContactLocalNamesService().initialize();
|
||||
print("Сервисы кеширования инициализированы");
|
||||
|
||||
print("Инициализируем AccountManager...");
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/models/profile.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 'dart:io';
|
||||
|
||||
@@ -20,40 +21,98 @@ 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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_firstNameController = TextEditingController(
|
||||
text: widget.myProfile?.firstName ?? '',
|
||||
);
|
||||
_lastNameController = TextEditingController(
|
||||
text: widget.myProfile?.lastName ?? '',
|
||||
);
|
||||
_descriptionController = TextEditingController(
|
||||
text: widget.myProfile?.description ?? '',
|
||||
);
|
||||
_initializeProfileData();
|
||||
}
|
||||
|
||||
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()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ApiService.instance.updateProfileText(
|
||||
_firstNameController.text.trim(),
|
||||
_lastNameController.text.trim(),
|
||||
_descriptionController.text.trim(),
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final firstName = _firstNameController.text.trim();
|
||||
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,
|
||||
);
|
||||
|
||||
_actualProfile = await _profileManager.getActualProfile(widget.myProfile);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Профиль успешно сохранен"),
|
||||
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 {
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -102,28 +161,63 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _pickAndUpdateProfilePhoto() async {
|
||||
Future<void> _pickAndUpdateProfilePhoto() async {
|
||||
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;
|
||||
|
||||
|
||||
if (image != null) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
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(
|
||||
content: Text("Фотография профиля обновляется..."),
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -155,7 +249,6 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
|
||||
_buildAvatarSection(theme),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -199,7 +292,6 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -234,7 +326,6 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
if (widget.myProfile != null)
|
||||
Card(
|
||||
elevation: 2,
|
||||
@@ -281,22 +372,32 @@ 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!);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: GestureDetector(
|
||||
|
||||
onTap: _pickAndUpdateProfilePhoto, // 2. Вызываем метод при нажатии
|
||||
onTap: _pickAndUpdateProfilePhoto,
|
||||
child: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 60,
|
||||
backgroundColor: theme.colorScheme.secondaryContainer,
|
||||
backgroundImage: widget.myProfile?.photoBaseUrl != null
|
||||
? NetworkImage(widget.myProfile!.photoBaseUrl!)
|
||||
: null,
|
||||
child: widget.myProfile?.photoBaseUrl == null
|
||||
backgroundImage: avatarImage,
|
||||
child: avatarImage == null
|
||||
? Icon(
|
||||
Icons.person,
|
||||
size: 60,
|
||||
@@ -304,6 +405,21 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
|
||||
)
|
||||
: 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(
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
@@ -329,7 +445,6 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
|
||||
IconData icon, {
|
||||
bool alignLabel = false,
|
||||
}) {
|
||||
|
||||
final prefixIcon = (label == "О себе")
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 60), // Смещаем иконку вверх
|
||||
|
||||
@@ -6,8 +6,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/otp_screen.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:gwid/screens/settings/auth_settings_screen.dart';
|
||||
import 'package:gwid/token_auth_screen.dart';
|
||||
import 'package:gwid/tos_screen.dart'; // Импорт экрана ToS
|
||||
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 {
|
||||
if (!_isButtonEnabled || _isLoading || !_isTosAccepted) return;
|
||||
setState(() => _isLoading = true);
|
||||
@@ -429,9 +424,14 @@ class _PhoneEntryScreenState extends State<PhoneEntryScreen>
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_AnonymityCard(isConfigured: _hasCustomAnonymity),
|
||||
const SizedBox(height: 16),
|
||||
_ProxyCard(isConfigured: _hasProxyConfigured),
|
||||
_SettingsButton(
|
||||
hasCustomAnonymity: _hasCustomAnonymity,
|
||||
hasProxyConfigured: _hasProxyConfigured,
|
||||
onRefresh: () {
|
||||
_checkAnonymitySettings();
|
||||
_checkProxySettings();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text.rich(
|
||||
textAlign: TextAlign.center,
|
||||
@@ -600,172 +600,159 @@ class _CountryPicker extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AnonymityCard extends StatelessWidget {
|
||||
final bool isConfigured;
|
||||
const _AnonymityCard({required this.isConfigured});
|
||||
class _SettingsButton extends StatelessWidget {
|
||||
final bool hasCustomAnonymity;
|
||||
final bool hasProxyConfigured;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
const _SettingsButton({
|
||||
required this.hasCustomAnonymity,
|
||||
required this.hasProxyConfigured,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
@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.verified_user_outlined
|
||||
: Icons.visibility_outlined;
|
||||
final hasAnySettings = hasCustomAnonymity || hasProxyConfigured;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: cardColor,
|
||||
color: hasAnySettings
|
||||
? colors.primaryContainer.withOpacity(0.3)
|
||||
: colors.surfaceContainerHighest.withOpacity(0.5),
|
||||
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,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: hasAnySettings
|
||||
? colors.primary.withOpacity(0.3)
|
||||
: colors.outline.withOpacity(0.3),
|
||||
width: hasAnySettings ? 2 : 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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 {
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const ProxySettingsScreen()),
|
||||
MaterialPageRoute(builder: (context) => const AuthSettingsScreen()),
|
||||
);
|
||||
onRefresh();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
final state = context.findAncestorStateOfType<_PhoneEntryScreenState>();
|
||||
state?.refreshProxySettings();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
610
lib/screens/edit_contact_screen.dart
Normal file
610
lib/screens/edit_contact_screen.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
410
lib/screens/settings/auth_settings_screen.dart
Normal file
410
lib/screens/settings/auth_settings_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
243
lib/services/contact_local_names_service.dart
Normal file
243
lib/services/contact_local_names_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
57
lib/services/local_profile_manager.dart
Normal file
57
lib/services/local_profile_manager.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
241
lib/services/profile_cache_service.dart
Normal file
241
lib/services/profile_cache_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
139
lib/widgets/contact_avatar_widget.dart
Normal file
139
lib/widgets/contact_avatar_widget.dart
Normal 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;
|
||||
}
|
||||
95
lib/widgets/contact_display_name.dart
Normal file
95
lib/widgets/contact_display_name.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/widgets/contact_name_widget.dart
Normal file
96
lib/widgets/contact_name_widget.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:gwid/models/contact.dart';
|
||||
import 'package:gwid/models/profile.dart';
|
||||
import 'package:gwid/api/api_service.dart';
|
||||
import 'package:gwid/widgets/chat_message_bubble.dart';
|
||||
import 'package:gwid/widgets/contact_name_widget.dart';
|
||||
import 'package:gwid/chat_screen.dart';
|
||||
|
||||
class ControlMessageChip extends StatelessWidget {
|
||||
@@ -26,8 +27,15 @@ class ControlMessageChip extends StatelessWidget {
|
||||
);
|
||||
|
||||
final eventType = controlAttach['event'];
|
||||
final senderName =
|
||||
contacts[message.senderId]?.name ?? 'ID ${message.senderId}';
|
||||
final senderContact = contacts[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 senderDisplayName = isMe ? 'Вы' : senderName;
|
||||
|
||||
@@ -40,7 +48,16 @@ class ControlMessageChip extends StatelessWidget {
|
||||
if (id == myId) {
|
||||
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)
|
||||
.join(', ');
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.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 {
|
||||
final int userId;
|
||||
@@ -33,27 +37,62 @@ class UserProfilePanel extends StatefulWidget {
|
||||
|
||||
class _UserProfilePanelState extends State<UserProfilePanel> {
|
||||
final ScrollController _nameScrollController = ScrollController();
|
||||
String? _localDescription;
|
||||
StreamSubscription? _changesSubscription;
|
||||
|
||||
String get _displayName {
|
||||
if (widget.firstName != null || widget.lastName != null) {
|
||||
final firstName = widget.firstName ?? '';
|
||||
final lastName = widget.lastName ?? '';
|
||||
final fullName = '$firstName $lastName'.trim();
|
||||
return fullName.isNotEmpty
|
||||
? fullName
|
||||
: (widget.name ?? 'ID ${widget.userId}');
|
||||
final displayName = getContactDisplayName(
|
||||
contactId: widget.userId,
|
||||
originalName: widget.name,
|
||||
originalFirstName: widget.firstName,
|
||||
originalLastName: widget.lastName,
|
||||
);
|
||||
return displayName;
|
||||
}
|
||||
return widget.name ?? 'ID ${widget.userId}';
|
||||
|
||||
String? get _displayDescription {
|
||||
if (_localDescription != null && _localDescription!.isNotEmpty) {
|
||||
return _localDescription;
|
||||
}
|
||||
return widget.description;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLocalDescription();
|
||||
|
||||
_changesSubscription = ContactLocalNamesService().changes.listen((
|
||||
contactId,
|
||||
) {
|
||||
if (contactId == widget.userId && mounted) {
|
||||
_loadLocalDescription();
|
||||
}
|
||||
});
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_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() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_nameScrollController.hasClients) {
|
||||
@@ -99,12 +138,6 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
@@ -130,11 +163,13 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
AvatarCacheService().getAvatarWidget(
|
||||
widget.avatarUrl,
|
||||
userId: widget.userId,
|
||||
size: 80,
|
||||
fallbackText: _displayName,
|
||||
ContactAvatarWidget(
|
||||
contactId: widget.userId,
|
||||
originalAvatarUrl: widget.avatarUrl,
|
||||
radius: 40,
|
||||
fallbackText: _displayName.isNotEmpty
|
||||
? _displayName[0].toUpperCase()
|
||||
: '?',
|
||||
backgroundColor: colors.primaryContainer,
|
||||
textColor: colors.onPrimaryContainer,
|
||||
),
|
||||
@@ -213,11 +248,11 @@ class _UserProfilePanelState extends State<UserProfilePanel> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.description != null &&
|
||||
widget.description!.isNotEmpty) ...[
|
||||
if (_displayDescription != null &&
|
||||
_displayDescription!.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
widget.description!,
|
||||
_displayDescription!,
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
|
||||
Reference in New Issue
Block a user