Chepuhagram/lib/presentation/screens/contacts_screen.dart

867 lines
30 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

import 'dart:convert';
import 'package:chepuhagram/core/constants.dart';
import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../widgets/contact_tile.dart';
import '../screens/settings_screen.dart';
import '../screens/new_chat_screen.dart';
import '../screens/chat_screen.dart';
import '/logic/contact_provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '/logic/auth_provider.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';
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;
@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> _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();
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAppUpdate();
});
// Дальнейшая логика выполнится только после того, как loadContacts завершится
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() {
print("Пользователь вернулся на этот экран!");
_refreshData();
}
@override
void dispose() {
routeObserver.unsubscribe(this);
_socketSubscription?.cancel();
super.dispose();
}
void _refreshData() {
print("Обновляем данные контактов и сообщений...");
final contactProvider = context.read<ContactProvider>();
contactProvider.loadContacts();
}
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();
// Поддерживаем старый payload (только sender_id) и новый (type+sender_id)
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) async {
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}');
currentActiveChatContactId = targetChatId; // Устанавливаем активный чат
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
if (result != null) {
_refreshData(); // Обновляем данные при возвращении с чата, если нужно
}
} catch (e) {
print('Target contact with id $targetChatId not found: $e');
}
}
Future<void> _checkAppUpdate() async {
print('Проверка обновлений');
PackageInfo packageInfo = await PackageInfo.fromPlatform();
try {
// 1. Запрос к вашему FastAPI
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'];
print('444444');
print(latestVersion);
print(packageInfo.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 {
// Request permissions
await FirebaseMessaging.instance.requestPermission();
String? token = await FirebaseMessaging.instance.getToken();
if (token != null) {
ApiService apiService = ApiService();
print(token);
await apiService.updateFcmToken(token);
}
// Listen for token refresh
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) {
ApiService apiService = ApiService();
apiService.updateFcmToken(newToken);
print('FCM Token refreshed: $newToken');
});
// Listen for foreground messages
FirebaseMessaging.onMessage.listen(_handleIncomingMessage);
// Handle notification tap when app was terminated/backgrounded
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
print('Notification tapped, app opened: ${message.data}');
if (message.data['type'] == 'enc_message') {
final senderId = int.tryParse(
message.data['sender_id']?.toString() ?? '',
);
if (senderId != null) {
_navigateToChatFromNotification(senderId);
} else {
print(
'Notification tap contains invalid sender_id: ${message.data['sender_id']}',
);
}
}
});
}
void _navigateToChatFromNotification(int senderId) async {
final contactProvider = context.read<ContactProvider>();
print('Navigate to chat from notification with senderId: $senderId');
// Если контакты еще не загружены, ждем их загрузки
if (contactProvider.contacts.isEmpty) {
print('Contacts not loaded yet, waiting...');
// Ждем немного и пробуем снова
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
_navigateToChatFromNotification(senderId);
}
});
return;
}
try {
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == senderId,
);
print('Navigating to chat from notification: ${contact.username}');
currentActiveChatContactId = senderId; // Устанавливаем активный чат
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
if (result != null) {
_refreshData(); // Обновляем данные при возвращении с чата, если нужно
}
} catch (e) {
// Contact not found, stay on contacts screen
print('Contact not found for notification: $senderId');
}
}
Future<void> _handleIncomingMessage(dynamic data) async {
if (data is RemoteMessage) {
// FCM message
await _handleFCMMessage(data);
} else if (data is Map<String, dynamic>) {
// WebSocket message
print('WebSocket message received: $data');
if (data['type'] == 'user_updated') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {
final contactProvider = context.read<ContactProvider>();
contactProvider.updateContact(userId);
}
}
if (data['type'] == 'user_online') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {
final contactProvider = context.read<ContactProvider>();
contactProvider.updateContactOnlineStatus(userId, true);
}
}
if (data['type'] == 'user_offline') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {
final contactProvider = context.read<ContactProvider>();
contactProvider.updateContactOnlineStatus(userId, false);
}
}
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 contactProvider = context.read<ContactProvider>();
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 (e) {
print('Error decrypting edited message for contacts list: $e');
}
}
// Единая точка обновления состояния
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 contactProvider = context.read<ContactProvider>();
final contactIndex = contactProvider.contacts.indexWhere(
(c) => c.lastMessageId == messageId,
);
if (contactIndex != -1) {
final contactId = contactProvider.contacts[contactIndex].id;
await contactProvider.refreshContactLastMessage(contactId);
}
}
}
}
}
Future<void> _handleFCMMessage(RemoteMessage message) async {
try {
// Проверяем, не находимся ли мы уже в чате с отправителем
final senderId = int.tryParse(
message.data['sender_id']?.toString() ?? '',
);
if (senderId != null && currentActiveChatContactId == senderId) {
print('Already in chat with sender $senderId, skipping notification');
return;
}
// Ensure notification channel exists
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) {
print('Private key not found, cannot decrypt message');
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');
// Show local notification
await flutterLocalNotificationsPlugin.show(
senderId,
'',
'',
NotificationDetails(
android: AndroidNotificationDetails(
'Messages',
'Новые сообщения',
groupKey: groupKey,
setAsGroupSummary: true,
importance: Importance.high,
priority: Priority.high,
groupAlertBehavior: GroupAlertBehavior.all,
),
),
);
await flutterLocalNotificationsPlugin.show(
message.hashCode,
title,
decryptedText,
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') {
print('Received private message FCM, updating contact $senderId');
final contactProvider = context.read<ContactProvider>();
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()) ?? 1
: null,
);
}
} catch (e) {
print('Error processing foreground FCM message: $e');
}
}
@override
Widget build(BuildContext context) {
double bannerHeight = 0.0;
if (_showUpdateBanner) {
bannerHeight = _isDownloading ? 152.0 : 96.0;
}
final double fabBottomPadding = _showUpdateBanner
? (bannerHeight + 20.0)
: 16.0;
return Scaffold(
appBar: AppBar(
title: Text(
"Chepuhagram",
style: TextStyle(fontWeight: FontWeight.bold),
),
centerTitle: false,
elevation: 0,
actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})],
),
body: Stack(
children: [
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,
),
);
}
return ListView.separated(
itemCount: contactProvider.contacts.length,
separatorBuilder: (context, index) => Divider(
height: 1,
indent: 80,
color: Theme.of(context).colorScheme.primaryContainer,
),
itemBuilder: (context, index) {
final contact = contactProvider.contacts[index];
return ContactTile(
contact: contact,
onTap: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatScreen(contact: contact),
),
);
if (result != null) {
_refreshData(); // Обновляем данные при возвращении с чата, если нужно
}
},
);
},
);
},
),
if (_showUpdateBanner)
Positioned(
left: 0,
right: 0,
bottom: 40,
child: _buildUpdateBanner(),
),
],
),
floatingActionButton: AnimatedPadding(
duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut,
padding: EdgeInsets.only(bottom: fabBottomPadding),
child: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const NewChatScreen()),
);
},
child: const Icon(Icons.edit),
),
),
drawer: Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
// Шапка меню с данными юзера
Consumer<AuthProvider>(
builder: (context, authProvider, _) {
final username = authProvider.username;
final displayName = authProvider.displayName;
final initials =
(displayName.isNotEmpty ? displayName : (username ?? 'U'))
.trim()
.split(RegExp(r'\s+'))
.where((p) => p.isNotEmpty)
.take(2)
.map((p) => p[0].toUpperCase())
.join();
return UserAccountsDrawerHeader(
accountName: Text(
displayName,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
accountEmail: Text(
username == null || username.isEmpty ? '' : '@$username',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
currentAccountPicture: CircleAvatar(
backgroundColor:
authProvider.avatarUrl == null &&
authProvider.avatarPath == null
? Theme.of(context).colorScheme.onSurface
: null,
backgroundImage: authProvider.avatarUrl != null
? CachedNetworkImageProvider(authProvider.avatarUrl!)
: authProvider.avatarPath != null
? FileImage(File(authProvider.avatarPath!))
: null,
child:
(authProvider.avatarUrl == null &&
authProvider.avatarPath == null)
? Text(
initials.isEmpty ? 'U' : initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(
context,
).colorScheme.primaryContainer,
),
)
: null,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.inversePrimary,
),
);
},
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text("Настройки"),
onTap: () {
// Закрываем Drawer и переходим на экран настроек
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(builder: (_) => SettingsScreen()),
);
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text("О приложении"),
onTap: () {
/* ... */
},
),
],
),
),
);
}
Future<void> _startDownload() async {
if (_latestApkUrl == null) return;
// Показываем индикатор
setState(() => _isDownloading = true);
final 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;
});
}
},
);
// После успешного скачивания — установка
final result = await OpenFilex.open(path);
if (result.type != ResultType.done) {
print("Ошибка при установке: ${result.message}");
}
} on DioException catch (e) {
if (e.type != DioExceptionType.cancel) {
print("Ошибка скачивания: $e");
}
} catch (e) {
print("Ошибка: $e");
} finally {
if (mounted) {
setState(() {
_isDownloading = false;
_downloadProgress = 0.0;
_downloadedBytes = 0;
_downloadTotalBytes = 0;
});
}
}
}
Future<int> _fetchApkSize(String url) async {
try {
final response = await http.head(Uri.parse(url));
final lengthHeader = response.headers['content-length'];
if (lengthHeader == null) return 0;
return int.tryParse(lengthHeader) ?? 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';
}
Widget _buildUpdateBanner() {
return Container(
margin: const EdgeInsets.fromLTRB(
12,
0,
12,
16,
), // Отступы от краев и снизу
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: [
const Icon(
Icons.system_update_alt,
color: Colors.white,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_isDownloading
? 'Скачивание ${(_downloadProgress * 100).toStringAsFixed(0)}%'
: _apkFileSizeBytes > 0
? 'Доступно новое обновление: ${_formatBytes(_apkFileSizeBytes)}'
: 'Доступно новое обновление!',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
TextButton(
onPressed: () async {
if (_isDownloading) {
// Если уже качаем — отменяем
_cancelToken?.cancel("Пользователь отменил загрузку");
setState(() {
_isDownloading = false;
_cancelToken = null; // Обязательно обнуляем токен!
_downloadProgress = 0.0;
_downloadedBytes = 0;
_downloadTotalBytes = 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),
),
),
],
],
),
),
),
);
}
}