Chepuhagram/lib/presentation/screens/contacts_screen.dart

1372 lines
48 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 'user_profile_screen.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 = {};
@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();
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;
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;
});
}
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 (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;
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.lastMessage ?? 'Нет сообщений');
final Color lastMessageColor =
(contact.lastMessage != null && !isLastMessageEmpty)
? colorScheme.onSurfaceVariant
: colorScheme.outline.withOpacity(1);
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: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary.withOpacity(0.08),
),
child: ClipOval(
child: Stack(
alignment: Alignment.center,
children: [
Text(
contactInitials,
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
if (contact.avatarUrl != null)
Image.network(
contact.avatarUrl!,
fit: BoxFit.cover,
width: 52,
height: 52,
errorBuilder:
(context, error, stackTrace) =>
const SizedBox.shrink(),
loadingBuilder:
(context, child, loadingProgress) {
if (loadingProgress == null)
return child;
return const SizedBox.shrink();
},
),
],
),
),
),
if (contact.isOnline)
Positioned(
right: -1,
bottom: -1,
child: Container(
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,
),
),
),
),
],
),
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),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
displayLastMessage,
style: TextStyle(
color: lastMessageColor,
fontSize: 14,
fontStyle: isLastMessageEmpty
? FontStyle.italic
: FontStyle.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (contact.unreadCount != null &&
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 _buildChatPane() {
if (_selectedContact == null) {
return _buildPlaceholder('Выберите чат слева, чтобы начать переписку.');
}
return ChatScreen(
key: ValueKey(_selectedContact!.id),
contact: _selectedContact!,
onOpenProfile: _openProfile,
onBack: _clearSelectedContact,
showBackButton: false,
);
}
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 1:
titleText = "Профиль";
showSearch = false;
bodyWidget = const MyProfileScreen(isFromList: true);
break;
case 2:
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: Text(
titleText,
style: const TextStyle(
fontWeight: FontWeight.w800,
fontSize: 24,
letterSpacing: -0.5,
),
),
centerTitle: false,
actions: [
if (showSearch)
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: ClipOval(
child: Material(
child: IconButton(
icon: const Icon(Icons.search_rounded, size: 22),
onPressed: () {},
),
),
),
),
],
),
body: Column(
children: [
Expanded(child: bodyWidget),
if (_showUpdateBanner)
SafeArea(top: false, child: _buildUpdateBanner(isPhone)),
],
),
floatingActionButton: (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.person_outline_rounded),
activeIcon: Icon(Icons.person_rounded),
label: "Профиль",
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
label: "Настройки",
),
],
)
: null,
);
}
Widget _buildUpdateBanner(bool isPhone) {
final isCollapsed =
!isPhone && (_contactsPaneWidth <= _collapsedContactsWidth);
if (isCollapsed) return const SizedBox.shrink();
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: const 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: Colors.white24,
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.white24,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
'${_formatBytes(_downloadedBytes)} из ${_formatBytes(_downloadTotalBytes)}',
style: const TextStyle(color: Colors.white70, 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.25),
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,
),
_buildRailItem(
Icons.settings_outlined,
Icons.settings_rounded,
"Настройки",
2,
),
_buildRailItem(
Icons.person_outline_rounded,
Icons.person_rounded,
"Профиль",
1,
),
],
),
);
}
Widget _buildRailItem(
IconData icon,
IconData activeIcon,
String label,
int index,
) {
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: InkWell(
onTap: () => setState(() => _currentIndex = index),
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary.withOpacity(0.08)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: _isLeftRailExpanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
Icon(isSelected ? activeIcon : icon, color: color, size: 22),
if (_isLeftRailExpanded) ...[
const SizedBox(width: 16),
Expanded(
child: Text(
label,
style: TextStyle(
color: color,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
),
);
}
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;
// 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 0:
default:
centerPane = Expanded(
child: Row(
children: [
SizedBox(
width: _contactsPaneWidth,
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 (_profileContact != null && _isDesktopLayout(context)) ...[
_buildResizableDivider(
onPanUpdate: (details) {
setState(() {
final screenWidth = MediaQuery.of(context).size.width;
_profilePaneWidth =
(screenWidth - details.globalPosition.dx).clamp(
280,
500,
);
});
},
),
SizedBox(width: _profilePaneWidth, child: _buildProfilePane()),
],
],
),
);
break;
}
return Scaffold(
body: Row(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();
try {
final response = await http.get(
Uri.parse('${AppConstants.baseUrl}/check-update'),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final String latestVersion = data['latest_version'];
if (latestVersion != packageInfo.version) {
setState(() {
_showUpdateBanner = true;
_latestApkUrl = 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'] == '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) {
final 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!,
);
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 {
final senderId = int.tryParse(
message.data['sender_id']?.toString() ?? '',
);
if (senderId != null && currentActiveChatContactId == senderId) return;
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'Messages',
'Новые сообщения',
description: 'Chat messages notifications',
importance: Importance.high,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
final crypto = CryptoService();
final myPrivKey = await crypto.getPrivateKey();
if (myPrivKey == null) return;
final sharedSecret = await crypto.deriveSharedSecret(
myPrivKey,
message.data['public_key'],
);
final decryptedText = await crypto.decryptMessage(
message.data['content'],
sharedSecret,
);
if (senderId == null) return;
final String groupKey = 'ru.chepuhagram.app.$senderId';
final prefs = await SharedPreferences.getInstance();
final String? firstName = prefs.getString(
'firstname_${message.data['sender_id']}',
);
final String? lastName = prefs.getString(
'lastname_${message.data['sender_id']}',
);
final String localFullName = '${firstName ?? ''} ${lastName ?? ''}'
.trim();
final String title = localFullName.isNotEmpty
? localFullName
: (message.data['username'] ?? 'Unknown');
await flutterLocalNotificationsPlugin.show(
id: senderId,
title: '',
body: '',
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
'Messages',
'Новые сообщения',
groupKey: groupKey,
setAsGroupSummary: true,
importance: Importance.high,
priority: Priority.high,
groupAlertBehavior: GroupAlertBehavior.all,
),
),
);
await flutterLocalNotificationsPlugin.show(
id: message.hashCode,
title: title,
body: decryptedText,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
'Messages',
'Новые сообщения',
groupKey: groupKey,
importance: Importance.high,
priority: Priority.high,
showWhen: true,
),
),
payload: jsonEncode({
'type': 'enc_message',
'sender_id': message.data['sender_id'],
'timestamp':
message.data['timestamp'] ?? DateTime.now().toIso8601String(),
}),
);
if (message.data['type'] == 'enc_message') {
context.read<ContactProvider>().updateContact(
senderId,
lastMessage: decryptedText,
lastMessageTime: DateTime.tryParse(
message.data['timestamp'] ?? DateTime.now().toIso8601String(),
),
isLastMsgDecrypted: true,
unreadCount: message.data['unread_count'] != null
? int.tryParse(message.data['unread_count'].toString())
: null,
);
}
} catch (e) {
print('Error processing foreground FCM: $e');
}
}
Future<void> _startDownload() async {
if (_latestApkUrl == null) return;
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';
}
}