From 2d28fcc1fe3af2905683a0bf37507311e427684d Mon Sep 17 00:00:00 2001 From: Artur Date: Sun, 26 Apr 2026 21:20:03 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D0=B5=D0=B9,=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F.=20=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .metadata | 15 - android/app/src/main/AndroidManifest.xml | 5 +- .../kotlin/ru/chepuhagram/app/MainActivity.kt | 4 +- ios/Runner/Info.plist | 2 + lib/data/datasources/local_db_service.dart | 68 ++- lib/data/datasources/ws_client.dart | 21 +- lib/data/models/contact_model.dart | 40 +- lib/data/models/message_model.dart | 87 +++- lib/data/repositories/contact_repository.dart | 24 +- lib/domain/services/api_service.dart | 137 ++++++ lib/domain/services/crypto_service.dart | 17 + lib/logic/auth_provider.dart | 77 ++++ lib/logic/contact_provider.dart | 124 +++++- lib/main.dart | 75 +++- .../screens/account_settings_screen.dart | 167 ++++++++ lib/presentation/screens/chat_screen.dart | 398 ++++++++++++++++-- lib/presentation/screens/contacts_screen.dart | 88 ++-- .../screens/privacy_settings_menu_screen.dart | 44 ++ .../screens/privacy_settings_screen.dart | 190 +++++++++ .../screens/security_settings_screen.dart | 316 ++++++++++++++ lib/presentation/screens/settings_screen.dart | 50 ++- .../screens/user_profile_screen.dart | 187 ++++++++ lib/presentation/widgets/contact_tile.dart | 4 +- lib/presentation/widgets/message_bubble.dart | 167 ++++++-- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 56 +++ pubspec.yaml | 1 + srv/alembic/versions/fec40bfbf131_.py | 32 ++ srv/app/api/endpoints/messages.py | 3 +- srv/app/api/endpoints/users.py | 234 +++++++++- srv/app/api/schemas.py | 38 +- srv/app/db/models.py | 63 ++- srv/app/websocket/connection_manager.py | 112 ++++- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 35 files changed, 2651 insertions(+), 201 deletions(-) create mode 100644 lib/presentation/screens/account_settings_screen.dart create mode 100644 lib/presentation/screens/privacy_settings_menu_screen.dart create mode 100644 lib/presentation/screens/privacy_settings_screen.dart create mode 100644 lib/presentation/screens/security_settings_screen.dart create mode 100644 lib/presentation/screens/user_profile_screen.dart create mode 100644 srv/alembic/versions/fec40bfbf131_.py diff --git a/.metadata b/.metadata index 2c6187b..defd946 100644 --- a/.metadata +++ b/.metadata @@ -18,21 +18,6 @@ migration: - platform: android create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 - - platform: ios - create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 - base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 - - platform: linux - create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 - base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 - - platform: macos - create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 - base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 - - platform: web - create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 - base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 - - platform: windows - create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 - base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 # User provided section diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8849fc9..315825c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,14 @@ + + + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> LaunchScreen UIMainStoryboardFile Main + NSFaceIDUsageDescription + Используется для подтверждения доступа к ключу шифрования UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/lib/data/datasources/local_db_service.dart b/lib/data/datasources/local_db_service.dart index 41aa72e..8cc717e 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: 1, + version: 3, onCreate: (db, version) async { await db.execute(''' CREATE TABLE messages( @@ -27,10 +27,24 @@ class LocalDbService { sender_id INTEGER, receiver_id INTEGER, content TEXT, - timestamp TEXT + timestamp TEXT, + delivered_at TEXT, + read_at TEXT, + reply_to_id INTEGER, + reply_to_text TEXT ) '''); }, + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 2) { + await db.execute('ALTER TABLE messages ADD COLUMN delivered_at TEXT'); + 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'); + } + }, ); } @@ -46,6 +60,8 @@ class LocalDbService { 'receiver_id': msg.receiverId, 'content': msg.text, // ВАЖНО: сохраняй зашифрованный текст! 'timestamp': msg.createdAt.toIso8601String(), + 'delivered_at': null, + 'read_at': null, }, conflictAlgorithm: ConflictAlgorithm.replace); } else { // Если это Map из API @@ -55,6 +71,10 @@ class LocalDbService { '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'], }, conflictAlgorithm: ConflictAlgorithm.replace); } } @@ -75,4 +95,48 @@ class LocalDbService { orderBy: 'timestamp ASC', ); } + + Future?> getLastMessage(int contactId, int myId) async { + final db = await database; + final rows = await db.query( + 'messages', + columns: ['sender_id', 'receiver_id', 'content', 'timestamp'], + where: + '(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)', + whereArgs: [contactId, myId, myId, contactId], + orderBy: 'timestamp DESC', + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first; + } + + Future updateDeliveredAt(int messageId, DateTime deliveredAt) async { + final db = await database; + await db.update( + 'messages', + {'delivered_at': deliveredAt.toIso8601String()}, + where: 'id = ?', + whereArgs: [messageId], + ); + } + + Future updateReadAt(int messageId, DateTime readAt) async { + final db = await database; + await db.update( + 'messages', + {'read_at': readAt.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 1cf79d8..e2f82fd 100644 --- a/lib/data/datasources/ws_client.dart +++ b/lib/data/datasources/ws_client.dart @@ -23,6 +23,10 @@ class SocketService { Future connect(ApiService apiService) async { final token = await apiService.getAccessToken(); if (_channel != null) return; // Уже подключены + if (token == null || token.isEmpty) { + print('❌ SocketService.connect: no access token, skipping connect'); + return; + } // В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке final uri = Uri.parse("ws://${AppConstants.baseUrl}/ws?token=$token"); @@ -32,6 +36,7 @@ class SocketService { _channel!.stream.listen( (data) { final decoded = jsonDecode(data); + print("🚀 СООБЩЕНИЕ ПОЛУЧЕНО ИЗ SINK: $decoded"); _messageController.add(decoded); }, onError: (error) => _reconnect(apiService), @@ -44,10 +49,11 @@ class SocketService { Future.delayed(const Duration(seconds: 5), () => connect(apiService)); } - void sendMessage(Map data) { + bool sendMessage(Map data, {int retryCnt = 0}) { if (_channel == null) { - print("❌ ОШИБКА: Попытка отправить сообщение через NULL канал."); - return; + //print("❌ ОШИБКА: Попытка отправить сообщение через NULL канал."); + sendMessage(data, retryCnt: retryCnt + 1); + return false; } try { final encodedData = jsonEncode(data); @@ -57,11 +63,20 @@ class SocketService { // 2. Добавляем принт подтверждения print("🚀 СООБЩЕНИЕ ОТПРАВЛЕНО В SINK: $encodedData"); + return true; } catch (e) { print("❌ КРИТИЧЕСКАЯ ОШИБКА ПРИ ОТПРАВКЕ: $e"); + return false; } } + bool sendReadReceipt(int messageId) { + return sendMessage({ + 'type': 'read_receipt', + 'message_id': messageId, + }); + } + void disconnect() { _channel?.sink.close(status.goingAway); _channel = null; diff --git a/lib/data/models/contact_model.dart b/lib/data/models/contact_model.dart index ca38142..ecf6443 100644 --- a/lib/data/models/contact_model.dart +++ b/lib/data/models/contact_model.dart @@ -23,13 +23,51 @@ class Contact { this.publicKey, }); + Contact copyWith({ + int? id, + String? username, + String? name, + String? surname, + String? lastMessage, + String? avatarUrl, + DateTime? lastMessageTime, + bool? isOnline, + int? unreadCount, + String? publicKey, + }) { + return Contact( + id: id ?? this.id, + username: username ?? this.username, + name: name ?? this.name, + surname: surname ?? this.surname, + lastMessage: lastMessage ?? this.lastMessage, + avatarUrl: avatarUrl ?? this.avatarUrl, + lastMessageTime: lastMessageTime ?? this.lastMessageTime, + isOnline: isOnline ?? this.isOnline, + unreadCount: unreadCount ?? this.unreadCount, + publicKey: publicKey ?? this.publicKey, + ); + } + factory Contact.fromJson(Map json) { + DateTime? parseTime(dynamic value) { + if (value == null) return null; + if (value is DateTime) return value; + final asString = value.toString(); + return DateTime.tryParse(asString); + } + return Contact( id: json['id'], username: json['username'] ?? 'Unknown', name: json['name'] ?? 'Unknown', surname: json['surname'] ?? 'Unknown', + lastMessage: json['last_message'] ?? json['lastMessage'], + avatarUrl: json['avatar_url'] ?? json['avatarUrl'], + lastMessageTime: parseTime(json['last_message_time'] ?? json['lastMessageTime']), + isOnline: (json['is_online'] ?? json['isOnline']) == true, + unreadCount: int.tryParse((json['unread_count'] ?? json['unreadCount'] ?? 0).toString()) ?? 0, publicKey: json['public_key'], ); } -} \ No newline at end of file +} diff --git a/lib/data/models/message_model.dart b/lib/data/models/message_model.dart index 88dd875..ad189c6 100644 --- a/lib/data/models/message_model.dart +++ b/lib/data/models/message_model.dart @@ -1,41 +1,86 @@ +enum MessageStatus { sending, sent, delivered, read, failed } + class MessageModel { - final int? id; // ID из базы данных (null, если сообщение еще не отправлено) - final int senderId; // ID отправителя - final int receiverId; // ID отправителя - final String text; // Текст сообщения - final DateTime createdAt; // Время отправки - final bool isMe; // Локальный флаг для UI (мое/чужое) + final int? id; // server id (null пока не подтверждено сервером) + final int? tempId; // client temp id (для сопоставления ack) + final int senderId; + final int receiverId; + final String text; // текст для UI (у нас уже расшифрованный) + final DateTime createdAt; + final bool isMe; + final MessageStatus status; + final int? replyToId; // ID сообщения, на которое отвечают + final String? replyToText; // текст сообщения, на которое отвечают (для отображения) MessageModel({ this.id, + this.tempId, required this.senderId, required this.receiverId, required this.text, required this.createdAt, - this.isMe = false, + required this.isMe, + this.status = MessageStatus.sent, + this.replyToId, + this.replyToText, }); - // Превращаем JSON от бэкенда в объект Dart - factory MessageModel.fromJson(Map json, int currentUserId) { + MessageModel copyWith({ + int? id, + int? tempId, + int? senderId, + int? receiverId, + String? text, + DateTime? createdAt, + bool? isMe, + MessageStatus? status, + int? replyToId, + String? replyToText, + }) { return MessageModel( - id: json['id'], - senderId: json['sender_id'], - receiverId: json['receiverId'], - text: json['text'] ?? '', - // Парсим дату из ISO строки или временной метки - createdAt: DateTime.parse(json['created_at']), - // Сразу вычисляем, наше ли это сообщение - isMe: json['sender_id'] == currentUserId, + id: id ?? this.id, + tempId: tempId ?? this.tempId, + senderId: senderId ?? this.senderId, + receiverId: receiverId ?? this.receiverId, + text: text ?? this.text, + createdAt: createdAt ?? this.createdAt, + isMe: isMe ?? this.isMe, + status: status ?? this.status, + replyToId: replyToId ?? this.replyToId, + replyToText: replyToText ?? this.replyToText, + ); + } + + factory MessageModel.fromJson(Map json, int currentUserId) { + final senderId = int.parse(json['sender_id'].toString()); + final receiverId = int.parse((json['receiver_id'] ?? json['recipient_id']).toString()); + final createdAtRaw = (json['created_at'] ?? json['timestamp']).toString(); + + return MessageModel( + id: json['id'] == null ? null : int.tryParse(json['id'].toString()), + tempId: json['temp_id'] == null ? null : int.tryParse(json['temp_id'].toString()), + senderId: senderId, + receiverId: receiverId, + text: (json['text'] ?? json['content'] ?? '').toString(), + createdAt: DateTime.tryParse(createdAtRaw) ?? DateTime.now(), + isMe: senderId == currentUserId, + 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(), ); } - // Превращаем объект Dart в JSON для отправки через WebSocket или API Map toJson() { return { - 'text': text, + 'id': id, + 'temp_id': tempId, 'sender_id': senderId, - // На бэкенд обычно отправляем строку в формате ISO 8601 + 'receiver_id': receiverId, + 'text': text, 'created_at': createdAt.toIso8601String(), + 'status': status.name, + 'reply_to_id': replyToId, + 'reply_to_text': replyToText, }; } -} \ No newline at end of file +} diff --git a/lib/data/repositories/contact_repository.dart b/lib/data/repositories/contact_repository.dart index 1e8dfe0..4665afd 100644 --- a/lib/data/repositories/contact_repository.dart +++ b/lib/data/repositories/contact_repository.dart @@ -8,7 +8,29 @@ class ContactRepository { final http.Client _client = http.Client(); final ApiService _apiService = ApiService(); - Future> fetchContacts() async { + Future> fetchChatContacts() async { + final token = await _apiService.getAccessToken(); + if (token == null) { + throw Exception('No access token'); + } + + final response = await _client.get( + Uri.http(AppConstants.baseUrl, 'users/chats'), + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + ); + + if (response.statusCode == 200) { + final List data = jsonDecode(utf8.decode(response.bodyBytes)); + return data.map((json) => Contact.fromJson(json)).toList(); + } else { + throw Exception('Failed to load contacts'); + } + } + + Future> fetchAllUsers() async { final token = await _apiService.getAccessToken(); if (token == null) { throw Exception('No access token'); diff --git a/lib/domain/services/api_service.dart b/lib/domain/services/api_service.dart index 63d5443..49618a5 100644 --- a/lib/domain/services/api_service.dart +++ b/lib/domain/services/api_service.dart @@ -117,6 +117,53 @@ class ApiService extends ChangeNotifier { } } + Future> getMe() async { + final token = await getAccessToken(); + final response = await _client.get( + Uri.http(AppConstants.baseUrl, 'users/me'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode == 200) { + return jsonDecode(utf8.decode(response.bodyBytes)) as Map; + } + throw Exception('Не удалось получить данные пользователя'); + } + + Future updateEncryptedPrivateKey(String encryptedPrivateKey) async { + final token = await getAccessToken(); + final response = await _client.put( + Uri.http(AppConstants.baseUrl, 'users/me/encryption-key'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: jsonEncode({'encrypted_private_key': encryptedPrivateKey}), + ); + + return response.statusCode == 200; + } + + Future changePassword(String currentPassword, String newPassword) async { + final token = await getAccessToken(); + final response = await _client.put( + Uri.http(AppConstants.baseUrl, 'users/me/password'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + 'current_password': currentPassword, + 'new_password': newPassword, + }), + ); + + return response.statusCode == 200; + } + Future> getChatHistory(int contactId) async { final token = await getAccessToken(); final response = await http.get( @@ -131,4 +178,94 @@ class ApiService extends ChangeNotifier { ); return jsonDecode(response.body) as List; } + + Future> updateMe({ + required String username, + required String firstName, + required String lastName, + String? phone, + String? email, + String? about, + }) async { + final token = await getAccessToken(); + final response = await _client.put( + Uri.http(AppConstants.baseUrl, 'users/me'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + 'username': username, + 'first_name': firstName, + 'last_name': lastName, + 'phone': (phone == null || phone.trim().isEmpty) ? null : phone.trim(), + 'email': (email == null || email.trim().isEmpty) ? null : email.trim(), + 'about': (about == null || about.trim().isEmpty) ? null : about.trim(), + }), + ); + + final decoded = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200) { + return decoded as Map; + } + throw Exception((decoded is Map && decoded['detail'] != null) ? decoded['detail'] : 'Failed to update profile'); + } + + Future> getUserById(int userId) async { + final token = await getAccessToken(); + final response = await _client.get( + Uri.http(AppConstants.baseUrl, 'users/$userId'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode == 200) { + return jsonDecode(utf8.decode(response.bodyBytes)) as Map; + } + throw Exception('Не удалось получить информацию о пользователе'); + } + + Future updatePrivacySettings({ + bool? showEmail, + bool? showPhone, + bool? showAvatar, + bool? showAbout, + bool? showUsername, + }) async { + final token = await getAccessToken(); + final response = await _client.put( + Uri.http(AppConstants.baseUrl, 'users/me/privacy'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + if (showEmail != null) 'show_email': showEmail, + if (showPhone != null) 'show_phone': showPhone, + if (showAvatar != null) 'show_avatar': showAvatar, + if (showAbout != null) 'show_about': showAbout, + if (showUsername != null) 'show_username': showUsername, + }), + ); + + return response.statusCode == 200; + } + + Future> getPrivacySettings() async { + final token = await getAccessToken(); + final response = await _client.get( + Uri.http(AppConstants.baseUrl, 'users/me/privacy'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode == 200) { + 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 d0aae98..be2eaff 100644 --- a/lib/domain/services/crypto_service.dart +++ b/lib/domain/services/crypto_service.dart @@ -65,6 +65,23 @@ class CryptoService { } } + Future encryptPrivateKeyWithPassword( + String privateKeyBase64, + String masterPassword, + ) async { + final privateKeyBytes = base64Decode(privateKeyBase64); + final masterKey = await _deriveKeyFromPassword(masterPassword); + final nonce = aesGcm.newNonce(); + final encrypted = await aesGcm.encrypt( + privateKeyBytes, + secretKey: masterKey, + nonce: nonce, + ); + + final encryptedData = nonce + encrypted.mac.bytes + encrypted.cipherText; + return base64Encode(encryptedData); + } + Future _deriveKeyFromPassword(String password) async { final pbkdf2 = Pbkdf2( macAlgorithm: Hmac.sha256(), diff --git a/lib/logic/auth_provider.dart b/lib/logic/auth_provider.dart index 2ce3513..e2ff634 100644 --- a/lib/logic/auth_provider.dart +++ b/lib/logic/auth_provider.dart @@ -14,6 +14,47 @@ class AuthProvider extends ChangeNotifier { int? _currentUserId; int? get currentUserId => _currentUserId; + String? _username; + String? get username => _username; + + String? _firstName; + String? get firstName => _firstName; + + String? _lastName; + String? get lastName => _lastName; + + String? _phone; + String? get phone => _phone; + + String? _email; + String? get email => _email; + + String? _about; + String? get about => _about; + + // Privacy settings + bool? _showEmail; + bool? get showEmail => _showEmail; + + bool? _showPhone; + bool? get showPhone => _showPhone; + + bool? _showAvatar; + bool? get showAvatar => _showAvatar; + + bool? _showAbout; + bool? get showAbout => _showAbout; + + bool? _showUsername; + bool? get showUsername => _showUsername; + + String get displayName { + final full = '${_firstName ?? ''} ${_lastName ?? ''}'.trim(); + if (full.isNotEmpty) return full; + if ((_username ?? '').isNotEmpty) return _username!; + return 'User'; + } + // Флаги для определения пути пользователя bool _needsSetup = false; bool get needsSetup => _needsSetup; @@ -91,6 +132,13 @@ class AuthProvider extends ChangeNotifier { final mode = await _storage.read(key: 'theme_mode'); final color = await _storage.read(key: 'accent_color'); await _storage.deleteAll(); + _currentUserId = null; + _username = null; + _firstName = null; + _lastName = null; + _phone = null; + _email = null; + _about = null; if (mode != null) { await _storage.write(key: 'theme_mode', value: mode); } @@ -192,6 +240,13 @@ class AuthProvider extends ChangeNotifier { if (response.statusCode == 200) { final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map; + + _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; @@ -213,6 +268,24 @@ class AuthProvider extends ChangeNotifier { _needsKeyRecovery = false; } } + + // Загружаем настройки конфиденциальности + try { + final privacyData = await _apiService.getPrivacySettings(); + _showEmail = privacyData['show_email'] as bool?; + _showPhone = privacyData['show_phone'] as bool?; + _showAvatar = privacyData['show_avatar'] as bool?; + _showAbout = privacyData['show_about'] as bool?; + _showUsername = privacyData['show_username'] as bool?; + } catch (e) { + print("Ошибка загрузки настроек конфиденциальности: $e"); + // Устанавливаем значения по умолчанию + _showEmail = true; + _showPhone = true; + _showAvatar = true; + _showAbout = true; + _showUsername = true; + } } catch (e) { print("Ошибка проверки статуса: $e"); _needsSetup = false; @@ -221,6 +294,10 @@ class AuthProvider extends ChangeNotifier { notifyListeners(); } + Future refreshMe() async { + await _checkAccountStatus(); + } + // Метод для начала с чистого листа (новые ключи) Future resetKeys() async { await _storage.delete(key: 'private_key'); diff --git a/lib/logic/contact_provider.dart b/lib/logic/contact_provider.dart index 7d9877c..55b3465 100644 --- a/lib/logic/contact_provider.dart +++ b/lib/logic/contact_provider.dart @@ -1,12 +1,17 @@ import 'package:flutter/material.dart'; import '/data/models/contact_model.dart'; import '/data/repositories/contact_repository.dart'; +import '/data/datasources/local_db_service.dart'; +import '/domain/services/crypto_service.dart'; class ContactProvider extends ChangeNotifier { final ContactRepository _repository = ContactRepository(); + final LocalDbService _localDbService = LocalDbService(); + final CryptoService _cryptoService = CryptoService(); List _contacts = []; List _allContacts = []; bool _isLoading = false; + bool _isFirstLoad = true; String? _error; int? _currentUserId; @@ -25,19 +30,33 @@ class ContactProvider extends ChangeNotifier { } Future loadContacts() async { - _isLoading = true; + if (_isFirstLoad) { + _isFirstLoad = false; + _isLoading = true; + } _error = null; notifyListeners(); try { - final allContacts = await _repository.fetchContacts(); + final allContacts = await _repository.fetchChatContacts(); // Фильтруем: исключаем себя (для основного списка - только чаты) - _contacts = allContacts.where((contact) => contact.id != _currentUserId).toList(); + _contacts = allContacts + .where((contact) => contact.id != _currentUserId) + .toList(); _allContacts = _contacts; + _isLoading = false; + notifyListeners(); + + // Обогащаем превью последним сообщением из локальной БД, не блокируя UI. + _enrichContactsWithLastMessages(); } catch (e) { _error = e.toString(); } finally { - _isLoading = false; + // Если ошибка — выходим из состояния загрузки тут. + // Если всё ок — `_isLoading` уже сброшен выше, чтобы показать список быстрее. + if (_error != null) { + _isLoading = false; + } notifyListeners(); } } @@ -49,9 +68,11 @@ class ContactProvider extends ChangeNotifier { notifyListeners(); try { - final allContacts = await _repository.fetchContacts(); + final allContacts = await _repository.fetchAllUsers(); // Фильтруем только исключение самого себя - _allContacts = allContacts.where((contact) => contact.id != _currentUserId).toList(); + _allContacts = allContacts + .where((contact) => contact.id != _currentUserId) + .toList(); } catch (e) { _error = e.toString(); } finally { @@ -59,4 +80,93 @@ class ContactProvider extends ChangeNotifier { notifyListeners(); } } -} \ No newline at end of file + + Future _enrichContactsWithLastMessages() async { + final myId = _currentUserId; + if (myId == null) return; + + final myPrivKey = await _cryptoService.getPrivateKey(); + + final List updated = List.from(_contacts); + + for (int i = 0; i < updated.length; i++) { + final contact = updated[i]; + + // 1) Если сервер уже прислал lastMessage — попробуем расшифровать превью. + if (contact.lastMessage != null && + contact.lastMessage!.isNotEmpty && + myPrivKey != null && + contact.publicKey != null) { + try { + final sharedSecret = await _cryptoService.deriveSharedSecret( + myPrivKey, + contact.publicKey!, + ); + final decrypted = await _cryptoService.decryptMessage( + contact.lastMessage!, + sharedSecret, + ); + updated[i] = contact.copyWith(lastMessage: decrypted); + } catch (_) { + // Если расшифровать не удалось — оставляем как есть, дальше попробуем локальную БД. + } + } + + // Если сервер уже отдал и сообщение, и время — не трогаем (контакты уже обогащены). + 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; + _allContacts = updated; + notifyListeners(); + } +} diff --git a/lib/main.dart b/lib/main.dart index f377d29..dfe6361 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,6 +27,8 @@ RemoteMessage? initialMessage; // Ключ для SharedPreferences const String _notificationLaunchKey = 'notification_launch_data'; +// Защита от повторной обработки одного и того же payload при следующих запусках по иконке +const String _lastHandledNotificationLaunchPayloadKey = 'notification_last_handled_payload'; Future _onSelectNotification(NotificationResponse notificationResponse) async { final payload = notificationResponse.payload; @@ -39,12 +41,17 @@ Future _onSelectNotification(NotificationResponse notificationResponse) as final context = navigatorKey.currentContext; final prefs = await SharedPreferences.getInstance(); + final canonicalPayload = jsonEncode(data); // Важно: не сохраняем payload в SharedPreferences, если можем сразу перейти в чат. // Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение // будет снова автопереходить в чат. if (context == null) { - await prefs.setString(_notificationLaunchKey, jsonEncode(data)); + final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey); + if (lastHandled != canonicalPayload) { + await prefs.setString(_notificationLaunchKey, canonicalPayload); + await prefs.setString(_lastHandledNotificationLaunchPayloadKey, canonicalPayload); + } print('Navigator context is null, saved notification payload to SharedPreferences'); } else { await prefs.remove(_notificationLaunchKey); @@ -124,9 +131,16 @@ void main() async { print('Message type: ${initialMessage!.data['type']}'); print('Sender ID: ${initialMessage!.data['sender_id']}'); - // Сохраняем данные уведомления - await prefs.setString(_notificationLaunchKey, jsonEncode(initialMessage!.data)); - print('Saved notification data to SharedPreferences'); + final payloadString = jsonEncode(initialMessage!.data); + final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey); + if (lastHandled != payloadString) { + // Сохраняем данные уведомления + await prefs.setString(_notificationLaunchKey, payloadString); + await prefs.setString(_lastHandledNotificationLaunchPayloadKey, payloadString); + print('Saved notification data to SharedPreferences'); + } else { + print('InitialMessage payload already handled earlier, skipping'); + } } else { print('No initial message - app launched normally'); // Очищаем сохраненные данные, если приложение запущено нормально @@ -148,10 +162,15 @@ void main() async { print('App launched from local notification, payload: $payload'); if (payload != null && payload.isNotEmpty) { try { - final data = jsonDecode(payload); - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_notificationLaunchKey, jsonEncode(data)); - print('Saved local notification launch payload to SharedPreferences'); + final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey); + if (lastHandled != payload) { + final data = jsonDecode(payload); + await prefs.setString(_notificationLaunchKey, jsonEncode(data)); + await prefs.setString(_lastHandledNotificationLaunchPayloadKey, payload); + print('Saved local notification launch payload to SharedPreferences'); + } else { + print('Local notification payload already handled earlier, skipping'); + } } catch (e) { print('Failed to save notification launch payload: $e'); } @@ -235,6 +254,7 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { payload: jsonEncode({ 'type': 'enc_message', 'sender_id': message.data['sender_id'], + 'timestamp': message.data['timestamp'] ?? DateTime.now().toIso8601String(), }), ); print('Notification shown successfully'); @@ -246,9 +266,46 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { } } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Закрываем сокет, как только приложение сворачивается. + if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive || + state == AppLifecycleState.detached) { + try { + context.read().closeRealtime(); + } catch (_) {} + return; + } + + // На возврате в приложение — пробуем переподключиться (если есть токен). + if (state == AppLifecycleState.resumed) { + try { + context.read().initRealtime(); + } catch (_) {} + } + } + @override Widget build(BuildContext context) { final themeProvider = context.watch(); diff --git a/lib/presentation/screens/account_settings_screen.dart b/lib/presentation/screens/account_settings_screen.dart new file mode 100644 index 0000000..a1fb404 --- /dev/null +++ b/lib/presentation/screens/account_settings_screen.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:chepuhagram/domain/services/api_service.dart'; +import 'package:chepuhagram/logic/auth_provider.dart'; + +class AccountSettingsScreen extends StatefulWidget { + const AccountSettingsScreen({super.key}); + + @override + State createState() => _AccountSettingsScreenState(); +} + +class _AccountSettingsScreenState extends State { + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + final _phoneController = TextEditingController(); + final _emailController = TextEditingController(); + final _aboutController = TextEditingController(); + bool _isSaving = false; + + @override + void initState() { + super.initState(); + final auth = context.read(); + _usernameController.text = auth.username ?? ''; + _firstNameController.text = auth.firstName ?? ''; + _lastNameController.text = auth.lastName ?? ''; + _phoneController.text = auth.phone ?? ''; + _emailController.text = auth.email ?? ''; + _aboutController.text = auth.about ?? ''; + } + + @override + void dispose() { + _usernameController.dispose(); + _firstNameController.dispose(); + _lastNameController.dispose(); + _phoneController.dispose(); + _emailController.dispose(); + _aboutController.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _isSaving = true); + try { + final api = ApiService(); + await api.updateMe( + username: _usernameController.text.trim(), + firstName: _firstNameController.text.trim(), + lastName: _lastNameController.text.trim(), + phone: _phoneController.text, + email: _emailController.text, + about: _aboutController.text, + ); + + if (!mounted) return; + await context.read().refreshMe(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Сохранено')), + ); + Navigator.of(context).pop(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))), + ); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Аккаунт'), + actions: [ + TextButton( + onPressed: _isSaving ? null : _save, + child: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text( + 'Сохранить', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Имя пользователя', + hintText: 'Латиница, цифры, подчеркивания', + ), + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Введите имя пользователя'; + if (!RegExp(r'^[a-zA-Z0-9_]{3,20}$').hasMatch(v.trim())) { + return 'Имя пользователя должно содержать от 3 до 20 символов (латиница, цифры, подчеркивания)'; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _firstNameController, + decoration: const InputDecoration( + labelText: 'Имя', + ), + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Введите имя'; + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _lastNameController, + decoration: const InputDecoration( + labelText: 'Фамилия', + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _phoneController, + decoration: const InputDecoration( + labelText: 'Телефон', + ), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'Почта', + ), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + TextFormField( + controller: _aboutController, + decoration: const InputDecoration( + labelText: 'О себе', + ), + minLines: 2, + maxLines: 5, + ), + ], + ), + ), + ); + } +} + diff --git a/lib/presentation/screens/chat_screen.dart b/lib/presentation/screens/chat_screen.dart index 0ef4277..7f0d324 100644 --- a/lib/presentation/screens/chat_screen.dart +++ b/lib/presentation/screens/chat_screen.dart @@ -14,6 +14,8 @@ import 'package:chepuhagram/data/datasources/local_db_service.dart'; import 'package:chepuhagram/main.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'contacts_screen.dart'; +import 'package:flutter/services.dart'; +import 'user_profile_screen.dart'; class ChatScreen extends StatefulWidget { final Contact contact; @@ -30,18 +32,23 @@ class _ChatScreenState extends State { late Contact _currentContact; bool _isKeyLoading = false; final TextEditingController _controller = TextEditingController(); + final FocusNode _inputFocusNode = FocusNode(); final ContactRepository _contactRepository = ContactRepository(); final apiService = ApiService(); final CryptoService _cryptoService = CryptoService(); List messages = []; StreamSubscription? _socketSubscription; + final Set _sentReadReceipts = {}; + final LocalDbService _localDbService = LocalDbService(); + MessageModel? _replyTo; @override void initState() { super.initState(); _currentContact = widget.contact; - - currentActiveChatContactId = _currentContact.id; // Устанавливаем активный чат + + currentActiveChatContactId = + _currentContact.id; // Устанавливаем активный чат final contactProvider = context.read(); myId = contactProvider.getCurrentUserId() ?? 0; // Если ключа нет, загружаем его при входе @@ -83,6 +90,7 @@ class _ChatScreenState extends State { currentActiveChatContactId = null; // Сбрасываем активный чат _socketSubscription?.cancel(); _controller.dispose(); + _inputFocusNode.dispose(); super.dispose(); } @@ -93,16 +101,26 @@ class _ChatScreenState extends State { leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - } else { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const ContactsScreen()), - ); - } + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const ContactsScreen()), + ); }, ), - title: Text(_currentContact.name), + title: GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => UserProfileScreen( + userId: _currentContact.id, + username: _currentContact.username, + name: _currentContact.name, + ), + ), + ); + }, + child: Text(_currentContact.name), + ), ), body: Column( children: [ @@ -113,9 +131,8 @@ class _ChatScreenState extends State { itemBuilder: (context, index) { final msg = messages[messages.length - 1 - index]; return MessageBubble( - message: msg.text, - time: msg.createdAt, - isMe: msg.isMe, + message: msg, + onTap: () => _showMessageActions(msg), ); }, ), @@ -126,26 +143,134 @@ class _ChatScreenState extends State { ); } + Future _showMessageActions(MessageModel msg) async { + if (!mounted) return; + + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (ctx) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.reply), + title: const Text('Ответить'), + onTap: () { + Navigator.of(ctx).pop(); + setState(() => _replyTo = msg); + _inputFocusNode.requestFocus(); + }, + ), + ListTile( + leading: const Icon(Icons.copy), + title: const Text('Скопировать'), + onTap: () async { + Navigator.of(ctx).pop(); + await Clipboard.setData(ClipboardData(text: msg.text)); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Скопировано')), + ); + }, + ), + ListTile( + leading: const Icon(Icons.forward), + 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 (_) {} + } + }, + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + } + Widget _buildMessageInput() { return SafeArea( // Добавляем SafeArea здесь - child: Padding( + child: Container( + color: Theme.of(context).colorScheme.surfaceVariant, padding: const EdgeInsets.all(8.0), - child: Row( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: TextField( - controller: _controller, - decoration: const InputDecoration( - hintText: "Напиши сообщение...", + if (_replyTo != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.reply, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _replyTo!.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: () => setState(() => _replyTo = null), + ), + ], ), ), - ), - IconButton( - icon: const Icon(Icons.send), - onPressed: () { - _sendMessage(); - }, + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _controller, + focusNode: _inputFocusNode, + minLines: 1, + maxLines: 5, + textInputAction: TextInputAction.newline, + decoration: const InputDecoration( + hintText: "Напиши сообщение...", + ), + ), + ), + IconButton( + icon: const Icon(Icons.send), + onPressed: () { + _sendMessage(); + }, + ), + ], ), ], ), @@ -181,30 +306,48 @@ class _ChatScreenState extends State { sharedSecret, ); + final tempId = DateTime.now().microsecondsSinceEpoch; + final localMessage = MessageModel( + tempId: tempId, + text: rawText, + isMe: true, + senderId: myId, + receiverId: _currentContact.id, + createdAt: DateTime.now(), + status: MessageStatus.sending, + replyToId: _replyTo?.id, + replyToText: _replyTo?.text, + ); + + setState(() { + messages.add(localMessage); + }); + // Формируем payload для сервера final payload = { "type": "private_message", "receiver_id": _currentContact.id, "content": encryptedText, "content50": encryptedText50, + "temp_id": tempId, + if (_replyTo?.id != null) ...{ + "reply_to_id": _replyTo!.id, + "reply_to_text": _replyTo!.text, + }, }; // Отправляем print("ОТПРАВКА: $payload"); - Provider.of(context, listen: false).sendMessage(payload); - - // Обновляем UI (себе показываем расшифрованный текст) + final ok = Provider.of(context, listen: false).sendMessage(payload); + if (!mounted) return; setState(() { - messages.add( - MessageModel( - text: rawText, - isMe: true, - senderId: myId, - receiverId: _currentContact.id, - createdAt: DateTime.now(), - ), + final idx = messages.indexWhere((m) => m.tempId == tempId); + if (idx == -1) return; + messages[idx] = messages[idx].copyWith( + status: ok ? MessageStatus.sent : MessageStatus.failed, ); + _replyTo = null; }); _controller.clear(); @@ -216,13 +359,105 @@ class _ChatScreenState extends State { } } - void _handleIncomingMessage(Map data) async { + // ACK от сервера: сообщение сохранено и получило server_id + if (data['type'] == 'message_sent') { + final tempId = int.tryParse(data['temp_id']?.toString() ?? ''); + final serverId = int.tryParse(data['server_id']?.toString() ?? ''); + final ts = DateTime.tryParse(data['timestamp']?.toString() ?? ''); + + if (tempId == null) return; + + if (!mounted) return; + setState(() { + final idx = messages.indexWhere((m) => m.tempId == tempId); + if (idx == -1) return; + messages[idx] = messages[idx].copyWith( + id: serverId ?? messages[idx].id, + createdAt: ts ?? messages[idx].createdAt, + status: MessageStatus.sent, + ); + }); + return; + } + + // Backward compatibility: старый ack мог приходить как message_delivered с temp_id/server_id + if (data['type'] == 'message_delivered' && data.containsKey('temp_id')) { + final tempId = int.tryParse(data['temp_id']?.toString() ?? ''); + final serverId = int.tryParse(data['server_id']?.toString() ?? ''); + final ts = DateTime.tryParse(data['timestamp']?.toString() ?? ''); + + if (tempId == null) return; + + if (!mounted) return; + setState(() { + final idx = messages.indexWhere((m) => m.tempId == tempId); + if (idx == -1) return; + messages[idx] = messages[idx].copyWith( + id: serverId ?? messages[idx].id, + createdAt: ts ?? messages[idx].createdAt, + status: MessageStatus.sent, + ); + }); + return; + } + + // Доставка онлайн (получатель был в сети) + if (data['type'] == 'message_delivered') { + final messageId = int.tryParse(data['message_id']?.toString() ?? ''); + final ts = DateTime.tryParse(data['timestamp']?.toString() ?? ''); + if (messageId == null) return; + + if (!mounted) return; + setState(() { + for (int i = 0; i < messages.length; i++) { + if (messages[i].id == messageId) { + messages[i] = messages[i].copyWith(status: MessageStatus.delivered); + } + } + }); + + if (ts != null) { + try { + await _localDbService.updateDeliveredAt(messageId, ts); + } catch (_) {} + } + return; + } + + if (data['type'] == 'message_read') { + final messageId = int.tryParse(data['message_id'].toString()); + if (messageId == null) return; + final ts = DateTime.tryParse(data['timestamp']?.toString() ?? ''); + + if (!mounted) return; + setState(() { + for (int i = 0; i < messages.length; i++) { + if (messages[i].id == messageId) { + messages[i] = messages[i].copyWith(status: MessageStatus.read); + } + } + }); + + if (ts != null) { + try { + await _localDbService.updateReadAt(messageId, ts); + } catch (_) {} + } + return; + } + if (data['type'] == 'private_message') { - final int senderId = int.parse(data['sender_id'].toString()); + final senderId = int.tryParse(data['sender_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']}'); + return; + } // 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся - if (senderId == widget.contact.id) { + final isFromPartnerToMe = senderId == widget.contact.id && receiverId == myId; + if (isFromPartnerToMe) { try { final myPrivKey = await _cryptoService.getPrivateKey(); @@ -241,14 +476,25 @@ class _ChatScreenState extends State { // 4. Добавляем в список и обновляем экран await LocalDbService().saveMessages([data]); if (!mounted) return; + + final serverMessageId = int.tryParse(data['id']?.toString() ?? ''); + if (serverMessageId != null && !_sentReadReceipts.contains(serverMessageId)) { + Provider.of(context, listen: false).sendReadReceipt(serverMessageId); + _sentReadReceipts.add(serverMessageId); + } + setState(() { messages.add( MessageModel( + id: int.tryParse(data['id']?.toString() ?? ''), text: decryptedText, isMe: false, senderId: senderId, 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(), ), ); }); @@ -265,19 +511,16 @@ class _ChatScreenState extends State { } Future _loadHistory() async { - initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа final prefs = await SharedPreferences.getInstance(); await prefs.remove(_notificationLaunchKey); - await prefs.setString(_notificationLaunchKey, ''); // Очищаем данные уведомления при загрузке ключа try { final myPrivKey = await _cryptoService.getPrivateKey(); final sharedSecret = await _cryptoService.deriveSharedSecret( myPrivKey!, widget.contact.publicKey!, ); - final localDb = LocalDbService(); - final cached = await localDb.getChatHistory(widget.contact.id, myId); + final cached = await _localDbService.getChatHistory(widget.contact.id, myId); try { List loadedLocalMessages = []; @@ -286,13 +529,36 @@ class _ChatScreenState extends State { msg['content'], sharedSecret, ); + + final deliveredAt = msg['delivered_at'] == null + ? null + : DateTime.tryParse(msg['delivered_at'].toString()); + final readAt = msg['read_at'] == null + ? null + : DateTime.tryParse(msg['read_at'].toString()); + + MessageStatus status = (msg['sender_id'] == myId) + ? MessageStatus.sent + : MessageStatus.delivered; + if (msg['sender_id'] == myId) { + if (readAt != null) { + status = MessageStatus.read; + } else if (deliveredAt != null) { + status = MessageStatus.delivered; + } + } + loadedLocalMessages.add( MessageModel( + id: int.tryParse(msg['id']?.toString() ?? ''), text: decrypted, isMe: msg['sender_id'] == myId, senderId: msg['sender_id'], 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(), ), ); } @@ -309,25 +575,56 @@ class _ChatScreenState extends State { final history = await apiService.getChatHistory(widget.contact.id); print(history); + final alreadyReadIncomingMessageIds = {}; List loadedMessages = []; for (var msg in history) { + final msgId = int.tryParse(msg['id']?.toString() ?? ''); + if (msgId != null && + msg['sender_id'] != myId && + msg['read_at'] != null) { + alreadyReadIncomingMessageIds.add(msgId); + } + final decrypted = await _cryptoService.decryptMessage( msg['content'], sharedSecret, ); + + final deliveredAt = msg['delivered_at'] == null + ? null + : DateTime.tryParse(msg['delivered_at'].toString()); + final readAt = msg['read_at'] == null + ? null + : DateTime.tryParse(msg['read_at'].toString()); + + MessageStatus status = (msg['sender_id'] == myId) + ? MessageStatus.sent + : MessageStatus.delivered; + if (msg['sender_id'] == myId) { + if (readAt != null) { + status = MessageStatus.read; + } else if (deliveredAt != null) { + status = MessageStatus.delivered; + } + } + loadedMessages.insert( 0, MessageModel( + id: int.tryParse(msg['id']?.toString() ?? ''), text: decrypted, isMe: msg['sender_id'] == myId, senderId: msg['sender_id'], 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(), ), ); } try { - await localDb.saveMessages(history); + await _localDbService.saveMessages(history); } catch (e) { print("Ошибка сохранения истории в локальную базу: $e"); } @@ -337,6 +634,17 @@ class _ChatScreenState extends State { messages = loadedMessages; _isKeyLoading = false; }); + + // Отправляем read_receipt для сообщений собеседника, которые уже на экране. + for (final m in loadedMessages) { + if (m.isMe) continue; + final id = m.id; + if (id == null) continue; + if (alreadyReadIncomingMessageIds.contains(id)) continue; + if (_sentReadReceipts.contains(id)) continue; + Provider.of(context, listen: false).sendReadReceipt(id); + _sentReadReceipts.add(id); + } } catch (e) { print("Ошибка загрузки истории: $e"); if (!mounted) return; diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 57b12e0..229049a 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -13,6 +13,8 @@ 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'; class ContactsScreen extends StatefulWidget { final int? targetChatId; @@ -25,13 +27,12 @@ class ContactsScreen extends StatefulWidget { class _ContactsScreenState extends State { static const String _notificationLaunchKey = 'notification_launch_data'; + StreamSubscription? _socketSubscription; @override void initState() { super.initState(); - print( - 'ContactsScreen initState, targetChatId: ${widget.targetChatId}', - ); + print('ContactsScreen initState, targetChatId: ${widget.targetChatId}'); _setupPushNotifications(); WidgetsBinding.instance.addPostFrameCallback((_) { final authProvider = context.read(); @@ -40,9 +41,7 @@ class _ContactsScreenState extends State { // Установить текущего пользователя и загрузить контакты с сообщениями contactProvider.setCurrentUserId(authProvider.currentUserId); contactProvider.loadContacts().then((_) { - print( - 'Contacts loaded, checking targetChatId: ${widget.targetChatId}', - ); + print('Contacts loaded, checking targetChatId: ${widget.targetChatId}'); // После загрузки контактов проверить, нужно ли перейти к чату if (widget.targetChatId != null) { _navigateToTargetChat(); @@ -91,17 +90,13 @@ class _ContactsScreenState extends State { } void _navigateToTargetChatWithId(int targetChatId) { - print( - '_navigateToTargetChat called with targetChatId: $targetChatId', - ); + print('_navigateToTargetChat called with targetChatId: $targetChatId'); final contactProvider = context.read(); try { final contact = contactProvider.contacts.firstWhere( (c) => c.id == targetChatId, ); - print( - 'Auto-navigating to chat with contact: ${contact.username}', - ); + print('Auto-navigating to chat with contact: ${contact.username}'); currentActiveChatContactId = targetChatId; // Устанавливаем активный чат Navigator.push( context, @@ -158,9 +153,7 @@ class _ContactsScreenState extends State { void _navigateToChatFromNotification(int senderId) { final contactProvider = context.read(); - print( - 'Navigate to chat from notification with senderId: $senderId', - ); + print('Navigate to chat from notification with senderId: $senderId'); // Если контакты еще не загружены, ждем их загрузки if (contactProvider.contacts.isEmpty) { @@ -178,9 +171,7 @@ class _ContactsScreenState extends State { final contact = contactProvider.contacts.firstWhere( (c) => c.id == senderId, ); - print( - 'Navigating to chat from notification: ${contact.username}', - ); + print('Navigating to chat from notification: ${contact.username}'); currentActiveChatContactId = senderId; // Устанавливаем активный чат Navigator.push( context, @@ -244,13 +235,26 @@ class _ContactsScreenState extends State { payload: jsonEncode({ 'type': 'enc_message', 'sender_id': message.data['sender_id'], + 'timestamp': + message.data['timestamp'] ?? DateTime.now().toIso8601String(), }), ); + + if (message.data['type'] == 'enc_message') { + final contactProvider = context.read(); + contactProvider.loadContacts(); + } } catch (e) { print('Error processing foreground message: $e'); } } + @override + void dispose() { + _socketSubscription?.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -309,20 +313,40 @@ class _ContactsScreenState extends State { padding: EdgeInsets.zero, children: [ // Шапка меню с данными юзера - UserAccountsDrawerHeader( - accountName: Text("Artur Karasevich"), - accountEmail: Text("@ArturKarasevich"), - currentAccountPicture: CircleAvatar( - backgroundColor: Theme.of(context).colorScheme.onSurface, - child: Icon( - Icons.person, - size: 40, - color: Theme.of(context).colorScheme.primaryContainer, - ), - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - ), + Consumer( + builder: (context, authProvider, _) { + final username = authProvider.username; + final displayName = authProvider.displayName; + final initials = + (displayName.isNotEmpty ? displayName : (username ?? 'U')) + .trim() + .split(RegExp(r'\s+')) + .where((p) => p.isNotEmpty) + .take(2) + .map((p) => p[0].toUpperCase()) + .join(); + + return UserAccountsDrawerHeader( + accountName: Text(displayName), + accountEmail: Text( + username == null || username.isEmpty ? '' : '@$username', + ), + currentAccountPicture: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.onSurface, + child: Text( + initials.isEmpty ? 'U' : initials, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primaryContainer, + ), + ), + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + ), + ); + }, ), ListTile( leading: const Icon(Icons.settings), diff --git a/lib/presentation/screens/privacy_settings_menu_screen.dart b/lib/presentation/screens/privacy_settings_menu_screen.dart new file mode 100644 index 0000000..b6b36cb --- /dev/null +++ b/lib/presentation/screens/privacy_settings_menu_screen.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'security_settings_screen.dart'; +import 'privacy_settings_screen.dart'; + +class PrivacySettingsMenuScreen extends StatelessWidget { + const PrivacySettingsMenuScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Конфиденциальность')), + body: ListView( + children: [ + const SizedBox(height: 12), + ListTile( + leading: const Icon(Icons.security_outlined), + title: const Text('Безопасность'), + subtitle: const Text('Сменить пароль, пароль шифрования, TOTP'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SecuritySettingsScreen()), + ); + }, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.privacy_tip_outlined), + title: const Text('Конфиденциальность'), + subtitle: const Text('Кто может видеть почту, телефон, аватар и информацию о вас'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const PrivacySettingsScreen()), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/screens/privacy_settings_screen.dart b/lib/presentation/screens/privacy_settings_screen.dart new file mode 100644 index 0000000..3376014 --- /dev/null +++ b/lib/presentation/screens/privacy_settings_screen.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:chepuhagram/domain/services/api_service.dart'; + +class PrivacySettingsScreen extends StatefulWidget { + const PrivacySettingsScreen({super.key}); + + @override + State createState() => _PrivacySettingsScreenState(); +} + +class _PrivacySettingsScreenState extends State { + static const _showEmailKey = 'privacy_show_email'; + static const _showPhoneKey = 'privacy_show_phone'; + static const _showAvatarKey = 'privacy_show_avatar'; + static const _showAboutKey = 'privacy_show_about'; + static const _showUsernameKey = 'privacy_show_username'; + + bool _showEmail = true; + bool _showPhone = true; + bool _showAvatar = true; + bool _showAbout = true; + bool _showUsername = true; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _loadPreferences(); + _loadServerSettings(); + } + + Future _loadPreferences() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _showEmail = prefs.getBool(_showEmailKey) ?? true; + _showPhone = prefs.getBool(_showPhoneKey) ?? true; + _showAvatar = prefs.getBool(_showAvatarKey) ?? true; + _showAbout = prefs.getBool(_showAboutKey) ?? true; + _showUsername = prefs.getBool(_showUsernameKey) ?? true; + }); + } + + Future _loadServerSettings() async { + try { + final api = ApiService(); + final data = await api.getPrivacySettings(); + setState(() { + _showEmail = data['show_email'] ?? true; + _showPhone = data['show_phone'] ?? true; + _showAvatar = data['show_avatar'] ?? true; + _showAbout = data['show_about'] ?? true; + _showUsername = data['show_username'] ?? true; + }); + // Сохраняем локально для быстрого доступа + await _savePreference(_showEmailKey, _showEmail); + await _savePreference(_showPhoneKey, _showPhone); + await _savePreference(_showAvatarKey, _showAvatar); + await _savePreference(_showAboutKey, _showAbout); + await _savePreference(_showUsernameKey, _showUsername); + } catch (e) { + // Если не удалось загрузить с сервера, используем локальные настройки + print('Ошибка загрузки настроек с сервера: $e'); + } + } + + Future _savePreference(String key, bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(key, value); + } + + Future _saveToServer() async { + if (_isSaving) return; + + setState(() => _isSaving = true); + + try { + final api = ApiService(); + final success = await api.updatePrivacySettings( + showEmail: _showEmail, + showPhone: _showPhone, + showAvatar: _showAvatar, + showAbout: _showAbout, + showUsername: _showUsername, + ); + + if (success) { + // Сохраняем локально только после успешного сохранения на сервере + await _savePreference(_showEmailKey, _showEmail); + await _savePreference(_showPhoneKey, _showPhone); + await _savePreference(_showAvatarKey, _showAvatar); + await _savePreference(_showAboutKey, _showAbout); + await _savePreference(_showUsernameKey, _showUsername); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Настройки сохранены')), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Не удалось сохранить настройки')), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Ошибка: ${e.toString().replaceAll('Exception: ', '')}')), + ); + } + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Конфиденциальность'), + actions: [ + TextButton( + onPressed: _isSaving ? null : _saveToServer, + child: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text( + 'Сохранить', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('Настройки видимости', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + SwitchListTile( + title: const Text('Показывать имя пользователя (@username)'), + value: _showUsername, + onChanged: (value) { + setState(() => _showUsername = value); + }, + ), + SwitchListTile( + title: const Text('Показывать почту другим'), + value: _showEmail, + onChanged: (value) { + setState(() => _showEmail = value); + }, + ), + SwitchListTile( + title: const Text('Показывать телефон другим'), + value: _showPhone, + onChanged: (value) { + setState(() => _showPhone = value); + }, + ), + SwitchListTile( + title: const Text('Показывать аватар другим'), + value: _showAvatar, + onChanged: (value) { + setState(() => _showAvatar = value); + }, + ), + SwitchListTile( + title: const Text('Показывать информацию «О себе»'), + value: _showAbout, + onChanged: (value) { + setState(() => _showAbout = value); + }, + ), + const SizedBox(height: 24), + const Text( + 'Эти настройки влияют на то, какую информацию о вас видят другие пользователи приложения.', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/screens/security_settings_screen.dart b/lib/presentation/screens/security_settings_screen.dart new file mode 100644 index 0000000..3516c2c --- /dev/null +++ b/lib/presentation/screens/security_settings_screen.dart @@ -0,0 +1,316 @@ +import 'package:flutter/material.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:chepuhagram/domain/services/api_service.dart'; +import 'package:chepuhagram/domain/services/crypto_service.dart'; + +class SecuritySettingsScreen extends StatefulWidget { + const SecuritySettingsScreen({super.key}); + + @override + State createState() => _SecuritySettingsScreenState(); +} + +class _SecuritySettingsScreenState extends State { + final _passwordFormKey = GlobalKey(); + final _encryptionFormKey = GlobalKey(); + final _totpFormKey = GlobalKey(); + + final _currentPasswordController = TextEditingController(); + final _newPasswordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + + final _currentEncryptPasswordController = TextEditingController(); + final _newEncryptPasswordController = TextEditingController(); + final _confirmEncryptPasswordController = TextEditingController(); + + final LocalAuthentication _localAuth = LocalAuthentication(); + bool _isBiometricAvailable = false; + bool _isSavingPassword = false; + bool _isSavingEncryption = false; + bool _isSavingTotp = false; + + @override + void initState() { + super.initState(); + _checkBiometricSupport(); + } + + @override + void dispose() { + _currentPasswordController.dispose(); + _newPasswordController.dispose(); + _confirmPasswordController.dispose(); + _currentEncryptPasswordController.dispose(); + _newEncryptPasswordController.dispose(); + _confirmEncryptPasswordController.dispose(); + super.dispose(); + } + + Future _checkBiometricSupport() async { + try { + final canCheckBiometrics = await _localAuth.canCheckBiometrics; + final isSupported = await _localAuth.isDeviceSupported(); + final availableBiometrics = await _localAuth.getAvailableBiometrics(); + if (!mounted) return; + setState(() { + _isBiometricAvailable = canCheckBiometrics && isSupported && availableBiometrics.isNotEmpty; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _isBiometricAvailable = false; + }); + } + } + + Future _authenticateBiometric() async { + try { + return await _localAuth.authenticate( + localizedReason: 'Подтвердите личность для смены пароля шифрования', + options: const AuthenticationOptions( + biometricOnly: false, + stickyAuth: false, + useErrorDialogs: true, + sensitiveTransaction: true, + ), + ); + } catch (error) { + debugPrint('Biometric authentication error: $error'); + return false; + } + } + + Future _savePassword() async { + if (!_passwordFormKey.currentState!.validate()) return; + setState(() => _isSavingPassword = true); + + try { + final api = ApiService(); + final success = await api.changePassword( + _currentPasswordController.text.trim(), + _newPasswordController.text.trim(), + ); + + if (!success) { + throw Exception('Не удалось изменить пароль'); + } + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Пароль успешно изменён')), + ); + _currentPasswordController.clear(); + _newPasswordController.clear(); + _confirmPasswordController.clear(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))), + ); + } finally { + if (!mounted) return; + setState(() => _isSavingPassword = false); + } + } + + Future _saveEncryptionPassword() async { + await _checkBiometricSupport(); + + if (!_encryptionFormKey.currentState!.validate()) return; + setState(() => _isSavingEncryption = true); + + try { + final newPassword = _newEncryptPasswordController.text.trim(); + final currentPassword = _currentEncryptPasswordController.text.trim(); + final cryptoService = CryptoService(); + String privateKeyBase64; + + if (currentPassword.isEmpty) { + if (!_isBiometricAvailable) { + throw Exception('Биометрия не настроена. Введите текущий пароль.'); + } + + final authenticated = await _authenticateBiometric(); + if (!authenticated) { + throw Exception('Биометрическая аутентификация не пройдена.'); + } + + final localPrivateKey = await cryptoService.getPrivateKey(); + if (localPrivateKey == null || localPrivateKey.isEmpty) { + throw Exception('Локальный приватный ключ не найден.'); + } + privateKeyBase64 = localPrivateKey; + } else { + final api = ApiService(); + final userData = await api.getMe(); + final encryptedPrivateKey = userData['encrypted_private_key']?.toString(); + + if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty) { + throw Exception('Зашифрованный ключ не найден на сервере.'); + } + + privateKeyBase64 = await cryptoService.decryptPrivateKey( + encryptedPrivateKey, + currentPassword, + ); + await cryptoService.savePrivateKey(privateKeyBase64); + } + + final updatedEncryptedPrivateKey = await cryptoService.encryptPrivateKeyWithPassword( + privateKeyBase64, + newPassword, + ); + + final success = await ApiService().updateEncryptedPrivateKey(updatedEncryptedPrivateKey); + if (!success) { + throw Exception('Не удалось обновить пароль шифрования на сервере.'); + } + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Пароль шифрования успешно обновлён')), + ); + _currentEncryptPasswordController.clear(); + _newEncryptPasswordController.clear(); + _confirmEncryptPasswordController.clear(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))), + ); + } finally { + if (!mounted) return; + setState(() => _isSavingEncryption = false); + } + } + + Future _setupTotp() async { + setState(() => _isSavingTotp = true); + await Future.delayed(const Duration(milliseconds: 500)); + if (!mounted) return; + setState(() => _isSavingTotp = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('TOTP пока не подключён на сервере')), + ); + } + + String? _currentEncryptionPasswordValidator(String? value) { + if (value == null || value.isEmpty) { + if (!_isBiometricAvailable) { + return 'Введите текущий пароль'; + } + } + return null; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Безопасность')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text('Смена пароля аккаунта', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Form( + key: _passwordFormKey, + child: Column( + children: [ + TextFormField( + controller: _currentPasswordController, + decoration: const InputDecoration(labelText: 'Текущий пароль'), + obscureText: true, + validator: (value) { + if (value == null || value.isEmpty) return 'Введите текущий пароль'; + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _newPasswordController, + decoration: const InputDecoration(labelText: 'Новый пароль'), + obscureText: true, + validator: (value) { + if (value == null || value.isEmpty) return 'Введите новый пароль'; + if (value.length < 6) return 'Пароль слишком короткий'; + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _confirmPasswordController, + decoration: const InputDecoration(labelText: 'Повторите пароль'), + obscureText: true, + validator: (value) { + if (value != _newPasswordController.text) return 'Пароли не совпадают'; + return null; + }, + ), + const SizedBox(height: 14), + ElevatedButton( + onPressed: _isSavingPassword ? null : _savePassword, + child: _isSavingPassword ? const CircularProgressIndicator(color: Colors.white) : const Text('Сохранить пароль'), + ), + ], + ), + ), + const SizedBox(height: 24), + const Text('Пароль шифрования сообщений', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Form( + key: _encryptionFormKey, + child: Column( + children: [ + TextFormField( + controller: _currentEncryptPasswordController, + decoration: InputDecoration( + labelText: 'Текущий пароль шифрования', + helperText: _isBiometricAvailable + ? 'Оставьте поле пустым и подтвердите биометрией' + : 'Требуется текущий пароль', + ), + obscureText: true, + validator: _currentEncryptionPasswordValidator, + ), + const SizedBox(height: 12), + TextFormField( + controller: _newEncryptPasswordController, + decoration: const InputDecoration(labelText: 'Новый пароль шифрования'), + obscureText: true, + validator: (value) { + if (value == null || value.length < 6) return 'Пароль слишком короткий'; + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _confirmEncryptPasswordController, + decoration: const InputDecoration(labelText: 'Повторите новый пароль'), + obscureText: true, + validator: (value) { + if (value != _newEncryptPasswordController.text) return 'Пароли не совпадают'; + return null; + }, + ), + const SizedBox(height: 14), + ElevatedButton( + onPressed: _isSavingEncryption ? null : _saveEncryptionPassword, + child: _isSavingEncryption ? const CircularProgressIndicator(color: Colors.white) : const Text('Сохранить пароль шифрования'), + ), + ], + ), + ), + const SizedBox(height: 24), + const Text('TOTP', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + const Text('Настройка одноразового кода (TOTP) пока не подключена на сервере.'), + const SizedBox(height: 12), + ElevatedButton( + onPressed: _isSavingTotp ? null : _setupTotp, + child: _isSavingTotp ? const CircularProgressIndicator(color: Colors.white) : const Text('Установить TOTP код'), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index f93da60..371cfaf 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -1,4 +1,6 @@ +import 'package:chepuhagram/presentation/screens/account_settings_screen.dart'; import 'package:chepuhagram/presentation/screens/login_screen.dart'; +import 'package:chepuhagram/presentation/screens/privacy_settings_menu_screen.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '/logic/auth_provider.dart'; @@ -10,22 +12,58 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { final themeProv = context.watch(); - final authProv = context.read(); + final authProv = context.watch(); + + final accountEmail = authProv.email?.isNotEmpty == true + ? authProv.email! + : authProv.username?.isNotEmpty == true + ? '@${authProv.username!}' + : 'Не указано'; return Scaffold( appBar: AppBar(title: const Text("Настройки")), body: Column( children: [ // Секция Профиля - const UserAccountsDrawerHeader( - accountName: Text("Artur Karasevich"), - accountEmail: Text("@ArturKarasevich"), - currentAccountPicture: CircleAvatar( + UserAccountsDrawerHeader( + accountName: Text(authProv.displayName), + accountEmail: Text(accountEmail), + currentAccountPicture: const CircleAvatar( child: Icon(Icons.person, size: 40), ), - decoration: BoxDecoration(color: Colors.transparent), + decoration: const BoxDecoration(color: Colors.transparent), ), + const Divider(), + ListTile( + leading: const Icon(Icons.person_outline), + title: const Text('Аккаунт'), + subtitle: const Text('Имя, телефон, почта, информация о себе'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const AccountSettingsScreen(), + ), + ); + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.shield_outlined), + title: const Text('Конфиденциальность'), + subtitle: const Text('Безопасность и видимость данных профиля'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const PrivacySettingsMenuScreen(), + ), + ); + }, + ), const Divider(), SwitchListTile( diff --git a/lib/presentation/screens/user_profile_screen.dart b/lib/presentation/screens/user_profile_screen.dart new file mode 100644 index 0000000..0638a97 --- /dev/null +++ b/lib/presentation/screens/user_profile_screen.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:chepuhagram/domain/services/api_service.dart'; + +class UserProfileScreen extends StatefulWidget { + final int userId; + final String username; + final String name; + + const UserProfileScreen({ + super.key, + required this.userId, + required this.username, + required this.name, + }); + + @override + State createState() => _UserProfileScreenState(); +} + +class _UserProfileScreenState extends State { + Map? _userData; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadUserData(); + } + + Future _loadUserData() async { + try { + final api = ApiService(); + final data = await api.getUserById(widget.userId); + if (mounted) { + setState(() { + _userData = data; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString().replaceAll('Exception: ', ''); + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Информация о пользователе'), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.red), + const SizedBox(height: 16), + Text(_error!, textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadUserData, + child: const Text('Повторить'), + ), + ], + ), + ) + : _buildUserInfo(), + ); + } + + Widget _buildUserInfo() { + if (_userData == null) return const SizedBox.shrink(); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Avatar placeholder + Center( + child: CircleAvatar( + radius: 50, + backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), + child: Text( + (_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty && + _userData!['last_name'] != null && _userData!['last_name'].isNotEmpty) + ? '${_userData!['first_name'][0]}${_userData!['last_name'][0]}'.toUpperCase() + : (_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty) + ? _userData!['first_name'][0].toUpperCase() + : (_userData!['username'] != null && _userData!['username'].isNotEmpty) + ? _userData!['username'][0].toUpperCase() + : '?', + style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(height: 24), + + // Name + if ((_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty) || + (_userData!['last_name'] != null && _userData!['last_name'].isNotEmpty)) + Text( + '${_userData!['first_name'] ?? ''} ${_userData!['last_name'] ?? ''}'.trim(), + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + + // Username + if (_userData!['username'] != null && _userData!['username'].isNotEmpty) + Text( + '@${_userData!['username']}', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + + // User ID + _buildInfoTile('ID пользователя', _userData!['id'].toString()), + + // Public Key (if available) + if (_userData!['public_key'] != null) + _buildInfoTile('Публичный ключ', _userData!['public_key'], maxLines: 3), + + // About + if (_userData!['about'] != null && _userData!['about'].isNotEmpty) + _buildInfoTile('О себе', _userData!['about'], maxLines: 5), + + // Phone + if (_userData!['phone'] != null && _userData!['phone'].isNotEmpty) + _buildInfoTile('Телефон', _userData!['phone']), + + // Email + if (_userData!['email'] != null && _userData!['email'].isNotEmpty) + _buildInfoTile('Почта', _userData!['email']), + + const SizedBox(height: 16), + if ((_userData!['username'] == null || _userData!['username'].isEmpty) && + (_userData!['first_name'] == null || _userData!['first_name'].isEmpty) && + (_userData!['last_name'] == null || _userData!['last_name'].isEmpty) && + (_userData!['about'] == null || _userData!['about'].isEmpty) && + (_userData!['phone'] == null || _userData!['phone'].isEmpty) && + (_userData!['email'] == null || _userData!['email'].isEmpty)) + const Text( + 'Пользователь скрыл дополнительную информацию', + style: TextStyle(color: Colors.grey), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildInfoTile(String label, String value, {int maxLines = 1}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle(fontSize: 16), + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + ), + const Divider(), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/widgets/contact_tile.dart b/lib/presentation/widgets/contact_tile.dart index 6ccb0f9..a832376 100644 --- a/lib/presentation/widgets/contact_tile.dart +++ b/lib/presentation/widgets/contact_tile.dart @@ -19,12 +19,12 @@ class ContactTile extends StatelessWidget { radius: 28, backgroundColor: primary.withAlpha((0.1 * 255).round()), child: Text( - contact.surname[0], + contact.name[0], style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold) ), ), title: Text( - contact.username, + contact.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), subtitle: Text( diff --git a/lib/presentation/widgets/message_bubble.dart b/lib/presentation/widgets/message_bubble.dart index 56ad074..3258c16 100644 --- a/lib/presentation/widgets/message_bubble.dart +++ b/lib/presentation/widgets/message_bubble.dart @@ -1,60 +1,161 @@ import 'package:flutter/material.dart'; +import '/data/models/message_model.dart'; class MessageBubble extends StatelessWidget { - final String message; - final DateTime time; - final bool isMe; + final MessageModel message; + final VoidCallback? onTap; const MessageBubble({ super.key, required this.message, - required this.time, - required this.isMe, + this.onTap, }); @override Widget build(BuildContext context) { + final isMe = message.isMe; return Align( // Выравниваем вправо, если это мое сообщение, и влево — если чужое alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), - constraints: BoxConstraints( - // Чтобы баббл не растягивался на весь экран, если текста мало - maxWidth: MediaQuery.of(context).size.width * 0.75, - ), - decoration: BoxDecoration( - color: isMe ? Theme.of(context).colorScheme.primary : Colors.grey[300], + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + // На телефонах иногда удобнее/надежнее long-press (как в мессенджерах), + // поэтому поддерживаем оба жеста. + onLongPress: onTap, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), - // Скругляем углы по-разному для "хвостика" сообщения bottomLeft: Radius.circular(isMe ? 16 : 0), bottomRight: Radius.circular(isMe ? 0 : 16), ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - message, - style: TextStyle( - color: isMe ? Colors.white : Colors.black87, - fontSize: 16, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), + constraints: BoxConstraints( + // Чтобы баббл не растягивался на весь экран, если текста мало + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + decoration: BoxDecoration( + color: isMe + ? Theme.of(context).colorScheme.primary + : Colors.grey[300], + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + // Скругляем углы по-разному для "хвостика" сообщения + bottomLeft: Radius.circular(isMe ? 16 : 0), + bottomRight: Radius.circular(isMe ? 0 : 16), ), ), - const SizedBox(height: 4), - Text( - time.toIso8601String(), - style: TextStyle( - color: isMe ? Colors.white70 : Colors.black54, - fontSize: 10, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (message.replyToText != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + margin: const EdgeInsets.only(bottom: 4), + decoration: BoxDecoration( + color: (isMe ? Colors.white : Colors.black).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border( + left: BorderSide( + color: isMe ? Colors.white70 : Colors.black38, + width: 2, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.reply, + size: 14, + color: isMe ? Colors.white70 : Colors.black54, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + message.replyToText!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: isMe ? Colors.white70 : Colors.black54, + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ], + Text( + message.text, + style: TextStyle( + color: isMe ? Colors.white : Colors.black87, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatTime(message.createdAt), + style: TextStyle( + color: isMe ? Colors.white70 : Colors.black54, + fontSize: 10, + ), + ), + if (isMe) ...[ + const SizedBox(width: 6), + Icon( + _statusIcon(message.status), + size: 12, + color: _statusColor(message.status, isMe), + ), + ], + ], + ), + ], ), - ], + ), ), ), ); } -} \ No newline at end of file + + IconData _statusIcon(MessageStatus status) { + switch (status) { + case MessageStatus.sending: + return Icons.access_time; + case MessageStatus.sent: + return Icons.done; + case MessageStatus.delivered: + return Icons.done_all; + case MessageStatus.read: + return Icons.done_all; + case MessageStatus.failed: + return Icons.error; + } + } + + Color _statusColor(MessageStatus status, bool isMe) { + switch (status) { + case MessageStatus.read: + return isMe ? Colors.blue : Colors.blue; + case MessageStatus.failed: + return Colors.red; + default: + return isMe ? Colors.white70 : Colors.black54; + } + } + + String _formatTime(DateTime time) { + final hour = time.hour.toString().padLeft(2, '0'); + final minute = time.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9493ab3..8f8e0ca 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import firebase_core import firebase_messaging import flutter_local_notifications import flutter_secure_storage_darwin +import local_auth_darwin import path_provider_foundation import shared_preferences_foundation import sqflite_darwin @@ -20,6 +21,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/pubspec.lock b/pubspec.lock index 2f379e8..bedff5d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -222,6 +222,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_secure_storage: dependency: "direct main" description: @@ -296,6 +304,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" jni: dependency: transitive description: @@ -360,6 +376,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 + url: "https://pub.dev" + source: hosted + version: "1.0.56" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" + url: "https://pub.dev" + source: hosted + version: "1.6.1" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a9e56fc..cc69a2b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: provider: ^6.1.5+1 http: ^1.6.0 flutter_secure_storage: ^10.0.0 + local_auth: ^2.1.4 jwt_decoder: ^2.0.1 web_socket_channel: ^3.0.3 cryptography: ^2.5.0 diff --git a/srv/alembic/versions/fec40bfbf131_.py b/srv/alembic/versions/fec40bfbf131_.py new file mode 100644 index 0000000..beab2f7 --- /dev/null +++ b/srv/alembic/versions/fec40bfbf131_.py @@ -0,0 +1,32 @@ +"""empty message + +Revision ID: fec40bfbf131 +Revises: b577fae9f973 +Create Date: 2026-04-26 09:31:26.295497 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fec40bfbf131' +down_revision: Union[str, Sequence[str], None] = 'b577fae9f973' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/srv/app/api/endpoints/messages.py b/srv/app/api/endpoints/messages.py index f917389..5b1792f 100644 --- a/srv/app/api/endpoints/messages.py +++ b/srv/app/api/endpoints/messages.py @@ -3,6 +3,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 fastapi.encoders import jsonable_encoder # бд @@ -31,5 +32,5 @@ async def get_chat_history( (models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id) ).order_by(models.Message.timestamp.desc()).limit(limit).all() - return messages + return jsonable_encoder(messages) diff --git a/srv/app/api/endpoints/users.py b/srv/app/api/endpoints/users.py index 94acfbd..ec9dd2f 100644 --- a/srv/app/api/endpoints/users.py +++ b/srv/app/api/endpoints/users.py @@ -4,6 +4,8 @@ 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.exc import IntegrityError # бд @@ -25,7 +27,145 @@ usersRouter = APIRouter( @usersRouter.get("/me") async def read_users_me(current_user: models.User = Depends(get_current_user)): - return {"id": current_user.id, "username": current_user.username, "first_name": current_user.first_name, "last_name": current_user.last_name, "public_key": current_user.public_key, "encrypted_private_key": current_user.encrypted_private_key} + return { + "id": current_user.id, + "username": current_user.username, + "first_name": current_user.first_name, + "last_name": current_user.last_name, + "phone": current_user.phone, + "email": getattr(current_user, "email", None), + "about": current_user.about, + "public_key": current_user.public_key, + "encrypted_private_key": current_user.encrypted_private_key, + } + + +@usersRouter.put("/me") +async def update_users_me( + data: schemas.UpdateMe, + current_user: models.User = Depends(get_current_user), + 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: + user_to_update.first_name = data.first_name + if data.last_name is not None: + user_to_update.last_name = data.last_name + if data.phone is not None: + user_to_update.phone = data.phone or None + if data.email is not None: + user_to_update.email = data.email or None + if data.about is not None: + user_to_update.about = data.about or None + + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=400, detail="phone/email already in use") + + db.refresh(user_to_update) + return { + "status": "ok", + "user": { + "id": user_to_update.id, + "username": user_to_update.username, + "first_name": user_to_update.first_name, + "last_name": user_to_update.last_name, + "phone": user_to_update.phone, + "email": getattr(user_to_update, "email", None), + "about": user_to_update.about, + }, + } + + +@usersRouter.put("/me/encryption-key") +async def update_encrypted_private_key( + data: schemas.UpdateEncryptedPrivateKey, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db), +): + user_to_update = db.merge(current_user) + user_to_update.encrypted_private_key = data.encrypted_private_key + + try: + db.commit() + except Exception: + db.rollback() + raise HTTPException(status_code=500, detail="Не удалось сохранить ключ шифрования") + + db.refresh(user_to_update) + return {"status": "ok"} + + +@usersRouter.put("/me/password") +async def change_password( + data: schemas.ChangePassword, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db), +): + from app.core.security import verify_password, get_password_hash + + if not verify_password(data.current_password, current_user.hashed_password): + raise HTTPException(status_code=400, detail="Неверный текущий пароль") + + user_to_update = db.merge(current_user) + user_to_update.hashed_password = get_password_hash(data.new_password) + + try: + db.commit() + except Exception: + db.rollback() + raise HTTPException(status_code=500, detail="Не удалось изменить пароль") + + db.refresh(user_to_update) + return {"status": "ok"} + + +@usersRouter.put("/me/privacy") +async def update_privacy_settings( + data: schemas.UpdatePrivacySettings, + current_user: models.User = Depends(get_current_user), + 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: + user_to_update.show_phone = 1 if data.show_phone else 0 + if data.show_avatar is not None: + user_to_update.show_avatar = 1 if data.show_avatar else 0 + if data.show_about is not None: + user_to_update.show_about = 1 if data.show_about else 0 + if data.show_username is not None: + user_to_update.show_username = 1 if data.show_username else 0 + + try: + db.commit() + except Exception: + db.rollback() + raise HTTPException(status_code=500, detail="Не удалось сохранить настройки конфиденциальности") + + db.refresh(user_to_update) + return {"status": "ok"} + + +@usersRouter.get("/me/privacy") +async def get_privacy_settings(current_user: models.User = Depends(get_current_user)): + """ + Получить настройки конфиденциальности текущего пользователя. + """ + return { + "show_email": bool(current_user.show_email), + "show_phone": bool(current_user.show_phone), + "show_avatar": bool(current_user.show_avatar), + "show_about": bool(current_user.show_about), + "show_username": bool(current_user.show_username), + } @usersRouter.get("/all") @@ -34,18 +174,104 @@ async def read_users_all(current_user: models.User = Depends(get_current_user), return [{"id": user.id, "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip(), "public_key": user.public_key} for user in users] -@usersRouter.get("/{user_id}", response_model=schemas.UserPublic) +@usersRouter.get("/chats") +async def read_users_chats( + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """ + Список контактов для экрана чатов: последний месседж + время + непрочитанные. + + last_message возвращается в том виде, как хранится в БД (зашифрованный content). + Клиент должен расшифровать превью локально. + """ + users = ( + db.query(models.User) + .filter(models.User.id != current_user.id) + .all() + ) + + result = [] + for user in users: + last_msg = ( + db.query(models.Message) + .filter( + or_( + and_( + models.Message.sender_id == current_user.id, + models.Message.receiver_id == user.id, + ), + and_( + models.Message.sender_id == user.id, + models.Message.receiver_id == current_user.id, + ), + ) + ) + .order_by(models.Message.timestamp.desc()) + .first() + ) + + unread_count = ( + db.query(models.Message) + .filter( + models.Message.sender_id == user.id, + models.Message.receiver_id == current_user.id, + models.Message.read_at.is_(None), + ) + .count() + ) + + result.append( + { + "id": user.id, + "username": user.username, + "name": f"{user.first_name} {user.last_name or ''}".strip(), + "public_key": user.public_key, + "last_message": last_msg.content if last_msg else None, + "last_message_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp else None), + "unread_count": unread_count, + } + ) + + return result + + +@usersRouter.get("/{user_id}", response_model=schemas.UserProfile) def get_user_by_id( user_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user) ): """ - Получить публичную информацию о пользователе, включая его публичный ключ. + Получить информацию о пользователе с учетом настроек конфиденциальности. """ user = db.query(models.User).filter(models.User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="Пользователь не найден") - return user + # Возвращаем информацию с учетом настроек конфиденциальности + profile_data = { + "id": user.id, + "public_key": user.public_key, + } + + # Проверяем настройки конфиденциальности + if user.show_username: + profile_data["username"] = user.username + + if user.show_avatar: + # Для аватара пока просто передаем имя, клиент сам сгенерирует аватар + profile_data["first_name"] = user.first_name + profile_data["last_name"] = user.last_name + + if user.show_about: + profile_data["about"] = user.about + + if user.show_phone: + profile_data["phone"] = user.phone + + if user.show_email: + profile_data["email"] = user.email + + return profile_data diff --git a/srv/app/api/schemas.py b/srv/app/api/schemas.py index 224c2bd..b0b8a08 100644 --- a/srv/app/api/schemas.py +++ b/srv/app/api/schemas.py @@ -21,4 +21,40 @@ class UserPublic(BaseModel): public_key: Optional[str] = None class Config: - from_attributes = True \ No newline at end of file + from_attributes = True + + +class UpdateMe(BaseModel): + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + about: Optional[str] = None + +class UpdateEncryptedPrivateKey(BaseModel): + encrypted_private_key: str + +class ChangePassword(BaseModel): + current_password: str + new_password: str + +class UpdatePrivacySettings(BaseModel): + show_email: Optional[bool] = None + show_phone: Optional[bool] = None + show_avatar: Optional[bool] = None + show_about: Optional[bool] = None + show_username: Optional[bool] = None + +class UserProfile(BaseModel): + id: int + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + about: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + public_key: Optional[str] = None + + class Config: + from_attributes = True diff --git a/srv/app/db/models.py b/srv/app/db/models.py index f0ae5cd..49fccd5 100644 --- a/srv/app/db/models.py +++ b/srv/app/db/models.py @@ -3,6 +3,7 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime from sqlalchemy.sql import func +from sqlalchemy import text SQLALCHEMY_DATABASE_URL = "sqlite:///./chepuhagram.db" engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) @@ -18,12 +19,20 @@ class User(Base): username = Column(String, unique=True, index=True) about = Column(String, nullable=True) phone = Column(String(20), unique=True, nullable=True) + email = Column(String(255), unique=True, nullable=True) totp_secret = Column(String(32), nullable=True) hashed_password = Column(String) public_key = Column(String, nullable=True) encrypted_private_key = Column(String, nullable=True) fcm_token = Column(String, nullable=True) + # Privacy settings + show_email = Column(Integer, nullable=False, server_default="1") # 1 = true, 0 = false + show_phone = Column(Integer, nullable=False, server_default="1") + show_avatar = Column(Integer, nullable=False, server_default="1") + show_about = Column(Integer, nullable=False, server_default="1") + show_username = Column(Integer, nullable=False, server_default="1") + class Message(Base): __tablename__ = "messages" id = Column(Integer, primary_key=True, index=True) @@ -31,5 +40,57 @@ class Message(Base): receiver_id = Column(Integer, ForeignKey("users.id")) content = Column(Text) timestamp = Column(DateTime(timezone=True), server_default=func.now()) + delivered_at = Column(DateTime(timezone=True), nullable=True) + 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) -Base.metadata.create_all(bind=engine) \ No newline at end of file +Base.metadata.create_all(bind=engine) + + +def _ensure_sqlite_message_columns(): + # Простая авто-миграция для SQLite без Alembic. + # Добавляет колонки, если приложение обновилось на уже существующей БД. + with engine.connect() as conn: + cols = conn.execute(text("PRAGMA table_info(messages)")).fetchall() + existing = {row[1] for row in cols} # row[1] = name + + if "delivered_at" not in existing: + conn.execute(text("ALTER TABLE messages ADD COLUMN delivered_at DATETIME")) + if "read_at" not in existing: + conn.execute(text("ALTER TABLE messages ADD COLUMN read_at DATETIME")) + if "reply_to_id" not in existing: + 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")) + conn.commit() + + +_ensure_sqlite_message_columns() + + +def _ensure_sqlite_user_columns(): + with engine.connect() as conn: + cols = conn.execute(text("PRAGMA table_info(users)")).fetchall() + existing = {row[1] for row in cols} + + if "about" not in existing: + conn.execute(text("ALTER TABLE users ADD COLUMN about TEXT")) + if "phone" not in existing: + conn.execute(text("ALTER TABLE users ADD COLUMN phone VARCHAR(20)")) + if "email" not in existing: + conn.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR(255)")) + if "show_email" not in existing: + conn.execute(text("ALTER TABLE users ADD COLUMN show_email INTEGER DEFAULT 1")) + if "show_phone" not in existing: + conn.execute(text("ALTER TABLE users ADD COLUMN show_phone INTEGER DEFAULT 1")) + if "show_avatar" not in existing: + conn.execute(text("ALTER TABLE users ADD COLUMN show_avatar INTEGER DEFAULT 1")) + if "show_about" not in existing: + conn.execute(text("ALTER TABLE users ADD COLUMN show_about INTEGER DEFAULT 1")) + if "show_username" not in existing: + conn.execute(text("ALTER TABLE users ADD COLUMN show_username INTEGER DEFAULT 1")) + conn.commit() + + +_ensure_sqlite_user_columns() diff --git a/srv/app/websocket/connection_manager.py b/srv/app/websocket/connection_manager.py index 0533946..bef171b 100644 --- a/srv/app/websocket/connection_manager.py +++ b/srv/app/websocket/connection_manager.py @@ -33,7 +33,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: await websocket.close(code=status.WS_1008_POLICY_VIOLATION) return try: - user_id = await test_token(token=token) + user_id = int(await test_token(token=token)) except HTTPException: await websocket.close(code=status.WS_1008_POLICY_VIOLATION) return @@ -51,18 +51,47 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: user = db.query(models.User).filter(models.User.id == user_id).first() receiver_id = message_data.get("receiver_id") + temp_id = message_data.get("temp_id") content = message_data.get("content") content50 = message_data.get("content50") + + if receiver_id is None or content is None: + await websocket.send_json({ + "type": "error", + "detail": "receiver_id/content required", + }) + continue + + try: + receiver_id = int(receiver_id) + except (TypeError, ValueError): + await websocket.send_json({ + "type": "error", + "detail": "receiver_id must be int", + }) + continue + new_msg = models.Message( sender_id=user_id, receiver_id=receiver_id, - content=content + content=content, + reply_to_id=message_data.get("reply_to_id"), + reply_to_text=message_data.get("reply_to_text") ) db.add(new_msg) db.commit() db.refresh(new_msg) - if receiver_id not in manager.active_connections and user.public_key != '': + # ACK отправителю: сервер принял и сохранил сообщение (нужно для статусов клиента). + await manager.send_personal_message({ + "type": "message_sent", + "temp_id": temp_id, + "server_id": new_msg.id, + "timestamp": (new_msg.timestamp or datetime.now()).isoformat(), + }, str(user_id)) + + # Если получатель оффлайн — отправим пуш (если есть токен и ключи). + if user.public_key: receiver = db.query(models.User).filter( models.User.id == receiver_id).first() if receiver.fcm_token: @@ -79,13 +108,61 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: "id": new_msg.id, "type": "private_message", "sender_id": user_id, - "reciever_id": receiver_id, + "receiver_id": receiver_id, "content": message_data.get("content"), - "timestamp": datetime.now().isoformat() + "timestamp": (new_msg.timestamp or datetime.now()).isoformat(), + "reply_to_id": new_msg.reply_to_id, + "reply_to_text": new_msg.reply_to_text, } # Пересылаем получателю, если он в сети - await manager.send_personal_message(outgoing_message, str(receiver_id)) + sent_to_receiver = await manager.send_personal_message(outgoing_message, str(receiver_id)) + + # Если сообщение реально ушло по сокету получателю — отмечаем delivered_at. + if sent_to_receiver: + try: + delivered_at = datetime.now() + new_msg.delivered_at = delivered_at + db.add(new_msg) + db.commit() + await manager.send_personal_message({ + "type": "message_delivered", + "message_id": new_msg.id, + "timestamp": delivered_at.isoformat(), + }, str(user_id)) + except Exception: + db.rollback() + + elif message_data.get("type") == "read_receipt": + message_id = message_data.get("message_id") + try: + message_id = int(message_id) + except (TypeError, ValueError): + continue + + msg = db.query(models.Message).filter(models.Message.id == message_id).first() + if msg is None: + continue + + # Безопасность: read_receipt может отправлять только получатель. + if int(msg.receiver_id) != int(user_id): + continue + + # Сохраняем read_at в БД + try: + read_at = datetime.now() + msg.read_at = read_at + db.add(msg) + db.commit() + except Exception: + db.rollback() + + sender_id = int(msg.sender_id) + await manager.send_personal_message({ + "type": "message_read", + "message_id": message_id, + "timestamp": read_at.isoformat() if 'read_at' in locals() else datetime.now().isoformat(), + }, str(sender_id)) except WebSocketDisconnect: pass finally: @@ -120,20 +197,27 @@ class ConnectionManager: # Храним активные соединения: {user_id: websocket} self.active_connections: Dict[str, WebSocket] = {} - async def connect(self, user_id: str, websocket: WebSocket): + async def connect(self, user_id: int, websocket: WebSocket): await websocket.accept() - self.active_connections[user_id] = websocket + self.active_connections[str(user_id)] = websocket - def disconnect(self, user_id: str): - if user_id in self.active_connections: - del self.active_connections[user_id] + def disconnect(self, user_id: int): + key = str(user_id) + if key in self.active_connections: + del self.active_connections[key] - async def send_personal_message(self, message: dict, user_id: str): + async def send_personal_message(self, message: dict, user_id: str) -> bool: if str(user_id) in self.active_connections: - await self.active_connections[str(user_id)].send_json(message) - print('Sent to socket') + try: + await self.active_connections[str(user_id)].send_json(message) + print('Sent to socket') + return True + except Exception as e: + print(f'Failed to send to socket: {e}') + return False else: print('User not active') + return False async def broadcast(self, message: dict): # Рассылка вообще всем (например, системное уведомление) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 39cedd3..8baacc7 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,13 @@ #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + LocalAuthPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LocalAuthPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b1ad9e1..09806b2 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST firebase_core flutter_secure_storage_windows + local_auth_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST