Chepuhagram/lib/presentation/screens/contacts_screen.dart

2286 lines
80 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'package:chepuhagram/core/constants.dart';
import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../screens/settings_screen.dart';
import '../screens/new_chat_screen.dart';
import '../screens/chat_screen.dart';
import 'my_profile_screen.dart';
import '/logic/contact_provider.dart';
import '/logic/auth_provider.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chepuhagram/main.dart';
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:open_filex/open_filex.dart';
import '/data/datasources/ws_client.dart';
import '/data/models/contact_model.dart';
import '/data/models/message_model.dart';
import '/data/repositories/contact_repository.dart';
import 'user_profile_screen.dart';
import 'admin_broadcast_screen.dart';
import 'package:url_launcher/url_launcher.dart';
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.dart';
class ContactsScreen extends StatefulWidget {
final int? targetChatId;
const ContactsScreen({super.key, this.targetChatId});
@override
State<ContactsScreen> createState() => _ContactsScreenState();
}
class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
static const String _notificationLaunchKey = 'notification_launch_data';
StreamSubscription<dynamic>? _socketSubscription;
bool _isDownloading = false;
double _downloadProgress = 0.0;
int _downloadedBytes = 0;
int _downloadTotalBytes = 0;
int _apkFileSizeBytes = 0;
CancelToken? _cancelToken = CancelToken();
String? _latestApkUrl;
bool _showUpdateBanner = false;
bool _contactsLoaded = false;
Timer? _contactLoadTimer;
Contact? _selectedContact;
Contact? _profileContact;
double _contactsPaneWidth = 290;
double _profilePaneWidth = 360;
final double _collapsedContactsWidth = 80;
final double _minExpandedContactsWidth = 290;
final double _maxExpandedContactsWidth = 500;
double _dragStartWidth = 0;
// Адаптивное состояние навигации
int _currentIndex = 0;
bool _isLeftRailExpanded = false;
// Хранилище стабильно загруженных локальных имён
Map<int, String> _localFullNames = {};
final Map<int, int> _pendingUnreadCounters = {};
bool _isSearchMode = false;
final TextEditingController _searchController = TextEditingController();
final List<Contact> _searchChatResults = [];
final List<Contact> _searchNewResults = [];
bool _isSearchLoading = false;
String? _searchError;
Timer? _searchDebounce;
@override
void initState() {
super.initState();
print('ContactsScreen initState, targetChatId: ${widget.targetChatId}');
_setupPushNotifications();
final socketService = Provider.of<SocketService>(context, listen: false);
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
WidgetsBinding.instance.addPostFrameCallback((_) {
final authProvider = context.read<AuthProvider>();
final contactProvider = context.read<ContactProvider>();
print(
'Setting current user ID in ContactProvider: ${authProvider.currentUserId}',
);
contactProvider.setCurrentUserId(authProvider.currentUserId);
_startContactsLoadTimer();
});
}
// Метод стабильной потокобезопасной подгрузки локальных имён из кэша
Future<void> _loadLocalNames() async {
final prefs = await SharedPreferences.getInstance();
final contactProvider = context.read<ContactProvider>();
final Map<int, String> tempNames = {};
for (var contact in contactProvider.contacts) {
final String? fName = prefs.getString('firstname_${contact.id}');
final String? lName = prefs.getString('lastname_${contact.id}');
if (fName != null || lName != null) {
tempNames[contact.id] = '${fName ?? ''} ${lName ?? ''}'.trim();
}
}
if (mounted) {
setState(() {
_localFullNames = tempNames;
});
}
}
Future<void> _startContactsLoadTimer() async {
if (_contactLoadTimer != null && _contactLoadTimer!.isActive) return;
_contactLoadTimer = Timer(const Duration(seconds: 2), () {
_initContacts();
});
}
Future<void> _initContacts() async {
if (_contactsLoaded) return;
final contactProvider = context.read<ContactProvider>();
await contactProvider.loadContacts();
await _loadLocalNames(); // Гарантированный вызов после загрузки контактов
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAppUpdate();
});
if (widget.targetChatId != null) {
_navigateToTargetChat();
} else {
_checkSavedNotificationTarget();
}
_contactLoadTimer?.cancel();
_contactLoadTimer = null;
_contactsLoaded = true;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
}
@override
void didPopNext() async {
print("Пользователь вернулся на этот экран!");
await _refreshData();
}
@override
void dispose() {
routeObserver.unsubscribe(this);
_socketSubscription?.cancel();
_searchController.dispose();
_searchDebounce?.cancel();
super.dispose();
}
Future<void> _refreshData() async {
print("Обновляем данные контактов и сообщений...");
final contactProvider = context.read<ContactProvider>();
await contactProvider.loadContacts();
await _loadLocalNames(); // Синхронизируем локальные имена при возврате
}
Future<void> _checkSavedNotificationTarget() async {
final prefs = await SharedPreferences.getInstance();
final savedData = prefs.getString(_notificationLaunchKey);
if (savedData == null) {
print('No saved notification data found in SharedPreferences');
return;
}
try {
final data = jsonDecode(savedData) as Map<String, dynamic>;
print('Recovered saved notification data: $data');
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
final type = data['type']?.toString();
if (senderId != null && (type == null || type == 'enc_message')) {
print('Recovered targetChatId from saved data: $senderId');
await prefs.remove(_notificationLaunchKey);
_navigateToTargetChatWithId(senderId);
return;
}
print('Saved notification data is not a valid payload: $data');
await prefs.remove(_notificationLaunchKey);
} catch (e) {
print('Error parsing saved notification data: $e');
await prefs.remove(_notificationLaunchKey);
}
}
void _navigateToTargetChat() {
if (widget.targetChatId == null) return;
_navigateToTargetChatWithId(widget.targetChatId!);
}
void _navigateToTargetChatWithId(int targetChatId) {
print('_navigateToTargetChat called with targetChatId: $targetChatId');
final contactProvider = context.read<ContactProvider>();
try {
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == targetChatId,
);
print('Auto-navigating to chat with contact: ${contact.username}');
_selectContact(contact);
} catch (e) {
print('Target contact with id $targetChatId not found: $e');
}
}
bool _isMobileLayout(BuildContext context) {
return MediaQuery.of(context).size.width < 700;
}
bool _isTabletLayout(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return width >= 700 && width < 1000;
}
bool _isDesktopLayout(BuildContext context) {
return MediaQuery.of(context).size.width >= 1000;
}
void _selectContact(Contact contact) {
setState(() {
_selectedContact = contact;
_pendingUnreadCounters.remove(contact.id);
if (_profileContact != null && _isDesktopLayout(context)) {
_profileContact = contact;
}
currentActiveChatContactId = contact.id;
});
}
void _openProfile(Contact contact) {
if (_isDesktopLayout(context)) {
setState(() {
_profileContact = contact;
});
return;
}
if (_isTabletLayout(context)) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (context) => SizedBox(
height: MediaQuery.of(context).size.height * 0.85,
child: UserProfileScreen(
userId: contact.id,
username: contact.username,
name: contact.name,
),
),
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => UserProfileScreen(
userId: contact.id,
username: contact.username,
name: contact.name,
),
),
);
}
void _clearSelectedContact() {
setState(() {
_selectedContact = null;
if (!_isDesktopLayout(context)) {
_profileContact = null;
}
currentActiveChatContactId = null;
});
}
void _toggleSearchMode(bool enabled) {
setState(() {
_isSearchMode = enabled;
if (enabled && _contactsPaneWidth <= _collapsedContactsWidth) {
_contactsPaneWidth = _minExpandedContactsWidth;
}
if (!enabled) {
_searchController.clear();
_searchChatResults.clear();
_searchError = null;
_isSearchLoading = false;
}
});
}
void _onSearchQueryChanged(String query) {
setState(() {});
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 250), () {
_executeSearch(query);
});
}
Future<void> _executeSearch(String query) async {
final trimmedQuery = query.trim();
if (trimmedQuery.isEmpty) {
setState(() {
_searchChatResults.clear();
_searchError = null;
_isSearchLoading = false;
});
return;
}
setState(() {
_isSearchLoading = true;
_searchError = null;
});
try {
final contactRepository = ContactRepository();
final chats = await contactRepository.fetchChatContacts(
query: trimmedQuery,
);
final allUsers = await contactRepository.fetchAllUsers(
query: trimmedQuery,
);
final activeChatIds = chats.map((contact) => contact.id).toSet();
final otherUsers = allUsers.where((contact) {
return !activeChatIds.contains(contact.id) &&
contact.id != context.read<AuthProvider>().currentUserId;
}).toList();
setState(() {
_searchChatResults
..clear()
..addAll(chats);
_searchNewResults
..clear()
..addAll(otherUsers);
_searchError = null;
_isSearchLoading = false;
});
} catch (e) {
setState(() {
_searchError = e.toString();
_searchChatResults.clear();
_searchNewResults.clear();
_isSearchLoading = false;
});
}
}
Widget _buildSearchTextField(ColorScheme colorScheme) {
return SizedBox(
height: 46,
child: TextField(
controller: _searchController,
autofocus: true,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Найти пользователя или чат',
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
_toggleSearchMode(false);
},
),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onChanged: _onSearchQueryChanged,
onSubmitted: _executeSearch,
),
);
}
Widget _buildSearchResultTile(Contact contact, {required bool isChat}) {
final localName = _localFullNames[contact.id];
final displayName = (localName != null && localName.isNotEmpty)
? localName
: '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'
.trim();
final title = displayName.isNotEmpty ? displayName : contact.username;
final subtitle = contact.username.isNotEmpty
? '@${contact.username}'
: 'ID: ${contact.id}';
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer,
child: Text(
title
.split(RegExp(r'\s+'))
.take(2)
.map((part) => part.isNotEmpty ? part[0].toUpperCase() : '')
.join(),
),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: isChat ? null : const Icon(Icons.person_add_outlined, size: 20),
onTap: () {
_selectContact(contact);
_toggleSearchMode(false);
},
);
}
Widget _buildSearchResults() {
if (_searchError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'Ошибка поиска: $_searchError',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
),
);
}
if (_searchController.text.trim().length < 2) {
return _buildPlaceholder('Введите минимум 2 символа для поиска.');
}
if (_isSearchLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_searchChatResults.isEmpty && _searchNewResults.isEmpty) {
return _buildPlaceholder('Ничего не найдено. Попробуйте другой запрос.');
}
final children = <Widget>[];
if (_searchChatResults.isNotEmpty) {
children.add(
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Активные чаты',
style: Theme.of(context).textTheme.titleMedium,
),
),
);
children.addAll(
_searchChatResults.map(
(contact) => _buildSearchResultTile(contact, isChat: true),
),
);
}
if (_searchNewResults.isNotEmpty) {
children.add(
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
'Пользователи',
style: Theme.of(context).textTheme.titleMedium,
),
),
);
children.addAll(
_searchNewResults.map(
(contact) => _buildSearchResultTile(contact, isChat: false),
),
);
}
return ListView(physics: const BouncingScrollPhysics(), children: children);
}
Widget _buildPlaceholder(String text) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
Widget _buildContactsPane() {
return Consumer<ContactProvider>(
builder: (context, contactProvider, child) {
if (_isSearchMode) {
return _buildSearchResults();
}
if (contactProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (contactProvider.error != null) {
return Center(
child: Text(
'${contactProvider.error?.replaceAll('Exception: ', '')}',
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
);
}
final isCollapsed =
!_isMobileLayout(context) &&
(_contactsPaneWidth <= _collapsedContactsWidth);
if (contactProvider.contacts.isEmpty) {
return _buildPlaceholder(
'Список чатов пуст. Нажмите карандаш, чтобы начать.',
);
}
return ListView.separated(
physics: const BouncingScrollPhysics(),
itemCount: contactProvider.contacts.length,
separatorBuilder: (context, index) => Divider(
height: 1,
indent: isCollapsed ? 12 : 84,
endIndent: 12,
color: Theme.of(
context,
).colorScheme.outlineVariant.withOpacity(0.15),
),
itemBuilder: (context, index) {
final contact = contactProvider.contacts[index];
final colorScheme = Theme.of(context).colorScheme;
final isSelected = _selectedContact?.id == contact.id;
final localName = _localFullNames[contact.id];
final displayName = (localName != null && localName.isNotEmpty)
? localName
: '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'
.trim();
final contactInitials = displayName.isNotEmpty
? displayName
.trim()
.split(RegExp(r'\s+'))
.take(2)
.map((e) => e[0].toUpperCase())
.join()
: '?';
String timeText = '';
if (contact.lastMessageTime != null) {
final localTime = contact.lastMessageTime!.toLocal();
timeText =
'${localTime.hour.toString().padLeft(2, '0')}:${localTime.minute.toString().padLeft(2, '0')}';
}
final bool isLastMessageEmpty =
contact.lastMessage != null &&
contact.lastMessage!.trim().isEmpty;
final String displayLastMessage = isLastMessageEmpty
? contact.lastMessageType != null
? MessageModel.getMediaPreview(contact.lastMessageType!)
: 'Вложение'
: (contact.lastMessage ?? 'Нет сообщений');
return Padding(
// ФИКС ОВЕРФЛОУ: уменьшаем внешний отступ при сжатии до 4px (было 8)
padding: EdgeInsets.symmetric(
horizontal: isCollapsed ? 4 : 8,
vertical: 2,
),
child: InkWell(
onTap: () => _selectContact(contact),
borderRadius: BorderRadius.circular(16),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
// ФИКС ОВЕРФЛОУ: уменьшаем внутренний отступ при сжатии до 6px (было 12)
padding: EdgeInsets.symmetric(
horizontal: isCollapsed ? 6 : 12,
vertical: 10,
),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primaryContainer.withOpacity(0.4)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
),
child: Row(
// Центрируем аватарку по оси, когда колонка зажата
mainAxisAlignment: isCollapsed
? MainAxisAlignment.center
: MainAxisAlignment.start,
children: [
ContactAvatar(
key: ValueKey('avatar_${contact.id}'),
initials: contactInitials,
avatarUrl: contact.avatarUrl,
isOnline: contact.isOnline,
isSelected: isSelected,
colorScheme: colorScheme,
),
if (!isCollapsed) ...[
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
displayName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
letterSpacing: -0.3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (timeText.isNotEmpty)
Text(
timeText,
style: TextStyle(
color: colorScheme.outline,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 5),
const SizedBox(height: 5),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: AnimatedSwitcher(
duration: const Duration(
milliseconds: 600,
),
reverseDuration: const Duration(
milliseconds: 200,
),
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder:
(
Widget child,
Animation<double> animation,
) {
return Align(
alignment: Alignment.centerLeft,
child: FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(
0.0,
0.15,
),
end: Offset.zero,
).animate(animation),
child: child,
),
),
);
},
child: _buildAnimatedLastMessage(
contact: contact,
displayLastMessage: displayLastMessage,
isLastMessageEmpty: isLastMessageEmpty,
colorScheme: colorScheme,
),
),
),
if (contact.unreadCount > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 3,
),
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(
minWidth: 20,
),
child: Text(
'${contact.unreadCount}',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
],
),
],
),
),
],
],
),
),
),
);
},
);
},
);
}
Widget _buildAnimatedLastMessage({
required Contact contact,
required String displayLastMessage,
required bool isLastMessageEmpty,
required ColorScheme colorScheme,
}) {
if (contact.lastMessage == null) {
return Text(
'Нет сообщений',
key: ValueKey('empty_${contact.id}'),
style: TextStyle(
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
// Состояние дешифровки
if (!contact.isLastMsgDecrypted) {
return Row(
key: ValueKey('decrypting_${contact.id}'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 10,
height: 10,
child: CircularProgressIndicator(
strokeWidth: 1.2,
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.primary.withOpacity(0.6),
),
),
),
const SizedBox(width: 6),
// КРИТИЧЕСКИЙ ФИКС: Flexible защищает от Overflow, если колонка чатов сильно сжата
Flexible(
child: Text(
'Дешифровка...', // Сократили текст, чтобы он гарантированно не ломал UI
style: TextStyle(
color: colorScheme.primary.withOpacity(0.7),
fontSize: 13,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
// Полностью расшифрованный текст
return Text(
displayLastMessage,
key: ValueKey('decrypted_${contact.id}_$displayLastMessage'),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 14,
fontStyle: isLastMessageEmpty ? FontStyle.italic : FontStyle.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
}
Widget _buildChatPane() {
if (_selectedContact == null) {
return _buildPlaceholder('Выберите чат слева, чтобы начать переписку.');
}
return ChatScreen(
key: ValueKey(_selectedContact!.id),
contact: _selectedContact!,
onOpenProfile: _openProfile,
onBack: _clearSelectedContact,
showBackButton: false,
onMessageRead: (contactId, nextAnchorId) {
final contactProvider = context.read<ContactProvider>();
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == contactId,
orElse: () => _selectedContact!,
);
int currentUnread =
_pendingUnreadCounters[contactId] ?? contact.unreadCount;
if (contact.unreadCount > currentUnread &&
!_pendingUnreadCounters.containsKey(contactId)) {
currentUnread = contact.unreadCount;
}
if (currentUnread > 0) {
final newCount = currentUnread - 1;
_pendingUnreadCounters[contactId] = newCount;
contactProvider.updateContact(
contactId,
unreadCount: newCount,
firstUnreadMessageId: nextAnchorId,
);
}
},
);
}
Widget _buildProfilePane() {
final contact = _profileContact;
if (contact == null) {
return _buildPlaceholder('Профиль выбранного пользователя будет здесь.');
}
return UserProfileScreen(
key: ValueKey(contact.id),
userId: contact.id,
username: contact.username,
name: contact.name,
onClose: () {
setState(() {
_profileContact = null;
});
},
);
}
Widget _buildContactsListWithScaffold(bool isPhone) {
final colorScheme = Theme.of(context).colorScheme;
final isCollapsed =
!isPhone && (_contactsPaneWidth <= _collapsedContactsWidth);
Widget bodyWidget;
String titleText = "Chepuhagram";
bool showSearch = true;
if (isPhone) {
switch (_currentIndex) {
case 2:
titleText = "Профиль";
showSearch = false;
bodyWidget = const MyProfileScreen(isFromList: true);
break;
case 1:
titleText = "Настройки";
showSearch = false;
bodyWidget = const SettingsScreen(isFromList: true);
break;
case 0:
default:
titleText = "Chepuhagram";
showSearch = true;
bodyWidget = _buildContactsPane();
break;
}
} else {
bodyWidget = _buildContactsPane();
}
return Scaffold(
backgroundColor: isPhone ? colorScheme.background : Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
title: _isSearchMode
? _buildSearchTextField(colorScheme)
: Text(
titleText,
style: TextStyle(
color: colorScheme.onBackground,
fontWeight: FontWeight.w800,
fontSize: 24,
letterSpacing: -0.5,
),
),
centerTitle: false,
actions: [
if (showSearch && !_isSearchMode)
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: ClipOval(
child: Material(
child: IconButton(
icon: Icon(Icons.search_rounded, size: 22),
color: colorScheme.onBackground,
onPressed: () {
if (_isSearchMode) {
_toggleSearchMode(false);
} else {
_toggleSearchMode(true);
}
},
),
),
),
),
],
),
body: Column(
children: [
Expanded(child: bodyWidget),
if (_showUpdateBanner)
SafeArea(top: false, child: _buildUpdateBanner(isPhone)),
],
),
floatingActionButton: null,
/* (isCollapsed || (isPhone && _currentIndex != 0))
? null
: AnimatedPadding(
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
padding: EdgeInsets.only(
bottom: _showUpdateBanner
? _isDownloading
? 150.0
: 100.0
: 16.0,
),
child: FloatingActionButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const NewChatScreen()),
),
child: const Icon(Icons.edit_note_rounded),
),
),*/
bottomNavigationBar: isPhone
? BottomNavigationBar(
currentIndex: _currentIndex,
elevation: 8,
onTap: (index) => setState(() => _currentIndex = index),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.chat_bubble_outline_rounded),
activeIcon: Icon(Icons.chat_bubble_rounded),
label: "Чаты",
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
label: "Настройки",
),
BottomNavigationBarItem(
icon: Icon(Icons.person_outline_rounded),
activeIcon: Icon(Icons.person_rounded),
label: "Профиль",
),
],
)
: null,
);
}
Widget _buildUpdateBanner(bool isPhone) {
final isCollapsed =
!isPhone && (_contactsPaneWidth <= _collapsedContactsWidth);
if (isCollapsed) return const SizedBox.shrink();
final colorScheme = Theme.of(context).colorScheme;
return Container(
// Сделали аккуратные отступы сверху для баннера
margin: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.orange.shade600, Colors.deepOrange.shade400],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
_isDownloading
? 'Скачивание ${(_downloadProgress * 100).toStringAsFixed(0)}%'
: _apkFileSizeBytes > 0
? 'Доступно новое обновление: ${_formatBytes(_apkFileSizeBytes)}'
: 'Доступно новое обновление!',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: () async {
if (_isDownloading) {
_cancelToken?.cancel("Пользователь отменил загрузку");
setState(() {
_isDownloading = false;
_downloadProgress = 0.0;
});
} else {
setState(() {
_isDownloading = true;
_cancelToken = CancelToken();
});
await _startDownload();
}
},
style: TextButton.styleFrom(
backgroundColor: colorScheme.onPrimary.withOpacity(0.12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
_isDownloading ? "Отмена" : "Обновить",
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
if (_isDownloading) ...[
const SizedBox(height: 12),
LinearProgressIndicator(
value: _downloadProgress,
color: Colors.white,
backgroundColor: Colors.white54,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
'${_formatBytes(_downloadedBytes)} из ${_formatBytes(_downloadTotalBytes)}',
style: TextStyle(color: Colors.white, fontSize: 14),
),
),
],
],
),
),
),
);
}
Widget _buildWindowsNavigationRail() {
final double railWidth = _isLeftRailExpanded ? 220 : 68;
final colorScheme = Theme.of(context).colorScheme;
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.fastOutSlowIn,
width: railWidth,
color: colorScheme.surfaceVariant.withOpacity(0.12),
child: SafeArea(
top: true,
bottom: true,
child: Column(
children: [
const SizedBox(height: 12),
IconButton(
icon: Icon(
_isLeftRailExpanded
? Icons.menu_open_rounded
: Icons.menu_rounded,
),
onPressed: () =>
setState(() => _isLeftRailExpanded = !_isLeftRailExpanded),
),
const Divider(height: 24, indent: 12, endIndent: 12),
_buildRailItem(
Icons.chat_bubble_outline_rounded,
Icons.chat_bubble_rounded,
"Чаты",
0,
onTap: () => setState(() => _currentIndex = 0),
),
const SizedBox(height: 8),
const Divider(height: 12, indent: 12, endIndent: 12),
_buildRailItem(
Icons.settings_outlined,
Icons.settings_rounded,
"Настройки",
1,
onTap: () => _showSettingsDialog(),
),
_buildRailItem(
Icons.person_outline_rounded,
Icons.person_rounded,
"Профиль",
2,
onTap: () => _showProfileDialog(),
),
],
),
),
);
}
Widget _buildRailItem(
IconData icon,
IconData activeIcon,
String label,
int index, {
Function()? onTap,
}) {
final isSelected = _currentIndex == index;
final colorScheme = Theme.of(context).colorScheme;
final color = isSelected ? colorScheme.primary : colorScheme.onSurface;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: LayoutBuilder(
builder: (itemContext, itemConstraints) {
final bool showLabel =
_isLeftRailExpanded && itemConstraints.maxWidth >= 140;
return InkWell(
onTap: onTap ?? () => setState(() => _currentIndex = index),
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
padding: EdgeInsets.symmetric(horizontal: showLabel ? 12 : 8),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withOpacity(0.08)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: showLabel
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
Icon(isSelected ? activeIcon : icon, color: color, size: 22),
if (showLabel) ...[
const SizedBox(width: 16),
Expanded(
child: Text(
label,
style: TextStyle(
color: color,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
);
},
),
);
}
void _showSettingsDialog() {
// Создаем нотификатор высоты с начальным значением
final heightNotifier = ValueNotifier<double>(450.0);
showDialog(
context: context,
builder: (ctx) {
return ValueListenableBuilder<double>(
valueListenable: heightNotifier,
builder: (context, currentHeight, child) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.antiAlias,
insetPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 24,
),
child: SizedBox(
width: 520,
height: currentHeight, // Примет идеальную высоту контента
child: Navigator(
onGenerateRoute: (settings) {
return MaterialPageRoute(
builder: (ctx2) => SettingsScreen(
isFromList: true,
// Передаем коллбек для замера высоты
onHeightMeasured: (measuredHeight) {
final maxHeight = MediaQuery.of(ctx).size.height - 32;
// Обновляем высоту, не превышая размеры экрана
heightNotifier.value = measuredHeight.clamp(
200.0,
maxHeight,
);
},
),
);
},
),
),
);
},
);
},
);
}
void _showProfileDialog() {
final heightNotifier = ValueNotifier<double>(400.0);
showDialog(
context: context,
builder: (ctx) {
return ValueListenableBuilder<double>(
valueListenable: heightNotifier,
builder: (context, currentHeight, child) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.antiAlias,
insetPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 24,
),
child: SizedBox(
width: 520,
height: currentHeight, // Примет идеальную высоту контента
child: Navigator(
onGenerateRoute: (settings) {
return MaterialPageRoute(
builder: (ctx2) => MyProfileScreen(
isFromList: true,
// Передаем коллбек для замера высоты
onHeightMeasured: (measuredHeight) {
final maxHeight = MediaQuery.of(ctx).size.height - 32;
heightNotifier.value = measuredHeight.clamp(
200.0,
maxHeight,
);
},
),
);
},
),
),
);
},
);
},
);
}
Widget _buildResizableDivider({
required Function(DragUpdateDetails) onPanUpdate,
Function(DragStartDetails)? onPanStart,
}) {
return GestureDetector(
onPanStart: onPanStart,
onPanUpdate: onPanUpdate,
child: MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
child: Container(
width: 8,
color: Colors.transparent,
alignment: Alignment.center,
child: VerticalDivider(
width: 1,
thickness: 1,
color: Theme.of(context).dividerColor.withOpacity(0.4),
),
),
),
);
}
Widget _buildResponsiveBody(bool isPhone) {
final media = MediaQuery.of(context);
// Проверка физического форм-фактора (надежнее, чем Platform.isAndroid)
final bool isPhoneFormFactor = media.size.shortestSide < 600;
//final bool isPhoneFormFactor = true;
// 1. ЛОГИКА ДЛЯ СМАРТФОНОВ (любая ОС, если экран маленький)
if (isPhoneFormFactor) {
if (_selectedContact != null) {
return ChatScreen(
contact: _selectedContact!,
onOpenProfile: _openProfile,
onBack: _clearSelectedContact,
showBackButton: true,
);
}
return _buildContactsListWithScaffold(true);
}
// 2. ЛОГИКА ДЛЯ ПЛАНШЕТОВ И КОМПЬЮТЕРОВ (Широкие экраны)
Widget centerPane;
switch (_currentIndex) {
case 1:
centerPane = const Expanded(child: MyProfileScreen(isFromList: false));
break;
case 2:
centerPane = const Expanded(child: SettingsScreen(isFromList: false));
break;
case 3:
centerPane = const Expanded(
child: Center(child: Text('Трекер задач (пока не реализован)')),
);
break;
case 4:
centerPane = const Expanded(
child: Center(child: Text('Хранилище файлов (пока не реализовано)')),
);
break;
case 0:
default:
centerPane = Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) {
final available = constraints.maxWidth;
final bool profileRequested =
_profileContact != null && _isDesktopLayout(context);
const double profileMin = 280;
const double chatMin = 320;
const double dividerWidth = 8;
final double reservedForProfile = profileRequested
? profileMin + dividerWidth
: 0;
// Вычисляем допустимую ширину панели контактов
final double maxContactsAllowed =
(available - reservedForProfile - chatMin - dividerWidth)
.clamp(
_collapsedContactsWidth,
_maxExpandedContactsWidth,
);
final double contactsWidth = _contactsPaneWidth.clamp(
_collapsedContactsWidth,
maxContactsAllowed,
);
// Если места на экране для профиля совсем нет — скрываем его
final bool showProfile =
profileRequested &&
(available >
(contactsWidth + dividerWidth + chatMin + profileMin));
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ИСПРАВЛЕНО: Убран Flexible. Левая панель занимает строго contactsWidth
SizedBox(
width: contactsWidth,
child: _buildContactsListWithScaffold(false),
),
_buildResizableDivider(
onPanStart: (details) =>
_dragStartWidth = _contactsPaneWidth,
onPanUpdate: (details) {
setState(() {
final newWidth = details.globalPosition.dx;
if (_dragStartWidth > _collapsedContactsWidth) {
if (newWidth < (_minExpandedContactsWidth / 2)) {
_contactsPaneWidth = _collapsedContactsWidth;
} else {
_contactsPaneWidth = newWidth.clamp(
_minExpandedContactsWidth,
_maxExpandedContactsWidth,
);
}
} else {
if (newWidth > (_minExpandedContactsWidth / 2) + 40) {
_contactsPaneWidth = newWidth.clamp(
_minExpandedContactsWidth,
_maxExpandedContactsWidth,
);
}
}
});
},
),
// Чат расширяется на всё оставшееся до самого края или до профиля пространство
Expanded(child: _buildChatPane()),
if (showProfile) ...[
_buildResizableDivider(
onPanUpdate: (details) {
setState(() {
final screenWidth = MediaQuery.of(context).size.width;
_profilePaneWidth =
(screenWidth - details.globalPosition.dx).clamp(
280,
500,
);
});
},
),
// ИСПРАВЛЕНО: Убран Flexible. Правая панель занимает строго _profilePaneWidth
SizedBox(
width: _profilePaneWidth,
child: _buildProfilePane(),
),
],
],
);
},
),
);
break;
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
body: SizedBox.expand(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [_buildWindowsNavigationRail(), centerPane],
),
),
);
}
@override
Widget build(BuildContext context) {
final bool isPhoneFormFactor =
MediaQuery.of(context).size.shortestSide < 600;
return PopScope(
canPop: _selectedContact == null || !isPhoneFormFactor,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
if (_selectedContact != null && isPhoneFormFactor) {
_clearSelectedContact();
}
},
child: _buildResponsiveBody(isPhoneFormFactor),
);
}
Future<void> _checkAppUpdate() async {
print('Проверка обновлений...');
PackageInfo packageInfo = await PackageInfo.fromPlatform();
// Определяем платформу для сервера
String platformQuery = Platform.isWindows ? 'windows' : 'android';
try {
final response = await http.get(
Uri.parse(
'${AppConstants.baseUrl}/check-update?platform=$platformQuery',
),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final String latestVersion = data['latest_version'];
if (latestVersion != packageInfo.version) {
setState(() {
_showUpdateBanner = true;
_latestApkUrl = data['download_url'] ?? data['apk_url'];
});
if (_latestApkUrl != null) {
final size = await _fetchApkSize(_latestApkUrl!);
if (mounted) {
setState(() => _apkFileSizeBytes = size);
}
}
}
}
} catch (e) {
print("Ошибка проверки обновлений: $e");
}
}
Future<void> _setupPushNotifications() async {
try {
if (Firebase.apps.isEmpty) {
print('Firebase is not initialized, skipping push notification setup.');
return;
}
await FirebaseMessaging.instance.requestPermission();
String? token = await FirebaseMessaging.instance.getToken();
if (token != null) {
ApiService apiService = ApiService();
await apiService.updateFcmToken(token);
}
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
ApiService apiService = ApiService();
apiService.updateFcmToken(newToken);
});
FirebaseMessaging.onMessage.listen(_handleIncomingMessage);
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
if (message.data['type'] == 'enc_message') {
final senderId = int.tryParse(
message.data['sender_id']?.toString() ?? '',
);
if (senderId != null) _navigateToChatFromNotification(senderId);
}
});
} catch (e) {
print('Push notification setup failed: $e');
}
}
void _navigateToChatFromNotification(int senderId) {
final contactProvider = context.read<ContactProvider>();
if (contactProvider.contacts.isEmpty) {
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) _navigateToChatFromNotification(senderId);
});
return;
}
try {
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == senderId,
);
_selectContact(contact);
} catch (_) {}
}
Future<void> _handleIncomingMessage(dynamic data) async {
if (data is RemoteMessage) {
await _handleFCMMessage(data);
} else if (data is Map<String, dynamic>) {
print('WebSocket message received in ContactsScreen: $data');
final contactProvider = context.read<ContactProvider>();
if (data['type'] == 'private_message') {
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
// Обрабатываем сообщение ТОЛЬКО если оно от другого чата
if (senderId != null && senderId != currentActiveChatContactId) {
var contact = contactProvider.contacts
.where((c) => c.id == senderId)
.firstOrNull;
if (contact == null) {
await contactProvider.updateContact(senderId);
contact = contactProvider.contacts
.where((c) => c.id == senderId)
.firstOrNull;
}
if (contact != null) {
final currentUnread = contact.unreadCount;
final msgId = int.tryParse(data['id']?.toString() ?? '');
final newAnchor = (currentUnread == 0)
? msgId
: contact.firstUnreadMessageId;
String decryptedText = 'Новое сообщение';
bool isDecrypted = false;
try {
final myPrivKey = await CryptoService().getPrivateKey();
if (myPrivKey != null && contact.publicKey != null) {
final sharedSecret = await CryptoService().deriveSharedSecret(
myPrivKey,
contact.publicKey!,
contactId: senderId,
);
decryptedText = await CryptoService().decryptMessage(
data['content'],
sharedSecret,
);
isDecrypted = true;
}
} catch (_) {}
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
final timestamp =
data['timestamp'] ?? DateTime.now().toIso8601String();
// Список недопустимых системных строк
final invalidValues = {'unknown', 'uncnown', 'null', ''};
// Функция очистки полей контакта
String cleanField(String? value) {
if (value == null) return '';
final trimmed = value.trim();
return invalidValues.contains(trimmed.toLowerCase())
? ''
: trimmed;
}
String cleanName = cleanField(contact.name);
String cleanSurname = cleanField(contact.surname);
String cleanUsername = cleanField(contact.username);
// Формируем полное имя
final String localFullName = '$cleanName $cleanSurname'.trim();
// Задаем итоговый заголовок. Если всё пусто или unknown — пишем «Без имени»
final String title = localFullName.isNotEmpty
? localFullName
: (cleanUsername.isNotEmpty ? cleanUsername : 'Без имени');
final msgHashCode =
msgId ?? (DateTime.now().microsecondsSinceEpoch % 1000000000);
print('show notification from $senderId $title: $decryptedText');
await _showLocalNotification(
senderId: senderId,
title: title, // Теперь здесь гарантированно не будет unknown
body: decryptedText,
timestamp: timestamp,
messageHashCode: msgHashCode,
);
await contactProvider.updateContact(
senderId,
lastMessage: decryptedText,
lastMessageTime: DateTime.tryParse(
data['timestamp'] ?? '',
)?.add(offset),
isLastMsgDecrypted: isDecrypted,
unreadCount:
data['unread_count'] ??
currentUnread + 1, // Увеличиваем счетчик
firstUnreadMessageId:
newAnchor, // Сохраняем анкер для будущего прыжка!
);
}
}
}
if (data['type'] == 'message_read') {
// message_id — сообщение, которое стало прочитанным
// sender_id — ID того, чье сообщение прочитали (наш собеседник)
// receiver_id — ID того, кто прочитал (наш текущий пользователь)
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
final receiverId = int.tryParse(data['receiver_id']?.toString() ?? '');
final myId = context.read<AuthProvider>().currentUserId;
// Проверяем: если уведомление пришло нам (мы — receiver_id)
if (receiverId == myId && senderId != null) {
final contactProvider = context.read<ContactProvider>();
// Ищем контакт в списке
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == senderId,
orElse: () =>
Contact(id: senderId, username: '', name: '', surname: ''),
);
// Если этот контакт имел непрочитанные сообщения, уменьшаем счетчик
if (contact.unreadCount > 0) {
await contactProvider.updateContact(
senderId,
unreadCount: data['unread_count'] ?? contact.unreadCount - 1,
);
print(
'DEBUG: Счетчик для чата с пользователем $senderId уменьшен на 1',
);
}
}
return;
}
if (data['type'] == 'all_chat_read') {
// message_id — сообщение, которое стало прочитанным
// sender_id — ID того, чье сообщение прочитали (наш собеседник)
// receiver_id — ID того, кто прочитал (наш текущий пользователь)
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
final receiverId = int.tryParse(data['reader_id']?.toString() ?? '');
final myId = context.read<AuthProvider>().currentUserId;
// Проверяем: если уведомление пришло нам (мы — receiver_id)
if (receiverId == myId && senderId != null) {
final contactProvider = context.read<ContactProvider>();
// Ищем контакт в списке
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == senderId,
orElse: () =>
Contact(id: senderId, username: '', name: '', surname: ''),
);
// Если этот контакт имел непрочитанные сообщения, уменьшаем счетчик
if (contact.unreadCount > 0) {
await contactProvider.updateContact(senderId, unreadCount: 0);
print(
'DEBUG: Счетчик для чата с пользователем $senderId установлен в 0',
);
}
}
return;
}
if (data['type'] == 'message_sent') {
final receiverId = int.tryParse(data['receiver_id']?.toString() ?? '');
if (receiverId != null) {
var contact = contactProvider.contacts
.where((c) => c.id == receiverId)
.firstOrNull;
if (contact == null) {
await contactProvider.updateContact(receiverId);
contact = contactProvider.contacts
.where((c) => c.id == receiverId)
.firstOrNull;
}
if (contact != null) {
final messageType = MessageModel.parseMessageType(
data['message_type']?.toString() ?? 'text',
);
String lastMessage = MessageModel.getMediaPreview(messageType);
bool isDecrypted = false;
if (data['content'] != null &&
data['content'].toString().isNotEmpty) {
try {
final myPrivKey = await CryptoService().getPrivateKey();
if (myPrivKey != null && contact.publicKey != null) {
final sharedSecret = await CryptoService().deriveSharedSecret(
myPrivKey,
contact.publicKey!,
contactId: receiverId,
);
final _lastMessage = await CryptoService().decryptMessage(
data['content'],
sharedSecret,
);
if (_lastMessage.trim().isNotEmpty) {
lastMessage = _lastMessage;
}
isDecrypted = true;
}
} catch (_) {
lastMessage = data['content']?.toString() ?? lastMessage;
}
}
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
final timestamp = DateTime.tryParse(
data['timestamp']?.toString() ?? '',
)?.add(offset);
final serverId = int.tryParse(data['server_id']?.toString() ?? '');
await contactProvider.updateContactLastMessage(
receiverId,
lastMessage: lastMessage.isNotEmpty ? lastMessage : null,
lastMessageTime: timestamp,
isLastMsgDecrypted: isDecrypted,
lastMessageId: serverId,
);
if (_selectedContact != null &&
_selectedContact!.id == receiverId) {
_selectedContact = _selectedContact!.copyWith(
lastMessage: lastMessage.isNotEmpty ? lastMessage : null,
lastMessageTime: timestamp ?? _selectedContact!.lastMessageTime,
isLastMsgDecrypted:
isDecrypted || _selectedContact!.isLastMsgDecrypted,
lastMessageId: serverId ?? _selectedContact!.lastMessageId,
);
}
}
}
}
if (data['type'] == 'user_updated') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {
await contactProvider.updateContact(userId);
await _loadLocalNames(); // Синхронно обновляем кэш имен на сокет
}
}
if (data['type'] == 'user_online') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {
contactProvider.updateContactOnlineStatus(userId, true);
if (mounted) {
setState(() {
if (_selectedContact != null && _selectedContact!.id == userId) {
_selectedContact = contactProvider.contacts.firstWhere(
(c) => c.id == userId,
orElse: () => _selectedContact!,
);
}
});
}
}
}
if (data['type'] == 'user_offline') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {
contactProvider.updateContactOnlineStatus(userId, false);
if (mounted) {
setState(() {
if (_selectedContact != null && _selectedContact!.id == userId) {
_selectedContact = contactProvider.contacts.firstWhere(
(c) => c.id == userId,
orElse: () => _selectedContact!,
);
}
});
}
}
}
if (data['type'] == 'message_edited') {
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
if (messageId != null && senderId != null) {
var contact = contactProvider.contacts
.where((c) => c.id == senderId)
.firstOrNull;
if (contact == null) {
await contactProvider.updateContact(senderId);
contact = contactProvider.contacts
.where((c) => c.id == senderId)
.firstOrNull;
}
if (contact != null) {
final editedAt = DateTime.tryParse(
data['edited_at']?.toString() ?? '',
);
String lastMessageText = contact.lastMessage ?? '';
bool isDecrypted = false;
final myPrivKey = await CryptoService().getPrivateKey();
if (myPrivKey != null && contact.publicKey != null) {
try {
final sharedSecret = await CryptoService().deriveSharedSecret(
myPrivKey,
contact.publicKey!,
contactId: senderId,
);
lastMessageText = await CryptoService().decryptMessage(
data['content']?.toString() ?? '',
sharedSecret,
);
isDecrypted = true;
} catch (_) {}
}
await contactProvider.updateContactLastMessage(
contact.id,
lastMessage: lastMessageText,
lastMessageTime: editedAt,
isLastMsgDecrypted: isDecrypted,
lastMessageId: messageId,
isEdited: true,
);
}
}
}
if (data['type'] == 'message_deleted') {
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
if (messageId != null) {
final contactIndex = contactProvider.contacts.indexWhere(
(c) => c.lastMessageId == messageId,
);
if (contactIndex != -1) {
await contactProvider.refreshContactLastMessage(
contactProvider.contacts[contactIndex].id,
);
}
}
}
}
}
Future<void> _handleFCMMessage(RemoteMessage message) async {
try {
// 1. Быстрый парсинг и валидация ID отправителя на самом старте
final senderIdRaw = message.data['sender_id']?.toString() ?? '';
final senderId = int.tryParse(senderIdRaw);
if (senderId == null) return;
// 2. Блокировка дубликатов, если чат уже открыт
if (currentActiveChatContactId == senderId) return;
// 3. Безопасное получение SocketService (лучше через GetIt, пример с context ниже)
// Если метод вызывается строго в Foreground, проверяем mounted
if (!mounted) return;
final socketService = Provider.of<SocketService>(context, listen: false);
if (socketService.isConnected()) return;
// 4. Криптография
final crypto = CryptoService();
final myPrivKey = await crypto.getPrivateKey();
if (myPrivKey == null) return;
final publicKey = message.data['public_key'];
final encryptedContent = message.data['content'];
if (publicKey == null || encryptedContent == null) return;
final sharedSecret = await crypto.deriveSharedSecret(
myPrivKey,
publicKey,
contactId: senderId,
);
final decryptedText = await crypto.decryptMessage(
encryptedContent,
sharedSecret,
);
// 5. Работа с локальным кэшем имен
final prefs = await SharedPreferences.getInstance();
String? firstName = prefs.getString('firstname_$senderIdRaw');
String? lastName = prefs.getString('lastname_$senderIdRaw');
// Очистка некорректных значений имен
final invalidNames = {'unknown', 'uncnown', 'null', ''};
if (firstName == null ||
invalidNames.contains(firstName.toLowerCase().trim())) {
firstName = 'Без имени';
}
if (lastName == null ||
invalidNames.contains(lastName.toLowerCase().trim())) {
lastName = '';
}
final String localFullName = '$firstName $lastName'.trim();
final String title = localFullName.isNotEmpty
? localFullName
: (message.data['username'] ?? 'Unknown');
final timestamp =
message.data['timestamp'] ?? DateTime.now().toIso8601String();
// 6. Показ локального уведомления (ID берем от senderId, чтобы не плодить путаницу)
await _showLocalNotification(
senderId: senderId,
title: title,
body: decryptedText,
timestamp: timestamp,
messageHashCode: senderId.toUnsigned(31),
);
// 7. Безопасное обновление провайдера контактов (только если экран активен)
if (message.data['type'] == 'enc_message' && mounted) {
final unreadCountRaw = message.data['unread_count'];
context.read<ContactProvider>().updateContact(
senderId,
lastMessage: decryptedText,
lastMessageTime: DateTime.tryParse(timestamp),
isLastMsgDecrypted: true,
unreadCount: unreadCountRaw != null
? int.tryParse(unreadCountRaw.toString())
: null,
);
}
} catch (e, stackTrace) {
print('Error processing foreground FCM: $e');
print(stackTrace); // Поможет быстрее найти баг при изменении API
}
}
Future<void> _showLocalNotification({
required int senderId,
required String title,
required String body,
required String timestamp,
required int messageHashCode,
}) async {
const String channelId = 'Messages';
const String channelName = 'Новые сообщения';
final String groupKey = 'ru.chepuhagram.app.$senderId';
// Создаем канал (безопасно вызывать многократно)
const AndroidNotificationChannel channel = AndroidNotificationChannel(
channelId,
channelName,
description: 'Chat messages notifications',
importance: Importance.high,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
// Показываем Summary (группировщик)
if (Platform.isAndroid) {
await flutterLocalNotificationsPlugin.show(
id: senderId, // ID группы - это ID отправителя
title: '',
body: '',
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
channelId,
channelName,
groupKey: groupKey,
setAsGroupSummary: true,
importance: Importance.high,
priority: Priority.high,
groupAlertBehavior: GroupAlertBehavior.all,
),
),
);
}
// Показываем само сообщение
await flutterLocalNotificationsPlugin.show(
id: messageHashCode, // Уникальный ID для каждого сообщения
title: title,
body: body,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
channelId,
channelName,
groupKey: groupKey,
importance: Importance.high,
priority: Priority.high,
showWhen: true,
),
),
payload: jsonEncode({
'type': 'enc_message',
'sender_id': senderId,
'timestamp': timestamp,
}),
);
}
Future<void> _startDownload() async {
if (_latestApkUrl == null) return;
if (Platform.isWindows) {
setState(() => _isDownloading = true);
final tempDir = await getTemporaryDirectory();
final savePath = '${tempDir.path}\\chepuhagram_setup.exe';
final file = File(savePath);
if (await file.exists()) await file.delete();
try {
setState(() {
_downloadProgress = 0.0;
_downloadedBytes = 0;
_downloadTotalBytes = 0;
});
await Dio().download(
_latestApkUrl!,
savePath,
cancelToken: _cancelToken,
onReceiveProgress: (rec, total) {
if (mounted) {
setState(() {
_downloadProgress = total > 0 ? rec / total : 0.0;
_downloadedBytes = rec;
_downloadTotalBytes = total;
});
}
},
);
await Process.start(savePath, ['/update', '/VERYSILENT']);
exit(0);
} catch (e) {
print("Ошибка при обновлении: $e");
setState(() => _isDownloading = false);
}
return;
}
// Логика для Android (Остается без изменений)
setState(() => _isDownloading = true);
Directory? dir = await getExternalStorageDirectory();
final path = '${dir!.path}/update.apk';
final file = File(path);
if (await file.exists()) await file.delete();
try {
setState(() {
_downloadProgress = 0.0;
_downloadedBytes = 0;
_downloadTotalBytes = 0;
});
await Dio().download(
_latestApkUrl!,
path,
cancelToken: _cancelToken,
onReceiveProgress: (rec, total) {
if (mounted) {
setState(() {
_downloadedBytes = rec;
_downloadTotalBytes = total > 0 ? total : 0;
_downloadProgress = total > 0 ? rec / total : 0.0;
});
}
},
);
await OpenFilex.open(path);
} catch (_) {
} finally {
if (mounted) {
setState(() {
_isDownloading = false;
_downloadProgress = 0.0;
});
}
}
}
Future<int> _fetchApkSize(String url) async {
try {
final response = await http.head(Uri.parse(url));
return int.tryParse(response.headers['content-length'] ?? '') ?? 0;
} catch (_) {
return 0;
}
}
String _formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
const kb = 1024;
const mb = kb * 1024;
if (bytes < kb) return '$bytes B';
if (bytes < mb) return '${(bytes / kb).toStringAsFixed(1)} KB';
return '${(bytes / mb).toStringAsFixed(1)} MB';
}
}
class ContactAvatar extends StatelessWidget {
final String initials;
final String? avatarUrl;
final bool isOnline;
final bool isSelected;
final ColorScheme colorScheme;
const ContactAvatar({
super.key,
required this.initials,
required this.avatarUrl,
required this.isOnline,
required this.isSelected,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer,
),
child: ClipOval(
child: Stack(
alignment: Alignment.center,
children: [
Text(
initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
),
if (avatarUrl != null)
CachedNetworkImage(
imageUrl: avatarUrl!,
fit: BoxFit.cover,
width: 52,
height: 52,
placeholder: (context, url) => const SizedBox.shrink(),
errorWidget: (context, url, error) =>
const SizedBox.shrink(),
),
],
),
),
),
Positioned(
right: -1,
bottom: -1,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(
scale: CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
),
child: FadeTransition(opacity: animation, child: child),
);
},
child: isOnline
? Container(
key: const ValueKey('online_dot'),
width: 14,
height: 14,
decoration: BoxDecoration(
color: Colors.green.shade500,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? Color.alphaBlend(
colorScheme.primaryContainer.withOpacity(0.4),
colorScheme.background,
)
: colorScheme.background,
width: 2.5,
),
),
)
: const SizedBox.shrink(key: ValueKey('offline_dot')),
),
),
],
);
}
}