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

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
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
- platform: ios
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
- platform: linux
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
- platform: macos
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
- platform: web
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
- platform: windows
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
# User provided section

View File

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

View File

@ -1,5 +1,5 @@
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>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>NSFaceIDUsageDescription</key>
<string>Используется для подтверждения доступа к ключу шифрования</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>

View File

@ -19,7 +19,7 @@ class LocalDbService {
String path = join(await getDatabasesPath(), 'chat_app.db');
return await openDatabase(
path,
version: 1,
version: 3,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE messages(
@ -27,10 +27,24 @@ class LocalDbService {
sender_id INTEGER,
receiver_id INTEGER,
content TEXT,
timestamp TEXT
timestamp TEXT,
delivered_at TEXT,
read_at TEXT,
reply_to_id INTEGER,
reply_to_text TEXT
)
''');
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute('ALTER TABLE messages ADD COLUMN delivered_at TEXT');
await db.execute('ALTER TABLE messages ADD COLUMN read_at TEXT');
}
if (oldVersion < 3) {
await db.execute('ALTER TABLE messages ADD COLUMN reply_to_id INTEGER');
await db.execute('ALTER TABLE messages ADD COLUMN reply_to_text TEXT');
}
},
);
}
@ -46,6 +60,8 @@ class LocalDbService {
'receiver_id': msg.receiverId,
'content': msg.text, // ВАЖНО: сохраняй зашифрованный текст!
'timestamp': msg.createdAt.toIso8601String(),
'delivered_at': null,
'read_at': null,
}, conflictAlgorithm: ConflictAlgorithm.replace);
} else {
// Если это Map из API
@ -55,6 +71,10 @@ class LocalDbService {
'receiver_id': msg['receiver_id'], // Убедись, что ключ совпадает с API
'content': msg['content'],
'timestamp': msg['timestamp'],
'delivered_at': msg['delivered_at'],
'read_at': msg['read_at'],
'reply_to_id': msg['reply_to_id'],
'reply_to_text': msg['reply_to_text'],
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
}
@ -75,4 +95,48 @@ class LocalDbService {
orderBy: 'timestamp ASC',
);
}
Future<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 {
final token = await apiService.getAccessToken();
if (_channel != null) return; // Уже подключены
if (token == null || token.isEmpty) {
print('❌ SocketService.connect: no access token, skipping connect');
return;
}
// В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке
final uri = Uri.parse("ws://${AppConstants.baseUrl}/ws?token=$token");
@ -32,6 +36,7 @@ class SocketService {
_channel!.stream.listen(
(data) {
final decoded = jsonDecode(data);
print("🚀 СООБЩЕНИЕ ПОЛУЧЕНО ИЗ SINK: $decoded");
_messageController.add(decoded);
},
onError: (error) => _reconnect(apiService),
@ -44,10 +49,11 @@ class SocketService {
Future.delayed(const Duration(seconds: 5), () => connect(apiService));
}
void sendMessage(Map<String, dynamic> data) {
bool sendMessage(Map<String, dynamic> data, {int retryCnt = 0}) {
if (_channel == null) {
print("❌ ОШИБКА: Попытка отправить сообщение через NULL канал.");
return;
//print("❌ ОШИБКА: Попытка отправить сообщение через NULL канал.");
sendMessage(data, retryCnt: retryCnt + 1);
return false;
}
try {
final encodedData = jsonEncode(data);
@ -57,11 +63,20 @@ class SocketService {
// 2. Добавляем принт подтверждения
print("🚀 СООБЩЕНИЕ ОТПРАВЛЕНО В SINK: $encodedData");
return true;
} catch (e) {
print("❌ КРИТИЧЕСКАЯ ОШИБКА ПРИ ОТПРАВКЕ: $e");
return false;
}
}
bool sendReadReceipt(int messageId) {
return sendMessage({
'type': 'read_receipt',
'message_id': messageId,
});
}
void disconnect() {
_channel?.sink.close(status.goingAway);
_channel = null;

View File

@ -23,13 +23,51 @@ class Contact {
this.publicKey,
});
Contact copyWith({
int? id,
String? username,
String? name,
String? surname,
String? lastMessage,
String? avatarUrl,
DateTime? lastMessageTime,
bool? isOnline,
int? unreadCount,
String? publicKey,
}) {
return Contact(
id: id ?? this.id,
username: username ?? this.username,
name: name ?? this.name,
surname: surname ?? this.surname,
lastMessage: lastMessage ?? this.lastMessage,
avatarUrl: avatarUrl ?? this.avatarUrl,
lastMessageTime: lastMessageTime ?? this.lastMessageTime,
isOnline: isOnline ?? this.isOnline,
unreadCount: unreadCount ?? this.unreadCount,
publicKey: publicKey ?? this.publicKey,
);
}
factory Contact.fromJson(Map<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(
id: json['id'],
username: json['username'] ?? 'Unknown',
name: json['name'] ?? 'Unknown',
surname: json['surname'] ?? 'Unknown',
lastMessage: json['last_message'] ?? json['lastMessage'],
avatarUrl: json['avatar_url'] ?? json['avatarUrl'],
lastMessageTime: parseTime(json['last_message_time'] ?? json['lastMessageTime']),
isOnline: (json['is_online'] ?? json['isOnline']) == true,
unreadCount: int.tryParse((json['unread_count'] ?? json['unreadCount'] ?? 0).toString()) ?? 0,
publicKey: json['public_key'],
);
}
}
}

View File

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

View File

@ -8,7 +8,29 @@ class ContactRepository {
final http.Client _client = http.Client();
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();
if (token == null) {
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 {
final token = await getAccessToken();
final response = await http.get(
@ -131,4 +178,94 @@ class ApiService extends ChangeNotifier {
);
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 {
final pbkdf2 = Pbkdf2(
macAlgorithm: Hmac.sha256(),

View File

@ -14,6 +14,47 @@ class AuthProvider extends ChangeNotifier {
int? _currentUserId;
int? get currentUserId => _currentUserId;
String? _username;
String? get username => _username;
String? _firstName;
String? get firstName => _firstName;
String? _lastName;
String? get lastName => _lastName;
String? _phone;
String? get phone => _phone;
String? _email;
String? get email => _email;
String? _about;
String? get about => _about;
// Privacy settings
bool? _showEmail;
bool? get showEmail => _showEmail;
bool? _showPhone;
bool? get showPhone => _showPhone;
bool? _showAvatar;
bool? get showAvatar => _showAvatar;
bool? _showAbout;
bool? get showAbout => _showAbout;
bool? _showUsername;
bool? get showUsername => _showUsername;
String get displayName {
final full = '${_firstName ?? ''} ${_lastName ?? ''}'.trim();
if (full.isNotEmpty) return full;
if ((_username ?? '').isNotEmpty) return _username!;
return 'User';
}
// Флаги для определения пути пользователя
bool _needsSetup = false;
bool get needsSetup => _needsSetup;
@ -91,6 +132,13 @@ class AuthProvider extends ChangeNotifier {
final mode = await _storage.read(key: 'theme_mode');
final color = await _storage.read(key: 'accent_color');
await _storage.deleteAll();
_currentUserId = null;
_username = null;
_firstName = null;
_lastName = null;
_phone = null;
_email = null;
_about = null;
if (mode != null) {
await _storage.write(key: 'theme_mode', value: mode);
}
@ -192,6 +240,13 @@ class AuthProvider extends ChangeNotifier {
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map;
_username = data['username']?.toString();
_firstName = data['first_name']?.toString();
_lastName = data['last_name']?.toString();
_phone = data['phone']?.toString();
_email = data['email']?.toString();
_about = data['about']?.toString();
// Проверяем наличие публичного ключа на сервере
_hasPublicKeyOnServer = data['public_key'] != null && data['public_key'].isNotEmpty;
@ -213,6 +268,24 @@ class AuthProvider extends ChangeNotifier {
_needsKeyRecovery = false;
}
}
// Загружаем настройки конфиденциальности
try {
final privacyData = await _apiService.getPrivacySettings();
_showEmail = privacyData['show_email'] as bool?;
_showPhone = privacyData['show_phone'] as bool?;
_showAvatar = privacyData['show_avatar'] as bool?;
_showAbout = privacyData['show_about'] as bool?;
_showUsername = privacyData['show_username'] as bool?;
} catch (e) {
print("Ошибка загрузки настроек конфиденциальности: $e");
// Устанавливаем значения по умолчанию
_showEmail = true;
_showPhone = true;
_showAvatar = true;
_showAbout = true;
_showUsername = true;
}
} catch (e) {
print("Ошибка проверки статуса: $e");
_needsSetup = false;
@ -221,6 +294,10 @@ class AuthProvider extends ChangeNotifier {
notifyListeners();
}
Future<void> refreshMe() async {
await _checkAccountStatus();
}
// Метод для начала с чистого листа (новые ключи)
Future<void> resetKeys() async {
await _storage.delete(key: 'private_key');

View File

@ -1,12 +1,17 @@
import 'package:flutter/material.dart';
import '/data/models/contact_model.dart';
import '/data/repositories/contact_repository.dart';
import '/data/datasources/local_db_service.dart';
import '/domain/services/crypto_service.dart';
class ContactProvider extends ChangeNotifier {
final ContactRepository _repository = ContactRepository();
final LocalDbService _localDbService = LocalDbService();
final CryptoService _cryptoService = CryptoService();
List<Contact> _contacts = [];
List<Contact> _allContacts = [];
bool _isLoading = false;
bool _isFirstLoad = true;
String? _error;
int? _currentUserId;
@ -25,19 +30,33 @@ class ContactProvider extends ChangeNotifier {
}
Future<void> loadContacts() async {
_isLoading = true;
if (_isFirstLoad) {
_isFirstLoad = false;
_isLoading = true;
}
_error = null;
notifyListeners();
try {
final allContacts = await _repository.fetchContacts();
final allContacts = await _repository.fetchChatContacts();
// Фильтруем: исключаем себя (для основного списка - только чаты)
_contacts = allContacts.where((contact) => contact.id != _currentUserId).toList();
_contacts = allContacts
.where((contact) => contact.id != _currentUserId)
.toList();
_allContacts = _contacts;
_isLoading = false;
notifyListeners();
// Обогащаем превью последним сообщением из локальной БД, не блокируя UI.
_enrichContactsWithLastMessages();
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
// Если ошибка выходим из состояния загрузки тут.
// Если всё ок `_isLoading` уже сброшен выше, чтобы показать список быстрее.
if (_error != null) {
_isLoading = false;
}
notifyListeners();
}
}
@ -49,9 +68,11 @@ class ContactProvider extends ChangeNotifier {
notifyListeners();
try {
final allContacts = await _repository.fetchContacts();
final allContacts = await _repository.fetchAllUsers();
// Фильтруем только исключение самого себя
_allContacts = allContacts.where((contact) => contact.id != _currentUserId).toList();
_allContacts = allContacts
.where((contact) => contact.id != _currentUserId)
.toList();
} catch (e) {
_error = e.toString();
} finally {
@ -59,4 +80,93 @@ class ContactProvider extends ChangeNotifier {
notifyListeners();
}
}
}
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
const String _notificationLaunchKey = 'notification_launch_data';
// Защита от повторной обработки одного и того же payload при следующих запусках по иконке
const String _lastHandledNotificationLaunchPayloadKey = 'notification_last_handled_payload';
Future<void> _onSelectNotification(NotificationResponse notificationResponse) async {
final payload = notificationResponse.payload;
@ -39,12 +41,17 @@ Future<void> _onSelectNotification(NotificationResponse notificationResponse) as
final context = navigatorKey.currentContext;
final prefs = await SharedPreferences.getInstance();
final canonicalPayload = jsonEncode(data);
// Важно: не сохраняем payload в SharedPreferences, если можем сразу перейти в чат.
// Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение
// будет снова автопереходить в чат.
if (context == null) {
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
if (lastHandled != canonicalPayload) {
await prefs.setString(_notificationLaunchKey, canonicalPayload);
await prefs.setString(_lastHandledNotificationLaunchPayloadKey, canonicalPayload);
}
print('Navigator context is null, saved notification payload to SharedPreferences');
} else {
await prefs.remove(_notificationLaunchKey);
@ -124,9 +131,16 @@ void main() async {
print('Message type: ${initialMessage!.data['type']}');
print('Sender ID: ${initialMessage!.data['sender_id']}');
// Сохраняем данные уведомления
await prefs.setString(_notificationLaunchKey, jsonEncode(initialMessage!.data));
print('Saved notification data to SharedPreferences');
final payloadString = jsonEncode(initialMessage!.data);
final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
if (lastHandled != payloadString) {
// Сохраняем данные уведомления
await prefs.setString(_notificationLaunchKey, payloadString);
await prefs.setString(_lastHandledNotificationLaunchPayloadKey, payloadString);
print('Saved notification data to SharedPreferences');
} else {
print('InitialMessage payload already handled earlier, skipping');
}
} else {
print('No initial message - app launched normally');
// Очищаем сохраненные данные, если приложение запущено нормально
@ -148,10 +162,15 @@ void main() async {
print('App launched from local notification, payload: $payload');
if (payload != null && payload.isNotEmpty) {
try {
final data = jsonDecode(payload);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
print('Saved local notification launch payload to SharedPreferences');
final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
if (lastHandled != payload) {
final data = jsonDecode(payload);
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
await prefs.setString(_lastHandledNotificationLaunchPayloadKey, payload);
print('Saved local notification launch payload to SharedPreferences');
} else {
print('Local notification payload already handled earlier, skipping');
}
} catch (e) {
print('Failed to save notification launch payload: $e');
}
@ -235,6 +254,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
payload: jsonEncode({
'type': 'enc_message',
'sender_id': message.data['sender_id'],
'timestamp': message.data['timestamp'] ?? DateTime.now().toIso8601String(),
}),
);
print('Notification shown successfully');
@ -246,9 +266,46 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
}
}
class MyApp extends StatelessWidget {
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Закрываем сокет, как только приложение сворачивается.
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
try {
context.read<AuthProvider>().closeRealtime();
} catch (_) {}
return;
}
// На возврате в приложение пробуем переподключиться (если есть токен).
if (state == AppLifecycleState.resumed) {
try {
context.read<AuthProvider>().initRealtime();
} catch (_) {}
}
}
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();

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:shared_preferences/shared_preferences.dart';
import 'contacts_screen.dart';
import 'package:flutter/services.dart';
import 'user_profile_screen.dart';
class ChatScreen extends StatefulWidget {
final Contact contact;
@ -30,18 +32,23 @@ class _ChatScreenState extends State<ChatScreen> {
late Contact _currentContact;
bool _isKeyLoading = false;
final TextEditingController _controller = TextEditingController();
final FocusNode _inputFocusNode = FocusNode();
final ContactRepository _contactRepository = ContactRepository();
final apiService = ApiService();
final CryptoService _cryptoService = CryptoService();
List<MessageModel> messages = [];
StreamSubscription<dynamic>? _socketSubscription;
final Set<int> _sentReadReceipts = <int>{};
final LocalDbService _localDbService = LocalDbService();
MessageModel? _replyTo;
@override
void initState() {
super.initState();
_currentContact = widget.contact;
currentActiveChatContactId = _currentContact.id; // Устанавливаем активный чат
currentActiveChatContactId =
_currentContact.id; // Устанавливаем активный чат
final contactProvider = context.read<ContactProvider>();
myId = contactProvider.getCurrentUserId() ?? 0;
// Если ключа нет, загружаем его при входе
@ -83,6 +90,7 @@ class _ChatScreenState extends State<ChatScreen> {
currentActiveChatContactId = null; // Сбрасываем активный чат
_socketSubscription?.cancel();
_controller.dispose();
_inputFocusNode.dispose();
super.dispose();
}
@ -93,16 +101,26 @@ class _ChatScreenState extends State<ChatScreen> {
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
}
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
},
),
title: Text(_currentContact.name),
title: GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => UserProfileScreen(
userId: _currentContact.id,
username: _currentContact.username,
name: _currentContact.name,
),
),
);
},
child: Text(_currentContact.name),
),
),
body: Column(
children: [
@ -113,9 +131,8 @@ class _ChatScreenState extends State<ChatScreen> {
itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index];
return MessageBubble(
message: msg.text,
time: msg.createdAt,
isMe: msg.isMe,
message: msg,
onTap: () => _showMessageActions(msg),
);
},
),
@ -126,26 +143,134 @@ class _ChatScreenState extends State<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() {
return SafeArea(
// Добавляем SafeArea здесь
child: Padding(
child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
padding: const EdgeInsets.all(8.0),
child: Row(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: "Напиши сообщение...",
if (_replyTo != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.reply, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
_replyTo!.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => setState(() => _replyTo = null),
),
],
),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
_sendMessage();
},
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _controller,
focusNode: _inputFocusNode,
minLines: 1,
maxLines: 5,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
hintText: "Напиши сообщение...",
),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
_sendMessage();
},
),
],
),
],
),
@ -181,30 +306,48 @@ class _ChatScreenState extends State<ChatScreen> {
sharedSecret,
);
final tempId = DateTime.now().microsecondsSinceEpoch;
final localMessage = MessageModel(
tempId: tempId,
text: rawText,
isMe: true,
senderId: myId,
receiverId: _currentContact.id,
createdAt: DateTime.now(),
status: MessageStatus.sending,
replyToId: _replyTo?.id,
replyToText: _replyTo?.text,
);
setState(() {
messages.add(localMessage);
});
// Формируем payload для сервера
final payload = {
"type": "private_message",
"receiver_id": _currentContact.id,
"content": encryptedText,
"content50": encryptedText50,
"temp_id": tempId,
if (_replyTo?.id != null) ...{
"reply_to_id": _replyTo!.id,
"reply_to_text": _replyTo!.text,
},
};
// Отправляем
print("ОТПРАВКА: $payload");
Provider.of<SocketService>(context, listen: false).sendMessage(payload);
// Обновляем UI (себе показываем расшифрованный текст)
final ok = Provider.of<SocketService>(context, listen: false).sendMessage(payload);
if (!mounted) return;
setState(() {
messages.add(
MessageModel(
text: rawText,
isMe: true,
senderId: myId,
receiverId: _currentContact.id,
createdAt: DateTime.now(),
),
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx == -1) return;
messages[idx] = messages[idx].copyWith(
status: ok ? MessageStatus.sent : MessageStatus.failed,
);
_replyTo = null;
});
_controller.clear();
@ -216,13 +359,105 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
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') {
final int senderId = int.parse(data['sender_id'].toString());
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
final receiverId = int.tryParse((data['receiver_id'] ?? data['recipient_id'])?.toString() ?? '');
if (senderId == null || receiverId == null) {
print('Invalid private_message ids: sender_id=${data['sender_id']} receiver_id=${data['receiver_id'] ?? data['recipient_id']}');
return;
}
// 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся
if (senderId == widget.contact.id) {
final isFromPartnerToMe = senderId == widget.contact.id && receiverId == myId;
if (isFromPartnerToMe) {
try {
final myPrivKey = await _cryptoService.getPrivateKey();
@ -241,14 +476,25 @@ class _ChatScreenState extends State<ChatScreen> {
// 4. Добавляем в список и обновляем экран
await LocalDbService().saveMessages([data]);
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(() {
messages.add(
MessageModel(
id: int.tryParse(data['id']?.toString() ?? ''),
text: decryptedText,
isMe: false,
senderId: senderId,
receiverId: myId,
createdAt: DateTime.parse(data['timestamp']),
status: MessageStatus.delivered,
replyToId: data['reply_to_id'] == null ? null : int.tryParse(data['reply_to_id'].toString()),
replyToText: data['reply_to_text'] == null ? null : data['reply_to_text'].toString(),
),
);
});
@ -265,19 +511,16 @@ class _ChatScreenState extends State<ChatScreen> {
}
Future<void> _loadHistory() async {
initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
await prefs.setString(_notificationLaunchKey, ''); // Очищаем данные уведомления при загрузке ключа
try {
final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
widget.contact.publicKey!,
);
final localDb = LocalDbService();
final cached = await localDb.getChatHistory(widget.contact.id, myId);
final cached = await _localDbService.getChatHistory(widget.contact.id, myId);
try {
List<MessageModel> loadedLocalMessages = [];
@ -286,13 +529,36 @@ class _ChatScreenState extends State<ChatScreen> {
msg['content'],
sharedSecret,
);
final deliveredAt = msg['delivered_at'] == null
? null
: DateTime.tryParse(msg['delivered_at'].toString());
final readAt = msg['read_at'] == null
? null
: DateTime.tryParse(msg['read_at'].toString());
MessageStatus status = (msg['sender_id'] == myId)
? MessageStatus.sent
: MessageStatus.delivered;
if (msg['sender_id'] == myId) {
if (readAt != null) {
status = MessageStatus.read;
} else if (deliveredAt != null) {
status = MessageStatus.delivered;
}
}
loadedLocalMessages.add(
MessageModel(
id: int.tryParse(msg['id']?.toString() ?? ''),
text: decrypted,
isMe: msg['sender_id'] == myId,
senderId: msg['sender_id'],
receiverId: msg['receiver_id'],
createdAt: DateTime.parse(msg['timestamp']),
status: status,
replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()),
replyToText: msg['reply_to_text'] == null ? null : msg['reply_to_text'].toString(),
),
);
}
@ -309,25 +575,56 @@ class _ChatScreenState extends State<ChatScreen> {
final history = await apiService.getChatHistory(widget.contact.id);
print(history);
final alreadyReadIncomingMessageIds = <int>{};
List<MessageModel> loadedMessages = [];
for (var msg in history) {
final msgId = int.tryParse(msg['id']?.toString() ?? '');
if (msgId != null &&
msg['sender_id'] != myId &&
msg['read_at'] != null) {
alreadyReadIncomingMessageIds.add(msgId);
}
final decrypted = await _cryptoService.decryptMessage(
msg['content'],
sharedSecret,
);
final deliveredAt = msg['delivered_at'] == null
? null
: DateTime.tryParse(msg['delivered_at'].toString());
final readAt = msg['read_at'] == null
? null
: DateTime.tryParse(msg['read_at'].toString());
MessageStatus status = (msg['sender_id'] == myId)
? MessageStatus.sent
: MessageStatus.delivered;
if (msg['sender_id'] == myId) {
if (readAt != null) {
status = MessageStatus.read;
} else if (deliveredAt != null) {
status = MessageStatus.delivered;
}
}
loadedMessages.insert(
0,
MessageModel(
id: int.tryParse(msg['id']?.toString() ?? ''),
text: decrypted,
isMe: msg['sender_id'] == myId,
senderId: msg['sender_id'],
receiverId: msg['receiver_id'],
createdAt: DateTime.parse(msg['timestamp']),
status: status,
replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()),
replyToText: msg['reply_to_text'] == null ? null : msg['reply_to_text'].toString(),
),
);
}
try {
await localDb.saveMessages(history);
await _localDbService.saveMessages(history);
} catch (e) {
print("Ошибка сохранения истории в локальную базу: $e");
}
@ -337,6 +634,17 @@ class _ChatScreenState extends State<ChatScreen> {
messages = loadedMessages;
_isKeyLoading = false;
});
// Отправляем read_receipt для сообщений собеседника, которые уже на экране.
for (final m in loadedMessages) {
if (m.isMe) continue;
final id = m.id;
if (id == null) continue;
if (alreadyReadIncomingMessageIds.contains(id)) continue;
if (_sentReadReceipts.contains(id)) continue;
Provider.of<SocketService>(context, listen: false).sendReadReceipt(id);
_sentReadReceipts.add(id);
}
} catch (e) {
print("Ошибка загрузки истории: $e");
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:shared_preferences/shared_preferences.dart';
import 'package:chepuhagram/main.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'dart:async';
class ContactsScreen extends StatefulWidget {
final int? targetChatId;
@ -25,13 +27,12 @@ class ContactsScreen extends StatefulWidget {
class _ContactsScreenState extends State<ContactsScreen> {
static const String _notificationLaunchKey = 'notification_launch_data';
StreamSubscription<dynamic>? _socketSubscription;
@override
void initState() {
super.initState();
print(
'ContactsScreen initState, targetChatId: ${widget.targetChatId}',
);
print('ContactsScreen initState, targetChatId: ${widget.targetChatId}');
_setupPushNotifications();
WidgetsBinding.instance.addPostFrameCallback((_) {
final authProvider = context.read<AuthProvider>();
@ -40,9 +41,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
// Установить текущего пользователя и загрузить контакты с сообщениями
contactProvider.setCurrentUserId(authProvider.currentUserId);
contactProvider.loadContacts().then((_) {
print(
'Contacts loaded, checking targetChatId: ${widget.targetChatId}',
);
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
// После загрузки контактов проверить, нужно ли перейти к чату
if (widget.targetChatId != null) {
_navigateToTargetChat();
@ -91,17 +90,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
}
void _navigateToTargetChatWithId(int targetChatId) {
print(
'_navigateToTargetChat called with targetChatId: $targetChatId',
);
print('_navigateToTargetChat called with targetChatId: $targetChatId');
final contactProvider = context.read<ContactProvider>();
try {
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == targetChatId,
);
print(
'Auto-navigating to chat with contact: ${contact.username}',
);
print('Auto-navigating to chat with contact: ${contact.username}');
currentActiveChatContactId = targetChatId; // Устанавливаем активный чат
Navigator.push(
context,
@ -158,9 +153,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
void _navigateToChatFromNotification(int senderId) {
final contactProvider = context.read<ContactProvider>();
print(
'Navigate to chat from notification with senderId: $senderId',
);
print('Navigate to chat from notification with senderId: $senderId');
// Если контакты еще не загружены, ждем их загрузки
if (contactProvider.contacts.isEmpty) {
@ -178,9 +171,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == senderId,
);
print(
'Navigating to chat from notification: ${contact.username}',
);
print('Navigating to chat from notification: ${contact.username}');
currentActiveChatContactId = senderId; // Устанавливаем активный чат
Navigator.push(
context,
@ -244,13 +235,26 @@ class _ContactsScreenState extends State<ContactsScreen> {
payload: jsonEncode({
'type': 'enc_message',
'sender_id': message.data['sender_id'],
'timestamp':
message.data['timestamp'] ?? DateTime.now().toIso8601String(),
}),
);
if (message.data['type'] == 'enc_message') {
final contactProvider = context.read<ContactProvider>();
contactProvider.loadContacts();
}
} catch (e) {
print('Error processing foreground message: $e');
}
}
@override
void dispose() {
_socketSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -309,20 +313,40 @@ class _ContactsScreenState extends State<ContactsScreen> {
padding: EdgeInsets.zero,
children: [
// Шапка меню с данными юзера
UserAccountsDrawerHeader(
accountName: Text("Artur Karasevich"),
accountEmail: Text("@ArturKarasevich"),
currentAccountPicture: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.onSurface,
child: Icon(
Icons.person,
size: 40,
color: Theme.of(context).colorScheme.primaryContainer,
),
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
),
Consumer<AuthProvider>(
builder: (context, authProvider, _) {
final username = authProvider.username;
final displayName = authProvider.displayName;
final initials =
(displayName.isNotEmpty ? displayName : (username ?? 'U'))
.trim()
.split(RegExp(r'\s+'))
.where((p) => p.isNotEmpty)
.take(2)
.map((p) => p[0].toUpperCase())
.join();
return UserAccountsDrawerHeader(
accountName: Text(displayName),
accountEmail: Text(
username == null || username.isEmpty ? '' : '@$username',
),
currentAccountPicture: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.onSurface,
child: Text(
initials.isEmpty ? 'U' : initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primaryContainer,
),
),
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
),
);
},
),
ListTile(
leading: const Icon(Icons.settings),

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/privacy_settings_menu_screen.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '/logic/auth_provider.dart';
@ -10,22 +12,58 @@ class SettingsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final themeProv = context.watch<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(
appBar: AppBar(title: const Text("Настройки")),
body: Column(
children: [
// Секция Профиля
const UserAccountsDrawerHeader(
accountName: Text("Artur Karasevich"),
accountEmail: Text("@ArturKarasevich"),
currentAccountPicture: CircleAvatar(
UserAccountsDrawerHeader(
accountName: Text(authProv.displayName),
accountEmail: Text(accountEmail),
currentAccountPicture: const CircleAvatar(
child: Icon(Icons.person, size: 40),
),
decoration: BoxDecoration(color: Colors.transparent),
decoration: const BoxDecoration(color: Colors.transparent),
),
const Divider(),
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('Аккаунт'),
subtitle: const Text('Имя, телефон, почта, информация о себе'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AccountSettingsScreen(),
),
);
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.shield_outlined),
title: const Text('Конфиденциальность'),
subtitle: const Text('Безопасность и видимость данных профиля'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PrivacySettingsMenuScreen(),
),
);
},
),
const Divider(),
SwitchListTile(

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,
backgroundColor: primary.withAlpha((0.1 * 255).round()),
child: Text(
contact.surname[0],
contact.name[0],
style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)
),
),
title: Text(
contact.username,
contact.name,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
subtitle: Text(

View File

@ -1,60 +1,161 @@
import 'package:flutter/material.dart';
import '/data/models/message_model.dart';
class MessageBubble extends StatelessWidget {
final String message;
final DateTime time;
final bool isMe;
final MessageModel message;
final VoidCallback? onTap;
const MessageBubble({
super.key,
required this.message,
required this.time,
required this.isMe,
this.onTap,
});
@override
Widget build(BuildContext context) {
final isMe = message.isMe;
return Align(
// Выравниваем вправо, если это мое сообщение, и влево если чужое
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
constraints: BoxConstraints(
// Чтобы баббл не растягивался на весь экран, если текста мало
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: isMe ? Theme.of(context).colorScheme.primary : Colors.grey[300],
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
// На телефонах иногда удобнее/надежнее long-press (как в мессенджерах),
// поэтому поддерживаем оба жеста.
onLongPress: onTap,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
// Скругляем углы по-разному для "хвостика" сообщения
bottomLeft: Radius.circular(isMe ? 16 : 0),
bottomRight: Radius.circular(isMe ? 0 : 16),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
message,
style: TextStyle(
color: isMe ? Colors.white : Colors.black87,
fontSize: 16,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
constraints: BoxConstraints(
// Чтобы баббл не растягивался на весь экран, если текста мало
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: isMe
? Theme.of(context).colorScheme.primary
: Colors.grey[300],
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
// Скругляем углы по-разному для "хвостика" сообщения
bottomLeft: Radius.circular(isMe ? 16 : 0),
bottomRight: Radius.circular(isMe ? 0 : 16),
),
),
const SizedBox(height: 4),
Text(
time.toIso8601String(),
style: TextStyle(
color: isMe ? Colors.white70 : Colors.black54,
fontSize: 10,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (message.replyToText != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration(
color: (isMe ? Colors.white : Colors.black).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border(
left: BorderSide(
color: isMe ? Colors.white70 : Colors.black38,
width: 2,
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.reply,
size: 14,
color: isMe ? Colors.white70 : Colors.black54,
),
const SizedBox(width: 4),
Expanded(
child: Text(
message.replyToText!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isMe ? Colors.white70 : Colors.black54,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
],
Text(
message.text,
style: TextStyle(
color: isMe ? Colors.white : Colors.black87,
fontSize: 16,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.createdAt),
style: TextStyle(
color: isMe ? Colors.white70 : Colors.black54,
fontSize: 10,
),
),
if (isMe) ...[
const SizedBox(width: 6),
Icon(
_statusIcon(message.status),
size: 12,
color: _statusColor(message.status, isMe),
),
],
],
),
],
),
],
),
),
),
);
}
}
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 flutter_local_notifications
import flutter_secure_storage_darwin
import local_auth_darwin
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
@ -20,6 +21,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))

View File

@ -222,6 +222,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.2.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
url: "https://pub.dev"
source: hosted
version: "2.0.34"
flutter_secure_storage:
dependency: "direct main"
description:
@ -296,6 +304,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
jni:
dependency: transitive
description:
@ -360,6 +376,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
local_auth:
dependency: "direct main"
description:
name: local_auth
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
local_auth_android:
dependency: transitive
description:
name: local_auth_android
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
url: "https://pub.dev"
source: hosted
version: "1.0.56"
local_auth_darwin:
dependency: transitive
description:
name: local_auth_darwin
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
url: "https://pub.dev"
source: hosted
version: "1.6.1"
local_auth_platform_interface:
dependency: transitive
description:
name: local_auth_platform_interface
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
url: "https://pub.dev"
source: hosted
version: "1.1.0"
local_auth_windows:
dependency: transitive
description:
name: local_auth_windows
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
url: "https://pub.dev"
source: hosted
version: "1.0.11"
matcher:
dependency: transitive
description:

View File

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

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.core.security import get_current_user
from app.api import schemas
from fastapi.encoders import jsonable_encoder
# бд
@ -31,5 +32,5 @@ async def get_chat_history(
(models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)
).order_by(models.Message.timestamp.desc()).limit(limit).all()
return messages
return jsonable_encoder(messages)

View File

@ -4,6 +4,8 @@ from sqlalchemy.orm import Session
from app.db import models
from app.core.security import get_current_user
from app.api import schemas
from sqlalchemy import or_, and_
from sqlalchemy.exc import IntegrityError
# бд
@ -25,7 +27,145 @@ usersRouter = APIRouter(
@usersRouter.get("/me")
async def read_users_me(current_user: models.User = Depends(get_current_user)):
return {"id": current_user.id, "username": current_user.username, "first_name": current_user.first_name, "last_name": current_user.last_name, "public_key": current_user.public_key, "encrypted_private_key": current_user.encrypted_private_key}
return {
"id": current_user.id,
"username": current_user.username,
"first_name": current_user.first_name,
"last_name": current_user.last_name,
"phone": current_user.phone,
"email": getattr(current_user, "email", None),
"about": current_user.about,
"public_key": current_user.public_key,
"encrypted_private_key": current_user.encrypted_private_key,
}
@usersRouter.put("/me")
async def update_users_me(
data: schemas.UpdateMe,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
user_to_update = db.merge(current_user)
if data.username is not None:
user_to_update.username = data.username
if data.first_name is not None:
user_to_update.first_name = data.first_name
if data.last_name is not None:
user_to_update.last_name = data.last_name
if data.phone is not None:
user_to_update.phone = data.phone or None
if data.email is not None:
user_to_update.email = data.email or None
if data.about is not None:
user_to_update.about = data.about or None
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="phone/email already in use")
db.refresh(user_to_update)
return {
"status": "ok",
"user": {
"id": user_to_update.id,
"username": user_to_update.username,
"first_name": user_to_update.first_name,
"last_name": user_to_update.last_name,
"phone": user_to_update.phone,
"email": getattr(user_to_update, "email", None),
"about": user_to_update.about,
},
}
@usersRouter.put("/me/encryption-key")
async def update_encrypted_private_key(
data: schemas.UpdateEncryptedPrivateKey,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
user_to_update = db.merge(current_user)
user_to_update.encrypted_private_key = data.encrypted_private_key
try:
db.commit()
except Exception:
db.rollback()
raise HTTPException(status_code=500, detail="Не удалось сохранить ключ шифрования")
db.refresh(user_to_update)
return {"status": "ok"}
@usersRouter.put("/me/password")
async def change_password(
data: schemas.ChangePassword,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
from app.core.security import verify_password, get_password_hash
if not verify_password(data.current_password, current_user.hashed_password):
raise HTTPException(status_code=400, detail="Неверный текущий пароль")
user_to_update = db.merge(current_user)
user_to_update.hashed_password = get_password_hash(data.new_password)
try:
db.commit()
except Exception:
db.rollback()
raise HTTPException(status_code=500, detail="Не удалось изменить пароль")
db.refresh(user_to_update)
return {"status": "ok"}
@usersRouter.put("/me/privacy")
async def update_privacy_settings(
data: schemas.UpdatePrivacySettings,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
user_to_update = db.merge(current_user)
if data.show_email is not None:
user_to_update.show_email = 1 if data.show_email else 0
if data.show_phone is not None:
user_to_update.show_phone = 1 if data.show_phone else 0
if data.show_avatar is not None:
user_to_update.show_avatar = 1 if data.show_avatar else 0
if data.show_about is not None:
user_to_update.show_about = 1 if data.show_about else 0
if data.show_username is not None:
user_to_update.show_username = 1 if data.show_username else 0
try:
db.commit()
except Exception:
db.rollback()
raise HTTPException(status_code=500, detail="Не удалось сохранить настройки конфиденциальности")
db.refresh(user_to_update)
return {"status": "ok"}
@usersRouter.get("/me/privacy")
async def get_privacy_settings(current_user: models.User = Depends(get_current_user)):
"""
Получить настройки конфиденциальности текущего пользователя.
"""
return {
"show_email": bool(current_user.show_email),
"show_phone": bool(current_user.show_phone),
"show_avatar": bool(current_user.show_avatar),
"show_about": bool(current_user.show_about),
"show_username": bool(current_user.show_username),
}
@usersRouter.get("/all")
@ -34,18 +174,104 @@ async def read_users_all(current_user: models.User = Depends(get_current_user),
return [{"id": user.id, "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip(), "public_key": user.public_key} for user in users]
@usersRouter.get("/{user_id}", response_model=schemas.UserPublic)
@usersRouter.get("/chats")
async def read_users_chats(
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Список контактов для экрана чатов: последний месседж + время + непрочитанные.
last_message возвращается в том виде, как хранится в БД (зашифрованный content).
Клиент должен расшифровать превью локально.
"""
users = (
db.query(models.User)
.filter(models.User.id != current_user.id)
.all()
)
result = []
for user in users:
last_msg = (
db.query(models.Message)
.filter(
or_(
and_(
models.Message.sender_id == current_user.id,
models.Message.receiver_id == user.id,
),
and_(
models.Message.sender_id == user.id,
models.Message.receiver_id == current_user.id,
),
)
)
.order_by(models.Message.timestamp.desc())
.first()
)
unread_count = (
db.query(models.Message)
.filter(
models.Message.sender_id == user.id,
models.Message.receiver_id == current_user.id,
models.Message.read_at.is_(None),
)
.count()
)
result.append(
{
"id": user.id,
"username": user.username,
"name": f"{user.first_name} {user.last_name or ''}".strip(),
"public_key": user.public_key,
"last_message": last_msg.content if last_msg else None,
"last_message_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp else None),
"unread_count": unread_count,
}
)
return result
@usersRouter.get("/{user_id}", response_model=schemas.UserProfile)
def get_user_by_id(
user_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
"""
Получить публичную информацию о пользователе, включая его публичный ключ.
Получить информацию о пользователе с учетом настроек конфиденциальности.
"""
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден")
return user
# Возвращаем информацию с учетом настроек конфиденциальности
profile_data = {
"id": user.id,
"public_key": user.public_key,
}
# Проверяем настройки конфиденциальности
if user.show_username:
profile_data["username"] = user.username
if user.show_avatar:
# Для аватара пока просто передаем имя, клиент сам сгенерирует аватар
profile_data["first_name"] = user.first_name
profile_data["last_name"] = user.last_name
if user.show_about:
profile_data["about"] = user.about
if user.show_phone:
profile_data["phone"] = user.phone
if user.show_email:
profile_data["email"] = user.email
return profile_data

View File

@ -21,4 +21,40 @@ class UserPublic(BaseModel):
public_key: Optional[str] = None
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 import Column, Integer, Text, ForeignKey, DateTime
from sqlalchemy.sql import func
from sqlalchemy import text
SQLALCHEMY_DATABASE_URL = "sqlite:///./chepuhagram.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
@ -18,12 +19,20 @@ class User(Base):
username = Column(String, unique=True, index=True)
about = Column(String, nullable=True)
phone = Column(String(20), unique=True, nullable=True)
email = Column(String(255), unique=True, nullable=True)
totp_secret = Column(String(32), nullable=True)
hashed_password = Column(String)
public_key = Column(String, nullable=True)
encrypted_private_key = Column(String, nullable=True)
fcm_token = Column(String, nullable=True)
# Privacy settings
show_email = Column(Integer, nullable=False, server_default="1") # 1 = true, 0 = false
show_phone = Column(Integer, nullable=False, server_default="1")
show_avatar = Column(Integer, nullable=False, server_default="1")
show_about = Column(Integer, nullable=False, server_default="1")
show_username = Column(Integer, nullable=False, server_default="1")
class Message(Base):
__tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True)
@ -31,5 +40,57 @@ class Message(Base):
receiver_id = Column(Integer, ForeignKey("users.id"))
content = Column(Text)
timestamp = Column(DateTime(timezone=True), server_default=func.now())
delivered_at = Column(DateTime(timezone=True), nullable=True)
read_at = Column(DateTime(timezone=True), nullable=True)
reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True)
reply_to_text = Column(Text, nullable=True)
Base.metadata.create_all(bind=engine)
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)
return
try:
user_id = await test_token(token=token)
user_id = int(await test_token(token=token))
except HTTPException:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
@ -51,18 +51,47 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
user = db.query(models.User).filter(models.User.id == user_id).first()
receiver_id = message_data.get("receiver_id")
temp_id = message_data.get("temp_id")
content = message_data.get("content")
content50 = message_data.get("content50")
if receiver_id is None or content is None:
await websocket.send_json({
"type": "error",
"detail": "receiver_id/content required",
})
continue
try:
receiver_id = int(receiver_id)
except (TypeError, ValueError):
await websocket.send_json({
"type": "error",
"detail": "receiver_id must be int",
})
continue
new_msg = models.Message(
sender_id=user_id,
receiver_id=receiver_id,
content=content
content=content,
reply_to_id=message_data.get("reply_to_id"),
reply_to_text=message_data.get("reply_to_text")
)
db.add(new_msg)
db.commit()
db.refresh(new_msg)
if receiver_id not in manager.active_connections and user.public_key != '':
# ACK отправителю: сервер принял и сохранил сообщение (нужно для статусов клиента).
await manager.send_personal_message({
"type": "message_sent",
"temp_id": temp_id,
"server_id": new_msg.id,
"timestamp": (new_msg.timestamp or datetime.now()).isoformat(),
}, str(user_id))
# Если получатель оффлайн — отправим пуш (если есть токен и ключи).
if user.public_key:
receiver = db.query(models.User).filter(
models.User.id == receiver_id).first()
if receiver.fcm_token:
@ -79,13 +108,61 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"id": new_msg.id,
"type": "private_message",
"sender_id": user_id,
"reciever_id": receiver_id,
"receiver_id": receiver_id,
"content": message_data.get("content"),
"timestamp": datetime.now().isoformat()
"timestamp": (new_msg.timestamp or datetime.now()).isoformat(),
"reply_to_id": new_msg.reply_to_id,
"reply_to_text": new_msg.reply_to_text,
}
# Пересылаем получателю, если он в сети
await manager.send_personal_message(outgoing_message, str(receiver_id))
sent_to_receiver = await manager.send_personal_message(outgoing_message, str(receiver_id))
# Если сообщение реально ушло по сокету получателю — отмечаем delivered_at.
if sent_to_receiver:
try:
delivered_at = datetime.now()
new_msg.delivered_at = delivered_at
db.add(new_msg)
db.commit()
await manager.send_personal_message({
"type": "message_delivered",
"message_id": new_msg.id,
"timestamp": delivered_at.isoformat(),
}, str(user_id))
except Exception:
db.rollback()
elif message_data.get("type") == "read_receipt":
message_id = message_data.get("message_id")
try:
message_id = int(message_id)
except (TypeError, ValueError):
continue
msg = db.query(models.Message).filter(models.Message.id == message_id).first()
if msg is None:
continue
# Безопасность: read_receipt может отправлять только получатель.
if int(msg.receiver_id) != int(user_id):
continue
# Сохраняем read_at в БД
try:
read_at = datetime.now()
msg.read_at = read_at
db.add(msg)
db.commit()
except Exception:
db.rollback()
sender_id = int(msg.sender_id)
await manager.send_personal_message({
"type": "message_read",
"message_id": message_id,
"timestamp": read_at.isoformat() if 'read_at' in locals() else datetime.now().isoformat(),
}, str(sender_id))
except WebSocketDisconnect:
pass
finally:
@ -120,20 +197,27 @@ class ConnectionManager:
# Храним активные соединения: {user_id: websocket}
self.active_connections: Dict[str, WebSocket] = {}
async def connect(self, user_id: str, websocket: WebSocket):
async def connect(self, user_id: int, websocket: WebSocket):
await websocket.accept()
self.active_connections[user_id] = websocket
self.active_connections[str(user_id)] = websocket
def disconnect(self, user_id: str):
if user_id in self.active_connections:
del self.active_connections[user_id]
def disconnect(self, user_id: int):
key = str(user_id)
if key in self.active_connections:
del self.active_connections[key]
async def send_personal_message(self, message: dict, user_id: str):
async def send_personal_message(self, message: dict, user_id: str) -> bool:
if str(user_id) in self.active_connections:
await self.active_connections[str(user_id)].send_json(message)
print('Sent to socket')
try:
await self.active_connections[str(user_id)].send_json(message)
print('Sent to socket')
return True
except Exception as e:
print(f'Failed to send to socket: {e}')
return False
else:
print('User not active')
return False
async def broadcast(self, message: dict):
# Рассылка вообще всем (например, системное уведомление)

View File

@ -8,10 +8,13 @@
#include <firebase_core/firebase_core_plugin_c_api.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) {
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
}

View File

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