From 1a36cbccd3d1a936521af0f9d9636b76bd2450a3 Mon Sep 17 00:00:00 2001 From: Artur Date: Thu, 30 Apr 2026 23:07:36 +0500 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B0=D0=BF=D0=B4=D0=B5=D0=B9=D1=82=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 13 +- .../reports/problems/problems-report.html | 663 ++++++++++++++++++ lib/core/app_colors.dart | 25 - lib/core/constants.dart | 3 +- lib/core/theme_manager.dart | 4 + lib/data/datasources/local_db_service.dart | 47 +- lib/data/datasources/ws_client.dart | 44 +- lib/data/models/contact_model.dart | 4 +- lib/data/models/message_model.dart | 12 + lib/domain/services/api_service.dart | 62 +- lib/domain/services/crypto_service.dart | 41 +- lib/logic/auth_provider.dart | 22 +- lib/logic/contact_provider.dart | 55 +- lib/main.dart | 119 +++- lib/presentation/screens/chat_screen.dart | 613 ++++++++++++++-- lib/presentation/screens/contacts_screen.dart | 352 ++++++++-- .../screens/key_recovery_screen.dart | 2 +- lib/presentation/screens/settings_screen.dart | 71 +- lib/presentation/screens/splash_screen.dart | 105 ++- lib/presentation/widgets/contact_tile.dart | 38 +- lib/presentation/widgets/message_bubble.dart | 42 +- linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 8 + pubspec.lock | 280 ++++++++ pubspec.yaml | 9 +- srv/app/api/endpoints/auth.py | 11 +- srv/app/api/endpoints/media.py | 53 ++ srv/app/api/endpoints/users.py | 31 +- srv/app/core/security.py | 11 +- srv/app/db/models.py | 7 +- srv/app/websocket/connection_manager.py | 70 ++ srv/main.py | 39 +- .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 35 files changed, 2530 insertions(+), 344 deletions(-) create mode 100644 android/build/reports/problems/problems-report.html delete mode 100644 lib/core/app_colors.dart create mode 100644 srv/app/api/endpoints/media.py diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 315825c..92046af 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,12 +3,15 @@ + + + android:usesCleartextTraffic="true" + android:enableOnBackInvokedCallback="true"> + + + + + + + + diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..8854391 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/lib/core/app_colors.dart b/lib/core/app_colors.dart deleted file mode 100644 index e1e60d0..0000000 --- a/lib/core/app_colors.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; - -class AppColors { - // --- Основные цвета акцентов --- - static const Color primary = Color(0xFF24A1DE); // Яркий синий (кнопки, активные элементы) - static const Color primaryDark = Color(0xFF1D84B5); // Темно-синий (для нажатых кнопок) - static const Color accent = Color(0xFF50B5E8); // Светло-синий (второстепенные элементы) - - // --- Фоны --- - static const Color background = Color(0xFFFFFFFF); // Чистый белый (основной фон) - static const Color surface = Color(0xFFF1F1F1); // Светло-серый (поля ввода, фон пузырей) - - // --- Текст --- - static const Color textMain = Color(0xFF1F1F1F); // Почти черный (основной текст) - static const Color textSecondary = Color(0xFF707579);// Серый (подписи, время, хинты) - static const Color textOnPrimary = Color(0xFFFFFFFF);// Белый (текст на синих кнопках) - - // --- Статусы --- - static const Color error = Color(0xFFE53935); // Красный (ошибки валидации) - static const Color success = Color(0xFF4CAF50); // Зеленый (статус "онлайн" или "доставлено") - - // --- Цвета чата (Пузыри) --- - static const Color bubbleMe = Color(0xFFEFFDDE); // Нежно-зеленый (мои сообщения, как в TG) - static const Color bubblePartner = Color(0xFFFFFFFF);// Белый (сообщения собеседника) -} \ No newline at end of file diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 759edbf..f25435d 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -1,4 +1,5 @@ class AppConstants { //static const baseUrl = '192.168.0.180:8000'; static const baseUrl = 'https://api.chepuhagram.ru'; -} \ No newline at end of file + static const wsUrl = 'wss://api.chepuhagram.ru'; +} diff --git a/lib/core/theme_manager.dart b/lib/core/theme_manager.dart index 0eeb67e..d99a510 100644 --- a/lib/core/theme_manager.dart +++ b/lib/core/theme_manager.dart @@ -10,6 +10,8 @@ class ThemeProvider extends ChangeNotifier { ThemeMode get themeMode => _themeMode; Color get accentColor => _accentColor; + bool isLight = false; + ThemeProvider() { _loadSettings(); } @@ -21,6 +23,7 @@ class ThemeProvider extends ChangeNotifier { if (mode != null) { _themeMode = mode == 'dark' ? ThemeMode.dark : ThemeMode.light; + isLight = mode == 'light'; } if (color != null) _accentColor = Color(int.parse(color)); notifyListeners(); @@ -28,6 +31,7 @@ class ThemeProvider extends ChangeNotifier { void toggleTheme(bool isDark) { _themeMode = isDark ? ThemeMode.dark : ThemeMode.light; + isLight = !isDark; _storage.write(key: 'theme_mode', value: isDark ? 'dark' : 'light'); notifyListeners(); } diff --git a/lib/data/datasources/local_db_service.dart b/lib/data/datasources/local_db_service.dart index 8cc717e..66a71d3 100644 --- a/lib/data/datasources/local_db_service.dart +++ b/lib/data/datasources/local_db_service.dart @@ -19,7 +19,7 @@ class LocalDbService { String path = join(await getDatabasesPath(), 'chat_app.db'); return await openDatabase( path, - version: 3, + version: 4, onCreate: (db, version) async { await db.execute(''' CREATE TABLE messages( @@ -31,7 +31,8 @@ class LocalDbService { delivered_at TEXT, read_at TEXT, reply_to_id INTEGER, - reply_to_text TEXT + reply_to_text TEXT, + edited_at TEXT ) '''); }, @@ -41,8 +42,15 @@ class LocalDbService { await db.execute('ALTER TABLE messages ADD COLUMN read_at TEXT'); } if (oldVersion < 3) { - await db.execute('ALTER TABLE messages ADD COLUMN reply_to_id INTEGER'); - await db.execute('ALTER TABLE messages ADD COLUMN reply_to_text TEXT'); + await db.execute( + 'ALTER TABLE messages ADD COLUMN reply_to_id INTEGER', + ); + await db.execute( + 'ALTER TABLE messages ADD COLUMN reply_to_text TEXT', + ); + } + if (oldVersion < 4) { + await db.execute('ALTER TABLE messages ADD COLUMN edited_at TEXT'); } }, ); @@ -62,19 +70,24 @@ class LocalDbService { 'timestamp': msg.createdAt.toIso8601String(), 'delivered_at': null, 'read_at': null, + 'reply_to_id': msg.replyToId, + 'reply_to_text': msg.replyToText, + 'edited_at': msg.editedAt?.toIso8601String(), }, conflictAlgorithm: ConflictAlgorithm.replace); } else { // Если это Map из API batch.insert('messages', { 'id': msg['id'], 'sender_id': msg['sender_id'], - 'receiver_id': msg['receiver_id'], // Убедись, что ключ совпадает с API + 'receiver_id': + msg['receiver_id'], // Убедись, что ключ совпадает с API 'content': msg['content'], 'timestamp': msg['timestamp'], 'delivered_at': msg['delivered_at'], 'read_at': msg['read_at'], 'reply_to_id': msg['reply_to_id'], 'reply_to_text': msg['reply_to_text'], + 'edited_at': msg['edited_at'], }, conflictAlgorithm: ConflictAlgorithm.replace); } } @@ -96,6 +109,16 @@ class LocalDbService { ); } + Future deleteChatHistory(int contactId, int myId) async { + final db = await database; + return await db.delete( + 'messages', + where: + '(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)', + whereArgs: [contactId, myId, myId, contactId], + ); + } + Future?> getLastMessage(int contactId, int myId) async { final db = await database; final rows = await db.query( @@ -131,12 +154,22 @@ class LocalDbService { ); } - Future deleteMessage(int messageId) async { + Future updateMessageContent( + int messageId, + String content, + DateTime? editedAt, + ) async { final db = await database; - await db.delete( + await db.update( 'messages', + {'content': content, 'edited_at': editedAt?.toIso8601String()}, where: 'id = ?', whereArgs: [messageId], ); } + + Future deleteMessage(int messageId) async { + final db = await database; + await db.delete('messages', where: 'id = ?', whereArgs: [messageId]); + } } diff --git a/lib/data/datasources/ws_client.dart b/lib/data/datasources/ws_client.dart index 794fe72..d711114 100644 --- a/lib/data/datasources/ws_client.dart +++ b/lib/data/datasources/ws_client.dart @@ -3,11 +3,12 @@ import 'dart:convert'; import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/status.dart' as status; +import 'package:web_socket_channel/io.dart'; import 'package:chepuhagram/core/constants.dart'; class SocketService { static final SocketService _instance = SocketService._internal(); - + factory SocketService() { return _instance; } @@ -29,19 +30,33 @@ class SocketService { } // В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке - final uri = Uri.parse("ws://${AppConstants.baseUrl.split('//')[1]}/ws?token=$token"); + final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token"); - _channel = WebSocketChannel.connect(uri); + //_channel = WebSocketChannel.connect(uri); - _channel!.stream.listen( - (data) { - final decoded = jsonDecode(data); - print("🚀 СООБЩЕНИЕ ПОЛУЧЕНО ИЗ SINK: $decoded"); - _messageController.add(decoded); - }, - onError: (error) => _reconnect(apiService), - onDone: () => _reconnect(apiService), + _channel = IOWebSocketChannel.connect( + uri, + connectTimeout: Duration(seconds: 10), ); + + try { + await _channel!.ready; + _channel!.stream.listen( + (data) { + final decoded = jsonDecode(data); + print("🚀 СООБЩЕНИЕ ПОЛУЧЕНО ИЗ SINK: $decoded"); + _messageController.add(decoded); + }, + onError: (error) => _reconnect(apiService), + onDone: () => _reconnect(apiService), + ); + } on TimeoutException catch (_) { + _channel = null; + throw Exception('timeout'); + } catch (e) { + _channel = null; + throw Exception("Ошибка подключения: $e"); + } } Future _reconnect(ApiService apiService) async { @@ -71,14 +86,11 @@ class SocketService { } bool sendReadReceipt(int messageId) { - return sendMessage({ - 'type': 'read_receipt', - 'message_id': messageId, - }); + return sendMessage({'type': 'read_receipt', 'message_id': messageId}); } void disconnect() { - _channel?.sink.close(status.goingAway); + _channel?.sink.close(status.normalClosure); _channel = null; } } diff --git a/lib/data/models/contact_model.dart b/lib/data/models/contact_model.dart index ecf6443..5384dfa 100644 --- a/lib/data/models/contact_model.dart +++ b/lib/data/models/contact_model.dart @@ -60,8 +60,8 @@ class Contact { return Contact( id: json['id'], username: json['username'] ?? 'Unknown', - name: json['name'] ?? 'Unknown', - surname: json['surname'] ?? 'Unknown', + name: json['name'] ?? json['first_name'] ?? 'Unknown', + surname: json['surname'] ?? json['last_name'] ?? 'Unknown', lastMessage: json['last_message'] ?? json['lastMessage'], avatarUrl: json['avatar_url'] ?? json['avatarUrl'], lastMessageTime: parseTime(json['last_message_time'] ?? json['lastMessageTime']), diff --git a/lib/data/models/message_model.dart b/lib/data/models/message_model.dart index ad189c6..5a0e167 100644 --- a/lib/data/models/message_model.dart +++ b/lib/data/models/message_model.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + enum MessageStatus { sending, sent, delivered, read, failed } class MessageModel { @@ -11,6 +13,8 @@ class MessageModel { final MessageStatus status; final int? replyToId; // ID сообщения, на которое отвечают final String? replyToText; // текст сообщения, на которое отвечают (для отображения) + final DateTime? editedAt; + final Uint8List? localFileBytes; MessageModel({ this.id, @@ -23,6 +27,8 @@ class MessageModel { this.status = MessageStatus.sent, this.replyToId, this.replyToText, + this.editedAt, + this.localFileBytes }); MessageModel copyWith({ @@ -36,6 +42,8 @@ class MessageModel { MessageStatus? status, int? replyToId, String? replyToText, + DateTime? editedAt, + Uint8List? localFileBytes, }) { return MessageModel( id: id ?? this.id, @@ -48,6 +56,8 @@ class MessageModel { status: status ?? this.status, replyToId: replyToId ?? this.replyToId, replyToText: replyToText ?? this.replyToText, + editedAt: editedAt ?? this.editedAt, + localFileBytes: localFileBytes ?? this.localFileBytes, ); } @@ -67,6 +77,7 @@ class MessageModel { status: MessageStatus.sent, replyToId: json['reply_to_id'] == null ? null : int.tryParse(json['reply_to_id'].toString()), replyToText: json['reply_to_text'] == null ? null : json['reply_to_text'].toString(), + editedAt: json['edited_at'] == null ? null : DateTime.tryParse(json['edited_at'].toString()), ); } @@ -81,6 +92,7 @@ class MessageModel { 'status': status.name, 'reply_to_id': replyToId, 'reply_to_text': replyToText, + 'edited_at': editedAt?.toIso8601String(), }; } } diff --git a/lib/domain/services/api_service.dart b/lib/domain/services/api_service.dart index 24c1b4a..b4f48b0 100644 --- a/lib/domain/services/api_service.dart +++ b/lib/domain/services/api_service.dart @@ -9,6 +9,44 @@ class ApiService extends ChangeNotifier { final _client = http.Client(); final _storage = const FlutterSecureStorage(); + Future uploadMedia(List bytes) async { + try { + final token = getAccessToken(); + var request = http.MultipartRequest( + 'POST', + Uri.parse('${AppConstants.baseUrl}/media/upload'), + ); + request.headers.addAll({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }); + // Добавляем файл в запрос + request.files.add( + http.MultipartFile.fromBytes( + 'file', + bytes, + filename: 'media.enc', // Имя файла для сервера + ), + ); + + // Добавь заголовки авторизации, если они у тебя есть (JWT и т.д.) + // request.headers.addAll({'Authorization': 'Bearer $token'}); + + var streamedResponse = await request.send(); + var response = await http.Response.fromStream(streamedResponse); + + if (response.statusCode == 200) { + // Предполагаем, что сервер возвращает JSON {"file_id": "..."} + final data = jsonDecode(response.body); + return data['file_id']; + } + return null; + } catch (e) { + print("Ошибка API при загрузке: $e"); + return null; + } + } + Future refreshToken() async { notifyListeners(); @@ -128,7 +166,8 @@ class ApiService extends ChangeNotifier { ); if (response.statusCode == 200) { - return jsonDecode(utf8.decode(response.bodyBytes)) as Map; + return jsonDecode(utf8.decode(response.bodyBytes)) + as Map; } throw Exception('Не удалось получить данные пользователя'); } @@ -147,7 +186,10 @@ class ApiService extends ChangeNotifier { return response.statusCode == 200; } - Future changePassword(String currentPassword, String newPassword) async { + Future changePassword( + String currentPassword, + String newPassword, + ) async { final token = await getAccessToken(); final response = await _client.put( Uri.parse('${AppConstants.baseUrl}/users/me/password'), @@ -167,7 +209,9 @@ class ApiService extends ChangeNotifier { Future> getChatHistory(int contactId) async { final token = await getAccessToken(); final response = await _client.get( - Uri.parse('${AppConstants.baseUrl}/messages/history/${contactId.toString()}'), + Uri.parse( + '${AppConstants.baseUrl}/messages/history/${contactId.toString()}', + ), headers: { 'Content-Type': 'application/json', "Authorization": "Bearer $token", @@ -205,7 +249,11 @@ class ApiService extends ChangeNotifier { if (response.statusCode == 200) { return decoded as Map; } - throw Exception((decoded is Map && decoded['detail'] != null) ? decoded['detail'] : 'Failed to update profile'); + throw Exception( + (decoded is Map && decoded['detail'] != null) + ? decoded['detail'] + : 'Failed to update profile', + ); } Future> getUserById(int userId) async { @@ -219,7 +267,8 @@ class ApiService extends ChangeNotifier { ); if (response.statusCode == 200) { - return jsonDecode(utf8.decode(response.bodyBytes)) as Map; + return jsonDecode(utf8.decode(response.bodyBytes)) + as Map; } throw Exception('Не удалось получить информацию о пользователе'); } @@ -261,7 +310,8 @@ class ApiService extends ChangeNotifier { ); if (response.statusCode == 200) { - return jsonDecode(utf8.decode(response.bodyBytes)) as Map; + return jsonDecode(utf8.decode(response.bodyBytes)) + as Map; } throw Exception('Не удалось получить настройки конфиденциальности'); } diff --git a/lib/domain/services/crypto_service.dart b/lib/domain/services/crypto_service.dart index be2eaff..841032a 100644 --- a/lib/domain/services/crypto_service.dart +++ b/lib/domain/services/crypto_service.dart @@ -1,3 +1,4 @@ +import 'dart:typed_data'; import 'package:cryptography/cryptography.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'dart:convert'; @@ -97,7 +98,9 @@ class CryptoService { String myPrivateKeyBase64, String theirPublicKeyBase64, ) async { - final myKeyPair = await algorithm.newKeyPairFromSeed(base64Decode(myPrivateKeyBase64)); + final myKeyPair = await algorithm.newKeyPairFromSeed( + base64Decode(myPrivateKeyBase64), + ); final theirPublicKey = SimplePublicKey( base64Decode(theirPublicKeyBase64), type: KeyPairType.x25519, @@ -119,10 +122,40 @@ class CryptoService { // Сохраняем Nonce + MAC + CipherText для передачи return base64Encode(nonce + encrypted.mac.bytes + encrypted.cipherText); } - + + Future<(List, String)?> encryptImage( + List fileBytes, + SecretKey sharedKey, + ) async { + try { + final SecretKey fileSecretKey = await aesGcm.newSecretKey(); + final List fileSecretKeyBytes = await fileSecretKey.extractBytes(); + + final SecretBox secretBox = await aesGcm.encrypt( + fileBytes, + secretKey: fileSecretKey, + ); + final List dataToUpload = secretBox.concatenation(); + + final encryptedKeyBox = await aesGcm.encrypt( + fileSecretKeyBytes, + secretKey: sharedKey, + ); + + final String encryptedKeyForServer = base64Encode( + encryptedKeyBox.concatenation(), + ); + + return (dataToUpload, encryptedKeyForServer); + } catch (e) { + print("Ошибка шифрования медиа: $e"); + return null; + } + } + Future decryptMessage(String base64Data, SecretKey sharedKey) async { final data = base64Decode(base64Data); - + final nonce = data.sublist(0, 12); final mac = data.sublist(12, 28); final cipherText = data.sublist(28); @@ -131,7 +164,7 @@ class CryptoService { SecretBox(cipherText, nonce: nonce, mac: Mac(mac)), secretKey: sharedKey, ); - + return utf8.decode(decrypted); } diff --git a/lib/logic/auth_provider.dart b/lib/logic/auth_provider.dart index bc2e254..57e5c01 100644 --- a/lib/logic/auth_provider.dart +++ b/lib/logic/auth_provider.dart @@ -72,7 +72,11 @@ class AuthProvider extends ChangeNotifier { final CryptoService _cryptoService = CryptoService(); Future initRealtime() async { - await _socketService.connect(_apiService); + try { + await _socketService.connect(_apiService); + } catch (e) { + throw Exception(e); + } } void closeRealtime() { @@ -153,7 +157,7 @@ class AuthProvider extends ChangeNotifier { if (token == null) return false; // Загружаем currentUserId из хранилища - final userIdStr = await _storage.read(key: 'user_id'); + /*final userIdStr = await _storage.read(key: 'user_id'); if (userIdStr != null) { _currentUserId = int.tryParse(userIdStr); } @@ -183,7 +187,8 @@ class AuthProvider extends ChangeNotifier { } catch (e) { // Если сервер недоступен, позволяем offline mode return true; - } + }*/ + return true; } Future updateProfileAndSecurity({ @@ -241,18 +246,21 @@ class AuthProvider extends ChangeNotifier { if (response.statusCode == 200) { final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map; + _currentUserId = data['id'] as int?; _username = data['username']?.toString(); _firstName = data['first_name']?.toString(); _lastName = data['last_name']?.toString(); _phone = data['phone']?.toString(); _email = data['email']?.toString(); _about = data['about']?.toString(); - + // Проверяем наличие публичного ключа на сервере - _hasPublicKeyOnServer = data['public_key'] != null && data['public_key'].isNotEmpty; - + _hasPublicKeyOnServer = + data['public_key'] != null && data['public_key'].isNotEmpty; + // Проверяем наличие приватного ключа локально - final hasLocalPrivateKey = await _storage.read(key: 'private_key') != null; + final hasLocalPrivateKey = + await _storage.read(key: 'private_key') != null; if (!_hasPublicKeyOnServer) { // Путь А: Первая настройка - нужно создать ключи и профиль diff --git a/lib/logic/contact_provider.dart b/lib/logic/contact_provider.dart index 55b3465..69e1479 100644 --- a/lib/logic/contact_provider.dart +++ b/lib/logic/contact_provider.dart @@ -82,8 +82,10 @@ class ContactProvider extends ChangeNotifier { } Future _enrichContactsWithLastMessages() async { + print("Начинаем обогащать контакты последними сообщениями из локальной БД... Для текущего пользователя ID: $_currentUserId"); final myId = _currentUserId; if (myId == null) return; + print("Текущий пользователь ID: $myId"); final myPrivKey = await _cryptoService.getPrivateKey(); @@ -93,6 +95,7 @@ class ContactProvider extends ChangeNotifier { final contact = updated[i]; // 1) Если сервер уже прислал lastMessage — попробуем расшифровать превью. + print(contact.lastMessage); if (contact.lastMessage != null && contact.lastMessage!.isNotEmpty && myPrivKey != null && @@ -111,58 +114,6 @@ class ContactProvider extends ChangeNotifier { // Если расшифровать не удалось — оставляем как есть, дальше попробуем локальную БД. } } - - // Если сервер уже отдал и сообщение, и время — не трогаем (контакты уже обогащены). - final contactAfterServer = updated[i]; - if (contactAfterServer.lastMessage != null && - contactAfterServer.lastMessage!.isNotEmpty && - contactAfterServer.publicKey == null) { - // Чтобы не показывать в списке контактов "ciphertext", если ключа нет. - updated[i] = contactAfterServer.copyWith( - lastMessage: 'Новое сообщение', - ); - } - - final contactAfterServer2 = updated[i]; - if (contactAfterServer2.lastMessage != null && - contactAfterServer2.lastMessageTime != null) { - continue; - } - - final last = await _localDbService.getLastMessage(contact.id, myId); - if (last == null) continue; - - final rawContent = last['content']?.toString(); - final rawTimestamp = last['timestamp']?.toString(); - final lastTime = rawTimestamp == null - ? null - : DateTime.tryParse(rawTimestamp); - - String? preview; - if (rawContent != null && rawContent.isNotEmpty) { - // Пытаемся расшифровать превью, если есть ключи. - try { - if (myPrivKey != null && contact.publicKey != null) { - final sharedSecret = await _cryptoService.deriveSharedSecret( - myPrivKey, - contact.publicKey!, - ); - preview = await _cryptoService.decryptMessage( - rawContent, - sharedSecret, - ); - } else { - preview = 'Новое сообщение'; - } - } catch (_) { - preview = 'Новое сообщение'; - } - } - - updated[i] = contactAfterServer2.copyWith( - lastMessage: preview, - lastMessageTime: contactAfterServer2.lastMessageTime ?? lastTime, - ); } _contacts = updated; diff --git a/lib/main.dart b/lib/main.dart index dfe6361..50a5340 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,9 +16,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'presentation/screens/splash_screen.dart'; -final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); +final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); final GlobalKey navigatorKey = GlobalKey(); +final RouteObserver routeObserver = RouteObserver(); + // Глобальная переменная для отслеживания текущего активного контакта в чате int? currentActiveChatContactId; @@ -28,9 +31,12 @@ RemoteMessage? initialMessage; // Ключ для SharedPreferences const String _notificationLaunchKey = 'notification_launch_data'; // Защита от повторной обработки одного и того же payload при следующих запусках по иконке -const String _lastHandledNotificationLaunchPayloadKey = 'notification_last_handled_payload'; +const String _lastHandledNotificationLaunchPayloadKey = + 'notification_last_handled_payload'; -Future _onSelectNotification(NotificationResponse notificationResponse) async { +Future _onSelectNotification( + NotificationResponse notificationResponse, +) async { final payload = notificationResponse.payload; if (payload != null) { try { @@ -47,12 +53,19 @@ Future _onSelectNotification(NotificationResponse notificationResponse) as // Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение // будет снова автопереходить в чат. if (context == null) { - final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey); + final lastHandled = prefs.getString( + _lastHandledNotificationLaunchPayloadKey, + ); if (lastHandled != canonicalPayload) { await prefs.setString(_notificationLaunchKey, canonicalPayload); - await prefs.setString(_lastHandledNotificationLaunchPayloadKey, canonicalPayload); + await prefs.setString( + _lastHandledNotificationLaunchPayloadKey, + canonicalPayload, + ); } - print('Navigator context is null, saved notification payload to SharedPreferences'); + print( + 'Navigator context is null, saved notification payload to SharedPreferences', + ); } else { await prefs.remove(_notificationLaunchKey); } @@ -60,7 +73,9 @@ Future _onSelectNotification(NotificationResponse notificationResponse) as // Navigate to chat with this contact (if context is ready) _navigateToChat(senderId); } else { - print('Notification payload has invalid sender_id: ${data['sender_id']}'); + print( + 'Notification payload has invalid sender_id: ${data['sender_id']}', + ); } } catch (e) { print('Error parsing notification payload: $e'); @@ -72,8 +87,11 @@ void _navigateToChat(int senderId) { print('Navigating to chat with senderId: $senderId'); final context = navigatorKey.currentContext; if (context != null) { - final contactProvider = Provider.of(context, listen: false); - + final contactProvider = Provider.of( + context, + listen: false, + ); + // Check if contacts are loaded if (contactProvider.contacts.isEmpty) { print('Contacts not loaded yet, navigating to contacts screen first'); @@ -86,7 +104,7 @@ void _navigateToChat(int senderId) { ); return; } - + try { final contact = contactProvider.contacts.firstWhere( (c) => c.id == senderId, @@ -96,18 +114,16 @@ void _navigateToChat(int senderId) { currentActiveChatContactId = senderId; // Устанавливаем активный чат Navigator.push( context, - MaterialPageRoute( - builder: (_) => ChatScreen(contact: contact), - ), + MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)), ); } catch (e) { - print('Contact with id $senderId not found, navigating to contacts screen'); + print( + 'Contact with id $senderId not found, navigating to contacts screen', + ); // Contact not found, go to contacts screen Navigator.push( context, - MaterialPageRoute( - builder: (_) => const ContactsScreen(), - ), + MaterialPageRoute(builder: (_) => const ContactsScreen()), ); } } else { @@ -132,11 +148,16 @@ void main() async { print('Sender ID: ${initialMessage!.data['sender_id']}'); final payloadString = jsonEncode(initialMessage!.data); - final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey); + final lastHandled = prefs.getString( + _lastHandledNotificationLaunchPayloadKey, + ); if (lastHandled != payloadString) { // Сохраняем данные уведомления await prefs.setString(_notificationLaunchKey, payloadString); - await prefs.setString(_lastHandledNotificationLaunchPayloadKey, payloadString); + await prefs.setString( + _lastHandledNotificationLaunchPayloadKey, + payloadString, + ); print('Saved notification data to SharedPreferences'); } else { print('InitialMessage payload already handled earlier, skipping'); @@ -148,25 +169,34 @@ void main() async { } // Initialize local notifications - const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher'); - final InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid); + const AndroidInitializationSettings initializationSettingsAndroid = + AndroidInitializationSettings('@mipmap/ic_launcher'); + final InitializationSettings initializationSettings = InitializationSettings( + android: initializationSettingsAndroid, + ); await flutterLocalNotificationsPlugin.initialize( initializationSettings, onDidReceiveNotificationResponse: _onSelectNotification, ); // Если приложение было запущено из локального уведомления, сохраним payload - final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); + 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); + final lastHandled = prefs.getString( + _lastHandledNotificationLaunchPayloadKey, + ); if (lastHandled != payload) { final data = jsonDecode(payload); await prefs.setString(_notificationLaunchKey, jsonEncode(data)); - await prefs.setString(_lastHandledNotificationLaunchPayloadKey, payload); + await prefs.setString( + _lastHandledNotificationLaunchPayloadKey, + payload, + ); print('Saved local notification launch payload to SharedPreferences'); } else { print('Local notification payload already handled earlier, skipping'); @@ -185,7 +215,11 @@ void main() async { importance: Importance.high, ); - await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation()?.createNotificationChannel(channel); + await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel(channel); FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); @@ -208,8 +242,10 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { if (message.data['type'] == 'enc_message') { try { // Initialize notifications for background - const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher'); - const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid); + const AndroidInitializationSettings initializationSettingsAndroid = + AndroidInitializationSettings('@mipmap/ic_launcher'); + const InitializationSettings initializationSettings = + InitializationSettings(android: initializationSettingsAndroid); await flutterLocalNotificationsPlugin.initialize(initializationSettings); // Create notification channel @@ -220,24 +256,35 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { importance: Importance.high, ); - await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation()?.createNotificationChannel(channel); + 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'}...'; + 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); + final sharedSecret = await crypto.deriveSharedSecret( + myPrivKey, + message.data['public_key'], + ); + final decryptedText = await crypto.decryptMessage( + message.data['content'], + sharedSecret, + ); notificationText = decryptedText; } } catch (e) { @@ -250,11 +297,14 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { message.hashCode, message.data['username'] ?? 'Unknown', notificationText, - const NotificationDetails(android: AndroidNotificationDetails('chat_id', 'Messages')), + const NotificationDetails( + android: AndroidNotificationDetails('chat_id', 'Messages'), + ), payload: jsonEncode({ 'type': 'enc_message', 'sender_id': message.data['sender_id'], - 'timestamp': message.data['timestamp'] ?? DateTime.now().toIso8601String(), + 'timestamp': + message.data['timestamp'] ?? DateTime.now().toIso8601String(), }), ); print('Notification shown successfully'); @@ -317,6 +367,7 @@ class _MyAppState extends State with WidgetsBindingObserver { theme: themeProvider.themeData, themeMode: themeProvider.themeMode, navigatorKey: navigatorKey, + navigatorObservers: [routeObserver], // Начальный экран home: const SplashScreen(), diff --git a/lib/presentation/screens/chat_screen.dart b/lib/presentation/screens/chat_screen.dart index 7f0d324..2205294 100644 --- a/lib/presentation/screens/chat_screen.dart +++ b/lib/presentation/screens/chat_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import '/data/models/message_model.dart'; import '/data/models/contact_model.dart'; @@ -16,6 +17,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'contacts_screen.dart'; import 'package:flutter/services.dart'; import 'user_profile_screen.dart'; +import 'package:image_picker/image_picker.dart'; class ChatScreen extends StatefulWidget { final Contact contact; @@ -40,6 +42,7 @@ class _ChatScreenState extends State { StreamSubscription? _socketSubscription; final Set _sentReadReceipts = {}; final LocalDbService _localDbService = LocalDbService(); + Uint8List? _pendingImageBytes; MessageModel? _replyTo; @override @@ -80,6 +83,13 @@ class _ChatScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("Не удалось получить ключ шифрования собеседника"), + behavior: SnackBarBehavior.floating, // Обязательно для margin + margin: EdgeInsets.only( + bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию) + left: 10.0, + right: 10.0, + ), + duration: Duration(seconds: 3), ), ); } @@ -101,9 +111,13 @@ class _ChatScreenState extends State { leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const ContactsScreen()), - ); + if (Navigator.canPop(context)) { + Navigator.pop(context); + } else { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const ContactsScreen()), + ); + } }, ), title: GestureDetector( @@ -163,6 +177,15 @@ class _ChatScreenState extends State { _inputFocusNode.requestFocus(); }, ), + if (msg.isMe) + ListTile( + leading: const Icon(Icons.edit), + title: const Text('Изменить'), + onTap: () { + Navigator.of(ctx).pop(); + _editMessage(msg); + }, + ), ListTile( leading: const Icon(Icons.copy), title: const Text('Скопировать'), @@ -171,7 +194,19 @@ class _ChatScreenState extends State { await Clipboard.setData(ClipboardData(text: msg.text)); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Скопировано')), + const SnackBar( + content: Text('Скопировано'), + behavior: + SnackBarBehavior.floating, // Обязательно для margin + margin: EdgeInsets.only( + bottom: + 80.0 + + 10.0, // 20px + стандартный отступ (по желанию) + left: 10.0, + right: 10.0, + ), + duration: Duration(seconds: 2), + ), ); }, ), @@ -180,32 +215,20 @@ class _ChatScreenState extends State { title: const Text('Переслать'), onTap: () { Navigator.of(ctx).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Пересылка пока не реализована')), - ); - }, - ), - ListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('Удалить'), - textColor: Colors.red, - iconColor: Colors.red, - onTap: () async { - Navigator.of(ctx).pop(); - setState(() { - messages.removeWhere( - (m) => (m.id != null && m.id == msg.id) || (m.tempId != null && m.tempId == msg.tempId), - ); - }); - - final id = msg.id; - if (id != null) { - try { - await _localDbService.deleteMessage(id); - } catch (_) {} - } + _showForwardContactPicker(msg); }, ), + if (msg.isMe) + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Удалить'), + textColor: Colors.red, + iconColor: Colors.red, + onTap: () async { + Navigator.of(ctx).pop(); + await _deleteMessage(msg); + }, + ), const SizedBox(height: 8), ], ), @@ -214,6 +237,276 @@ class _ChatScreenState extends State { ); } + Future _editMessage(MessageModel msg) async { + final controller = TextEditingController(text: msg.text); + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Изменить сообщение'), + content: TextField( + controller: controller, + minLines: 1, + maxLines: 5, + autofocus: true, + decoration: const InputDecoration(hintText: 'Новый текст сообщения'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Отмена'), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Сохранить'), + ), + ], + ), + ); + + if (result != true || controller.text.trim().isEmpty) return; + + final newText = controller.text.trim(); + final myPrivKey = await _cryptoService.getPrivateKey(); + if (myPrivKey == null) return; + final sharedSecret = await _cryptoService.deriveSharedSecret( + myPrivKey, + _currentContact.publicKey!, + ); + final encryptedContent = await _cryptoService.encryptMessage( + newText, + sharedSecret, + ); + + final content50 = newText.length > 50 ? newText.substring(0, 50) : newText; + final encryptedContent50 = await _cryptoService.encryptMessage( + content50, + sharedSecret, + ); + + setState(() { + messages = messages.map((m) { + if (m.id != null && m.id == msg.id) { + return m.copyWith(text: newText, editedAt: DateTime.now()); + } + return m; + }).toList(); + }); + + if (msg.id != null) { + try { + await _localDbService.updateMessageContent( + msg.id!, + encryptedContent, + DateTime.now(), + ); + } catch (_) {} + Provider.of(context, listen: false).sendMessage({ + 'type': 'edit_message', + 'message_id': msg.id, + 'content': encryptedContent, + 'content50': encryptedContent50, + }); + } + } + + Future _deleteMessage(MessageModel msg) async { + setState(() { + messages.removeWhere( + (m) => + (m.id != null && m.id == msg.id) || + (m.tempId != null && m.tempId == msg.tempId), + ); + }); + + final id = msg.id; + if (id != null) { + try { + await _localDbService.deleteMessage(id); + } catch (_) {} + Provider.of( + context, + listen: false, + ).sendMessage({'type': 'delete_message', 'message_id': id}); + } + } + + Future _showForwardContactPicker(MessageModel msg) async { + final contactProvider = context.read(); + contactProvider.setCurrentUserId(myId); + await contactProvider.loadAllContactsForNewChat(); + if (!mounted) return; + + final selectedContact = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) { + final provider = context.watch(); + if (provider.isLoading) { + return const SizedBox( + height: 150, + child: Center(child: CircularProgressIndicator()), + ); + } + if (provider.error != null) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Text('Ошибка загрузки контактов: ${provider.error}'), + ); + } + if (provider.allContacts.isEmpty) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Text('Нет доступных контактов для пересылки.'), + ); + } + return SafeArea( + child: ListView.builder( + shrinkWrap: true, + itemCount: provider.allContacts.length, + itemBuilder: (ctx2, index) { + final contact = provider.allContacts[index]; + return ListTile( + leading: CircleAvatar( + child: Text(contact.name.isNotEmpty ? contact.name[0] : '?'), + ), + title: Text(contact.name), + subtitle: Text(contact.username), + onTap: () => Navigator.of(ctx).pop(contact), + ); + }, + ), + ); + }, + ); + + if (selectedContact != null) { + await _forwardMessage(msg, selectedContact); + } + } + + Future _forwardMessage(MessageModel msg, Contact targetContact) async { + final forwardText = msg.text.trim(); + if (forwardText.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Нечего пересылать.'), + behavior: SnackBarBehavior.floating, // Обязательно для margin + margin: EdgeInsets.only( + bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию) + left: 10.0, + right: 10.0, + ), + duration: Duration(seconds: 5), + ), + ); + return; + } + + if (targetContact.publicKey == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Публичный ключ контакта ${targetContact.name} не найден.', + ), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), + duration: Duration(seconds: 3), + ), + ); + return; + } + + try { + final myPrivKey = await _cryptoService.getPrivateKey(); + if (myPrivKey == null) { + throw Exception('Не найден приватный ключ.'); + } + final sharedSecret = await _cryptoService.deriveSharedSecret( + myPrivKey, + targetContact.publicKey!, + ); + final encryptedContent = await _cryptoService.encryptMessage( + forwardText, + sharedSecret, + ); + final previewText = forwardText.length > 50 + ? forwardText.substring(0, 50) + : forwardText; + final encryptedContent50 = await _cryptoService.encryptMessage( + previewText, + sharedSecret, + ); + + final tempId = DateTime.now().microsecondsSinceEpoch; + final localMessage = MessageModel( + tempId: tempId, + text: forwardText.isNotEmpty ? forwardText : "[Фото]", + isMe: true, + senderId: myId, + receiverId: targetContact.id, + createdAt: DateTime.now(), + status: MessageStatus.sending, + localFileBytes: _pendingImageBytes, + ); + if (_currentContact.id == targetContact.id) { + setState(() { + messages.add(localMessage); + _pendingImageBytes = null; + }); + } + + final ok = Provider.of(context, listen: false) + .sendMessage({ + 'type': 'private_message', + 'receiver_id': targetContact.id, + 'message_type': 'text', + 'content': encryptedContent, + 'content50': encryptedContent50, + 'temp_id': tempId, + }); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + ok + ? 'Сообщение переслано контакту ${targetContact.name}.' + : 'Не удалось переслать сообщение.', + ), + behavior: SnackBarBehavior.floating, // Обязательно для margin + margin: EdgeInsets.only( + bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию) + left: 10.0, + right: 10.0, + ), + duration: Duration(seconds: 3), + ), + ); + + setState(() { + final idx = messages.indexWhere((m) => m.tempId == tempId); + if (idx != -1) { + messages[idx] = messages[idx].copyWith( + status: ok ? MessageStatus.sent : MessageStatus.failed, + ); + } + _replyTo = null; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка пересылки: $e'), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), + duration: Duration(seconds: 5), + ), + ); + } + } + Widget _buildMessageInput() { return SafeArea( // Добавляем SafeArea здесь @@ -225,7 +518,10 @@ class _ChatScreenState extends State { children: [ if (_replyTo != null) Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, @@ -249,9 +545,42 @@ class _ChatScreenState extends State { ], ), ), + if (_pendingImageBytes != null) + Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.memory( + _pendingImageBytes!, + fit: BoxFit.cover, + height: 120, + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => + setState(() => _pendingImageBytes = null), + ), + ], + ), + ), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ + IconButton( + icon: const Icon(Icons.photo), + onPressed: _pickImage, + ), Expanded( child: TextField( controller: _controller, @@ -259,6 +588,7 @@ class _ChatScreenState extends State { minLines: 1, maxLines: 5, textInputAction: TextInputAction.newline, + textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( hintText: "Напиши сообщение...", ), @@ -278,84 +608,148 @@ class _ChatScreenState extends State { ); } + Future _pickImage() async { + final ImagePicker _picker = ImagePicker(); + final XFile? image = await _picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1280, + maxHeight: 1280, + imageQuality: 80, + ); + if (image != null) { + final Uint8List fileBytes = await image.readAsBytes(); + if (!mounted) return; + setState(() { + _pendingImageBytes = fileBytes; + }); + } + } + Future _sendMessage() async { final rawText = _controller.text.trim(); - if (rawText.isEmpty) return; + final hasImage = _pendingImageBytes != null; + + // Если и текст пустой, и картинки нет — выходим + if (rawText.isEmpty && !hasImage) return; + + // Блокируем UI на время загрузки _controller.clear(); - if (_currentContact.publicKey == null) { - await _loadContactKey(); - if (_currentContact.publicKey == null) return; - } - try { + // 1. Подготовка ключей final myPrivKey = await _cryptoService.getPrivateKey(); - final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey!, _currentContact.publicKey!, ); - final encryptedText = await _cryptoService.encryptMessage( - rawText, + String? fileId; + String? encryptedFileKey; + String encryptedContent; + String encryptedContent50; + + // 2. Если есть изображение — сначала загружаем его + if (hasImage) { + final encryptionResult = await _cryptoService.encryptImage( + _pendingImageBytes!, + sharedSecret, + ); + if (encryptionResult == null) { + throw Exception("Ошибка шифрования медиа"); + } + final encryptedFileData = encryptionResult.$1; + final fileKeyForServer = encryptionResult.$2; + + fileId = await apiService.uploadMedia(encryptedFileData); + + if (fileId == null) throw Exception("Ошибка загрузки файла на сервер"); + + encryptedFileKey = fileKeyForServer; + } + + // 3. Шифруем текст сообщения (даже если там пусто, или есть подпись к фото) + // Если текста нет, но есть фото, отправим пустую строку или "[Фото]" + final String textToEncrypt = rawText.isNotEmpty + ? rawText + : (hasImage ? "" : ""); + + encryptedContent = await _cryptoService.encryptMessage( + textToEncrypt, sharedSecret, ); - final encryptedText50 = await _cryptoService.encryptMessage( - rawText.length > 50 ? rawText.substring(0, 50) : rawText, + String previewText = rawText.isNotEmpty ? rawText : "[Фото]"; + if (previewText.length > 50) previewText = previewText.substring(0, 50); + encryptedContent50 = await _cryptoService.encryptMessage( + previewText, sharedSecret, ); + // 4. Создаем локальную модель для мгновенного отображения final tempId = DateTime.now().microsecondsSinceEpoch; final localMessage = MessageModel( tempId: tempId, - text: rawText, + text: rawText.isNotEmpty ? rawText : "[Фото]", isMe: true, senderId: myId, receiverId: _currentContact.id, createdAt: DateTime.now(), status: MessageStatus.sending, + localFileBytes: _pendingImageBytes, replyToId: _replyTo?.id, replyToText: _replyTo?.text, ); setState(() { messages.add(localMessage); + _pendingImageBytes = null; // Очищаем черновик }); - // Формируем payload для сервера + // 5. Формируем финальный payload для сокета final payload = { "type": "private_message", "receiver_id": _currentContact.id, - "content": encryptedText, - "content50": encryptedText50, + "message_type": hasImage ? "image" : "text", + "content": encryptedContent, // Шифрованный текст (подпись) + "content50": encryptedContent50, // Шифрованное превью "temp_id": tempId, + if (hasImage) ...{ + "file_id": fileId, + "encrypted_key": encryptedFileKey, // Зашифрованный AES-ключ файла + }, if (_replyTo?.id != null) ...{ "reply_to_id": _replyTo!.id, "reply_to_text": _replyTo!.text, }, }; - // Отправляем - print("ОТПРАВКА: $payload"); - final ok = Provider.of(context, listen: false).sendMessage(payload); + // 6. Отправка через сокет + final ok = Provider.of( + context, + listen: false, + ).sendMessage(payload); - if (!mounted) return; + // Обновляем статус setState(() { final idx = messages.indexWhere((m) => m.tempId == tempId); - if (idx == -1) return; - messages[idx] = messages[idx].copyWith( - status: ok ? MessageStatus.sent : MessageStatus.failed, - ); + if (idx != -1) { + messages[idx] = messages[idx].copyWith( + status: ok ? MessageStatus.sent : MessageStatus.failed, + ); + } _replyTo = null; }); - - _controller.clear(); } catch (e) { + // В случае ошибки возвращаем текст в контроллер _controller.text = rawText; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text("Ошибка шифрования: $e"))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Ошибка отправки: $e"), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0), + duration: Duration(seconds: 5), + ), + ); } } @@ -425,6 +819,55 @@ class _ChatScreenState extends State { return; } + if (data['type'] == 'message_edited') { + final messageId = int.tryParse(data['message_id']?.toString() ?? ''); + final ts = DateTime.tryParse(data['edited_at']?.toString() ?? ''); + if (messageId == null) return; + + final myPrivKey = await _cryptoService.getPrivateKey(); + if (myPrivKey == null) return; + final sharedSecret = await _cryptoService.deriveSharedSecret( + myPrivKey, + _currentContact.publicKey!, + ); + final decryptedText = await _cryptoService.decryptMessage( + data['content'], + sharedSecret, + ); + + if (!mounted) return; + setState(() { + messages = messages.map((m) { + if (m.id != null && m.id == messageId) { + return m.copyWith(text: decryptedText, editedAt: ts); + } + return m; + }).toList(); + }); + + try { + await _localDbService.updateMessageContent( + messageId, + data['content'].toString(), + ts, + ); + } catch (_) {} + return; + } + + if (data['type'] == 'message_deleted') { + final messageId = int.tryParse(data['message_id']?.toString() ?? ''); + if (messageId == null) return; + if (!mounted) return; + setState(() { + messages.removeWhere((m) => m.id != null && m.id == messageId); + }); + try { + await _localDbService.deleteMessage(messageId); + } catch (_) {} + return; + } + if (data['type'] == 'message_read') { final messageId = int.tryParse(data['message_id'].toString()); if (messageId == null) return; @@ -449,14 +892,19 @@ class _ChatScreenState extends State { if (data['type'] == 'private_message') { final senderId = int.tryParse(data['sender_id']?.toString() ?? ''); - final receiverId = int.tryParse((data['receiver_id'] ?? data['recipient_id'])?.toString() ?? ''); + final receiverId = int.tryParse( + (data['receiver_id'] ?? data['recipient_id'])?.toString() ?? '', + ); if (senderId == null || receiverId == null) { - print('Invalid private_message ids: sender_id=${data['sender_id']} receiver_id=${data['receiver_id'] ?? data['recipient_id']}'); + print( + 'Invalid private_message ids: sender_id=${data['sender_id']} receiver_id=${data['receiver_id'] ?? data['recipient_id']}', + ); return; } // 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся - final isFromPartnerToMe = senderId == widget.contact.id && receiverId == myId; + final isFromPartnerToMe = + senderId == widget.contact.id && receiverId == myId; if (isFromPartnerToMe) { try { final myPrivKey = await _cryptoService.getPrivateKey(); @@ -478,8 +926,12 @@ class _ChatScreenState extends State { if (!mounted) return; final serverMessageId = int.tryParse(data['id']?.toString() ?? ''); - if (serverMessageId != null && !_sentReadReceipts.contains(serverMessageId)) { - Provider.of(context, listen: false).sendReadReceipt(serverMessageId); + if (serverMessageId != null && + !_sentReadReceipts.contains(serverMessageId)) { + Provider.of( + context, + listen: false, + ).sendReadReceipt(serverMessageId); _sentReadReceipts.add(serverMessageId); } @@ -493,8 +945,13 @@ class _ChatScreenState extends State { receiverId: myId, createdAt: DateTime.parse(data['timestamp']), status: MessageStatus.delivered, - replyToId: data['reply_to_id'] == null ? null : int.tryParse(data['reply_to_id'].toString()), - replyToText: data['reply_to_text'] == null ? null : data['reply_to_text'].toString(), + replyToId: data['reply_to_id'] == null + ? null + : int.tryParse(data['reply_to_id'].toString()), + replyToText: + data['reply_to_text'] != null + ? data['reply_to_text'].toString() + : null, ), ); }); @@ -520,7 +977,10 @@ class _ChatScreenState extends State { myPrivKey!, widget.contact.publicKey!, ); - final cached = await _localDbService.getChatHistory(widget.contact.id, myId); + final cached = await _localDbService.getChatHistory( + widget.contact.id, + myId, + ); try { List loadedLocalMessages = []; @@ -557,8 +1017,15 @@ class _ChatScreenState extends State { receiverId: msg['receiver_id'], createdAt: DateTime.parse(msg['timestamp']), status: status, - replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()), - replyToText: msg['reply_to_text'] == null ? null : msg['reply_to_text'].toString(), + replyToId: msg['reply_to_id'] == null + ? null + : int.tryParse(msg['reply_to_id'].toString()), + replyToText: msg['reply_to_text'] != null + ? msg['reply_to_text'].toString() + : null, + editedAt: msg['edited_at'] != null + ? DateTime.tryParse(msg['edited_at'].toString()) + : null, ), ); } @@ -618,8 +1085,15 @@ class _ChatScreenState extends State { receiverId: msg['receiver_id'], createdAt: DateTime.parse(msg['timestamp']), status: status, - replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()), - replyToText: msg['reply_to_text'] == null ? null : msg['reply_to_text'].toString(), + replyToId: msg['reply_to_id'] == null + ? null + : int.tryParse(msg['reply_to_id'].toString()), + replyToText: msg['reply_to_text'] != null + ? msg['reply_to_text'].toString() + : null, + editedAt: msg['edited_at'] != null + ? DateTime.tryParse(msg['edited_at'].toString()) + : null, ), ); } @@ -645,6 +1119,7 @@ class _ChatScreenState extends State { Provider.of(context, listen: false).sendReadReceipt(id); _sentReadReceipts.add(id); } + await _localDbService.deleteChatHistory(widget.contact.id, myId); } catch (e) { print("Ошибка загрузки истории: $e"); if (!mounted) return; diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 229049a..cba199a 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -1,4 +1,5 @@ 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'; @@ -13,8 +14,14 @@ 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 'package:chepuhagram/data/datasources/ws_client.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:package_info_plus/package_info_plus.dart'; +import 'package:open_filex/open_filex.dart'; class ContactsScreen extends StatefulWidget { final int? targetChatId; @@ -25,9 +32,14 @@ class ContactsScreen extends StatefulWidget { State createState() => _ContactsScreenState(); } -class _ContactsScreenState extends State { +class _ContactsScreenState extends State with RouteAware { static const String _notificationLaunchKey = 'notification_launch_data'; StreamSubscription? _socketSubscription; + bool _isDownloading = false; + double _downloadProgress = 0.0; + CancelToken? _cancelToken = CancelToken(); + String? _latestApkUrl; + bool _showUpdateBanner = false; @override void initState() { @@ -39,6 +51,9 @@ class _ContactsScreenState extends State { final contactProvider = context.read(); // Установить текущего пользователя и загрузить контакты с сообщениями + print( + 'Setting current user ID in ContactProvider: ${authProvider.currentUserId}', + ); contactProvider.setCurrentUserId(authProvider.currentUserId); contactProvider.loadContacts().then((_) { print('Contacts loaded, checking targetChatId: ${widget.targetChatId}'); @@ -50,6 +65,35 @@ class _ContactsScreenState extends State { } }); }); + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkAppUpdate(); + }); + } + + @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.loadContacts(); } Future _checkSavedNotificationTarget() async { @@ -89,7 +133,7 @@ class _ContactsScreenState extends State { _navigateToTargetChatWithId(widget.targetChatId!); } - void _navigateToTargetChatWithId(int targetChatId) { + void _navigateToTargetChatWithId(int targetChatId) async { print('_navigateToTargetChat called with targetChatId: $targetChatId'); final contactProvider = context.read(); try { @@ -98,15 +142,45 @@ class _ContactsScreenState extends State { ); print('Auto-navigating to chat with contact: ${contact.username}'); currentActiveChatContactId = targetChatId; // Устанавливаем активный чат - Navigator.push( + 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 _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']; + }); + } + } + } catch (e) { + print("Ошибка проверки обновлений: $e"); + } + } + Future _setupPushNotifications() async { // Request permissions await FirebaseMessaging.instance.requestPermission(); @@ -151,7 +225,7 @@ class _ContactsScreenState extends State { }); } - void _navigateToChatFromNotification(int senderId) { + void _navigateToChatFromNotification(int senderId) async { final contactProvider = context.read(); print('Navigate to chat from notification with senderId: $senderId'); @@ -173,10 +247,13 @@ class _ContactsScreenState extends State { ); print('Navigating to chat from notification: ${contact.username}'); currentActiveChatContactId = senderId; // Устанавливаем активный чат - Navigator.push( + 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'); @@ -249,14 +326,9 @@ class _ContactsScreenState extends State { } } - @override - void dispose() { - _socketSubscription?.cancel(); - super.dispose(); - } - @override Widget build(BuildContext context) { + final double fabBottomPadding = _showUpdateBanner ? 120.0 : 16.0; return Scaffold( appBar: AppBar( title: Text( @@ -267,46 +339,65 @@ class _ContactsScreenState extends State { elevation: 0, actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})], ), - body: Consumer( - builder: (context, contactProvider, child) { - if (contactProvider.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (contactProvider.error != null) { - return Center(child: Text('Error: ${contactProvider.error}')); - } - 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: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ChatScreen(contact: contact), - ), + body: Stack( + children: [ + Consumer( + builder: (context, contactProvider, child) { + if (contactProvider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (contactProvider.error != null) { + return Center(child: Text('Error: ${contactProvider.error}')); + } + 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: FloatingActionButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const NewChatScreen()), - ); - }, - child: Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurface), + floatingActionButton: AnimatedPadding( + duration: const Duration(milliseconds: 300), + 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( @@ -327,9 +418,17 @@ class _ContactsScreenState extends State { .join(); return UserAccountsDrawerHeader( - accountName: Text(displayName), + 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: Theme.of(context).colorScheme.onSurface, @@ -343,7 +442,7 @@ class _ContactsScreenState extends State { ), ), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, + color: Theme.of(context).colorScheme.inversePrimary, ), ); }, @@ -356,7 +455,7 @@ class _ContactsScreenState extends State { Navigator.pop(context); Navigator.push( context, - MaterialPageRoute(builder: (_) => const SettingsScreen()), + MaterialPageRoute(builder: (_) => SettingsScreen()), ); }, ), @@ -372,4 +471,157 @@ class _ContactsScreenState extends State { ), ); } + + Future _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 { + // Скачиваем файл «в лоб» + await Dio().download( + _latestApkUrl!, + path, + cancelToken: _cancelToken, + onReceiveProgress: (rec, total) { + if (total != -1) { + if (mounted) { + setState(() => _downloadProgress = rec / total); + } + } + }, + ); + + // После успешного скачивания — установка + 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); + } + } + } + + void _cancelDownload() { + _cancelToken?.cancel("Отменено"); + setState(() { + _isDownloading = false; + _downloadProgress = 0.0; + }); + } + + 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)}%' + : "Доступно новое обновление!", + 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; + }); + } 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, + ), + ], + ], + ), + ), + ), + ); + } } diff --git a/lib/presentation/screens/key_recovery_screen.dart b/lib/presentation/screens/key_recovery_screen.dart index 581a9f8..23046b4 100644 --- a/lib/presentation/screens/key_recovery_screen.dart +++ b/lib/presentation/screens/key_recovery_screen.dart @@ -70,7 +70,7 @@ class _KeyRecoveryScreenState extends State { // Скачиваем зашифрованный приватный ключ с сервера final response = await http.get( - Uri.http(AppConstants.baseUrl, 'users/me'), + Uri.parse('${AppConstants.baseUrl}/users/me'), headers: {'Authorization': 'Bearer $token'}, ); diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 371cfaf..b1dd0c4 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -5,10 +5,33 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '/logic/auth_provider.dart'; import '/core/theme_manager.dart'; +import 'package:package_info_plus/package_info_plus.dart'; -class SettingsScreen extends StatelessWidget { +class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + String? versionCode; + + @override + void initState() { + super.initState(); + _loadVersion(); + } + + void _loadVersion() async { + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + if (mounted) { + setState(() { + versionCode = packageInfo.version; + }); + } + } + @override Widget build(BuildContext context) { final themeProv = context.watch(); @@ -17,8 +40,18 @@ class SettingsScreen extends StatelessWidget { final accountEmail = authProv.email?.isNotEmpty == true ? authProv.email! : authProv.username?.isNotEmpty == true - ? '@${authProv.username!}' - : 'Не указано'; + ? '@${authProv.username!}' + : 'Не указано'; + + final username = authProv.username; + final displayName = authProv.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 Scaffold( appBar: AppBar(title: const Text("Настройки")), @@ -26,10 +59,23 @@ class SettingsScreen extends StatelessWidget { children: [ // Секция Профиля UserAccountsDrawerHeader( - accountName: Text(authProv.displayName), - accountEmail: Text(accountEmail), - currentAccountPicture: const CircleAvatar( - child: Icon(Icons.person, size: 40), + accountName: Text( + authProv.displayName, + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + ), + accountEmail: Text( + accountEmail, + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + ), + currentAccountPicture: CircleAvatar( + child: Text( + initials.isEmpty ? 'U' : initials, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), ), decoration: const BoxDecoration(color: Colors.transparent), ), @@ -82,7 +128,10 @@ class SettingsScreen extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - Icon(Icons.palette_outlined), + Icon( + Icons.palette_outlined, + color: Theme.of(context).colorScheme.onSurface, + ), SizedBox(width: 10), const Text("Цвет темы"), Spacer(), @@ -117,9 +166,9 @@ class SettingsScreen extends StatelessWidget { }, ), const Spacer(), - const Center( + Center( child: Text( - "Chepuhagram for Android v1.0.0", + "Chepuhagram for Android v$versionCode", style: TextStyle(color: Colors.grey, fontSize: 12), ), ), @@ -129,7 +178,7 @@ class SettingsScreen extends StatelessWidget { style: TextStyle(color: Colors.grey, fontSize: 12), ), ), - SizedBox(height: 10,) + const Spacer(), ], ), ); diff --git a/lib/presentation/screens/splash_screen.dart b/lib/presentation/screens/splash_screen.dart index 8f2b0ff..1f63a06 100644 --- a/lib/presentation/screens/splash_screen.dart +++ b/lib/presentation/screens/splash_screen.dart @@ -1,5 +1,9 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; import '../../logic/auth_provider.dart'; import '../../logic/contact_provider.dart'; import 'login_screen.dart'; @@ -21,7 +25,8 @@ class SplashScreen extends StatefulWidget { class _SplashScreenState extends State { int? _targetChatId; - + String? connectError; + // Ключ для SharedPreferences static const String _notificationLaunchKey = 'notification_launch_data'; @@ -39,7 +44,9 @@ class _SplashScreenState extends State { FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { print('App opened from notification: ${message.data}'); if (message.data['type'] == 'enc_message') { - final senderId = int.tryParse(message.data['sender_id']?.toString() ?? ''); + final senderId = int.tryParse( + message.data['sender_id']?.toString() ?? '', + ); if (senderId != null) { setState(() { _targetChatId = senderId; @@ -61,11 +68,37 @@ class _SplashScreenState extends State { final isLoggedIn = await authProvider.tryAutoLogin(); if (!mounted) return; - + bool connected = false; + int connectAttempt = 0; // 3. Навигация в зависимости от результата и статуса аккаунта if (isLoggedIn) { - await authProvider.initRealtime(); // Запускаем WebSocket сразу - + while (!connected) { + try { + await authProvider.initRealtime(); + connected = true; + } catch (e) { + connectAttempt++; + if (e.toString().contains('timeout')) { + setState(() { + connectError = + 'Превышено время ожидания.\n Проверьте интернет соеденение.\n Попытка соеденения: $connectAttempt'; + }); + } else if (e.toString().contains('Failed host lookup')) { + setState(() { + connectError = + 'Сервер недоступен. Проверьте интернет соеденение.\n Попытка соеденения: $connectAttempt'; + }); + } else { + setState(() { + connectError = e.toString().replaceAll('Exception: ', ''); + }); + } + + await Future.delayed(Duration(seconds: 2)); + } + } + await authProvider.refreshMe(); + // Определяем путь пользователя if (authProvider.needsSetup) { // Путь А: Первичная настройка @@ -82,24 +115,29 @@ class _SplashScreenState extends State { } else { // Путь Б: Нормальный вход в контакты // Проверяем, было ли приложение запущено из уведомления - int? targetChatId = _targetChatId; // Сначала проверяем из onMessageOpenedApp - + int? targetChatId = + _targetChatId; // Сначала проверяем из onMessageOpenedApp + // Если не установлено, проверяем SharedPreferences if (targetChatId == null) { final prefs = await SharedPreferences.getInstance(); final savedData = prefs.getString(_notificationLaunchKey); - + if (savedData != null) { try { final data = jsonDecode(savedData) as Map; print('Found saved notification data: $data'); - final senderId = int.tryParse(data['sender_id']?.toString() ?? ''); + 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')) { targetChatId = senderId; - print('App launched from saved notification, target chat: $targetChatId'); + print( + 'App launched from saved notification, target chat: $targetChatId', + ); } // Очищаем сохраненные данные после использования @@ -109,17 +147,21 @@ class _SplashScreenState extends State { await prefs.remove(_notificationLaunchKey); } } - + // Также проверяем initialMessage как fallback if (targetChatId == null) { print('Checking initialMessage: $initialMessage'); if (initialMessage != null) { print('Initial message data: ${initialMessage!.data}'); if (initialMessage!.data['type'] == 'enc_message') { - targetChatId = int.tryParse(initialMessage!.data['sender_id']?.toString() ?? ''); + targetChatId = int.tryParse( + initialMessage!.data['sender_id']?.toString() ?? '', + ); print('Set target chat from initialMessage: $targetChatId'); } else { - print('Initial message type is not enc_message: ${initialMessage!.data['type']}'); + print( + 'Initial message type is not enc_message: ${initialMessage!.data['type']}', + ); } } else { print('No initial message found'); @@ -130,29 +172,45 @@ class _SplashScreenState extends State { } if (targetChatId != null) { - print('Notification targetChatId resolved: $targetChatId, trying to open chat directly'); + print( + 'Notification targetChatId resolved: $targetChatId, trying to open chat directly', + ); try { final contactProvider = context.read(); contactProvider.setCurrentUserId(authProvider.currentUserId); await contactProvider.loadContacts(); - final contact = contactProvider.contacts.firstWhere((c) => c.id == targetChatId); + final contact = contactProvider.contacts.firstWhere( + (c) => c.id == targetChatId, + ); currentActiveChatContactId = targetChatId; - print('Directly navigating to ChatScreen for contact: ${contact.username}'); + print( + 'Directly navigating to ChatScreen for contact: ${contact.username}', + ); + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_notificationLaunchKey); Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)), ); return; } catch (e) { - print('Failed to open chat directly, falling back to ContactsScreen: $e'); + print( + 'Failed to open chat directly, falling back to ContactsScreen: $e', + ); } } print('Navigating to ContactsScreen, targetChatId: $targetChatId'); + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_notificationLaunchKey); Navigator.pushReplacement( context, - MaterialPageRoute(builder: (_) => ContactsScreen(targetChatId: targetChatId)), + MaterialPageRoute( + builder: (_) => ContactsScreen(targetChatId: targetChatId), + ), ); } } else { @@ -193,6 +251,15 @@ class _SplashScreenState extends State { CircularProgressIndicator( color: Theme.of(context).colorScheme.primary, ), + const SizedBox(height: 40), + Text( + connectError ?? '', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), const Spacer(), Text( 'Made by ArturKarasevich', @@ -201,7 +268,7 @@ class _SplashScreenState extends State { fontSize: 12, ), ), - const SizedBox(height: 40), + const SizedBox(height: 80), ], ), ), diff --git a/lib/presentation/widgets/contact_tile.dart b/lib/presentation/widgets/contact_tile.dart index a832376..c4b7f8f 100644 --- a/lib/presentation/widgets/contact_tile.dart +++ b/lib/presentation/widgets/contact_tile.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import '../../core/app_colors.dart'; import '/data/models/contact_model.dart'; class ContactTile extends StatelessWidget { @@ -8,10 +7,26 @@ class ContactTile extends StatelessWidget { const ContactTile({super.key, required this.contact, this.onTap}); + String get displayName { + final full = '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim(); + if (full.isNotEmpty) return full; + if ((contact.username != 'Unknown' ? contact.username : '').isNotEmpty) return contact.username!; + return 'User'; + } + @override Widget build(BuildContext context) { final primary = Theme.of(context).colorScheme.primary; + final username = contact.username; + final initials = (displayName.isNotEmpty ? displayName : (username != 'Unknown' ? username : 'U')) + .trim() + .split(RegExp(r'\s+')) + .where((p) => p.isNotEmpty) + .take(2) + .map((p) => p[0].toUpperCase()) + .join(); + return ListTile( onTap: onTap, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), @@ -19,8 +34,11 @@ class ContactTile extends StatelessWidget { radius: 28, backgroundColor: primary.withAlpha((0.1 * 255).round()), child: Text( - contact.name[0], - style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold) + initials, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), ), ), title: Text( @@ -31,7 +49,7 @@ class ContactTile extends StatelessWidget { contact.lastMessage ?? "Нет сообщений", maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: AppColors.textSecondary), + style: const TextStyle(color: Colors.grey), ), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -39,13 +57,19 @@ class ContactTile extends StatelessWidget { children: [ Text( _formatTime(contact.lastMessageTime), - style: const TextStyle(color: AppColors.textSecondary, fontSize: 12), + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), ), const SizedBox(height: 4), if (contact.unreadCount > 0) Container( padding: const EdgeInsets.all(6), - decoration: BoxDecoration(color: primary.withAlpha((0.5 * 255).round()), shape: BoxShape.circle), + decoration: BoxDecoration( + color: primary.withAlpha((0.5 * 255).round()), + shape: BoxShape.circle, + ), child: Text( '${contact.unreadCount}', style: const TextStyle(color: Colors.white, fontSize: 10), @@ -60,4 +84,4 @@ class ContactTile extends StatelessWidget { if (time == null) return ""; return "${time.hour}:${time.minute.toString().padLeft(2, '0')}"; } -} \ No newline at end of file +} diff --git a/lib/presentation/widgets/message_bubble.dart b/lib/presentation/widgets/message_bubble.dart index 3258c16..a224bf0 100644 --- a/lib/presentation/widgets/message_bubble.dart +++ b/lib/presentation/widgets/message_bubble.dart @@ -1,5 +1,9 @@ import 'package:flutter/material.dart'; import '/data/models/message_model.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:provider/provider.dart'; +import '/core/theme_manager.dart'; class MessageBubble extends StatelessWidget { final MessageModel message; @@ -14,6 +18,7 @@ class MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { final isMe = message.isMe; + final themeProv = context.watch(); return Align( // Выравниваем вправо, если это мое сообщение, и влево — если чужое alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, @@ -39,7 +44,7 @@ class MessageBubble extends StatelessWidget { ), decoration: BoxDecoration( color: isMe - ? Theme.of(context).colorScheme.primary + ? Theme.of(context).colorScheme.primaryFixedDim : Colors.grey[300], borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), @@ -61,7 +66,7 @@ class MessageBubble extends StatelessWidget { borderRadius: BorderRadius.circular(8), border: Border( left: BorderSide( - color: isMe ? Colors.white70 : Colors.black38, + color: isMe ? Colors.black54 : Colors.black38, width: 2, ), ), @@ -72,7 +77,7 @@ class MessageBubble extends StatelessWidget { Icon( Icons.reply, size: 14, - color: isMe ? Colors.white70 : Colors.black54, + color: isMe ? Colors.black54 : Colors.black54, ), const SizedBox(width: 4), Expanded( @@ -81,7 +86,7 @@ class MessageBubble extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: isMe ? Colors.white70 : Colors.black54, + color: isMe ? const Color.fromARGB(221, 21, 21, 21) : const Color.fromARGB(221, 21, 21, 21), fontSize: 12, fontStyle: FontStyle.italic, ), @@ -91,12 +96,16 @@ class MessageBubble extends StatelessWidget { ), ), ], - Text( - message.text, - style: TextStyle( - color: isMe ? Colors.white : Colors.black87, - fontSize: 16, - ), + Linkify( + onOpen: (link) async { + final Uri url = Uri.parse(link.url); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + throw Exception('Could not launch $url'); + } + }, + text: message.text, + style: TextStyle(color: isMe ? (themeProv.isLight ? Colors.black : Colors.black) : Colors.black), + linkStyle: TextStyle(color: const Color.fromARGB(255, 10, 87, 123), fontWeight: FontWeight.bold), ), const SizedBox(height: 4), Row( @@ -105,10 +114,21 @@ class MessageBubble extends StatelessWidget { Text( _formatTime(message.createdAt), style: TextStyle( - color: isMe ? Colors.white70 : Colors.black54, + color: isMe ? Colors.black87 : Colors.black54, fontSize: 10, ), ), + if (message.editedAt != null) ...[ + const SizedBox(width: 6), + Text( + '(изменено)', + style: TextStyle( + color: isMe ? Colors.black54 : Colors.black54, + fontSize: 10, + fontStyle: FontStyle.italic, + ), + ), + ], if (isMe) ...[ const SizedBox(width: 6), Icon( diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d0e7f79..3ccd551 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,18 @@ #include "generated_plugin_registrant.h" +#include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index ce58916..fbedf4a 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8f8e0ca..c1d2f3b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,24 +5,32 @@ import FlutterMacOS import Foundation +import file_selector_macos import firebase_analytics import firebase_core import firebase_messaging +import flutter_image_compress_macos import flutter_local_notifications import flutter_secure_storage_darwin import local_auth_darwin +import package_info_plus import path_provider_foundation import shared_preferences_foundation import sqflite_darwin +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index bedff5d..0d2d4b2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -89,6 +97,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" fake_async: dependency: transitive description: @@ -113,6 +137,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" firebase_analytics: dependency: "direct main" description: @@ -190,6 +246,62 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_image_compress: + dependency: "direct main" + description: + name: flutter_image_compress + sha256: "51d23be39efc2185e72e290042a0da41aed70b14ef97db362a6b5368d0523b27" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + flutter_image_compress_common: + dependency: transitive + description: + name: flutter_image_compress_common + sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb + url: "https://pub.dev" + source: hosted + version: "1.0.6" + flutter_image_compress_macos: + dependency: transitive + description: + name: flutter_image_compress_macos + sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + flutter_image_compress_ohos: + dependency: transitive + description: + name: flutter_image_compress_ohos + sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 + url: "https://pub.dev" + source: hosted + version: "0.0.3" + flutter_image_compress_platform_interface: + dependency: transitive + description: + name: flutter_image_compress_platform_interface + sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + flutter_image_compress_web: + dependency: transitive + description: + name: flutter_image_compress_web + sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96 + url: "https://pub.dev" + source: hosted + version: "0.1.5" + flutter_linkify: + dependency: "direct main" + description: + name: flutter_linkify + sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" + url: "https://pub.dev" + source: hosted + version: "6.0.0" flutter_lints: dependency: "direct dev" description: @@ -304,6 +416,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622" + url: "https://pub.dev" + source: hosted + version: "0.8.13+16" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: transitive description: @@ -368,6 +544,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + linkify: + dependency: transitive + description: + name: linkify + sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" + url: "https://pub.dev" + source: hosted + version: "5.0.0" lints: dependency: transitive description: @@ -440,6 +624,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" nested: dependency: transitive description: @@ -448,6 +640,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900" + url: "https://pub.dev" + source: hosted + version: "4.7.0" package_config: dependency: transitive description: @@ -456,6 +656,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: "direct main" description: @@ -717,6 +933,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cc69a2b..9ee5eff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 2.0.0+1 environment: sdk: ^3.10.0 @@ -48,6 +48,13 @@ dependencies: flutter_local_notifications: ^17.2.2 firebase_analytics: ^10.10.7 shared_preferences: ^2.5.5 + flutter_linkify: ^6.0.0 + url_launcher: ^6.3.2 + image_picker: ^1.0.4 + flutter_image_compress: ^2.1.0 + dio: ^5.9.2 + package_info_plus: ^9.0.1 + open_filex: ^4.3.2 dev_dependencies: flutter_test: diff --git a/srv/app/api/endpoints/auth.py b/srv/app/api/endpoints/auth.py index 38db9ec..705d7f5 100644 --- a/srv/app/api/endpoints/auth.py +++ b/srv/app/api/endpoints/auth.py @@ -26,7 +26,11 @@ authRouter = APIRouter( @authRouter.post("/register") -async def register(username: str, password: str, db: Session = Depends(get_db)): +async def register(username: str, password: str, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)): + if current_user.id != 1: + raise HTTPException( + status_code=403, detail='Forbidden' + ) if len(password.encode('utf-8')) > 72: raise HTTPException( status_code=400, detail="Пароль слишком длинный (макс. 72 байта)") @@ -41,7 +45,7 @@ async def register(username: str, password: str, db: Session = Depends(get_db)): new_user = models.User(username=username, hashed_password=hashed_pwd) db.add(new_user) db.commit() - return {"status": "ok", "message": "User created"} + return {"status": "ok", "message": "User created", "id": new_user.id} @authRouter.post("/hash") @@ -106,10 +110,11 @@ async def setup_account(data: schemas.SetupAccount, current_user: models.User = db.refresh(user_to_update) return {"status": "ok", "message": "Account setup completed"} + @authRouter.post("/update-fcm") async def update_fcm(token: str, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)): user_to_update = db.merge(current_user) user_to_update.fcm_token = token db.commit() db.refresh(user_to_update) - return {"status": "ok"} \ No newline at end of file + return {"status": "ok"} diff --git a/srv/app/api/endpoints/media.py b/srv/app/api/endpoints/media.py new file mode 100644 index 0000000..230895e --- /dev/null +++ b/srv/app/api/endpoints/media.py @@ -0,0 +1,53 @@ +from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, File, UploadFile +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from app.core import security +from app.api import schemas +from app.db import models +from jose import JWTError, jwt +from app.core.security import get_current_user +import os +import uuid +# бд + + +def get_db(): + db = models.SessionLocal() + try: + yield db + finally: + db.close() + + +mediaRouter = APIRouter( + prefix="/media", + tags=[], +) + +UPLOAD_FOLDER = 'uploads' +if not os.path.exists(UPLOAD_FOLDER): + os.makedirs(UPLOAD_FOLDER) + + +@mediaRouter.post('/upload') +async def upload_file(file: UploadFile = File(...)): + # Проверяем, есть ли файл в запросе + if not file.filename: + raise HTTPException(status_code=400, detail="No selected file") + + # Генерируем уникальное имя, чтобы файлы не перезаписывались + file_id = str(uuid.uuid4()) + filename = f"{file_id}.enc" + file_path = os.path.join(UPLOAD_FOLDER, filename) + + # Сохраняем + with open(file_path, "wb") as f: + content = await file.read() + f.write(content) + + print(f"Файл сохранен: {file_path}") + + return { + "status": "ok", + "file_id": file_id + } \ No newline at end of file diff --git a/srv/app/api/endpoints/users.py b/srv/app/api/endpoints/users.py index ec9dd2f..91b3106 100644 --- a/srv/app/api/endpoints/users.py +++ b/srv/app/api/endpoints/users.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session from app.db import models from app.core.security import get_current_user from app.api import schemas -from sqlalchemy import or_, and_ +from sqlalchemy import or_, and_, exists from sqlalchemy.exc import IntegrityError @@ -47,7 +47,7 @@ async def update_users_me( db: Session = Depends(get_db), ): user_to_update = db.merge(current_user) - + if data.username is not None: user_to_update.username = data.username if data.first_name is not None: @@ -65,7 +65,8 @@ async def update_users_me( db.commit() except IntegrityError: db.rollback() - raise HTTPException(status_code=400, detail="phone/email already in use") + raise HTTPException( + status_code=400, detail="phone/email already in use") db.refresh(user_to_update) return { @@ -95,7 +96,8 @@ async def update_encrypted_private_key( db.commit() except Exception: db.rollback() - raise HTTPException(status_code=500, detail="Не удалось сохранить ключ шифрования") + raise HTTPException( + status_code=500, detail="Не удалось сохранить ключ шифрования") db.refresh(user_to_update) return {"status": "ok"} @@ -119,7 +121,8 @@ async def change_password( db.commit() except Exception: db.rollback() - raise HTTPException(status_code=500, detail="Не удалось изменить пароль") + raise HTTPException( + status_code=500, detail="Не удалось изменить пароль") db.refresh(user_to_update) return {"status": "ok"} @@ -132,7 +135,7 @@ async def update_privacy_settings( db: Session = Depends(get_db), ): user_to_update = db.merge(current_user) - + if data.show_email is not None: user_to_update.show_email = 1 if data.show_email else 0 if data.show_phone is not None: @@ -148,7 +151,8 @@ async def update_privacy_settings( db.commit() except Exception: db.rollback() - raise HTTPException(status_code=500, detail="Не удалось сохранить настройки конфиденциальности") + raise HTTPException( + status_code=500, detail="Не удалось сохранить настройки конфиденциальности") db.refresh(user_to_update) return {"status": "ok"} @@ -185,9 +189,19 @@ async def read_users_chats( last_message возвращается в том виде, как хранится в БД (зашифрованный content). Клиент должен расшифровать превью локально. """ + + users = ( db.query(models.User) .filter(models.User.id != current_user.id) + .filter(exists().where( + or_( + and_(models.Message.sender_id == current_user.id, + models.Message.receiver_id == models.User.id), + and_(models.Message.sender_id == models.User.id, + models.Message.receiver_id == current_user.id) + ) + )) .all() ) @@ -233,6 +247,7 @@ async def read_users_chats( } ) + result.sort(key=lambda x: x['last_message_time'] or '', reverse=True) return result @@ -259,7 +274,7 @@ def get_user_by_id( # Проверяем настройки конфиденциальности if user.show_username: profile_data["username"] = user.username - + if user.show_avatar: # Для аватара пока просто передаем имя, клиент сам сгенерирует аватар profile_data["first_name"] = user.first_name diff --git a/srv/app/core/security.py b/srv/app/core/security.py index db039a2..4e841e7 100644 --- a/srv/app/core/security.py +++ b/srv/app/core/security.py @@ -2,21 +2,19 @@ from fastapi import FastAPI, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from datetime import datetime, timedelta from jose import jwt -from passlib.context import CryptContext import hashlib from sqlalchemy.orm import Session from app.db import models from dotenv import load_dotenv from jose import JWTError, jwt import os - +import bcrypt load_dotenv() SECRET_KEY = os.getenv("JWT_KEY").strip() ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60 -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") # бд @@ -28,10 +26,13 @@ def get_db(): db.close() def verify_password(plain_password, hashed_password): - return pwd_context.verify(plain_password, hashed_password) + try: + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password) + except TypeError: + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) def get_password_hash(password): - return pwd_context.hash(password) + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) def create_access_token(data: dict): to_encode = data.copy() diff --git a/srv/app/db/models.py b/srv/app/db/models.py index 49fccd5..9c78909 100644 --- a/srv/app/db/models.py +++ b/srv/app/db/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, create_engine +from sqlalchemy import Column, Integer, String, Sequence, create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime @@ -13,7 +13,7 @@ Base.metadata.create_all(bind=engine) class User(Base): __tablename__ = "users" - id = Column(Integer, primary_key=True, index=True) + id = Column(Integer, Sequence('user_id_seq', start=100), primary_key=True, index=True) first_name = Column(String(50), nullable=False, server_default="User") last_name = Column(String(50), nullable=True) username = Column(String, unique=True, index=True) @@ -44,6 +44,7 @@ class Message(Base): read_at = Column(DateTime(timezone=True), nullable=True) reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True) reply_to_text = Column(Text, nullable=True) + edited_at = Column(DateTime(timezone=True), nullable=True) Base.metadata.create_all(bind=engine) @@ -63,6 +64,8 @@ def _ensure_sqlite_message_columns(): conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_id INTEGER REFERENCES messages(id)")) if "reply_to_text" not in existing: conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT")) + if "edited_at" not in existing: + conn.execute(text("ALTER TABLE messages ADD COLUMN edited_at DATETIME")) conn.commit() diff --git a/srv/app/websocket/connection_manager.py b/srv/app/websocket/connection_manager.py index bef171b..b494769 100644 --- a/srv/app/websocket/connection_manager.py +++ b/srv/app/websocket/connection_manager.py @@ -133,6 +133,76 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: except Exception: db.rollback() + elif message_data.get("type") == "edit_message": + message_id = message_data.get("message_id") + content = message_data.get("content") + if message_id is None or content is None: + await websocket.send_json({ + "type": "error", + "detail": "message_id/content required", + }) + continue + try: + message_id = int(message_id) + except (TypeError, ValueError): + await websocket.send_json({ + "type": "error", + "detail": "message_id must be int", + }) + continue + msg = db.query(models.Message).filter(models.Message.id == message_id).first() + if msg is None or msg.sender_id != user_id: + continue + try: + msg.content = content + msg.edited_at = datetime.now() + db.add(msg) + db.commit() + except Exception: + db.rollback() + continue + event = { + "type": "message_edited", + "message_id": msg.id, + "content": msg.content, + "edited_at": msg.edited_at.isoformat() if msg.edited_at else None, + } + await manager.send_personal_message(event, str(msg.receiver_id)) + await manager.send_personal_message(event, str(msg.sender_id)) + + elif message_data.get("type") == "delete_message": + message_id = message_data.get("message_id") + if message_id is None: + await websocket.send_json({ + "type": "error", + "detail": "message_id required", + }) + continue + try: + message_id = int(message_id) + except (TypeError, ValueError): + await websocket.send_json({ + "type": "error", + "detail": "message_id must be int", + }) + continue + msg = db.query(models.Message).filter(models.Message.id == message_id).first() + if msg is None or msg.sender_id != user_id: + continue + receiver_id = msg.receiver_id + try: + db.delete(msg) + db.commit() + except Exception: + db.rollback() + continue + event = { + "type": "message_deleted", + "message_id": message_id, + } + await manager.send_personal_message(event, str(receiver_id)) + await manager.send_personal_message(event, str(user_id)) + elif message_data.get("type") == "read_receipt": message_id = message_data.get("message_id") try: diff --git a/srv/main.py b/srv/main.py index 1083559..eae1369 100644 --- a/srv/main.py +++ b/srv/main.py @@ -1,13 +1,16 @@ from fastapi import FastAPI -from app.api.endpoints import users, auth, messages +from fastapi.responses import FileResponse +from app.api.endpoints import users, auth, messages, media from app.websocket.connection_manager import wsRouter from fastapi.middleware.cors import CORSMiddleware +import os app = FastAPI() app.include_router(auth.authRouter) app.include_router(users.usersRouter) app.include_router(messages.messagesRouter) +app.include_router(media.mediaRouter) app.include_router(wsRouter) app.add_middleware( @@ -18,6 +21,38 @@ app.add_middleware( allow_headers=["*"], ) + +@app.get("/check-update") +async def check_update(): + return { + "latest_version": "2.0.0", + "apk_url": "https://api.chepuhagram.ru/get-update", + "force_update": False + } + + +@app.get("/get-update") +async def get_image(): + file_path = "app-release.apk" + if not os.path.exists(file_path): + return {"error": "Файл не найден"} + + return FileResponse(path=file_path, filename="chepuhagram-release.apk", + media_type="application/vnd.android.package-archive",) + + +@app.head("/get-update") +async def head_image(): + file_path = "app-release.apk" + if not os.path.exists(file_path): + return {"error": "Файл не найден"} + + return FileResponse( + path=file_path, + filename="chepuhagram-release.apk", + media_type="application/vnd.android.package-archive" + ) + if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8587) \ No newline at end of file + uvicorn.run(app, host="0.0.0.0", port=8587) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8baacc7..c1ddf6d 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,15 +6,21 @@ #include "generated_plugin_registrant.h" +#include #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalAuthPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalAuthPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 09806b2..21c3cff 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows firebase_core flutter_secure_storage_windows local_auth_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST