2286 lines
80 KiB
Dart
2286 lines
80 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 '/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')),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|