1372 lines
48 KiB
Dart
1372 lines
48 KiB
Dart
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';
|
||
}
|
||
}
|