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

добавлен баг с незагрузкой аватарок в чатах
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

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,