Добавлены смена паролей, данных пользователя. Сделан ответ на сообщения и копирование сообщений

This commit is contained in:
Artur 2026-04-26 21:20:03 +05:00
parent 1b8670d811
commit 2d28fcc1fe
35 changed files with 2651 additions and 201 deletions

View File

@ -18,21 +18,6 @@ migration:
- platform: android - platform: android
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
base_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 # User provided section

View File

@ -1,11 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<application <application
android:label="Chepuhagram" android:label="Chepuhagram"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@ -1,5 +1,5 @@
package ru.chepuhagram.app package ru.chepuhagram.app
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterActivity() class MainActivity : FlutterFragmentActivity()

View File

@ -28,6 +28,8 @@
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
<string>Main</string> <string>Main</string>
<key>NSFaceIDUsageDescription</key>
<string>Используется для подтверждения доступа к ключу шифрования</string>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>

View File

@ -19,7 +19,7 @@ class LocalDbService {
String path = join(await getDatabasesPath(), 'chat_app.db'); String path = join(await getDatabasesPath(), 'chat_app.db');
return await openDatabase( return await openDatabase(
path, path,
version: 1, version: 3,
onCreate: (db, version) async { onCreate: (db, version) async {
await db.execute(''' await db.execute('''
CREATE TABLE messages( CREATE TABLE messages(
@ -27,10 +27,24 @@ class LocalDbService {
sender_id INTEGER, sender_id INTEGER,
receiver_id INTEGER, receiver_id INTEGER,
content TEXT, 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, 'receiver_id': msg.receiverId,
'content': msg.text, // ВАЖНО: сохраняй зашифрованный текст! 'content': msg.text, // ВАЖНО: сохраняй зашифрованный текст!
'timestamp': msg.createdAt.toIso8601String(), 'timestamp': msg.createdAt.toIso8601String(),
'delivered_at': null,
'read_at': null,
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} else { } else {
// Если это Map из API // Если это Map из API
@ -55,6 +71,10 @@ class LocalDbService {
'receiver_id': msg['receiver_id'], // Убедись, что ключ совпадает с API 'receiver_id': msg['receiver_id'], // Убедись, что ключ совпадает с API
'content': msg['content'], 'content': msg['content'],
'timestamp': msg['timestamp'], '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); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }
} }
@ -75,4 +95,48 @@ class LocalDbService {
orderBy: 'timestamp ASC', orderBy: 'timestamp ASC',
); );
} }
Future<Map<String, dynamic>?> 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<void> updateDeliveredAt(int messageId, DateTime deliveredAt) async {
final db = await database;
await db.update(
'messages',
{'delivered_at': deliveredAt.toIso8601String()},
where: 'id = ?',
whereArgs: [messageId],
);
}
Future<void> updateReadAt(int messageId, DateTime readAt) async {
final db = await database;
await db.update(
'messages',
{'read_at': readAt.toIso8601String()},
where: 'id = ?',
whereArgs: [messageId],
);
}
Future<void> deleteMessage(int messageId) async {
final db = await database;
await db.delete(
'messages',
where: 'id = ?',
whereArgs: [messageId],
);
}
} }

View File

@ -23,6 +23,10 @@ class SocketService {
Future<void> connect(ApiService apiService) async { Future<void> connect(ApiService apiService) async {
final token = await apiService.getAccessToken(); final token = await apiService.getAccessToken();
if (_channel != null) return; // Уже подключены if (_channel != null) return; // Уже подключены
if (token == null || token.isEmpty) {
print('❌ SocketService.connect: no access token, skipping connect');
return;
}
// В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке // В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке
final uri = Uri.parse("ws://${AppConstants.baseUrl}/ws?token=$token"); final uri = Uri.parse("ws://${AppConstants.baseUrl}/ws?token=$token");
@ -32,6 +36,7 @@ class SocketService {
_channel!.stream.listen( _channel!.stream.listen(
(data) { (data) {
final decoded = jsonDecode(data); final decoded = jsonDecode(data);
print("🚀 СООБЩЕНИЕ ПОЛУЧЕНО ИЗ SINK: $decoded");
_messageController.add(decoded); _messageController.add(decoded);
}, },
onError: (error) => _reconnect(apiService), onError: (error) => _reconnect(apiService),
@ -44,10 +49,11 @@ class SocketService {
Future.delayed(const Duration(seconds: 5), () => connect(apiService)); Future.delayed(const Duration(seconds: 5), () => connect(apiService));
} }
void sendMessage(Map<String, dynamic> data) { bool sendMessage(Map<String, dynamic> data, {int retryCnt = 0}) {
if (_channel == null) { if (_channel == null) {
print("❌ ОШИБКА: Попытка отправить сообщение через NULL канал."); //print("❌ ОШИБКА: Попытка отправить сообщение через NULL канал.");
return; sendMessage(data, retryCnt: retryCnt + 1);
return false;
} }
try { try {
final encodedData = jsonEncode(data); final encodedData = jsonEncode(data);
@ -57,11 +63,20 @@ class SocketService {
// 2. Добавляем принт подтверждения // 2. Добавляем принт подтверждения
print("🚀 СООБЩЕНИЕ ОТПРАВЛЕНО В SINK: $encodedData"); print("🚀 СООБЩЕНИЕ ОТПРАВЛЕНО В SINK: $encodedData");
return true;
} catch (e) { } catch (e) {
print("❌ КРИТИЧЕСКАЯ ОШИБКА ПРИ ОТПРАВКЕ: $e"); print("❌ КРИТИЧЕСКАЯ ОШИБКА ПРИ ОТПРАВКЕ: $e");
return false;
} }
} }
bool sendReadReceipt(int messageId) {
return sendMessage({
'type': 'read_receipt',
'message_id': messageId,
});
}
void disconnect() { void disconnect() {
_channel?.sink.close(status.goingAway); _channel?.sink.close(status.goingAway);
_channel = null; _channel = null;

View File

@ -23,13 +23,51 @@ class Contact {
this.publicKey, 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<String, dynamic> json) { factory Contact.fromJson(Map<String, dynamic> 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( return Contact(
id: json['id'], id: json['id'],
username: json['username'] ?? 'Unknown', username: json['username'] ?? 'Unknown',
name: json['name'] ?? 'Unknown', name: json['name'] ?? 'Unknown',
surname: json['surname'] ?? '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'], publicKey: json['public_key'],
); );
} }
} }

View File

@ -1,41 +1,86 @@
enum MessageStatus { sending, sent, delivered, read, failed }
class MessageModel { class MessageModel {
final int? id; // ID из базы данных (null, если сообщение еще не отправлено) final int? id; // server id (null пока не подтверждено сервером)
final int senderId; // ID отправителя final int? tempId; // client temp id (для сопоставления ack)
final int receiverId; // ID отправителя final int senderId;
final String text; // Текст сообщения final int receiverId;
final DateTime createdAt; // Время отправки final String text; // текст для UI (у нас уже расшифрованный)
final bool isMe; // Локальный флаг для UI (мое/чужое) final DateTime createdAt;
final bool isMe;
final MessageStatus status;
final int? replyToId; // ID сообщения, на которое отвечают
final String? replyToText; // текст сообщения, на которое отвечают (для отображения)
MessageModel({ MessageModel({
this.id, this.id,
this.tempId,
required this.senderId, required this.senderId,
required this.receiverId, required this.receiverId,
required this.text, required this.text,
required this.createdAt, required this.createdAt,
this.isMe = false, required this.isMe,
this.status = MessageStatus.sent,
this.replyToId,
this.replyToText,
}); });
// Превращаем JSON от бэкенда в объект Dart MessageModel copyWith({
factory MessageModel.fromJson(Map<String, dynamic> json, int currentUserId) { int? id,
int? tempId,
int? senderId,
int? receiverId,
String? text,
DateTime? createdAt,
bool? isMe,
MessageStatus? status,
int? replyToId,
String? replyToText,
}) {
return MessageModel( return MessageModel(
id: json['id'], id: id ?? this.id,
senderId: json['sender_id'], tempId: tempId ?? this.tempId,
receiverId: json['receiverId'], senderId: senderId ?? this.senderId,
text: json['text'] ?? '', receiverId: receiverId ?? this.receiverId,
// Парсим дату из ISO строки или временной метки text: text ?? this.text,
createdAt: DateTime.parse(json['created_at']), createdAt: createdAt ?? this.createdAt,
// Сразу вычисляем, наше ли это сообщение isMe: isMe ?? this.isMe,
isMe: json['sender_id'] == currentUserId, status: status ?? this.status,
replyToId: replyToId ?? this.replyToId,
replyToText: replyToText ?? this.replyToText,
);
}
factory MessageModel.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'text': text, 'id': id,
'temp_id': tempId,
'sender_id': senderId, 'sender_id': senderId,
// На бэкенд обычно отправляем строку в формате ISO 8601 'receiver_id': receiverId,
'text': text,
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
'status': status.name,
'reply_to_id': replyToId,
'reply_to_text': replyToText,
}; };
} }
} }

View File

@ -8,7 +8,29 @@ class ContactRepository {
final http.Client _client = http.Client(); final http.Client _client = http.Client();
final ApiService _apiService = ApiService(); final ApiService _apiService = ApiService();
Future<List<Contact>> fetchContacts() async { Future<List<Contact>> 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<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
return data.map((json) => Contact.fromJson(json)).toList();
} else {
throw Exception('Failed to load contacts');
}
}
Future<List<Contact>> fetchAllUsers() async {
final token = await _apiService.getAccessToken(); final token = await _apiService.getAccessToken();
if (token == null) { if (token == null) {
throw Exception('No access token'); throw Exception('No access token');

View File

@ -117,6 +117,53 @@ class ApiService extends ChangeNotifier {
} }
} }
Future<Map<String, dynamic>> 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<String, dynamic>;
}
throw Exception('Не удалось получить данные пользователя');
}
Future<bool> 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<bool> 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<List<dynamic>> getChatHistory(int contactId) async { Future<List<dynamic>> getChatHistory(int contactId) async {
final token = await getAccessToken(); final token = await getAccessToken();
final response = await http.get( final response = await http.get(
@ -131,4 +178,94 @@ class ApiService extends ChangeNotifier {
); );
return jsonDecode(response.body) as List<dynamic>; return jsonDecode(response.body) as List<dynamic>;
} }
Future<Map<String, dynamic>> 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<String, dynamic>;
}
throw Exception((decoded is Map && decoded['detail'] != null) ? decoded['detail'] : 'Failed to update profile');
}
Future<Map<String, dynamic>> 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<String, dynamic>;
}
throw Exception('Не удалось получить информацию о пользователе');
}
Future<bool> 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<Map<String, dynamic>> 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<String, dynamic>;
}
throw Exception('Не удалось получить настройки конфиденциальности');
}
} }

View File

@ -65,6 +65,23 @@ class CryptoService {
} }
} }
Future<String> 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<SecretKey> _deriveKeyFromPassword(String password) async { Future<SecretKey> _deriveKeyFromPassword(String password) async {
final pbkdf2 = Pbkdf2( final pbkdf2 = Pbkdf2(
macAlgorithm: Hmac.sha256(), macAlgorithm: Hmac.sha256(),

View File

@ -14,6 +14,47 @@ class AuthProvider extends ChangeNotifier {
int? _currentUserId; int? _currentUserId;
int? get currentUserId => _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 _needsSetup = false;
bool get needsSetup => _needsSetup; bool get needsSetup => _needsSetup;
@ -91,6 +132,13 @@ class AuthProvider extends ChangeNotifier {
final mode = await _storage.read(key: 'theme_mode'); final mode = await _storage.read(key: 'theme_mode');
final color = await _storage.read(key: 'accent_color'); final color = await _storage.read(key: 'accent_color');
await _storage.deleteAll(); await _storage.deleteAll();
_currentUserId = null;
_username = null;
_firstName = null;
_lastName = null;
_phone = null;
_email = null;
_about = null;
if (mode != null) { if (mode != null) {
await _storage.write(key: 'theme_mode', value: mode); await _storage.write(key: 'theme_mode', value: mode);
} }
@ -192,6 +240,13 @@ class AuthProvider extends ChangeNotifier {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map; 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; _hasPublicKeyOnServer = data['public_key'] != null && data['public_key'].isNotEmpty;
@ -213,6 +268,24 @@ class AuthProvider extends ChangeNotifier {
_needsKeyRecovery = false; _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) { } catch (e) {
print("Ошибка проверки статуса: $e"); print("Ошибка проверки статуса: $e");
_needsSetup = false; _needsSetup = false;
@ -221,6 +294,10 @@ class AuthProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> refreshMe() async {
await _checkAccountStatus();
}
// Метод для начала с чистого листа (новые ключи) // Метод для начала с чистого листа (новые ключи)
Future<void> resetKeys() async { Future<void> resetKeys() async {
await _storage.delete(key: 'private_key'); await _storage.delete(key: 'private_key');

View File

@ -1,12 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '/data/models/contact_model.dart'; import '/data/models/contact_model.dart';
import '/data/repositories/contact_repository.dart'; import '/data/repositories/contact_repository.dart';
import '/data/datasources/local_db_service.dart';
import '/domain/services/crypto_service.dart';
class ContactProvider extends ChangeNotifier { class ContactProvider extends ChangeNotifier {
final ContactRepository _repository = ContactRepository(); final ContactRepository _repository = ContactRepository();
final LocalDbService _localDbService = LocalDbService();
final CryptoService _cryptoService = CryptoService();
List<Contact> _contacts = []; List<Contact> _contacts = [];
List<Contact> _allContacts = []; List<Contact> _allContacts = [];
bool _isLoading = false; bool _isLoading = false;
bool _isFirstLoad = true;
String? _error; String? _error;
int? _currentUserId; int? _currentUserId;
@ -25,19 +30,33 @@ class ContactProvider extends ChangeNotifier {
} }
Future<void> loadContacts() async { Future<void> loadContacts() async {
_isLoading = true; if (_isFirstLoad) {
_isFirstLoad = false;
_isLoading = true;
}
_error = null; _error = null;
notifyListeners(); notifyListeners();
try { 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; _allContacts = _contacts;
_isLoading = false;
notifyListeners();
// Обогащаем превью последним сообщением из локальной БД, не блокируя UI.
_enrichContactsWithLastMessages();
} catch (e) { } catch (e) {
_error = e.toString(); _error = e.toString();
} finally { } finally {
_isLoading = false; // Если ошибка выходим из состояния загрузки тут.
// Если всё ок `_isLoading` уже сброшен выше, чтобы показать список быстрее.
if (_error != null) {
_isLoading = false;
}
notifyListeners(); notifyListeners();
} }
} }
@ -49,9 +68,11 @@ class ContactProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
try { 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) { } catch (e) {
_error = e.toString(); _error = e.toString();
} finally { } finally {
@ -59,4 +80,93 @@ class ContactProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
} }
}
Future<void> _enrichContactsWithLastMessages() async {
final myId = _currentUserId;
if (myId == null) return;
final myPrivKey = await _cryptoService.getPrivateKey();
final List<Contact> updated = List<Contact>.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();
}
}

View File

@ -27,6 +27,8 @@ RemoteMessage? initialMessage;
// Ключ для SharedPreferences // Ключ для SharedPreferences
const String _notificationLaunchKey = 'notification_launch_data'; const String _notificationLaunchKey = 'notification_launch_data';
// Защита от повторной обработки одного и того же payload при следующих запусках по иконке
const String _lastHandledNotificationLaunchPayloadKey = 'notification_last_handled_payload';
Future<void> _onSelectNotification(NotificationResponse notificationResponse) async { Future<void> _onSelectNotification(NotificationResponse notificationResponse) async {
final payload = notificationResponse.payload; final payload = notificationResponse.payload;
@ -39,12 +41,17 @@ Future<void> _onSelectNotification(NotificationResponse notificationResponse) as
final context = navigatorKey.currentContext; final context = navigatorKey.currentContext;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final canonicalPayload = jsonEncode(data);
// Важно: не сохраняем payload в SharedPreferences, если можем сразу перейти в чат. // Важно: не сохраняем payload в SharedPreferences, если можем сразу перейти в чат.
// Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение // Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение
// будет снова автопереходить в чат. // будет снова автопереходить в чат.
if (context == null) { 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'); print('Navigator context is null, saved notification payload to SharedPreferences');
} else { } else {
await prefs.remove(_notificationLaunchKey); await prefs.remove(_notificationLaunchKey);
@ -124,9 +131,16 @@ void main() async {
print('Message type: ${initialMessage!.data['type']}'); print('Message type: ${initialMessage!.data['type']}');
print('Sender ID: ${initialMessage!.data['sender_id']}'); print('Sender ID: ${initialMessage!.data['sender_id']}');
// Сохраняем данные уведомления final payloadString = jsonEncode(initialMessage!.data);
await prefs.setString(_notificationLaunchKey, jsonEncode(initialMessage!.data)); final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
print('Saved notification data to SharedPreferences'); 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 { } else {
print('No initial message - app launched normally'); print('No initial message - app launched normally');
// Очищаем сохраненные данные, если приложение запущено нормально // Очищаем сохраненные данные, если приложение запущено нормально
@ -148,10 +162,15 @@ void main() async {
print('App launched from local notification, payload: $payload'); print('App launched from local notification, payload: $payload');
if (payload != null && payload.isNotEmpty) { if (payload != null && payload.isNotEmpty) {
try { try {
final data = jsonDecode(payload); final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
final prefs = await SharedPreferences.getInstance(); if (lastHandled != payload) {
await prefs.setString(_notificationLaunchKey, jsonEncode(data)); final data = jsonDecode(payload);
print('Saved local notification launch payload to SharedPreferences'); 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) { } catch (e) {
print('Failed to save notification launch payload: $e'); print('Failed to save notification launch payload: $e');
} }
@ -235,6 +254,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
payload: jsonEncode({ payload: jsonEncode({
'type': 'enc_message', 'type': 'enc_message',
'sender_id': message.data['sender_id'], 'sender_id': message.data['sender_id'],
'timestamp': message.data['timestamp'] ?? DateTime.now().toIso8601String(),
}), }),
); );
print('Notification shown successfully'); print('Notification shown successfully');
@ -246,9 +266,46 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
} }
} }
class MyApp extends StatelessWidget { class MyApp extends StatefulWidget {
const MyApp({super.key}); const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Закрываем сокет, как только приложение сворачивается.
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
try {
context.read<AuthProvider>().closeRealtime();
} catch (_) {}
return;
}
// На возврате в приложение пробуем переподключиться (если есть токен).
if (state == AppLifecycleState.resumed) {
try {
context.read<AuthProvider>().initRealtime();
} catch (_) {}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>(); final themeProvider = context.watch<ThemeProvider>();

View File

@ -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<AccountSettingsScreen> createState() => _AccountSettingsScreenState();
}
class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
final _formKey = GlobalKey<FormState>();
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<AuthProvider>();
_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<void> _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<AuthProvider>().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,
),
],
),
),
);
}
}

View File

@ -14,6 +14,8 @@ import 'package:chepuhagram/data/datasources/local_db_service.dart';
import 'package:chepuhagram/main.dart'; import 'package:chepuhagram/main.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'package:flutter/services.dart';
import 'user_profile_screen.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
final Contact contact; final Contact contact;
@ -30,18 +32,23 @@ class _ChatScreenState extends State<ChatScreen> {
late Contact _currentContact; late Contact _currentContact;
bool _isKeyLoading = false; bool _isKeyLoading = false;
final TextEditingController _controller = TextEditingController(); final TextEditingController _controller = TextEditingController();
final FocusNode _inputFocusNode = FocusNode();
final ContactRepository _contactRepository = ContactRepository(); final ContactRepository _contactRepository = ContactRepository();
final apiService = ApiService(); final apiService = ApiService();
final CryptoService _cryptoService = CryptoService(); final CryptoService _cryptoService = CryptoService();
List<MessageModel> messages = []; List<MessageModel> messages = [];
StreamSubscription<dynamic>? _socketSubscription; StreamSubscription<dynamic>? _socketSubscription;
final Set<int> _sentReadReceipts = <int>{};
final LocalDbService _localDbService = LocalDbService();
MessageModel? _replyTo;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentContact = widget.contact; _currentContact = widget.contact;
currentActiveChatContactId = _currentContact.id; // Устанавливаем активный чат currentActiveChatContactId =
_currentContact.id; // Устанавливаем активный чат
final contactProvider = context.read<ContactProvider>(); final contactProvider = context.read<ContactProvider>();
myId = contactProvider.getCurrentUserId() ?? 0; myId = contactProvider.getCurrentUserId() ?? 0;
// Если ключа нет, загружаем его при входе // Если ключа нет, загружаем его при входе
@ -83,6 +90,7 @@ class _ChatScreenState extends State<ChatScreen> {
currentActiveChatContactId = null; // Сбрасываем активный чат currentActiveChatContactId = null; // Сбрасываем активный чат
_socketSubscription?.cancel(); _socketSubscription?.cancel();
_controller.dispose(); _controller.dispose();
_inputFocusNode.dispose();
super.dispose(); super.dispose();
} }
@ -93,16 +101,26 @@ class _ChatScreenState extends State<ChatScreen> {
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () { onPressed: () {
if (Navigator.of(context).canPop()) { Navigator.of(context).pushReplacement(
Navigator.of(context).pop(); MaterialPageRoute(builder: (_) => const ContactsScreen()),
} else { );
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( body: Column(
children: [ children: [
@ -113,9 +131,8 @@ class _ChatScreenState extends State<ChatScreen> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index]; final msg = messages[messages.length - 1 - index];
return MessageBubble( return MessageBubble(
message: msg.text, message: msg,
time: msg.createdAt, onTap: () => _showMessageActions(msg),
isMe: msg.isMe,
); );
}, },
), ),
@ -126,26 +143,134 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
Future<void> _showMessageActions(MessageModel msg) async {
if (!mounted) return;
await showModalBottomSheet<void>(
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() { Widget _buildMessageInput() {
return SafeArea( return SafeArea(
// Добавляем SafeArea здесь // Добавляем SafeArea здесь
child: Padding( child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Row( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Expanded( if (_replyTo != null)
child: TextField( Container(
controller: _controller, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: const InputDecoration( margin: const EdgeInsets.only(bottom: 8),
hintText: "Напиши сообщение...", 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),
),
],
), ),
), ),
), Row(
IconButton( crossAxisAlignment: CrossAxisAlignment.end,
icon: const Icon(Icons.send), children: [
onPressed: () { Expanded(
_sendMessage(); 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<ChatScreen> {
sharedSecret, 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 для сервера // Формируем payload для сервера
final payload = { final payload = {
"type": "private_message", "type": "private_message",
"receiver_id": _currentContact.id, "receiver_id": _currentContact.id,
"content": encryptedText, "content": encryptedText,
"content50": encryptedText50, "content50": encryptedText50,
"temp_id": tempId,
if (_replyTo?.id != null) ...{
"reply_to_id": _replyTo!.id,
"reply_to_text": _replyTo!.text,
},
}; };
// Отправляем // Отправляем
print("ОТПРАВКА: $payload"); print("ОТПРАВКА: $payload");
Provider.of<SocketService>(context, listen: false).sendMessage(payload); final ok = Provider.of<SocketService>(context, listen: false).sendMessage(payload);
// Обновляем UI (себе показываем расшифрованный текст)
if (!mounted) return;
setState(() { setState(() {
messages.add( final idx = messages.indexWhere((m) => m.tempId == tempId);
MessageModel( if (idx == -1) return;
text: rawText, messages[idx] = messages[idx].copyWith(
isMe: true, status: ok ? MessageStatus.sent : MessageStatus.failed,
senderId: myId,
receiverId: _currentContact.id,
createdAt: DateTime.now(),
),
); );
_replyTo = null;
}); });
_controller.clear(); _controller.clear();
@ -216,13 +359,105 @@ class _ChatScreenState extends State<ChatScreen> {
} }
} }
void _handleIncomingMessage(Map<String, dynamic> data) async { void _handleIncomingMessage(Map<String, dynamic> 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') { 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. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся // 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся
if (senderId == widget.contact.id) { final isFromPartnerToMe = senderId == widget.contact.id && receiverId == myId;
if (isFromPartnerToMe) {
try { try {
final myPrivKey = await _cryptoService.getPrivateKey(); final myPrivKey = await _cryptoService.getPrivateKey();
@ -241,14 +476,25 @@ class _ChatScreenState extends State<ChatScreen> {
// 4. Добавляем в список и обновляем экран // 4. Добавляем в список и обновляем экран
await LocalDbService().saveMessages([data]); await LocalDbService().saveMessages([data]);
if (!mounted) return; if (!mounted) return;
final serverMessageId = int.tryParse(data['id']?.toString() ?? '');
if (serverMessageId != null && !_sentReadReceipts.contains(serverMessageId)) {
Provider.of<SocketService>(context, listen: false).sendReadReceipt(serverMessageId);
_sentReadReceipts.add(serverMessageId);
}
setState(() { setState(() {
messages.add( messages.add(
MessageModel( MessageModel(
id: int.tryParse(data['id']?.toString() ?? ''),
text: decryptedText, text: decryptedText,
isMe: false, isMe: false,
senderId: senderId, senderId: senderId,
receiverId: myId, receiverId: myId,
createdAt: DateTime.parse(data['timestamp']), 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<ChatScreen> {
} }
Future<void> _loadHistory() async { Future<void> _loadHistory() async {
initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey); await prefs.remove(_notificationLaunchKey);
await prefs.setString(_notificationLaunchKey, ''); // Очищаем данные уведомления при загрузке ключа
try { try {
final myPrivKey = await _cryptoService.getPrivateKey(); final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret( final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!, myPrivKey!,
widget.contact.publicKey!, widget.contact.publicKey!,
); );
final localDb = LocalDbService(); final cached = await _localDbService.getChatHistory(widget.contact.id, myId);
final cached = await localDb.getChatHistory(widget.contact.id, myId);
try { try {
List<MessageModel> loadedLocalMessages = []; List<MessageModel> loadedLocalMessages = [];
@ -286,13 +529,36 @@ class _ChatScreenState extends State<ChatScreen> {
msg['content'], msg['content'],
sharedSecret, 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( loadedLocalMessages.add(
MessageModel( MessageModel(
id: int.tryParse(msg['id']?.toString() ?? ''),
text: decrypted, text: decrypted,
isMe: msg['sender_id'] == myId, isMe: msg['sender_id'] == myId,
senderId: msg['sender_id'], senderId: msg['sender_id'],
receiverId: msg['receiver_id'], receiverId: msg['receiver_id'],
createdAt: DateTime.parse(msg['timestamp']), 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<ChatScreen> {
final history = await apiService.getChatHistory(widget.contact.id); final history = await apiService.getChatHistory(widget.contact.id);
print(history); print(history);
final alreadyReadIncomingMessageIds = <int>{};
List<MessageModel> loadedMessages = []; List<MessageModel> loadedMessages = [];
for (var msg in history) { 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( final decrypted = await _cryptoService.decryptMessage(
msg['content'], msg['content'],
sharedSecret, 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( loadedMessages.insert(
0, 0,
MessageModel( MessageModel(
id: int.tryParse(msg['id']?.toString() ?? ''),
text: decrypted, text: decrypted,
isMe: msg['sender_id'] == myId, isMe: msg['sender_id'] == myId,
senderId: msg['sender_id'], senderId: msg['sender_id'],
receiverId: msg['receiver_id'], receiverId: msg['receiver_id'],
createdAt: DateTime.parse(msg['timestamp']), 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 { try {
await localDb.saveMessages(history); await _localDbService.saveMessages(history);
} catch (e) { } catch (e) {
print("Ошибка сохранения истории в локальную базу: $e"); print("Ошибка сохранения истории в локальную базу: $e");
} }
@ -337,6 +634,17 @@ class _ChatScreenState extends State<ChatScreen> {
messages = loadedMessages; messages = loadedMessages;
_isKeyLoading = false; _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<SocketService>(context, listen: false).sendReadReceipt(id);
_sentReadReceipts.add(id);
}
} catch (e) { } catch (e) {
print("Ошибка загрузки истории: $e"); print("Ошибка загрузки истории: $e");
if (!mounted) return; if (!mounted) return;

View File

@ -13,6 +13,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart'; import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:chepuhagram/main.dart'; import 'package:chepuhagram/main.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'dart:async';
class ContactsScreen extends StatefulWidget { class ContactsScreen extends StatefulWidget {
final int? targetChatId; final int? targetChatId;
@ -25,13 +27,12 @@ class ContactsScreen extends StatefulWidget {
class _ContactsScreenState extends State<ContactsScreen> { class _ContactsScreenState extends State<ContactsScreen> {
static const String _notificationLaunchKey = 'notification_launch_data'; static const String _notificationLaunchKey = 'notification_launch_data';
StreamSubscription<dynamic>? _socketSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
print( print('ContactsScreen initState, targetChatId: ${widget.targetChatId}');
'ContactsScreen initState, targetChatId: ${widget.targetChatId}',
);
_setupPushNotifications(); _setupPushNotifications();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final authProvider = context.read<AuthProvider>(); final authProvider = context.read<AuthProvider>();
@ -40,9 +41,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
// Установить текущего пользователя и загрузить контакты с сообщениями // Установить текущего пользователя и загрузить контакты с сообщениями
contactProvider.setCurrentUserId(authProvider.currentUserId); contactProvider.setCurrentUserId(authProvider.currentUserId);
contactProvider.loadContacts().then((_) { contactProvider.loadContacts().then((_) {
print( print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
'Contacts loaded, checking targetChatId: ${widget.targetChatId}',
);
// После загрузки контактов проверить, нужно ли перейти к чату // После загрузки контактов проверить, нужно ли перейти к чату
if (widget.targetChatId != null) { if (widget.targetChatId != null) {
_navigateToTargetChat(); _navigateToTargetChat();
@ -91,17 +90,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
} }
void _navigateToTargetChatWithId(int targetChatId) { void _navigateToTargetChatWithId(int targetChatId) {
print( print('_navigateToTargetChat called with targetChatId: $targetChatId');
'_navigateToTargetChat called with targetChatId: $targetChatId',
);
final contactProvider = context.read<ContactProvider>(); final contactProvider = context.read<ContactProvider>();
try { try {
final contact = contactProvider.contacts.firstWhere( final contact = contactProvider.contacts.firstWhere(
(c) => c.id == targetChatId, (c) => c.id == targetChatId,
); );
print( print('Auto-navigating to chat with contact: ${contact.username}');
'Auto-navigating to chat with contact: ${contact.username}',
);
currentActiveChatContactId = targetChatId; // Устанавливаем активный чат currentActiveChatContactId = targetChatId; // Устанавливаем активный чат
Navigator.push( Navigator.push(
context, context,
@ -158,9 +153,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
void _navigateToChatFromNotification(int senderId) { void _navigateToChatFromNotification(int senderId) {
final contactProvider = context.read<ContactProvider>(); final contactProvider = context.read<ContactProvider>();
print( print('Navigate to chat from notification with senderId: $senderId');
'Navigate to chat from notification with senderId: $senderId',
);
// Если контакты еще не загружены, ждем их загрузки // Если контакты еще не загружены, ждем их загрузки
if (contactProvider.contacts.isEmpty) { if (contactProvider.contacts.isEmpty) {
@ -178,9 +171,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
final contact = contactProvider.contacts.firstWhere( final contact = contactProvider.contacts.firstWhere(
(c) => c.id == senderId, (c) => c.id == senderId,
); );
print( print('Navigating to chat from notification: ${contact.username}');
'Navigating to chat from notification: ${contact.username}',
);
currentActiveChatContactId = senderId; // Устанавливаем активный чат currentActiveChatContactId = senderId; // Устанавливаем активный чат
Navigator.push( Navigator.push(
context, context,
@ -244,13 +235,26 @@ class _ContactsScreenState extends State<ContactsScreen> {
payload: jsonEncode({ payload: jsonEncode({
'type': 'enc_message', 'type': 'enc_message',
'sender_id': message.data['sender_id'], 'sender_id': message.data['sender_id'],
'timestamp':
message.data['timestamp'] ?? DateTime.now().toIso8601String(),
}), }),
); );
if (message.data['type'] == 'enc_message') {
final contactProvider = context.read<ContactProvider>();
contactProvider.loadContacts();
}
} catch (e) { } catch (e) {
print('Error processing foreground message: $e'); print('Error processing foreground message: $e');
} }
} }
@override
void dispose() {
_socketSubscription?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -309,20 +313,40 @@ class _ContactsScreenState extends State<ContactsScreen> {
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
children: [ children: [
// Шапка меню с данными юзера // Шапка меню с данными юзера
UserAccountsDrawerHeader( Consumer<AuthProvider>(
accountName: Text("Artur Karasevich"), builder: (context, authProvider, _) {
accountEmail: Text("@ArturKarasevich"), final username = authProvider.username;
currentAccountPicture: CircleAvatar( final displayName = authProvider.displayName;
backgroundColor: Theme.of(context).colorScheme.onSurface, final initials =
child: Icon( (displayName.isNotEmpty ? displayName : (username ?? 'U'))
Icons.person, .trim()
size: 40, .split(RegExp(r'\s+'))
color: Theme.of(context).colorScheme.primaryContainer, .where((p) => p.isNotEmpty)
), .take(2)
), .map((p) => p[0].toUpperCase())
decoration: BoxDecoration( .join();
color: Theme.of(context).colorScheme.primaryContainer,
), 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( ListTile(
leading: const Icon(Icons.settings), leading: const Icon(Icons.settings),

View File

@ -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()),
);
},
),
],
),
);
}
}

View File

@ -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<PrivacySettingsScreen> createState() => _PrivacySettingsScreenState();
}
class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
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<void> _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<void> _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<void> _savePreference(String key, bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(key, value);
}
Future<void> _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),
),
],
),
);
}
}

View File

@ -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<SecuritySettingsScreen> createState() => _SecuritySettingsScreenState();
}
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
final _passwordFormKey = GlobalKey<FormState>();
final _encryptionFormKey = GlobalKey<FormState>();
final _totpFormKey = GlobalKey<FormState>();
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<void> _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<bool> _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<void> _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<void> _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<void> _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 код'),
),
],
),
);
}
}

View File

@ -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/login_screen.dart';
import 'package:chepuhagram/presentation/screens/privacy_settings_menu_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '/logic/auth_provider.dart'; import '/logic/auth_provider.dart';
@ -10,22 +12,58 @@ class SettingsScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>(); final themeProv = context.watch<ThemeProvider>();
final authProv = context.read<AuthProvider>(); final authProv = context.watch<AuthProvider>();
final accountEmail = authProv.email?.isNotEmpty == true
? authProv.email!
: authProv.username?.isNotEmpty == true
? '@${authProv.username!}'
: 'Не указано';
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text("Настройки")), appBar: AppBar(title: const Text("Настройки")),
body: Column( body: Column(
children: [ children: [
// Секция Профиля // Секция Профиля
const UserAccountsDrawerHeader( UserAccountsDrawerHeader(
accountName: Text("Artur Karasevich"), accountName: Text(authProv.displayName),
accountEmail: Text("@ArturKarasevich"), accountEmail: Text(accountEmail),
currentAccountPicture: CircleAvatar( currentAccountPicture: const CircleAvatar(
child: Icon(Icons.person, size: 40), 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(), const Divider(),
SwitchListTile( SwitchListTile(

View File

@ -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<UserProfileScreen> createState() => _UserProfileScreenState();
}
class _UserProfileScreenState extends State<UserProfileScreen> {
Map<String, dynamic>? _userData;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadUserData();
}
Future<void> _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(),
],
),
);
}
}

View File

@ -19,12 +19,12 @@ class ContactTile extends StatelessWidget {
radius: 28, radius: 28,
backgroundColor: primary.withAlpha((0.1 * 255).round()), backgroundColor: primary.withAlpha((0.1 * 255).round()),
child: Text( child: Text(
contact.surname[0], contact.name[0],
style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold) style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)
), ),
), ),
title: Text( title: Text(
contact.username, contact.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
), ),
subtitle: Text( subtitle: Text(

View File

@ -1,60 +1,161 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '/data/models/message_model.dart';
class MessageBubble extends StatelessWidget { class MessageBubble extends StatelessWidget {
final String message; final MessageModel message;
final DateTime time; final VoidCallback? onTap;
final bool isMe;
const MessageBubble({ const MessageBubble({
super.key, super.key,
required this.message, required this.message,
required this.time, this.onTap,
required this.isMe,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isMe = message.isMe;
return Align( return Align(
// Выравниваем вправо, если это мое сообщение, и влево если чужое // Выравниваем вправо, если это мое сообщение, и влево если чужое
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container( child: Material(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), color: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), child: InkWell(
constraints: BoxConstraints( onTap: onTap,
// Чтобы баббл не растягивался на весь экран, если текста мало // На телефонах иногда удобнее/надежнее long-press (как в мессенджерах),
maxWidth: MediaQuery.of(context).size.width * 0.75, // поэтому поддерживаем оба жеста.
), onLongPress: onTap,
decoration: BoxDecoration(
color: isMe ? Theme.of(context).colorScheme.primary : Colors.grey[300],
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16), topLeft: const Radius.circular(16),
topRight: const Radius.circular(16), topRight: const Radius.circular(16),
// Скругляем углы по-разному для "хвостика" сообщения
bottomLeft: Radius.circular(isMe ? 16 : 0), bottomLeft: Radius.circular(isMe ? 16 : 0),
bottomRight: Radius.circular(isMe ? 0 : 16), bottomRight: Radius.circular(isMe ? 0 : 16),
), ),
), child: Container(
child: Column( margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
crossAxisAlignment: CrossAxisAlignment.end, padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
children: [ constraints: BoxConstraints(
Text( // Чтобы баббл не растягивался на весь экран, если текста мало
message, maxWidth: MediaQuery.of(context).size.width * 0.75,
style: TextStyle( ),
color: isMe ? Colors.white : Colors.black87, decoration: BoxDecoration(
fontSize: 16, 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), child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.end,
time.toIso8601String(), children: [
style: TextStyle( if (message.replyToText != null) ...[
color: isMe ? Colors.white70 : Colors.black54, Container(
fontSize: 10, 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),
),
],
],
),
],
), ),
], ),
), ),
), ),
); );
} }
}
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';
}
}

View File

@ -10,6 +10,7 @@ import firebase_core
import firebase_messaging import firebase_messaging
import flutter_local_notifications import flutter_local_notifications
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import local_auth_darwin
import path_provider_foundation import path_provider_foundation
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
@ -20,6 +21,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))

View File

@ -222,6 +222,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.2.0" 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: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:
@ -296,6 +304,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
jni: jni:
dependency: transitive dependency: transitive
description: description:
@ -360,6 +376,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" 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: matcher:
dependency: transitive dependency: transitive
description: description:

View File

@ -37,6 +37,7 @@ dependencies:
provider: ^6.1.5+1 provider: ^6.1.5+1
http: ^1.6.0 http: ^1.6.0
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
local_auth: ^2.1.4
jwt_decoder: ^2.0.1 jwt_decoder: ^2.0.1
web_socket_channel: ^3.0.3 web_socket_channel: ^3.0.3
cryptography: ^2.5.0 cryptography: ^2.5.0

View File

@ -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 ###

View File

@ -3,6 +3,7 @@ from sqlalchemy.orm import Session
from app.db import models from app.db import models
from app.core.security import get_current_user from app.core.security import get_current_user
from app.api import schemas 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) (models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)
).order_by(models.Message.timestamp.desc()).limit(limit).all() ).order_by(models.Message.timestamp.desc()).limit(limit).all()
return messages return jsonable_encoder(messages)

View File

@ -4,6 +4,8 @@ from sqlalchemy.orm import Session
from app.db import models from app.db import models
from app.core.security import get_current_user from app.core.security import get_current_user
from app.api import schemas from app.api import schemas
from sqlalchemy import or_, and_
from sqlalchemy.exc import IntegrityError
# бд # бд
@ -25,7 +27,145 @@ usersRouter = APIRouter(
@usersRouter.get("/me") @usersRouter.get("/me")
async def read_users_me(current_user: models.User = Depends(get_current_user)): 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") @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] 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( def get_user_by_id(
user_id: int, user_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user) current_user: models.User = Depends(get_current_user)
): ):
""" """
Получить публичную информацию о пользователе, включая его публичный ключ. Получить информацию о пользователе с учетом настроек конфиденциальности.
""" """
user = db.query(models.User).filter(models.User.id == user_id).first() user = db.query(models.User).filter(models.User.id == user_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден") 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

View File

@ -21,4 +21,40 @@ class UserPublic(BaseModel):
public_key: Optional[str] = None public_key: Optional[str] = None
class Config: class Config:
from_attributes = True 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

View File

@ -3,6 +3,7 @@ from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime
from sqlalchemy.sql import func from sqlalchemy.sql import func
from sqlalchemy import text
SQLALCHEMY_DATABASE_URL = "sqlite:///./chepuhagram.db" SQLALCHEMY_DATABASE_URL = "sqlite:///./chepuhagram.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) 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) username = Column(String, unique=True, index=True)
about = Column(String, nullable=True) about = Column(String, nullable=True)
phone = Column(String(20), unique=True, 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) totp_secret = Column(String(32), nullable=True)
hashed_password = Column(String) hashed_password = Column(String)
public_key = Column(String, nullable=True) public_key = Column(String, nullable=True)
encrypted_private_key = Column(String, nullable=True) encrypted_private_key = Column(String, nullable=True)
fcm_token = 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): class Message(Base):
__tablename__ = "messages" __tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
@ -31,5 +40,57 @@ class Message(Base):
receiver_id = Column(Integer, ForeignKey("users.id")) receiver_id = Column(Integer, ForeignKey("users.id"))
content = Column(Text) content = Column(Text)
timestamp = Column(DateTime(timezone=True), server_default=func.now()) 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) 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()

View File

@ -33,7 +33,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION) await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return return
try: try:
user_id = await test_token(token=token) user_id = int(await test_token(token=token))
except HTTPException: except HTTPException:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION) await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return 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() user = db.query(models.User).filter(models.User.id == user_id).first()
receiver_id = message_data.get("receiver_id") receiver_id = message_data.get("receiver_id")
temp_id = message_data.get("temp_id")
content = message_data.get("content") content = message_data.get("content")
content50 = message_data.get("content50") 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( new_msg = models.Message(
sender_id=user_id, sender_id=user_id,
receiver_id=receiver_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.add(new_msg)
db.commit() db.commit()
db.refresh(new_msg) 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( receiver = db.query(models.User).filter(
models.User.id == receiver_id).first() models.User.id == receiver_id).first()
if receiver.fcm_token: if receiver.fcm_token:
@ -79,13 +108,61 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"id": new_msg.id, "id": new_msg.id,
"type": "private_message", "type": "private_message",
"sender_id": user_id, "sender_id": user_id,
"reciever_id": receiver_id, "receiver_id": receiver_id,
"content": message_data.get("content"), "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: except WebSocketDisconnect:
pass pass
finally: finally:
@ -120,20 +197,27 @@ class ConnectionManager:
# Храним активные соединения: {user_id: websocket} # Храним активные соединения: {user_id: websocket}
self.active_connections: Dict[str, 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() await websocket.accept()
self.active_connections[user_id] = websocket self.active_connections[str(user_id)] = websocket
def disconnect(self, user_id: str): def disconnect(self, user_id: int):
if user_id in self.active_connections: key = str(user_id)
del self.active_connections[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: if str(user_id) in self.active_connections:
await self.active_connections[str(user_id)].send_json(message) try:
print('Sent to socket') 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: else:
print('User not active') print('User not active')
return False
async def broadcast(self, message: dict): async def broadcast(self, message: dict):
# Рассылка вообще всем (например, системное уведомление) # Рассылка вообще всем (например, системное уведомление)

View File

@ -8,10 +8,13 @@
#include <firebase_core/firebase_core_plugin_c_api.h> #include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_auth_windows/local_auth_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
FirebaseCorePluginCApiRegisterWithRegistrar( FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
} }

View File

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
firebase_core firebase_core
flutter_secure_storage_windows flutter_secure_storage_windows
local_auth_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST