Chepuhagram/lib/main.dart

570 lines
21 KiB
Dart
Raw Permalink 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 'package:chepuhagram/data/datasources/ws_client.dart';
import 'package:chepuhagram/logic/auth_provider.dart';
import 'package:chepuhagram/logic/contact_provider.dart';
import 'package:chepuhagram/core/theme_manager.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:path_provider/path_provider.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'dart:convert';
import 'package:chepuhagram/data/datasources/local_db_service.dart';
import 'package:chepuhagram/presentation/screens/chat_screen.dart';
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:path/path.dart' as p;
import 'package:chepuhagram/presentation/screens/splash_screen.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:chepuhagram/presentation/screens/call_screen.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
import 'package:timeago/timeago.dart' as timeago;
import 'package:timeago/src/messages/ru_messages.dart';
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
int? currentActiveChatContactId;
RemoteMessage? initialMessage;
// Ключ для SharedPreferences
const String _notificationLaunchKey = 'notification_launch_data';
const String _lastHandledNotificationLaunchPayloadKey =
'notification_last_handled_payload';
Future<void> _onSelectNotification(
NotificationResponse notificationResponse,
) async {
final payload = notificationResponse.payload;
if (payload != null) {
try {
final data = jsonDecode(payload);
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
if (senderId != null) {
print('Notification selected, payload sender_id=$senderId');
final context = navigatorKey.currentContext;
final prefs = await SharedPreferences.getInstance();
final canonicalPayload = jsonEncode(data);
if (context == null) {
final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey,
);
if (lastHandled != canonicalPayload) {
await prefs.setString(_notificationLaunchKey, canonicalPayload);
await prefs.setString(
_lastHandledNotificationLaunchPayloadKey,
canonicalPayload,
);
}
print(
'Navigator context is null, saved notification payload to SharedPreferences',
);
} else {
await prefs.remove(_notificationLaunchKey);
}
_navigateToChat(senderId);
} else {
print(
'Notification payload has invalid sender_id: ${data['sender_id']}',
);
}
} catch (e) {
print('Error parsing notification payload: $e');
}
}
}
void _navigateToChat(int senderId) {
print('Navigating to chat with senderId: $senderId');
final context = navigatorKey.currentContext;
if (context != null) {
final contactProvider = Provider.of<ContactProvider>(
context,
listen: false,
);
// ВАЖНОЕ ИЗМЕНЕНИЕ:
// Независимо от того, загружены контакты или нет, на десктопе/планшете мы ВСЕГДА
// сбрасываем навигацию до экрана ContactsScreen, передавая ему targetChatId.
// Это заставит приложение открыть чат в его родном правом окошке, сохранив боковую панель.
currentActiveChatContactId = senderId; // Устанавливаем активный чат
// Очищаем весь стек экранов и открываем ContactsScreen со встроенным параметром чата
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => ContactsScreen(targetChatId: senderId)),
(route) => false, // Удаляет все предыдущие полноэкранные оверлеи
);
} else {
print('Navigator context is null');
}
}
bool firebaseInitialized = false;
void main() async {
// Initialize timeago for Russian locale
timeago.setLocaleMessages('ru', timeago.RuMessages());
WidgetsFlutterBinding.ensureInitialized();
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
sqfliteFfiInit();
// ИСПОЛЬЗУЕМ СУППОРТ-ДИРЕКТОРИЮ (App Data / Roaming) вместо Документов
final appSupportDir = await getApplicationSupportDirectory();
// Создаем подпапку внутри AppData, там Windows никогда не выдаст ошибку 2
final sqfliteDbPath = p.join(appSupportDir.path, 'sqflite_databases');
await Directory(sqfliteDbPath).create(recursive: true);
// Привязываем фабрику sqflite к новому безопасному пути
databaseFactory = databaseFactoryFfi;
await databaseFactory.setDatabasesPath(sqfliteDbPath);
print('Безопасный путь для SQFlite на Windows: $sqfliteDbPath');
}
try {
print('Initializing LocalDbService in main...');
final db = LocalDbService();
print('База данных сохранена по пути: ${db.connection.connectionData}');
print('LocalDbService initialized successfully.');
} catch (e, st) {
print('LocalDbService init failed in main: $e');
print(st);
}
if (Platform.isAndroid || Platform.isIOS) {
await Firebase.initializeApp();
firebaseInitialized = true;
initialMessage = await FirebaseMessaging.instance.getInitialMessage();
print('Initial message from main(): $initialMessage');
} else {
print('Skipping Firebase initialization on desktop.');
}
// Сохраняем информацию в SharedPreferences для надежности
final prefs = await SharedPreferences.getInstance();
if (initialMessage != null) {
print('App launched from notification: ${initialMessage!.data}');
print('Message type: ${initialMessage!.data['type']}');
print('Sender ID: ${initialMessage!.data['sender_id']}');
final payloadString = jsonEncode(initialMessage!.data);
final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey,
);
if (lastHandled != payloadString) {
// Сохраняем данные уведомления
await prefs.setString(_notificationLaunchKey, payloadString);
await prefs.setString(
_lastHandledNotificationLaunchPayloadKey,
payloadString,
);
print('Saved notification data to SharedPreferences');
} else {
print('InitialMessage payload already handled earlier, skipping');
}
} else {
print('No initial message - app launched normally');
// Очищаем сохраненные данные, если приложение запущено нормально
await prefs.remove(_notificationLaunchKey);
}
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const WindowsInitializationSettings initializationSettingsWindows =
WindowsInitializationSettings(
appName: 'Chepuhagram',
appUserModelId: 'ru.ArturKarasevich.Chepuhagram',
guid: '6c0af055-e0b5-4f10-9aed-c12dc078f949',
);
final InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
windows: initializationSettingsWindows,
);
await flutterLocalNotificationsPlugin.initialize(
settings: initializationSettings,
onDidReceiveNotificationResponse: _onSelectNotification,
);
// Если приложение было запущено из локального уведомления, сохраним payload
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
final payload = notificationAppLaunchDetails?.notificationResponse?.payload;
print('App launched from local notification, payload: $payload');
if (payload != null && payload.isNotEmpty) {
try {
final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey,
);
if (lastHandled != payload) {
final data = jsonDecode(payload);
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
await prefs.setString(
_lastHandledNotificationLaunchPayloadKey,
payload,
);
print('Saved local notification launch payload to SharedPreferences');
} else {
print('Local notification payload already handled earlier, skipping');
}
} catch (e) {
print('Failed to save notification launch payload: $e');
}
}
// Create notification channel for Android 8+
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'chat_id', // id
'Messages', // title
description: 'Chat messages notifications', // description
importance: Importance.high,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
if (firebaseInitialized) {
FirebaseMessaging.onBackgroundMessage(
_firebaseMessagingBackgroundHandler,
);
}
}
if (Platform.isAndroid || Platform.isIOS) {
initCallkitListener();
} else {
print('Skipping CallKit listener on desktop platform.');
}
runApp(
MultiProvider(
providers: [
Provider(create: (_) => CryptoService()),
Provider(create: (_) => SocketService()),
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => ThemeProvider()),
Provider(create: (_) => SocketService()),
ChangeNotifierProvider(
create: (context) => ContactProvider(context.read<CryptoService>()),
),
],
child: const MyApp(),
),
);
}
void initCallkitListener() {
if (!(Platform.isAndroid || Platform.isIOS)) {
print('Skipping CallKit event listener on non-mobile platform.');
return;
}
try {
FlutterCallkitIncoming.onEvent.listen((event) {
if (event == null) return;
switch (event.event) {
case Event.actionCallIncoming:
// Звонок получен, но CallKit уже показал экран.
// Здесь можно логировать или обновить статус в БД.
print("Incoming call: ${event.body['id']}");
break;
case Event.actionCallStart:
// Исходящий звонок начат
break;
case Event.actionCallAccept:
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ПРИНЯТЬ"
// 1. Уведомляем сервер (чтобы другая сторона узнала о начале звонка)
SocketService().sendMessage({
"type": "call_accept",
"call_id": event.body['id'],
});
// 2. Переходим на экран звонка
navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (_) => CallScreen(
callId: event.body['id'],
isIncoming: true,
callerName: event.body['nameCaller'] ?? 'Unknown',
onAccept: () {},
onHangup: () => _handleHangupGlobal(event.body['id']),
),
),
);
break;
case Event.actionCallDecline:
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ОТКЛОНИТЬ"
SocketService().sendMessage({
"type": "decline",
"call_id": event.body['id'],
});
break;
case Event.actionCallEnded:
case Event.actionCallTimeout:
// Звонок завершен или пропущен
print("Call ended or timeout");
break;
default:
print("Event unhandled: ${event.event}");
break;
}
});
} catch (e, st) {
print('CallKit listener initialization failed: $e');
print(st);
}
}
void _handleHangupGlobal(String callId) {
SocketService().sendMessage({"type": "hangup", "call_id": callId});
}
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
print("Фоновый пуш получен: ${message.data}");
if (message.data['type'] == 'enc_message') {
try {
// Initialize notifications for background
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const WindowsInitializationSettings initializationSettingsWindows =
WindowsInitializationSettings(
appName: 'Chepuhagram',
appUserModelId: 'ru.ArturKarasevich.Chepuhagram',
guid: '6c0af055-e0b5-4f10-9aed-c12dc078f949',
);
final InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
windows: initializationSettingsWindows,
);
await flutterLocalNotificationsPlugin.initialize(
settings: initializationSettings,
onDidReceiveNotificationResponse: _onSelectNotification,
);
// Create notification channel
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'chat_id',
'Messages',
description: 'Chat messages notifications',
importance: Importance.high,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
// Try to decrypt
String notificationText = 'New encrypted message';
try {
// 1. Инициализируем крипто-сервис
final crypto = CryptoService();
// 2. Достаем ключи (они должны быть в SecureStorage)
final myPrivKey = await crypto.getPrivateKey();
print('Private key retrieved: ${myPrivKey != null}');
if (myPrivKey == null) {
print('Private key not found, showing encrypted message');
notificationText =
'Encrypted message: ${message.data['content']?.substring(0, 50) ?? 'N/A'}...';
} else {
// 3. Расшифровываем
final sharedSecret = await crypto.deriveSharedSecret(
myPrivKey,
message.data['public_key'],
);
final decryptedText = await crypto.decryptMessage(
message.data['content'],
sharedSecret,
);
notificationText = decryptedText;
}
} catch (e) {
print('Decryption failed: $e');
notificationText = 'Failed to decrypt: ${e.toString()}';
}
final String senderIdRaw = message.data['sender_id']?.toString() ?? '';
final senderId = int.tryParse(senderIdRaw);
// ==========================================================
// ОБРАБОТКА ИМЕНИ: КЭШ УСТРОЙСТВА + ДАННЫЕ ОТ СЕРВЕРА
// ==========================================================
final prefs = await SharedPreferences.getInstance();
// 1. Пытаемся прочитать из локального кэша устройства
final String? cachedFirstName = prefs.getString('firstname_$senderIdRaw');
final String? cachedLastName = prefs.getString('lastname_$senderIdRaw');
// Список «мусорных» системных строк для фильтрации
final invalidValues = {'unknown', 'uncnown', 'null', ''};
// Функция очистки строк от пробелов и мусора
String cleanField(String? value) {
if (value == null) return '';
final trimmed = value.trim();
return invalidValues.contains(trimmed.toLowerCase()) ? '' : trimmed;
}
// Очищаем данные из кэша
String firstName = cleanField(cachedFirstName);
String lastName = cleanField(cachedLastName);
// 2. Если в кэше пусто, берем данные, которые сервер прислал в push-уведомлении
if (firstName.isEmpty) {
firstName = cleanField(message.data['firstname']?.toString());
}
if (lastName.isEmpty) {
lastName = cleanField(message.data['lastname']?.toString());
}
// Собираем полное имя
String title = '$firstName $lastName'.trim();
// 3. Если имени нет ни в кэше, ни в полях сервера, берем username
if (title.isEmpty) {
title = cleanField(message.data['username']?.toString());
}
// 4. Если вообще всё было "unknown" или пусто — ставим заглушку
if (title.isEmpty) {
title = 'Без имени';
}
// ==========================================================
// 4. Показываем локальное уведомление
final String groupKey = 'ru.chepuhagram.app.$senderId';
await flutterLocalNotificationsPlugin.show(
id: senderId ?? 0,
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: notificationText,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
'chat_id',
'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(),
}),
);
print('Notification shown successfully');
} catch (e) {
print('Error processing background message: $e');
}
} else {
print('Message type is not enc_message: ${message.data['type']}');
}
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Закрываем сокет, как только приложение сворачивается.
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
try {
//context.read<AuthProvider>().closeRealtime();
} catch (_) {}
return;
}
// На возврате в приложение — пробуем переподключиться (если есть токен).
if (state == AppLifecycleState.resumed) {
try {
context.read<AuthProvider>().initRealtime();
} catch (_) {}
}
}
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
return MaterialApp(
title: 'Chepuhagram',
debugShowCheckedModeBanner: false,
themeAnimationDuration: const Duration(milliseconds: 300),
themeAnimationCurve: Curves.easeInOut,
theme: themeProvider.themeData,
themeMode: themeProvider.themeMode,
navigatorKey: navigatorKey,
navigatorObservers: [routeObserver],
routes: {'/splash': (context) => const SplashScreen()},
initialRoute: '/splash',
);
}
}