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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -18,6 +18,7 @@ import 'package:gwid/services/account_manager.dart';
import 'package:gwid/services/avatar_cache_service.dart';
import 'package:gwid/services/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';

View File

@@ -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)));
}
final int contactSeq = _sendMessage(32, {
"contactIds": contactIds.toList(),
});
final contactResponse = await messages.firstWhere(
(msg) => msg['seq'] == contactSeq,
);
final List<dynamic> contactListJson =
contactResponse['payload']?['contacts'] ?? [];
List<dynamic> contactListJson = [];
if (contactIds.isNotEmpty) {
final int contactSeq = _sendMessage(32, {
"contactIds": contactIds.toList(),
});
final contactResponse = await messages.firstWhere(
(msg) => msg['seq'] == contactSeq,
);
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();

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,10 @@ import 'package:gwid/user_id_lookup_screen.dart';
import 'package:gwid/screens/music_library_screen.dart';
import 'package:gwid/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 = actualProfile;
_isProfileLoading = false;
});
return;
}
} catch (e) {
print('Ошибка загрузки локального профиля: $e');
}
if (mounted && serverProfile != null) {
setState(() {
_myProfile = Profile.fromJson(cachedProfileData);
_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();
}
_loadFolders(snapshot.data!);
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,39 +2541,41 @@ class _ChatsScreenState extends State<ChatsScreen>
children: [
Stack(
children: [
CircleAvatar(
radius: 28,
backgroundColor: colors.primaryContainer,
backgroundImage:
!isSavedMessages &&
!isGroupChat &&
contact?.photoBaseUrl != null
? NetworkImage(contact?.photoBaseUrl ?? '')
: (isGroupChat && chat.baseIconUrl != null)
? NetworkImage(chat.baseIconUrl ?? '')
: null,
child:
isSavedMessages ||
(isGroupChat && chat.baseIconUrl == null)
? Icon(
isSavedMessages ? Icons.bookmark : Icons.group,
color: colors.onPrimaryContainer,
size: 20,
)
: (contact?.photoBaseUrl == null
? Text(
(contact != null &&
contact.name.isNotEmpty)
? contact.name[0].toUpperCase()
: '?',
style: TextStyle(
color: colors.onSurface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
isSavedMessages || isGroupChat
? CircleAvatar(
radius: 28,
backgroundColor: colors.primaryContainer,
backgroundImage:
isGroupChat && chat.baseIconUrl != null
? NetworkImage(chat.baseIconUrl ?? '')
: null,
child:
isSavedMessages ||
(isGroupChat && chat.baseIconUrl == null)
? Icon(
isSavedMessages
? Icons.bookmark
: Icons.group,
color: colors.onPrimaryContainer,
size: 20,
)
: null),
),
: null,
)
: contact != null
? ContactAvatarWidget(
contactId: contact.id,
originalAvatarUrl: contact.photoBaseUrl,
radius: 28,
fallbackText: contact.name.isNotEmpty
? contact.name[0].toUpperCase()
: '?',
backgroundColor: colors.primaryContainer,
)
: CircleAvatar(
radius: 28,
backgroundColor: colors.primaryContainer,
child: const Text('?'),
),
if (chat.newMessages > 0)
Positioned(
@@ -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 {

View File

@@ -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...");

View File

@@ -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,39 +21,97 @@ 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;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Профиль успешно сохранен"),
behavior: SnackBarBehavior.floating,
duration: Duration(seconds: 2),
),
);
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("Профиль сохранен локально"),
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 {
@@ -102,27 +161,62 @@ class _ManageAccountScreenState extends State<ManageAccountScreen> {
}
}
void _pickAndUpdateProfilePhoto() async {
final ImagePicker picker = ImagePicker();
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,
);
}
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Фотография профиля обновляется..."),
behavior: SnackBarBehavior.floating,
),
);
if (mounted) {
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,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@@ -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), // Смещаем иконку вверх

View File

@@ -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)),
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: hasAnySettings
? colors.primary.withOpacity(0.3)
: colors.outline.withOpacity(0.3),
width: hasAnySettings ? 2 : 1,
),
),
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,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () async {
await Navigator.of(context).push(
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,
),
),
],
],
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: isConfigured
? FilledButton.tonalIcon(
onPressed: _navigateToSpoofingScreen(context),
icon: const Icon(Icons.settings, size: 18),
label: Text(
'Изменить настройки',
style: GoogleFonts.manrope(fontWeight: FontWeight.bold),
),
)
: FilledButton.icon(
onPressed: _navigateToSpoofingScreen(context),
icon: const Icon(Icons.visibility_off, size: 18),
label: Text(
'Настроить анонимность',
style: GoogleFonts.manrope(fontWeight: FontWeight.bold),
),
),
),
],
],
),
),
),
);
}
VoidCallback _navigateToSpoofingScreen(BuildContext context) {
return () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const SessionSpoofingScreen()),
);
};
}
}
class _ProxyCard extends StatelessWidget {
final bool isConfigured;
const _ProxyCard({required this.isConfigured});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final Color cardColor = isConfigured
? colors.secondaryContainer
: colors.surfaceContainerHighest.withOpacity(0.5);
final Color onCardColor = isConfigured
? colors.onSecondaryContainer
: colors.onSurfaceVariant;
final IconData icon = isConfigured ? Icons.vpn_key : Icons.vpn_key_outlined;
return Card(
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outline.withOpacity(0.5)),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
Icon(icon, color: onCardColor, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
isConfigured
? 'Прокси-сервер настроен и активен'
: 'Настройте прокси-сервер для подключения',
style: GoogleFonts.manrope(
textStyle: textTheme.bodyMedium,
color: onCardColor,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: isConfigured
? FilledButton.tonalIcon(
onPressed: _navigateToProxyScreen(context),
icon: const Icon(Icons.settings, size: 18),
label: Text(
'Изменить настройки',
style: GoogleFonts.manrope(fontWeight: FontWeight.bold),
),
)
: FilledButton.icon(
onPressed: _navigateToProxyScreen(context),
icon: const Icon(Icons.vpn_key, size: 18),
label: Text(
'Настроить прокси',
style: GoogleFonts.manrope(fontWeight: FontWeight.bold),
),
),
),
],
),
),
);
}
VoidCallback _navigateToProxyScreen(BuildContext context) {
return () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ProxySettingsScreen()),
);
if (context.mounted) {
final state = context.findAncestorStateOfType<_PhoneEntryScreenState>();
state?.refreshProxySettings();
}
};
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
import 'package:gwid/models/profile.dart';
import 'package:gwid/services/profile_cache_service.dart';
class LocalProfileManager {
static final LocalProfileManager _instance = LocalProfileManager._internal();
factory LocalProfileManager() => _instance;
LocalProfileManager._internal();
final ProfileCacheService _profileCache = ProfileCacheService();
bool _initialized = false;
Future<void> initialize() async {
if (_initialized) return;
await _profileCache.initialize();
_initialized = true;
}
Future<Profile?> getActualProfile(Profile? serverProfile) async {
await initialize();
final localAvatarPath = await _profileCache.getLocalAvatarPath();
final mergedProfile = await _profileCache.getMergedProfile(serverProfile);
if (mergedProfile != null && localAvatarPath != null) {
return Profile(
id: mergedProfile.id,
phone: mergedProfile.phone,
firstName: mergedProfile.firstName,
lastName: mergedProfile.lastName,
description: mergedProfile.description,
photoBaseUrl: 'file://$localAvatarPath',
photoId: mergedProfile.photoId,
updateTime: mergedProfile.updateTime,
options: mergedProfile.options,
accountStatus: mergedProfile.accountStatus,
profileOptions: mergedProfile.profileOptions,
);
}
return mergedProfile;
}
Future<String?> getLocalAvatarPath() async {
await initialize();
return await _profileCache.getLocalAvatarPath();
}
Future<bool> hasLocalChanges() async {
await initialize();
return await _profileCache.hasLocalChanges();
}
Future<void> clearLocalChanges() async {
await initialize();
await _profileCache.clearProfileCache();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import 'package:gwid/models/contact.dart';
import 'package:gwid/models/profile.dart';
import 'package:gwid/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(', ');

View File

@@ -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;
}
String? get _displayDescription {
if (_localDescription != null && _localDescription!.isNotEmpty) {
return _localDescription;
}
return widget.name ?? 'ID ${widget.userId}';
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,