Добавлены смена паролей, данных пользователя. Сделан ответ на сообщения и копирование сообщений
This commit is contained in:
parent
1b8670d811
commit
2d28fcc1fe
15
.metadata
15
.metadata
|
|
@ -18,21 +18,6 @@ migration:
|
||||||
- platform: android
|
- platform: android
|
||||||
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||||
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||||
- platform: ios
|
|
||||||
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
|
||||||
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
|
||||||
- platform: linux
|
|
||||||
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
|
||||||
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
|
||||||
- platform: macos
|
|
||||||
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
|
||||||
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
|
||||||
- platform: web
|
|
||||||
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
|
||||||
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
|
||||||
- platform: windows
|
|
||||||
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
|
||||||
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="Chepuhagram"
|
android:label="Chepuhagram"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
package ru.chepuhagram.app
|
package ru.chepuhagram.app
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterFragmentActivity()
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
<string>Main</string>
|
<string>Main</string>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Используется для подтверждения доступа к ключу шифрования</string>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class LocalDbService {
|
||||||
String path = join(await getDatabasesPath(), 'chat_app.db');
|
String path = join(await getDatabasesPath(), 'chat_app.db');
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 1,
|
version: 3,
|
||||||
onCreate: (db, version) async {
|
onCreate: (db, version) async {
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE messages(
|
CREATE TABLE messages(
|
||||||
|
|
@ -27,10 +27,24 @@ class LocalDbService {
|
||||||
sender_id INTEGER,
|
sender_id INTEGER,
|
||||||
receiver_id INTEGER,
|
receiver_id INTEGER,
|
||||||
content TEXT,
|
content TEXT,
|
||||||
timestamp TEXT
|
timestamp TEXT,
|
||||||
|
delivered_at TEXT,
|
||||||
|
read_at TEXT,
|
||||||
|
reply_to_id INTEGER,
|
||||||
|
reply_to_text TEXT
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
},
|
},
|
||||||
|
onUpgrade: (db, oldVersion, newVersion) async {
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
await db.execute('ALTER TABLE messages ADD COLUMN delivered_at TEXT');
|
||||||
|
await db.execute('ALTER TABLE messages ADD COLUMN read_at TEXT');
|
||||||
|
}
|
||||||
|
if (oldVersion < 3) {
|
||||||
|
await db.execute('ALTER TABLE messages ADD COLUMN reply_to_id INTEGER');
|
||||||
|
await db.execute('ALTER TABLE messages ADD COLUMN reply_to_text TEXT');
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,6 +60,8 @@ class LocalDbService {
|
||||||
'receiver_id': msg.receiverId,
|
'receiver_id': msg.receiverId,
|
||||||
'content': msg.text, // ВАЖНО: сохраняй зашифрованный текст!
|
'content': msg.text, // ВАЖНО: сохраняй зашифрованный текст!
|
||||||
'timestamp': msg.createdAt.toIso8601String(),
|
'timestamp': msg.createdAt.toIso8601String(),
|
||||||
|
'delivered_at': null,
|
||||||
|
'read_at': null,
|
||||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
} else {
|
} else {
|
||||||
// Если это Map из API
|
// Если это Map из API
|
||||||
|
|
@ -55,6 +71,10 @@ class LocalDbService {
|
||||||
'receiver_id': msg['receiver_id'], // Убедись, что ключ совпадает с API
|
'receiver_id': msg['receiver_id'], // Убедись, что ключ совпадает с API
|
||||||
'content': msg['content'],
|
'content': msg['content'],
|
||||||
'timestamp': msg['timestamp'],
|
'timestamp': msg['timestamp'],
|
||||||
|
'delivered_at': msg['delivered_at'],
|
||||||
|
'read_at': msg['read_at'],
|
||||||
|
'reply_to_id': msg['reply_to_id'],
|
||||||
|
'reply_to_text': msg['reply_to_text'],
|
||||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,4 +95,48 @@ class LocalDbService {
|
||||||
orderBy: 'timestamp ASC',
|
orderBy: 'timestamp ASC',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getLastMessage(int contactId, int myId) async {
|
||||||
|
final db = await database;
|
||||||
|
final rows = await db.query(
|
||||||
|
'messages',
|
||||||
|
columns: ['sender_id', 'receiver_id', 'content', 'timestamp'],
|
||||||
|
where:
|
||||||
|
'(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)',
|
||||||
|
whereArgs: [contactId, myId, myId, contactId],
|
||||||
|
orderBy: 'timestamp DESC',
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
if (rows.isEmpty) return null;
|
||||||
|
return rows.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateDeliveredAt(int messageId, DateTime deliveredAt) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.update(
|
||||||
|
'messages',
|
||||||
|
{'delivered_at': deliveredAt.toIso8601String()},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [messageId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateReadAt(int messageId, DateTime readAt) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.update(
|
||||||
|
'messages',
|
||||||
|
{'read_at': readAt.toIso8601String()},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [messageId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteMessage(int messageId) async {
|
||||||
|
final db = await database;
|
||||||
|
await db.delete(
|
||||||
|
'messages',
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [messageId],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ class SocketService {
|
||||||
Future<void> connect(ApiService apiService) async {
|
Future<void> connect(ApiService apiService) async {
|
||||||
final token = await apiService.getAccessToken();
|
final token = await apiService.getAccessToken();
|
||||||
if (_channel != null) return; // Уже подключены
|
if (_channel != null) return; // Уже подключены
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
print('❌ SocketService.connect: no access token, skipping connect');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке
|
// В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке
|
||||||
final uri = Uri.parse("ws://${AppConstants.baseUrl}/ws?token=$token");
|
final uri = Uri.parse("ws://${AppConstants.baseUrl}/ws?token=$token");
|
||||||
|
|
@ -32,6 +36,7 @@ class SocketService {
|
||||||
_channel!.stream.listen(
|
_channel!.stream.listen(
|
||||||
(data) {
|
(data) {
|
||||||
final decoded = jsonDecode(data);
|
final decoded = jsonDecode(data);
|
||||||
|
print("🚀 СООБЩЕНИЕ ПОЛУЧЕНО ИЗ SINK: $decoded");
|
||||||
_messageController.add(decoded);
|
_messageController.add(decoded);
|
||||||
},
|
},
|
||||||
onError: (error) => _reconnect(apiService),
|
onError: (error) => _reconnect(apiService),
|
||||||
|
|
@ -44,10 +49,11 @@ class SocketService {
|
||||||
Future.delayed(const Duration(seconds: 5), () => connect(apiService));
|
Future.delayed(const Duration(seconds: 5), () => connect(apiService));
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendMessage(Map<String, dynamic> data) {
|
bool sendMessage(Map<String, dynamic> data, {int retryCnt = 0}) {
|
||||||
if (_channel == null) {
|
if (_channel == null) {
|
||||||
print("❌ ОШИБКА: Попытка отправить сообщение через NULL канал.");
|
//print("❌ ОШИБКА: Попытка отправить сообщение через NULL канал.");
|
||||||
return;
|
sendMessage(data, retryCnt: retryCnt + 1);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final encodedData = jsonEncode(data);
|
final encodedData = jsonEncode(data);
|
||||||
|
|
@ -57,11 +63,20 @@ class SocketService {
|
||||||
|
|
||||||
// 2. Добавляем принт подтверждения
|
// 2. Добавляем принт подтверждения
|
||||||
print("🚀 СООБЩЕНИЕ ОТПРАВЛЕНО В SINK: $encodedData");
|
print("🚀 СООБЩЕНИЕ ОТПРАВЛЕНО В SINK: $encodedData");
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("❌ КРИТИЧЕСКАЯ ОШИБКА ПРИ ОТПРАВКЕ: $e");
|
print("❌ КРИТИЧЕСКАЯ ОШИБКА ПРИ ОТПРАВКЕ: $e");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool sendReadReceipt(int messageId) {
|
||||||
|
return sendMessage({
|
||||||
|
'type': 'read_receipt',
|
||||||
|
'message_id': messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void disconnect() {
|
void disconnect() {
|
||||||
_channel?.sink.close(status.goingAway);
|
_channel?.sink.close(status.goingAway);
|
||||||
_channel = null;
|
_channel = null;
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,50 @@ class Contact {
|
||||||
this.publicKey,
|
this.publicKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Contact copyWith({
|
||||||
|
int? id,
|
||||||
|
String? username,
|
||||||
|
String? name,
|
||||||
|
String? surname,
|
||||||
|
String? lastMessage,
|
||||||
|
String? avatarUrl,
|
||||||
|
DateTime? lastMessageTime,
|
||||||
|
bool? isOnline,
|
||||||
|
int? unreadCount,
|
||||||
|
String? publicKey,
|
||||||
|
}) {
|
||||||
|
return Contact(
|
||||||
|
id: id ?? this.id,
|
||||||
|
username: username ?? this.username,
|
||||||
|
name: name ?? this.name,
|
||||||
|
surname: surname ?? this.surname,
|
||||||
|
lastMessage: lastMessage ?? this.lastMessage,
|
||||||
|
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||||
|
lastMessageTime: lastMessageTime ?? this.lastMessageTime,
|
||||||
|
isOnline: isOnline ?? this.isOnline,
|
||||||
|
unreadCount: unreadCount ?? this.unreadCount,
|
||||||
|
publicKey: publicKey ?? this.publicKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
factory Contact.fromJson(Map<String, dynamic> json) {
|
factory Contact.fromJson(Map<String, dynamic> json) {
|
||||||
|
DateTime? parseTime(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is DateTime) return value;
|
||||||
|
final asString = value.toString();
|
||||||
|
return DateTime.tryParse(asString);
|
||||||
|
}
|
||||||
|
|
||||||
return Contact(
|
return Contact(
|
||||||
id: json['id'],
|
id: json['id'],
|
||||||
username: json['username'] ?? 'Unknown',
|
username: json['username'] ?? 'Unknown',
|
||||||
name: json['name'] ?? 'Unknown',
|
name: json['name'] ?? 'Unknown',
|
||||||
surname: json['surname'] ?? 'Unknown',
|
surname: json['surname'] ?? 'Unknown',
|
||||||
|
lastMessage: json['last_message'] ?? json['lastMessage'],
|
||||||
|
avatarUrl: json['avatar_url'] ?? json['avatarUrl'],
|
||||||
|
lastMessageTime: parseTime(json['last_message_time'] ?? json['lastMessageTime']),
|
||||||
|
isOnline: (json['is_online'] ?? json['isOnline']) == true,
|
||||||
|
unreadCount: int.tryParse((json['unread_count'] ?? json['unreadCount'] ?? 0).toString()) ?? 0,
|
||||||
publicKey: json['public_key'],
|
publicKey: json['public_key'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,86 @@
|
||||||
|
enum MessageStatus { sending, sent, delivered, read, failed }
|
||||||
|
|
||||||
class MessageModel {
|
class MessageModel {
|
||||||
final int? id; // ID из базы данных (null, если сообщение еще не отправлено)
|
final int? id; // server id (null пока не подтверждено сервером)
|
||||||
final int senderId; // ID отправителя
|
final int? tempId; // client temp id (для сопоставления ack)
|
||||||
final int receiverId; // ID отправителя
|
final int senderId;
|
||||||
final String text; // Текст сообщения
|
final int receiverId;
|
||||||
final DateTime createdAt; // Время отправки
|
final String text; // текст для UI (у нас уже расшифрованный)
|
||||||
final bool isMe; // Локальный флаг для UI (мое/чужое)
|
final DateTime createdAt;
|
||||||
|
final bool isMe;
|
||||||
|
final MessageStatus status;
|
||||||
|
final int? replyToId; // ID сообщения, на которое отвечают
|
||||||
|
final String? replyToText; // текст сообщения, на которое отвечают (для отображения)
|
||||||
|
|
||||||
MessageModel({
|
MessageModel({
|
||||||
this.id,
|
this.id,
|
||||||
|
this.tempId,
|
||||||
required this.senderId,
|
required this.senderId,
|
||||||
required this.receiverId,
|
required this.receiverId,
|
||||||
required this.text,
|
required this.text,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
this.isMe = false,
|
required this.isMe,
|
||||||
|
this.status = MessageStatus.sent,
|
||||||
|
this.replyToId,
|
||||||
|
this.replyToText,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Превращаем JSON от бэкенда в объект Dart
|
MessageModel copyWith({
|
||||||
factory MessageModel.fromJson(Map<String, dynamic> json, int currentUserId) {
|
int? id,
|
||||||
|
int? tempId,
|
||||||
|
int? senderId,
|
||||||
|
int? receiverId,
|
||||||
|
String? text,
|
||||||
|
DateTime? createdAt,
|
||||||
|
bool? isMe,
|
||||||
|
MessageStatus? status,
|
||||||
|
int? replyToId,
|
||||||
|
String? replyToText,
|
||||||
|
}) {
|
||||||
return MessageModel(
|
return MessageModel(
|
||||||
id: json['id'],
|
id: id ?? this.id,
|
||||||
senderId: json['sender_id'],
|
tempId: tempId ?? this.tempId,
|
||||||
receiverId: json['receiverId'],
|
senderId: senderId ?? this.senderId,
|
||||||
text: json['text'] ?? '',
|
receiverId: receiverId ?? this.receiverId,
|
||||||
// Парсим дату из ISO строки или временной метки
|
text: text ?? this.text,
|
||||||
createdAt: DateTime.parse(json['created_at']),
|
createdAt: createdAt ?? this.createdAt,
|
||||||
// Сразу вычисляем, наше ли это сообщение
|
isMe: isMe ?? this.isMe,
|
||||||
isMe: json['sender_id'] == currentUserId,
|
status: status ?? this.status,
|
||||||
|
replyToId: replyToId ?? this.replyToId,
|
||||||
|
replyToText: replyToText ?? this.replyToText,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory MessageModel.fromJson(Map<String, dynamic> json, int currentUserId) {
|
||||||
|
final senderId = int.parse(json['sender_id'].toString());
|
||||||
|
final receiverId = int.parse((json['receiver_id'] ?? json['recipient_id']).toString());
|
||||||
|
final createdAtRaw = (json['created_at'] ?? json['timestamp']).toString();
|
||||||
|
|
||||||
|
return MessageModel(
|
||||||
|
id: json['id'] == null ? null : int.tryParse(json['id'].toString()),
|
||||||
|
tempId: json['temp_id'] == null ? null : int.tryParse(json['temp_id'].toString()),
|
||||||
|
senderId: senderId,
|
||||||
|
receiverId: receiverId,
|
||||||
|
text: (json['text'] ?? json['content'] ?? '').toString(),
|
||||||
|
createdAt: DateTime.tryParse(createdAtRaw) ?? DateTime.now(),
|
||||||
|
isMe: senderId == currentUserId,
|
||||||
|
status: MessageStatus.sent,
|
||||||
|
replyToId: json['reply_to_id'] == null ? null : int.tryParse(json['reply_to_id'].toString()),
|
||||||
|
replyToText: json['reply_to_text'] == null ? null : json['reply_to_text'].toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Превращаем объект Dart в JSON для отправки через WebSocket или API
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
'text': text,
|
'id': id,
|
||||||
|
'temp_id': tempId,
|
||||||
'sender_id': senderId,
|
'sender_id': senderId,
|
||||||
// На бэкенд обычно отправляем строку в формате ISO 8601
|
'receiver_id': receiverId,
|
||||||
|
'text': text,
|
||||||
'created_at': createdAt.toIso8601String(),
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
'status': status.name,
|
||||||
|
'reply_to_id': replyToId,
|
||||||
|
'reply_to_text': replyToText,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8,7 +8,29 @@ class ContactRepository {
|
||||||
final http.Client _client = http.Client();
|
final http.Client _client = http.Client();
|
||||||
final ApiService _apiService = ApiService();
|
final ApiService _apiService = ApiService();
|
||||||
|
|
||||||
Future<List<Contact>> fetchContacts() async {
|
Future<List<Contact>> fetchChatContacts() async {
|
||||||
|
final token = await _apiService.getAccessToken();
|
||||||
|
if (token == null) {
|
||||||
|
throw Exception('No access token');
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await _client.get(
|
||||||
|
Uri.http(AppConstants.baseUrl, 'users/chats'),
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
return data.map((json) => Contact.fromJson(json)).toList();
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to load contacts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Contact>> fetchAllUsers() async {
|
||||||
final token = await _apiService.getAccessToken();
|
final token = await _apiService.getAccessToken();
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
throw Exception('No access token');
|
throw Exception('No access token');
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,53 @@ class ApiService extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getMe() async {
|
||||||
|
final token = await getAccessToken();
|
||||||
|
final response = await _client.get(
|
||||||
|
Uri.http(AppConstants.baseUrl, 'users/me'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
throw Exception('Не удалось получить данные пользователя');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> updateEncryptedPrivateKey(String encryptedPrivateKey) async {
|
||||||
|
final token = await getAccessToken();
|
||||||
|
final response = await _client.put(
|
||||||
|
Uri.http(AppConstants.baseUrl, 'users/me/encryption-key'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
|
body: jsonEncode({'encrypted_private_key': encryptedPrivateKey}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.statusCode == 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> changePassword(String currentPassword, String newPassword) async {
|
||||||
|
final token = await getAccessToken();
|
||||||
|
final response = await _client.put(
|
||||||
|
Uri.http(AppConstants.baseUrl, 'users/me/password'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'current_password': currentPassword,
|
||||||
|
'new_password': newPassword,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.statusCode == 200;
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<dynamic>> getChatHistory(int contactId) async {
|
Future<List<dynamic>> getChatHistory(int contactId) async {
|
||||||
final token = await getAccessToken();
|
final token = await getAccessToken();
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
|
|
@ -131,4 +178,94 @@ class ApiService extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
return jsonDecode(response.body) as List<dynamic>;
|
return jsonDecode(response.body) as List<dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> updateMe({
|
||||||
|
required String username,
|
||||||
|
required String firstName,
|
||||||
|
required String lastName,
|
||||||
|
String? phone,
|
||||||
|
String? email,
|
||||||
|
String? about,
|
||||||
|
}) async {
|
||||||
|
final token = await getAccessToken();
|
||||||
|
final response = await _client.put(
|
||||||
|
Uri.http(AppConstants.baseUrl, 'users/me'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'username': username,
|
||||||
|
'first_name': firstName,
|
||||||
|
'last_name': lastName,
|
||||||
|
'phone': (phone == null || phone.trim().isEmpty) ? null : phone.trim(),
|
||||||
|
'email': (email == null || email.trim().isEmpty) ? null : email.trim(),
|
||||||
|
'about': (about == null || about.trim().isEmpty) ? null : about.trim(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final decoded = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return decoded as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
throw Exception((decoded is Map && decoded['detail'] != null) ? decoded['detail'] : 'Failed to update profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getUserById(int userId) async {
|
||||||
|
final token = await getAccessToken();
|
||||||
|
final response = await _client.get(
|
||||||
|
Uri.http(AppConstants.baseUrl, 'users/$userId'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
throw Exception('Не удалось получить информацию о пользователе');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> updatePrivacySettings({
|
||||||
|
bool? showEmail,
|
||||||
|
bool? showPhone,
|
||||||
|
bool? showAvatar,
|
||||||
|
bool? showAbout,
|
||||||
|
bool? showUsername,
|
||||||
|
}) async {
|
||||||
|
final token = await getAccessToken();
|
||||||
|
final response = await _client.put(
|
||||||
|
Uri.http(AppConstants.baseUrl, 'users/me/privacy'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
if (showEmail != null) 'show_email': showEmail,
|
||||||
|
if (showPhone != null) 'show_phone': showPhone,
|
||||||
|
if (showAvatar != null) 'show_avatar': showAvatar,
|
||||||
|
if (showAbout != null) 'show_about': showAbout,
|
||||||
|
if (showUsername != null) 'show_username': showUsername,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.statusCode == 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getPrivacySettings() async {
|
||||||
|
final token = await getAccessToken();
|
||||||
|
final response = await _client.get(
|
||||||
|
Uri.http(AppConstants.baseUrl, 'users/me/privacy'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
throw Exception('Не удалось получить настройки конфиденциальности');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,23 @@ class CryptoService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> encryptPrivateKeyWithPassword(
|
||||||
|
String privateKeyBase64,
|
||||||
|
String masterPassword,
|
||||||
|
) async {
|
||||||
|
final privateKeyBytes = base64Decode(privateKeyBase64);
|
||||||
|
final masterKey = await _deriveKeyFromPassword(masterPassword);
|
||||||
|
final nonce = aesGcm.newNonce();
|
||||||
|
final encrypted = await aesGcm.encrypt(
|
||||||
|
privateKeyBytes,
|
||||||
|
secretKey: masterKey,
|
||||||
|
nonce: nonce,
|
||||||
|
);
|
||||||
|
|
||||||
|
final encryptedData = nonce + encrypted.mac.bytes + encrypted.cipherText;
|
||||||
|
return base64Encode(encryptedData);
|
||||||
|
}
|
||||||
|
|
||||||
Future<SecretKey> _deriveKeyFromPassword(String password) async {
|
Future<SecretKey> _deriveKeyFromPassword(String password) async {
|
||||||
final pbkdf2 = Pbkdf2(
|
final pbkdf2 = Pbkdf2(
|
||||||
macAlgorithm: Hmac.sha256(),
|
macAlgorithm: Hmac.sha256(),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,47 @@ class AuthProvider extends ChangeNotifier {
|
||||||
int? _currentUserId;
|
int? _currentUserId;
|
||||||
int? get currentUserId => _currentUserId;
|
int? get currentUserId => _currentUserId;
|
||||||
|
|
||||||
|
String? _username;
|
||||||
|
String? get username => _username;
|
||||||
|
|
||||||
|
String? _firstName;
|
||||||
|
String? get firstName => _firstName;
|
||||||
|
|
||||||
|
String? _lastName;
|
||||||
|
String? get lastName => _lastName;
|
||||||
|
|
||||||
|
String? _phone;
|
||||||
|
String? get phone => _phone;
|
||||||
|
|
||||||
|
String? _email;
|
||||||
|
String? get email => _email;
|
||||||
|
|
||||||
|
String? _about;
|
||||||
|
String? get about => _about;
|
||||||
|
|
||||||
|
// Privacy settings
|
||||||
|
bool? _showEmail;
|
||||||
|
bool? get showEmail => _showEmail;
|
||||||
|
|
||||||
|
bool? _showPhone;
|
||||||
|
bool? get showPhone => _showPhone;
|
||||||
|
|
||||||
|
bool? _showAvatar;
|
||||||
|
bool? get showAvatar => _showAvatar;
|
||||||
|
|
||||||
|
bool? _showAbout;
|
||||||
|
bool? get showAbout => _showAbout;
|
||||||
|
|
||||||
|
bool? _showUsername;
|
||||||
|
bool? get showUsername => _showUsername;
|
||||||
|
|
||||||
|
String get displayName {
|
||||||
|
final full = '${_firstName ?? ''} ${_lastName ?? ''}'.trim();
|
||||||
|
if (full.isNotEmpty) return full;
|
||||||
|
if ((_username ?? '').isNotEmpty) return _username!;
|
||||||
|
return 'User';
|
||||||
|
}
|
||||||
|
|
||||||
// Флаги для определения пути пользователя
|
// Флаги для определения пути пользователя
|
||||||
bool _needsSetup = false;
|
bool _needsSetup = false;
|
||||||
bool get needsSetup => _needsSetup;
|
bool get needsSetup => _needsSetup;
|
||||||
|
|
@ -91,6 +132,13 @@ class AuthProvider extends ChangeNotifier {
|
||||||
final mode = await _storage.read(key: 'theme_mode');
|
final mode = await _storage.read(key: 'theme_mode');
|
||||||
final color = await _storage.read(key: 'accent_color');
|
final color = await _storage.read(key: 'accent_color');
|
||||||
await _storage.deleteAll();
|
await _storage.deleteAll();
|
||||||
|
_currentUserId = null;
|
||||||
|
_username = null;
|
||||||
|
_firstName = null;
|
||||||
|
_lastName = null;
|
||||||
|
_phone = null;
|
||||||
|
_email = null;
|
||||||
|
_about = null;
|
||||||
if (mode != null) {
|
if (mode != null) {
|
||||||
await _storage.write(key: 'theme_mode', value: mode);
|
await _storage.write(key: 'theme_mode', value: mode);
|
||||||
}
|
}
|
||||||
|
|
@ -193,6 +241,13 @@ class AuthProvider extends ChangeNotifier {
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map;
|
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map;
|
||||||
|
|
||||||
|
_username = data['username']?.toString();
|
||||||
|
_firstName = data['first_name']?.toString();
|
||||||
|
_lastName = data['last_name']?.toString();
|
||||||
|
_phone = data['phone']?.toString();
|
||||||
|
_email = data['email']?.toString();
|
||||||
|
_about = data['about']?.toString();
|
||||||
|
|
||||||
// Проверяем наличие публичного ключа на сервере
|
// Проверяем наличие публичного ключа на сервере
|
||||||
_hasPublicKeyOnServer = data['public_key'] != null && data['public_key'].isNotEmpty;
|
_hasPublicKeyOnServer = data['public_key'] != null && data['public_key'].isNotEmpty;
|
||||||
|
|
||||||
|
|
@ -213,6 +268,24 @@ class AuthProvider extends ChangeNotifier {
|
||||||
_needsKeyRecovery = false;
|
_needsKeyRecovery = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Загружаем настройки конфиденциальности
|
||||||
|
try {
|
||||||
|
final privacyData = await _apiService.getPrivacySettings();
|
||||||
|
_showEmail = privacyData['show_email'] as bool?;
|
||||||
|
_showPhone = privacyData['show_phone'] as bool?;
|
||||||
|
_showAvatar = privacyData['show_avatar'] as bool?;
|
||||||
|
_showAbout = privacyData['show_about'] as bool?;
|
||||||
|
_showUsername = privacyData['show_username'] as bool?;
|
||||||
|
} catch (e) {
|
||||||
|
print("Ошибка загрузки настроек конфиденциальности: $e");
|
||||||
|
// Устанавливаем значения по умолчанию
|
||||||
|
_showEmail = true;
|
||||||
|
_showPhone = true;
|
||||||
|
_showAvatar = true;
|
||||||
|
_showAbout = true;
|
||||||
|
_showUsername = true;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Ошибка проверки статуса: $e");
|
print("Ошибка проверки статуса: $e");
|
||||||
_needsSetup = false;
|
_needsSetup = false;
|
||||||
|
|
@ -221,6 +294,10 @@ class AuthProvider extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refreshMe() async {
|
||||||
|
await _checkAccountStatus();
|
||||||
|
}
|
||||||
|
|
||||||
// Метод для начала с чистого листа (новые ключи)
|
// Метод для начала с чистого листа (новые ключи)
|
||||||
Future<void> resetKeys() async {
|
Future<void> resetKeys() async {
|
||||||
await _storage.delete(key: 'private_key');
|
await _storage.delete(key: 'private_key');
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '/data/models/contact_model.dart';
|
import '/data/models/contact_model.dart';
|
||||||
import '/data/repositories/contact_repository.dart';
|
import '/data/repositories/contact_repository.dart';
|
||||||
|
import '/data/datasources/local_db_service.dart';
|
||||||
|
import '/domain/services/crypto_service.dart';
|
||||||
|
|
||||||
class ContactProvider extends ChangeNotifier {
|
class ContactProvider extends ChangeNotifier {
|
||||||
final ContactRepository _repository = ContactRepository();
|
final ContactRepository _repository = ContactRepository();
|
||||||
|
final LocalDbService _localDbService = LocalDbService();
|
||||||
|
final CryptoService _cryptoService = CryptoService();
|
||||||
List<Contact> _contacts = [];
|
List<Contact> _contacts = [];
|
||||||
List<Contact> _allContacts = [];
|
List<Contact> _allContacts = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
bool _isFirstLoad = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
int? _currentUserId;
|
int? _currentUserId;
|
||||||
|
|
||||||
|
|
@ -25,19 +30,33 @@ class ContactProvider extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadContacts() async {
|
Future<void> loadContacts() async {
|
||||||
_isLoading = true;
|
if (_isFirstLoad) {
|
||||||
|
_isFirstLoad = false;
|
||||||
|
_isLoading = true;
|
||||||
|
}
|
||||||
_error = null;
|
_error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final allContacts = await _repository.fetchContacts();
|
final allContacts = await _repository.fetchChatContacts();
|
||||||
// Фильтруем: исключаем себя (для основного списка - только чаты)
|
// Фильтруем: исключаем себя (для основного списка - только чаты)
|
||||||
_contacts = allContacts.where((contact) => contact.id != _currentUserId).toList();
|
_contacts = allContacts
|
||||||
|
.where((contact) => contact.id != _currentUserId)
|
||||||
|
.toList();
|
||||||
_allContacts = _contacts;
|
_allContacts = _contacts;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
// Обогащаем превью последним сообщением из локальной БД, не блокируя UI.
|
||||||
|
_enrichContactsWithLastMessages();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
} finally {
|
} finally {
|
||||||
_isLoading = false;
|
// Если ошибка — выходим из состояния загрузки тут.
|
||||||
|
// Если всё ок — `_isLoading` уже сброшен выше, чтобы показать список быстрее.
|
||||||
|
if (_error != null) {
|
||||||
|
_isLoading = false;
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -49,9 +68,11 @@ class ContactProvider extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final allContacts = await _repository.fetchContacts();
|
final allContacts = await _repository.fetchAllUsers();
|
||||||
// Фильтруем только исключение самого себя
|
// Фильтруем только исключение самого себя
|
||||||
_allContacts = allContacts.where((contact) => contact.id != _currentUserId).toList();
|
_allContacts = allContacts
|
||||||
|
.where((contact) => contact.id != _currentUserId)
|
||||||
|
.toList();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -59,4 +80,93 @@ class ContactProvider extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _enrichContactsWithLastMessages() async {
|
||||||
|
final myId = _currentUserId;
|
||||||
|
if (myId == null) return;
|
||||||
|
|
||||||
|
final myPrivKey = await _cryptoService.getPrivateKey();
|
||||||
|
|
||||||
|
final List<Contact> updated = List<Contact>.from(_contacts);
|
||||||
|
|
||||||
|
for (int i = 0; i < updated.length; i++) {
|
||||||
|
final contact = updated[i];
|
||||||
|
|
||||||
|
// 1) Если сервер уже прислал lastMessage — попробуем расшифровать превью.
|
||||||
|
if (contact.lastMessage != null &&
|
||||||
|
contact.lastMessage!.isNotEmpty &&
|
||||||
|
myPrivKey != null &&
|
||||||
|
contact.publicKey != null) {
|
||||||
|
try {
|
||||||
|
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
||||||
|
myPrivKey,
|
||||||
|
contact.publicKey!,
|
||||||
|
);
|
||||||
|
final decrypted = await _cryptoService.decryptMessage(
|
||||||
|
contact.lastMessage!,
|
||||||
|
sharedSecret,
|
||||||
|
);
|
||||||
|
updated[i] = contact.copyWith(lastMessage: decrypted);
|
||||||
|
} catch (_) {
|
||||||
|
// Если расшифровать не удалось — оставляем как есть, дальше попробуем локальную БД.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если сервер уже отдал и сообщение, и время — не трогаем (контакты уже обогащены).
|
||||||
|
final contactAfterServer = updated[i];
|
||||||
|
if (contactAfterServer.lastMessage != null &&
|
||||||
|
contactAfterServer.lastMessage!.isNotEmpty &&
|
||||||
|
contactAfterServer.publicKey == null) {
|
||||||
|
// Чтобы не показывать в списке контактов "ciphertext", если ключа нет.
|
||||||
|
updated[i] = contactAfterServer.copyWith(
|
||||||
|
lastMessage: 'Новое сообщение',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final contactAfterServer2 = updated[i];
|
||||||
|
if (contactAfterServer2.lastMessage != null &&
|
||||||
|
contactAfterServer2.lastMessageTime != null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final last = await _localDbService.getLastMessage(contact.id, myId);
|
||||||
|
if (last == null) continue;
|
||||||
|
|
||||||
|
final rawContent = last['content']?.toString();
|
||||||
|
final rawTimestamp = last['timestamp']?.toString();
|
||||||
|
final lastTime = rawTimestamp == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(rawTimestamp);
|
||||||
|
|
||||||
|
String? preview;
|
||||||
|
if (rawContent != null && rawContent.isNotEmpty) {
|
||||||
|
// Пытаемся расшифровать превью, если есть ключи.
|
||||||
|
try {
|
||||||
|
if (myPrivKey != null && contact.publicKey != null) {
|
||||||
|
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
||||||
|
myPrivKey,
|
||||||
|
contact.publicKey!,
|
||||||
|
);
|
||||||
|
preview = await _cryptoService.decryptMessage(
|
||||||
|
rawContent,
|
||||||
|
sharedSecret,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
preview = 'Новое сообщение';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
preview = 'Новое сообщение';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updated[i] = contactAfterServer2.copyWith(
|
||||||
|
lastMessage: preview,
|
||||||
|
lastMessageTime: contactAfterServer2.lastMessageTime ?? lastTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_contacts = updated;
|
||||||
|
_allContacts = updated;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +27,8 @@ RemoteMessage? initialMessage;
|
||||||
|
|
||||||
// Ключ для SharedPreferences
|
// Ключ для SharedPreferences
|
||||||
const String _notificationLaunchKey = 'notification_launch_data';
|
const String _notificationLaunchKey = 'notification_launch_data';
|
||||||
|
// Защита от повторной обработки одного и того же payload при следующих запусках по иконке
|
||||||
|
const String _lastHandledNotificationLaunchPayloadKey = 'notification_last_handled_payload';
|
||||||
|
|
||||||
Future<void> _onSelectNotification(NotificationResponse notificationResponse) async {
|
Future<void> _onSelectNotification(NotificationResponse notificationResponse) async {
|
||||||
final payload = notificationResponse.payload;
|
final payload = notificationResponse.payload;
|
||||||
|
|
@ -39,12 +41,17 @@ Future<void> _onSelectNotification(NotificationResponse notificationResponse) as
|
||||||
|
|
||||||
final context = navigatorKey.currentContext;
|
final context = navigatorKey.currentContext;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final canonicalPayload = jsonEncode(data);
|
||||||
|
|
||||||
// Важно: не сохраняем payload в SharedPreferences, если можем сразу перейти в чат.
|
// Важно: не сохраняем payload в SharedPreferences, если можем сразу перейти в чат.
|
||||||
// Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение
|
// Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение
|
||||||
// будет снова автопереходить в чат.
|
// будет снова автопереходить в чат.
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
|
final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
|
||||||
|
if (lastHandled != canonicalPayload) {
|
||||||
|
await prefs.setString(_notificationLaunchKey, canonicalPayload);
|
||||||
|
await prefs.setString(_lastHandledNotificationLaunchPayloadKey, canonicalPayload);
|
||||||
|
}
|
||||||
print('Navigator context is null, saved notification payload to SharedPreferences');
|
print('Navigator context is null, saved notification payload to SharedPreferences');
|
||||||
} else {
|
} else {
|
||||||
await prefs.remove(_notificationLaunchKey);
|
await prefs.remove(_notificationLaunchKey);
|
||||||
|
|
@ -124,9 +131,16 @@ void main() async {
|
||||||
print('Message type: ${initialMessage!.data['type']}');
|
print('Message type: ${initialMessage!.data['type']}');
|
||||||
print('Sender ID: ${initialMessage!.data['sender_id']}');
|
print('Sender ID: ${initialMessage!.data['sender_id']}');
|
||||||
|
|
||||||
// Сохраняем данные уведомления
|
final payloadString = jsonEncode(initialMessage!.data);
|
||||||
await prefs.setString(_notificationLaunchKey, jsonEncode(initialMessage!.data));
|
final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
|
||||||
print('Saved notification data to SharedPreferences');
|
if (lastHandled != payloadString) {
|
||||||
|
// Сохраняем данные уведомления
|
||||||
|
await prefs.setString(_notificationLaunchKey, payloadString);
|
||||||
|
await prefs.setString(_lastHandledNotificationLaunchPayloadKey, payloadString);
|
||||||
|
print('Saved notification data to SharedPreferences');
|
||||||
|
} else {
|
||||||
|
print('InitialMessage payload already handled earlier, skipping');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
print('No initial message - app launched normally');
|
print('No initial message - app launched normally');
|
||||||
// Очищаем сохраненные данные, если приложение запущено нормально
|
// Очищаем сохраненные данные, если приложение запущено нормально
|
||||||
|
|
@ -148,10 +162,15 @@ void main() async {
|
||||||
print('App launched from local notification, payload: $payload');
|
print('App launched from local notification, payload: $payload');
|
||||||
if (payload != null && payload.isNotEmpty) {
|
if (payload != null && payload.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(payload);
|
final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
|
||||||
final prefs = await SharedPreferences.getInstance();
|
if (lastHandled != payload) {
|
||||||
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
|
final data = jsonDecode(payload);
|
||||||
print('Saved local notification launch payload to SharedPreferences');
|
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
|
||||||
|
await prefs.setString(_lastHandledNotificationLaunchPayloadKey, payload);
|
||||||
|
print('Saved local notification launch payload to SharedPreferences');
|
||||||
|
} else {
|
||||||
|
print('Local notification payload already handled earlier, skipping');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Failed to save notification launch payload: $e');
|
print('Failed to save notification launch payload: $e');
|
||||||
}
|
}
|
||||||
|
|
@ -235,6 +254,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
payload: jsonEncode({
|
payload: jsonEncode({
|
||||||
'type': 'enc_message',
|
'type': 'enc_message',
|
||||||
'sender_id': message.data['sender_id'],
|
'sender_id': message.data['sender_id'],
|
||||||
|
'timestamp': message.data['timestamp'] ?? DateTime.now().toIso8601String(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
print('Notification shown successfully');
|
print('Notification shown successfully');
|
||||||
|
|
@ -246,9 +266,46 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatefulWidget {
|
||||||
const MyApp({super.key});
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyApp> createState() => _MyAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// Закрываем сокет, как только приложение сворачивается.
|
||||||
|
if (state == AppLifecycleState.paused ||
|
||||||
|
state == AppLifecycleState.inactive ||
|
||||||
|
state == AppLifecycleState.detached) {
|
||||||
|
try {
|
||||||
|
context.read<AuthProvider>().closeRealtime();
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// На возврате в приложение — пробуем переподключиться (если есть токен).
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
try {
|
||||||
|
context.read<AuthProvider>().initRealtime();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final themeProvider = context.watch<ThemeProvider>();
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -14,6 +14,8 @@ import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
||||||
import 'package:chepuhagram/main.dart';
|
import 'package:chepuhagram/main.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'contacts_screen.dart';
|
import 'contacts_screen.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'user_profile_screen.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
|
|
@ -30,18 +32,23 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
late Contact _currentContact;
|
late Contact _currentContact;
|
||||||
bool _isKeyLoading = false;
|
bool _isKeyLoading = false;
|
||||||
final TextEditingController _controller = TextEditingController();
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
final FocusNode _inputFocusNode = FocusNode();
|
||||||
final ContactRepository _contactRepository = ContactRepository();
|
final ContactRepository _contactRepository = ContactRepository();
|
||||||
final apiService = ApiService();
|
final apiService = ApiService();
|
||||||
final CryptoService _cryptoService = CryptoService();
|
final CryptoService _cryptoService = CryptoService();
|
||||||
List<MessageModel> messages = [];
|
List<MessageModel> messages = [];
|
||||||
StreamSubscription<dynamic>? _socketSubscription;
|
StreamSubscription<dynamic>? _socketSubscription;
|
||||||
|
final Set<int> _sentReadReceipts = <int>{};
|
||||||
|
final LocalDbService _localDbService = LocalDbService();
|
||||||
|
MessageModel? _replyTo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_currentContact = widget.contact;
|
_currentContact = widget.contact;
|
||||||
|
|
||||||
currentActiveChatContactId = _currentContact.id; // Устанавливаем активный чат
|
currentActiveChatContactId =
|
||||||
|
_currentContact.id; // Устанавливаем активный чат
|
||||||
final contactProvider = context.read<ContactProvider>();
|
final contactProvider = context.read<ContactProvider>();
|
||||||
myId = contactProvider.getCurrentUserId() ?? 0;
|
myId = contactProvider.getCurrentUserId() ?? 0;
|
||||||
// Если ключа нет, загружаем его при входе
|
// Если ключа нет, загружаем его при входе
|
||||||
|
|
@ -83,6 +90,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
currentActiveChatContactId = null; // Сбрасываем активный чат
|
currentActiveChatContactId = null; // Сбрасываем активный чат
|
||||||
_socketSubscription?.cancel();
|
_socketSubscription?.cancel();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
|
_inputFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,16 +101,26 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (Navigator.of(context).canPop()) {
|
Navigator.of(context).pushReplacement(
|
||||||
Navigator.of(context).pop();
|
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||||
} else {
|
);
|
||||||
Navigator.of(context).pushReplacement(
|
|
||||||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
title: Text(_currentContact.name),
|
title: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => UserProfileScreen(
|
||||||
|
userId: _currentContact.id,
|
||||||
|
username: _currentContact.username,
|
||||||
|
name: _currentContact.name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(_currentContact.name),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -113,9 +131,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final msg = messages[messages.length - 1 - index];
|
final msg = messages[messages.length - 1 - index];
|
||||||
return MessageBubble(
|
return MessageBubble(
|
||||||
message: msg.text,
|
message: msg,
|
||||||
time: msg.createdAt,
|
onTap: () => _showMessageActions(msg),
|
||||||
isMe: msg.isMe,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -126,26 +143,134 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showMessageActions(MessageModel msg) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (ctx) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.reply),
|
||||||
|
title: const Text('Ответить'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
setState(() => _replyTo = msg);
|
||||||
|
_inputFocusNode.requestFocus();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.copy),
|
||||||
|
title: const Text('Скопировать'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
await Clipboard.setData(ClipboardData(text: msg.text));
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Скопировано')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.forward),
|
||||||
|
title: const Text('Переслать'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Пересылка пока не реализована')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete_outline),
|
||||||
|
title: const Text('Удалить'),
|
||||||
|
textColor: Colors.red,
|
||||||
|
iconColor: Colors.red,
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
setState(() {
|
||||||
|
messages.removeWhere(
|
||||||
|
(m) => (m.id != null && m.id == msg.id) || (m.tempId != null && m.tempId == msg.tempId),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final id = msg.id;
|
||||||
|
if (id != null) {
|
||||||
|
try {
|
||||||
|
await _localDbService.deleteMessage(id);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMessageInput() {
|
Widget _buildMessageInput() {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
// Добавляем SafeArea здесь
|
// Добавляем SafeArea здесь
|
||||||
child: Padding(
|
child: Container(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
if (_replyTo != null)
|
||||||
child: TextField(
|
Container(
|
||||||
controller: _controller,
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
decoration: const InputDecoration(
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
hintText: "Напиши сообщение...",
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.reply, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_replyTo!.text,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
onPressed: () => setState(() => _replyTo = null),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Row(
|
||||||
IconButton(
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
icon: const Icon(Icons.send),
|
children: [
|
||||||
onPressed: () {
|
Expanded(
|
||||||
_sendMessage();
|
child: TextField(
|
||||||
},
|
controller: _controller,
|
||||||
|
focusNode: _inputFocusNode,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 5,
|
||||||
|
textInputAction: TextInputAction.newline,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: "Напиши сообщение...",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
onPressed: () {
|
||||||
|
_sendMessage();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -181,30 +306,48 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
sharedSecret,
|
sharedSecret,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final tempId = DateTime.now().microsecondsSinceEpoch;
|
||||||
|
final localMessage = MessageModel(
|
||||||
|
tempId: tempId,
|
||||||
|
text: rawText,
|
||||||
|
isMe: true,
|
||||||
|
senderId: myId,
|
||||||
|
receiverId: _currentContact.id,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
status: MessageStatus.sending,
|
||||||
|
replyToId: _replyTo?.id,
|
||||||
|
replyToText: _replyTo?.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
messages.add(localMessage);
|
||||||
|
});
|
||||||
|
|
||||||
// Формируем payload для сервера
|
// Формируем payload для сервера
|
||||||
final payload = {
|
final payload = {
|
||||||
"type": "private_message",
|
"type": "private_message",
|
||||||
"receiver_id": _currentContact.id,
|
"receiver_id": _currentContact.id,
|
||||||
"content": encryptedText,
|
"content": encryptedText,
|
||||||
"content50": encryptedText50,
|
"content50": encryptedText50,
|
||||||
|
"temp_id": tempId,
|
||||||
|
if (_replyTo?.id != null) ...{
|
||||||
|
"reply_to_id": _replyTo!.id,
|
||||||
|
"reply_to_text": _replyTo!.text,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Отправляем
|
// Отправляем
|
||||||
print("ОТПРАВКА: $payload");
|
print("ОТПРАВКА: $payload");
|
||||||
Provider.of<SocketService>(context, listen: false).sendMessage(payload);
|
final ok = Provider.of<SocketService>(context, listen: false).sendMessage(payload);
|
||||||
|
|
||||||
// Обновляем UI (себе показываем расшифрованный текст)
|
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
messages.add(
|
final idx = messages.indexWhere((m) => m.tempId == tempId);
|
||||||
MessageModel(
|
if (idx == -1) return;
|
||||||
text: rawText,
|
messages[idx] = messages[idx].copyWith(
|
||||||
isMe: true,
|
status: ok ? MessageStatus.sent : MessageStatus.failed,
|
||||||
senderId: myId,
|
|
||||||
receiverId: _currentContact.id,
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
_replyTo = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
|
|
@ -216,13 +359,105 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void _handleIncomingMessage(Map<String, dynamic> data) async {
|
void _handleIncomingMessage(Map<String, dynamic> data) async {
|
||||||
|
// ACK от сервера: сообщение сохранено и получило server_id
|
||||||
|
if (data['type'] == 'message_sent') {
|
||||||
|
final tempId = int.tryParse(data['temp_id']?.toString() ?? '');
|
||||||
|
final serverId = int.tryParse(data['server_id']?.toString() ?? '');
|
||||||
|
final ts = DateTime.tryParse(data['timestamp']?.toString() ?? '');
|
||||||
|
|
||||||
|
if (tempId == null) return;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
final idx = messages.indexWhere((m) => m.tempId == tempId);
|
||||||
|
if (idx == -1) return;
|
||||||
|
messages[idx] = messages[idx].copyWith(
|
||||||
|
id: serverId ?? messages[idx].id,
|
||||||
|
createdAt: ts ?? messages[idx].createdAt,
|
||||||
|
status: MessageStatus.sent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility: старый ack мог приходить как message_delivered с temp_id/server_id
|
||||||
|
if (data['type'] == 'message_delivered' && data.containsKey('temp_id')) {
|
||||||
|
final tempId = int.tryParse(data['temp_id']?.toString() ?? '');
|
||||||
|
final serverId = int.tryParse(data['server_id']?.toString() ?? '');
|
||||||
|
final ts = DateTime.tryParse(data['timestamp']?.toString() ?? '');
|
||||||
|
|
||||||
|
if (tempId == null) return;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
final idx = messages.indexWhere((m) => m.tempId == tempId);
|
||||||
|
if (idx == -1) return;
|
||||||
|
messages[idx] = messages[idx].copyWith(
|
||||||
|
id: serverId ?? messages[idx].id,
|
||||||
|
createdAt: ts ?? messages[idx].createdAt,
|
||||||
|
status: MessageStatus.sent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Доставка онлайн (получатель был в сети)
|
||||||
|
if (data['type'] == 'message_delivered') {
|
||||||
|
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
|
||||||
|
final ts = DateTime.tryParse(data['timestamp']?.toString() ?? '');
|
||||||
|
if (messageId == null) return;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
for (int i = 0; i < messages.length; i++) {
|
||||||
|
if (messages[i].id == messageId) {
|
||||||
|
messages[i] = messages[i].copyWith(status: MessageStatus.delivered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ts != null) {
|
||||||
|
try {
|
||||||
|
await _localDbService.updateDeliveredAt(messageId, ts);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data['type'] == 'message_read') {
|
||||||
|
final messageId = int.tryParse(data['message_id'].toString());
|
||||||
|
if (messageId == null) return;
|
||||||
|
final ts = DateTime.tryParse(data['timestamp']?.toString() ?? '');
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
for (int i = 0; i < messages.length; i++) {
|
||||||
|
if (messages[i].id == messageId) {
|
||||||
|
messages[i] = messages[i].copyWith(status: MessageStatus.read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ts != null) {
|
||||||
|
try {
|
||||||
|
await _localDbService.updateReadAt(messageId, ts);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (data['type'] == 'private_message') {
|
if (data['type'] == 'private_message') {
|
||||||
final int senderId = int.parse(data['sender_id'].toString());
|
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
|
||||||
|
final receiverId = int.tryParse((data['receiver_id'] ?? data['recipient_id'])?.toString() ?? '');
|
||||||
|
if (senderId == null || receiverId == null) {
|
||||||
|
print('Invalid private_message ids: sender_id=${data['sender_id']} receiver_id=${data['receiver_id'] ?? data['recipient_id']}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся
|
// 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся
|
||||||
if (senderId == widget.contact.id) {
|
final isFromPartnerToMe = senderId == widget.contact.id && receiverId == myId;
|
||||||
|
if (isFromPartnerToMe) {
|
||||||
try {
|
try {
|
||||||
final myPrivKey = await _cryptoService.getPrivateKey();
|
final myPrivKey = await _cryptoService.getPrivateKey();
|
||||||
|
|
||||||
|
|
@ -241,14 +476,25 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
// 4. Добавляем в список и обновляем экран
|
// 4. Добавляем в список и обновляем экран
|
||||||
await LocalDbService().saveMessages([data]);
|
await LocalDbService().saveMessages([data]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final serverMessageId = int.tryParse(data['id']?.toString() ?? '');
|
||||||
|
if (serverMessageId != null && !_sentReadReceipts.contains(serverMessageId)) {
|
||||||
|
Provider.of<SocketService>(context, listen: false).sendReadReceipt(serverMessageId);
|
||||||
|
_sentReadReceipts.add(serverMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
messages.add(
|
messages.add(
|
||||||
MessageModel(
|
MessageModel(
|
||||||
|
id: int.tryParse(data['id']?.toString() ?? ''),
|
||||||
text: decryptedText,
|
text: decryptedText,
|
||||||
isMe: false,
|
isMe: false,
|
||||||
senderId: senderId,
|
senderId: senderId,
|
||||||
receiverId: myId,
|
receiverId: myId,
|
||||||
createdAt: DateTime.parse(data['timestamp']),
|
createdAt: DateTime.parse(data['timestamp']),
|
||||||
|
status: MessageStatus.delivered,
|
||||||
|
replyToId: data['reply_to_id'] == null ? null : int.tryParse(data['reply_to_id'].toString()),
|
||||||
|
replyToText: data['reply_to_text'] == null ? null : data['reply_to_text'].toString(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -265,19 +511,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadHistory() async {
|
Future<void> _loadHistory() async {
|
||||||
|
|
||||||
initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа
|
initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_notificationLaunchKey);
|
await prefs.remove(_notificationLaunchKey);
|
||||||
await prefs.setString(_notificationLaunchKey, ''); // Очищаем данные уведомления при загрузке ключа
|
|
||||||
try {
|
try {
|
||||||
final myPrivKey = await _cryptoService.getPrivateKey();
|
final myPrivKey = await _cryptoService.getPrivateKey();
|
||||||
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
||||||
myPrivKey!,
|
myPrivKey!,
|
||||||
widget.contact.publicKey!,
|
widget.contact.publicKey!,
|
||||||
);
|
);
|
||||||
final localDb = LocalDbService();
|
final cached = await _localDbService.getChatHistory(widget.contact.id, myId);
|
||||||
final cached = await localDb.getChatHistory(widget.contact.id, myId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
List<MessageModel> loadedLocalMessages = [];
|
List<MessageModel> loadedLocalMessages = [];
|
||||||
|
|
@ -286,13 +529,36 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
msg['content'],
|
msg['content'],
|
||||||
sharedSecret,
|
sharedSecret,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final deliveredAt = msg['delivered_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(msg['delivered_at'].toString());
|
||||||
|
final readAt = msg['read_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(msg['read_at'].toString());
|
||||||
|
|
||||||
|
MessageStatus status = (msg['sender_id'] == myId)
|
||||||
|
? MessageStatus.sent
|
||||||
|
: MessageStatus.delivered;
|
||||||
|
if (msg['sender_id'] == myId) {
|
||||||
|
if (readAt != null) {
|
||||||
|
status = MessageStatus.read;
|
||||||
|
} else if (deliveredAt != null) {
|
||||||
|
status = MessageStatus.delivered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadedLocalMessages.add(
|
loadedLocalMessages.add(
|
||||||
MessageModel(
|
MessageModel(
|
||||||
|
id: int.tryParse(msg['id']?.toString() ?? ''),
|
||||||
text: decrypted,
|
text: decrypted,
|
||||||
isMe: msg['sender_id'] == myId,
|
isMe: msg['sender_id'] == myId,
|
||||||
senderId: msg['sender_id'],
|
senderId: msg['sender_id'],
|
||||||
receiverId: msg['receiver_id'],
|
receiverId: msg['receiver_id'],
|
||||||
createdAt: DateTime.parse(msg['timestamp']),
|
createdAt: DateTime.parse(msg['timestamp']),
|
||||||
|
status: status,
|
||||||
|
replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()),
|
||||||
|
replyToText: msg['reply_to_text'] == null ? null : msg['reply_to_text'].toString(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -309,25 +575,56 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
|
|
||||||
final history = await apiService.getChatHistory(widget.contact.id);
|
final history = await apiService.getChatHistory(widget.contact.id);
|
||||||
print(history);
|
print(history);
|
||||||
|
final alreadyReadIncomingMessageIds = <int>{};
|
||||||
List<MessageModel> loadedMessages = [];
|
List<MessageModel> loadedMessages = [];
|
||||||
for (var msg in history) {
|
for (var msg in history) {
|
||||||
|
final msgId = int.tryParse(msg['id']?.toString() ?? '');
|
||||||
|
if (msgId != null &&
|
||||||
|
msg['sender_id'] != myId &&
|
||||||
|
msg['read_at'] != null) {
|
||||||
|
alreadyReadIncomingMessageIds.add(msgId);
|
||||||
|
}
|
||||||
|
|
||||||
final decrypted = await _cryptoService.decryptMessage(
|
final decrypted = await _cryptoService.decryptMessage(
|
||||||
msg['content'],
|
msg['content'],
|
||||||
sharedSecret,
|
sharedSecret,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final deliveredAt = msg['delivered_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(msg['delivered_at'].toString());
|
||||||
|
final readAt = msg['read_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(msg['read_at'].toString());
|
||||||
|
|
||||||
|
MessageStatus status = (msg['sender_id'] == myId)
|
||||||
|
? MessageStatus.sent
|
||||||
|
: MessageStatus.delivered;
|
||||||
|
if (msg['sender_id'] == myId) {
|
||||||
|
if (readAt != null) {
|
||||||
|
status = MessageStatus.read;
|
||||||
|
} else if (deliveredAt != null) {
|
||||||
|
status = MessageStatus.delivered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadedMessages.insert(
|
loadedMessages.insert(
|
||||||
0,
|
0,
|
||||||
MessageModel(
|
MessageModel(
|
||||||
|
id: int.tryParse(msg['id']?.toString() ?? ''),
|
||||||
text: decrypted,
|
text: decrypted,
|
||||||
isMe: msg['sender_id'] == myId,
|
isMe: msg['sender_id'] == myId,
|
||||||
senderId: msg['sender_id'],
|
senderId: msg['sender_id'],
|
||||||
receiverId: msg['receiver_id'],
|
receiverId: msg['receiver_id'],
|
||||||
createdAt: DateTime.parse(msg['timestamp']),
|
createdAt: DateTime.parse(msg['timestamp']),
|
||||||
|
status: status,
|
||||||
|
replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()),
|
||||||
|
replyToText: msg['reply_to_text'] == null ? null : msg['reply_to_text'].toString(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await localDb.saveMessages(history);
|
await _localDbService.saveMessages(history);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Ошибка сохранения истории в локальную базу: $e");
|
print("Ошибка сохранения истории в локальную базу: $e");
|
||||||
}
|
}
|
||||||
|
|
@ -337,6 +634,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
messages = loadedMessages;
|
messages = loadedMessages;
|
||||||
_isKeyLoading = false;
|
_isKeyLoading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Отправляем read_receipt для сообщений собеседника, которые уже на экране.
|
||||||
|
for (final m in loadedMessages) {
|
||||||
|
if (m.isMe) continue;
|
||||||
|
final id = m.id;
|
||||||
|
if (id == null) continue;
|
||||||
|
if (alreadyReadIncomingMessageIds.contains(id)) continue;
|
||||||
|
if (_sentReadReceipts.contains(id)) continue;
|
||||||
|
Provider.of<SocketService>(context, listen: false).sendReadReceipt(id);
|
||||||
|
_sentReadReceipts.add(id);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Ошибка загрузки истории: $e");
|
print("Ошибка загрузки истории: $e");
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:chepuhagram/main.dart';
|
import 'package:chepuhagram/main.dart';
|
||||||
|
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
class ContactsScreen extends StatefulWidget {
|
class ContactsScreen extends StatefulWidget {
|
||||||
final int? targetChatId;
|
final int? targetChatId;
|
||||||
|
|
@ -25,13 +27,12 @@ class ContactsScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _ContactsScreenState extends State<ContactsScreen> {
|
class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
static const String _notificationLaunchKey = 'notification_launch_data';
|
static const String _notificationLaunchKey = 'notification_launch_data';
|
||||||
|
StreamSubscription<dynamic>? _socketSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
print(
|
print('ContactsScreen initState, targetChatId: ${widget.targetChatId}');
|
||||||
'ContactsScreen initState, targetChatId: ${widget.targetChatId}',
|
|
||||||
);
|
|
||||||
_setupPushNotifications();
|
_setupPushNotifications();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final authProvider = context.read<AuthProvider>();
|
final authProvider = context.read<AuthProvider>();
|
||||||
|
|
@ -40,9 +41,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
// Установить текущего пользователя и загрузить контакты с сообщениями
|
// Установить текущего пользователя и загрузить контакты с сообщениями
|
||||||
contactProvider.setCurrentUserId(authProvider.currentUserId);
|
contactProvider.setCurrentUserId(authProvider.currentUserId);
|
||||||
contactProvider.loadContacts().then((_) {
|
contactProvider.loadContacts().then((_) {
|
||||||
print(
|
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
|
||||||
'Contacts loaded, checking targetChatId: ${widget.targetChatId}',
|
|
||||||
);
|
|
||||||
// После загрузки контактов проверить, нужно ли перейти к чату
|
// После загрузки контактов проверить, нужно ли перейти к чату
|
||||||
if (widget.targetChatId != null) {
|
if (widget.targetChatId != null) {
|
||||||
_navigateToTargetChat();
|
_navigateToTargetChat();
|
||||||
|
|
@ -91,17 +90,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToTargetChatWithId(int targetChatId) {
|
void _navigateToTargetChatWithId(int targetChatId) {
|
||||||
print(
|
print('_navigateToTargetChat called with targetChatId: $targetChatId');
|
||||||
'_navigateToTargetChat called with targetChatId: $targetChatId',
|
|
||||||
);
|
|
||||||
final contactProvider = context.read<ContactProvider>();
|
final contactProvider = context.read<ContactProvider>();
|
||||||
try {
|
try {
|
||||||
final contact = contactProvider.contacts.firstWhere(
|
final contact = contactProvider.contacts.firstWhere(
|
||||||
(c) => c.id == targetChatId,
|
(c) => c.id == targetChatId,
|
||||||
);
|
);
|
||||||
print(
|
print('Auto-navigating to chat with contact: ${contact.username}');
|
||||||
'Auto-navigating to chat with contact: ${contact.username}',
|
|
||||||
);
|
|
||||||
currentActiveChatContactId = targetChatId; // Устанавливаем активный чат
|
currentActiveChatContactId = targetChatId; // Устанавливаем активный чат
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|
@ -158,9 +153,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
|
|
||||||
void _navigateToChatFromNotification(int senderId) {
|
void _navigateToChatFromNotification(int senderId) {
|
||||||
final contactProvider = context.read<ContactProvider>();
|
final contactProvider = context.read<ContactProvider>();
|
||||||
print(
|
print('Navigate to chat from notification with senderId: $senderId');
|
||||||
'Navigate to chat from notification with senderId: $senderId',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Если контакты еще не загружены, ждем их загрузки
|
// Если контакты еще не загружены, ждем их загрузки
|
||||||
if (contactProvider.contacts.isEmpty) {
|
if (contactProvider.contacts.isEmpty) {
|
||||||
|
|
@ -178,9 +171,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
final contact = contactProvider.contacts.firstWhere(
|
final contact = contactProvider.contacts.firstWhere(
|
||||||
(c) => c.id == senderId,
|
(c) => c.id == senderId,
|
||||||
);
|
);
|
||||||
print(
|
print('Navigating to chat from notification: ${contact.username}');
|
||||||
'Navigating to chat from notification: ${contact.username}',
|
|
||||||
);
|
|
||||||
currentActiveChatContactId = senderId; // Устанавливаем активный чат
|
currentActiveChatContactId = senderId; // Устанавливаем активный чат
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|
@ -244,13 +235,26 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
payload: jsonEncode({
|
payload: jsonEncode({
|
||||||
'type': 'enc_message',
|
'type': 'enc_message',
|
||||||
'sender_id': message.data['sender_id'],
|
'sender_id': message.data['sender_id'],
|
||||||
|
'timestamp':
|
||||||
|
message.data['timestamp'] ?? DateTime.now().toIso8601String(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (message.data['type'] == 'enc_message') {
|
||||||
|
final contactProvider = context.read<ContactProvider>();
|
||||||
|
contactProvider.loadContacts();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error processing foreground message: $e');
|
print('Error processing foreground message: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_socketSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -309,20 +313,40 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
children: [
|
children: [
|
||||||
// Шапка меню с данными юзера
|
// Шапка меню с данными юзера
|
||||||
UserAccountsDrawerHeader(
|
Consumer<AuthProvider>(
|
||||||
accountName: Text("Artur Karasevich"),
|
builder: (context, authProvider, _) {
|
||||||
accountEmail: Text("@ArturKarasevich"),
|
final username = authProvider.username;
|
||||||
currentAccountPicture: CircleAvatar(
|
final displayName = authProvider.displayName;
|
||||||
backgroundColor: Theme.of(context).colorScheme.onSurface,
|
final initials =
|
||||||
child: Icon(
|
(displayName.isNotEmpty ? displayName : (username ?? 'U'))
|
||||||
Icons.person,
|
.trim()
|
||||||
size: 40,
|
.split(RegExp(r'\s+'))
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
.where((p) => p.isNotEmpty)
|
||||||
),
|
.take(2)
|
||||||
),
|
.map((p) => p[0].toUpperCase())
|
||||||
decoration: BoxDecoration(
|
.join();
|
||||||
color: Theme.of(context).colorScheme.primaryContainer,
|
|
||||||
),
|
return UserAccountsDrawerHeader(
|
||||||
|
accountName: Text(displayName),
|
||||||
|
accountEmail: Text(
|
||||||
|
username == null || username.isEmpty ? '' : '@$username',
|
||||||
|
),
|
||||||
|
currentAccountPicture: CircleAvatar(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.onSurface,
|
||||||
|
child: Text(
|
||||||
|
initials.isEmpty ? 'U' : initials,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.settings),
|
leading: const Icon(Icons.settings),
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 код'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import 'package:chepuhagram/presentation/screens/account_settings_screen.dart';
|
||||||
import 'package:chepuhagram/presentation/screens/login_screen.dart';
|
import 'package:chepuhagram/presentation/screens/login_screen.dart';
|
||||||
|
import 'package:chepuhagram/presentation/screens/privacy_settings_menu_screen.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '/logic/auth_provider.dart';
|
import '/logic/auth_provider.dart';
|
||||||
|
|
@ -10,22 +12,58 @@ class SettingsScreen extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final themeProv = context.watch<ThemeProvider>();
|
final themeProv = context.watch<ThemeProvider>();
|
||||||
final authProv = context.read<AuthProvider>();
|
final authProv = context.watch<AuthProvider>();
|
||||||
|
|
||||||
|
final accountEmail = authProv.email?.isNotEmpty == true
|
||||||
|
? authProv.email!
|
||||||
|
: authProv.username?.isNotEmpty == true
|
||||||
|
? '@${authProv.username!}'
|
||||||
|
: 'Не указано';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text("Настройки")),
|
appBar: AppBar(title: const Text("Настройки")),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// Секция Профиля
|
// Секция Профиля
|
||||||
const UserAccountsDrawerHeader(
|
UserAccountsDrawerHeader(
|
||||||
accountName: Text("Artur Karasevich"),
|
accountName: Text(authProv.displayName),
|
||||||
accountEmail: Text("@ArturKarasevich"),
|
accountEmail: Text(accountEmail),
|
||||||
currentAccountPicture: CircleAvatar(
|
currentAccountPicture: const CircleAvatar(
|
||||||
child: Icon(Icons.person, size: 40),
|
child: Icon(Icons.person, size: 40),
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(color: Colors.transparent),
|
decoration: const BoxDecoration(color: Colors.transparent),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person_outline),
|
||||||
|
title: const Text('Аккаунт'),
|
||||||
|
subtitle: const Text('Имя, телефон, почта, информация о себе'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const AccountSettingsScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.shield_outlined),
|
||||||
|
title: const Text('Конфиденциальность'),
|
||||||
|
subtitle: const Text('Безопасность и видимость данных профиля'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const PrivacySettingsMenuScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,12 +19,12 @@ class ContactTile extends StatelessWidget {
|
||||||
radius: 28,
|
radius: 28,
|
||||||
backgroundColor: primary.withAlpha((0.1 * 255).round()),
|
backgroundColor: primary.withAlpha((0.1 * 255).round()),
|
||||||
child: Text(
|
child: Text(
|
||||||
contact.surname[0],
|
contact.name[0],
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)
|
style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
contact.username,
|
contact.name,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,161 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '/data/models/message_model.dart';
|
||||||
|
|
||||||
class MessageBubble extends StatelessWidget {
|
class MessageBubble extends StatelessWidget {
|
||||||
final String message;
|
final MessageModel message;
|
||||||
final DateTime time;
|
final VoidCallback? onTap;
|
||||||
final bool isMe;
|
|
||||||
|
|
||||||
const MessageBubble({
|
const MessageBubble({
|
||||||
super.key,
|
super.key,
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.time,
|
this.onTap,
|
||||||
required this.isMe,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isMe = message.isMe;
|
||||||
return Align(
|
return Align(
|
||||||
// Выравниваем вправо, если это мое сообщение, и влево — если чужое
|
// Выравниваем вправо, если это мое сообщение, и влево — если чужое
|
||||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
child: Container(
|
child: Material(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
color: Colors.transparent,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
|
child: InkWell(
|
||||||
constraints: BoxConstraints(
|
onTap: onTap,
|
||||||
// Чтобы баббл не растягивался на весь экран, если текста мало
|
// На телефонах иногда удобнее/надежнее long-press (как в мессенджерах),
|
||||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
// поэтому поддерживаем оба жеста.
|
||||||
),
|
onLongPress: onTap,
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isMe ? Theme.of(context).colorScheme.primary : Colors.grey[300],
|
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
topLeft: const Radius.circular(16),
|
topLeft: const Radius.circular(16),
|
||||||
topRight: const Radius.circular(16),
|
topRight: const Radius.circular(16),
|
||||||
// Скругляем углы по-разному для "хвостика" сообщения
|
|
||||||
bottomLeft: Radius.circular(isMe ? 16 : 0),
|
bottomLeft: Radius.circular(isMe ? 16 : 0),
|
||||||
bottomRight: Radius.circular(isMe ? 0 : 16),
|
bottomRight: Radius.circular(isMe ? 0 : 16),
|
||||||
),
|
),
|
||||||
),
|
child: Container(
|
||||||
child: Column(
|
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
|
||||||
children: [
|
constraints: BoxConstraints(
|
||||||
Text(
|
// Чтобы баббл не растягивался на весь экран, если текста мало
|
||||||
message,
|
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||||
style: TextStyle(
|
),
|
||||||
color: isMe ? Colors.white : Colors.black87,
|
decoration: BoxDecoration(
|
||||||
fontSize: 16,
|
color: isMe
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: const Radius.circular(16),
|
||||||
|
topRight: const Radius.circular(16),
|
||||||
|
// Скругляем углы по-разному для "хвостика" сообщения
|
||||||
|
bottomLeft: Radius.circular(isMe ? 16 : 0),
|
||||||
|
bottomRight: Radius.circular(isMe ? 0 : 16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
child: Column(
|
||||||
Text(
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
time.toIso8601String(),
|
children: [
|
||||||
style: TextStyle(
|
if (message.replyToText != null) ...[
|
||||||
color: isMe ? Colors.white70 : Colors.black54,
|
Container(
|
||||||
fontSize: 10,
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
),
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (isMe ? Colors.white : Colors.black).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border(
|
||||||
|
left: BorderSide(
|
||||||
|
color: isMe ? Colors.white70 : Colors.black38,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.reply,
|
||||||
|
size: 14,
|
||||||
|
color: isMe ? Colors.white70 : Colors.black54,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message.replyToText!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isMe ? Colors.white70 : Colors.black54,
|
||||||
|
fontSize: 12,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
message.text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isMe ? Colors.white : Colors.black87,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatTime(message.createdAt),
|
||||||
|
style: TextStyle(
|
||||||
|
color: isMe ? Colors.white70 : Colors.black54,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isMe) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Icon(
|
||||||
|
_statusIcon(message.status),
|
||||||
|
size: 12,
|
||||||
|
color: _statusColor(message.status, isMe),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconData _statusIcon(MessageStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case MessageStatus.sending:
|
||||||
|
return Icons.access_time;
|
||||||
|
case MessageStatus.sent:
|
||||||
|
return Icons.done;
|
||||||
|
case MessageStatus.delivered:
|
||||||
|
return Icons.done_all;
|
||||||
|
case MessageStatus.read:
|
||||||
|
return Icons.done_all;
|
||||||
|
case MessageStatus.failed:
|
||||||
|
return Icons.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _statusColor(MessageStatus status, bool isMe) {
|
||||||
|
switch (status) {
|
||||||
|
case MessageStatus.read:
|
||||||
|
return isMe ? Colors.blue : Colors.blue;
|
||||||
|
case MessageStatus.failed:
|
||||||
|
return Colors.red;
|
||||||
|
default:
|
||||||
|
return isMe ? Colors.white70 : Colors.black54;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime time) {
|
||||||
|
final hour = time.hour.toString().padLeft(2, '0');
|
||||||
|
final minute = time.minute.toString().padLeft(2, '0');
|
||||||
|
return '$hour:$minute';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import firebase_core
|
||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
|
import local_auth_darwin
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
|
|
@ -20,6 +21,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
|
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
|
|
|
||||||
56
pubspec.lock
56
pubspec.lock
|
|
@ -222,6 +222,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.2.0"
|
version: "7.2.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.34"
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -296,6 +304,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
intl:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.20.2"
|
||||||
jni:
|
jni:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -360,6 +376,46 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.1.0"
|
||||||
|
local_auth:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: local_auth
|
||||||
|
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
local_auth_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_android
|
||||||
|
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.56"
|
||||||
|
local_auth_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_darwin
|
||||||
|
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.1"
|
||||||
|
local_auth_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_platform_interface
|
||||||
|
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
local_auth_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: local_auth_windows
|
||||||
|
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.11"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ dependencies:
|
||||||
provider: ^6.1.5+1
|
provider: ^6.1.5+1
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
local_auth: ^2.1.4
|
||||||
jwt_decoder: ^2.0.1
|
jwt_decoder: ^2.0.1
|
||||||
web_socket_channel: ^3.0.3
|
web_socket_channel: ^3.0.3
|
||||||
cryptography: ^2.5.0
|
cryptography: ^2.5.0
|
||||||
|
|
|
||||||
|
|
@ -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 ###
|
||||||
|
|
@ -3,6 +3,7 @@ from sqlalchemy.orm import Session
|
||||||
from app.db import models
|
from app.db import models
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.api import schemas
|
from app.api import schemas
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
|
||||||
|
|
||||||
# бд
|
# бд
|
||||||
|
|
@ -31,5 +32,5 @@ async def get_chat_history(
|
||||||
(models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)
|
(models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)
|
||||||
).order_by(models.Message.timestamp.desc()).limit(limit).all()
|
).order_by(models.Message.timestamp.desc()).limit(limit).all()
|
||||||
|
|
||||||
return messages
|
return jsonable_encoder(messages)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ from sqlalchemy.orm import Session
|
||||||
from app.db import models
|
from app.db import models
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.api import schemas
|
from app.api import schemas
|
||||||
|
from sqlalchemy import or_, and_
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
|
||||||
# бд
|
# бд
|
||||||
|
|
@ -25,7 +27,145 @@ usersRouter = APIRouter(
|
||||||
|
|
||||||
@usersRouter.get("/me")
|
@usersRouter.get("/me")
|
||||||
async def read_users_me(current_user: models.User = Depends(get_current_user)):
|
async def read_users_me(current_user: models.User = Depends(get_current_user)):
|
||||||
return {"id": current_user.id, "username": current_user.username, "first_name": current_user.first_name, "last_name": current_user.last_name, "public_key": current_user.public_key, "encrypted_private_key": current_user.encrypted_private_key}
|
return {
|
||||||
|
"id": current_user.id,
|
||||||
|
"username": current_user.username,
|
||||||
|
"first_name": current_user.first_name,
|
||||||
|
"last_name": current_user.last_name,
|
||||||
|
"phone": current_user.phone,
|
||||||
|
"email": getattr(current_user, "email", None),
|
||||||
|
"about": current_user.about,
|
||||||
|
"public_key": current_user.public_key,
|
||||||
|
"encrypted_private_key": current_user.encrypted_private_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@usersRouter.put("/me")
|
||||||
|
async def update_users_me(
|
||||||
|
data: schemas.UpdateMe,
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
user_to_update = db.merge(current_user)
|
||||||
|
|
||||||
|
if data.username is not None:
|
||||||
|
user_to_update.username = data.username
|
||||||
|
if data.first_name is not None:
|
||||||
|
user_to_update.first_name = data.first_name
|
||||||
|
if data.last_name is not None:
|
||||||
|
user_to_update.last_name = data.last_name
|
||||||
|
if data.phone is not None:
|
||||||
|
user_to_update.phone = data.phone or None
|
||||||
|
if data.email is not None:
|
||||||
|
user_to_update.email = data.email or None
|
||||||
|
if data.about is not None:
|
||||||
|
user_to_update.about = data.about or None
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=400, detail="phone/email already in use")
|
||||||
|
|
||||||
|
db.refresh(user_to_update)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"user": {
|
||||||
|
"id": user_to_update.id,
|
||||||
|
"username": user_to_update.username,
|
||||||
|
"first_name": user_to_update.first_name,
|
||||||
|
"last_name": user_to_update.last_name,
|
||||||
|
"phone": user_to_update.phone,
|
||||||
|
"email": getattr(user_to_update, "email", None),
|
||||||
|
"about": user_to_update.about,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@usersRouter.put("/me/encryption-key")
|
||||||
|
async def update_encrypted_private_key(
|
||||||
|
data: schemas.UpdateEncryptedPrivateKey,
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
user_to_update = db.merge(current_user)
|
||||||
|
user_to_update.encrypted_private_key = data.encrypted_private_key
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail="Не удалось сохранить ключ шифрования")
|
||||||
|
|
||||||
|
db.refresh(user_to_update)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@usersRouter.put("/me/password")
|
||||||
|
async def change_password(
|
||||||
|
data: schemas.ChangePassword,
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.core.security import verify_password, get_password_hash
|
||||||
|
|
||||||
|
if not verify_password(data.current_password, current_user.hashed_password):
|
||||||
|
raise HTTPException(status_code=400, detail="Неверный текущий пароль")
|
||||||
|
|
||||||
|
user_to_update = db.merge(current_user)
|
||||||
|
user_to_update.hashed_password = get_password_hash(data.new_password)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail="Не удалось изменить пароль")
|
||||||
|
|
||||||
|
db.refresh(user_to_update)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@usersRouter.put("/me/privacy")
|
||||||
|
async def update_privacy_settings(
|
||||||
|
data: schemas.UpdatePrivacySettings,
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
user_to_update = db.merge(current_user)
|
||||||
|
|
||||||
|
if data.show_email is not None:
|
||||||
|
user_to_update.show_email = 1 if data.show_email else 0
|
||||||
|
if data.show_phone is not None:
|
||||||
|
user_to_update.show_phone = 1 if data.show_phone else 0
|
||||||
|
if data.show_avatar is not None:
|
||||||
|
user_to_update.show_avatar = 1 if data.show_avatar else 0
|
||||||
|
if data.show_about is not None:
|
||||||
|
user_to_update.show_about = 1 if data.show_about else 0
|
||||||
|
if data.show_username is not None:
|
||||||
|
user_to_update.show_username = 1 if data.show_username else 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail="Не удалось сохранить настройки конфиденциальности")
|
||||||
|
|
||||||
|
db.refresh(user_to_update)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@usersRouter.get("/me/privacy")
|
||||||
|
async def get_privacy_settings(current_user: models.User = Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
Получить настройки конфиденциальности текущего пользователя.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"show_email": bool(current_user.show_email),
|
||||||
|
"show_phone": bool(current_user.show_phone),
|
||||||
|
"show_avatar": bool(current_user.show_avatar),
|
||||||
|
"show_about": bool(current_user.show_about),
|
||||||
|
"show_username": bool(current_user.show_username),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@usersRouter.get("/all")
|
@usersRouter.get("/all")
|
||||||
|
|
@ -34,18 +174,104 @@ async def read_users_all(current_user: models.User = Depends(get_current_user),
|
||||||
return [{"id": user.id, "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip(), "public_key": user.public_key} for user in users]
|
return [{"id": user.id, "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip(), "public_key": user.public_key} for user in users]
|
||||||
|
|
||||||
|
|
||||||
@usersRouter.get("/{user_id}", response_model=schemas.UserPublic)
|
@usersRouter.get("/chats")
|
||||||
|
async def read_users_chats(
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Список контактов для экрана чатов: последний месседж + время + непрочитанные.
|
||||||
|
|
||||||
|
last_message возвращается в том виде, как хранится в БД (зашифрованный content).
|
||||||
|
Клиент должен расшифровать превью локально.
|
||||||
|
"""
|
||||||
|
users = (
|
||||||
|
db.query(models.User)
|
||||||
|
.filter(models.User.id != current_user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for user in users:
|
||||||
|
last_msg = (
|
||||||
|
db.query(models.Message)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
and_(
|
||||||
|
models.Message.sender_id == current_user.id,
|
||||||
|
models.Message.receiver_id == user.id,
|
||||||
|
),
|
||||||
|
and_(
|
||||||
|
models.Message.sender_id == user.id,
|
||||||
|
models.Message.receiver_id == current_user.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(models.Message.timestamp.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
unread_count = (
|
||||||
|
db.query(models.Message)
|
||||||
|
.filter(
|
||||||
|
models.Message.sender_id == user.id,
|
||||||
|
models.Message.receiver_id == current_user.id,
|
||||||
|
models.Message.read_at.is_(None),
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"name": f"{user.first_name} {user.last_name or ''}".strip(),
|
||||||
|
"public_key": user.public_key,
|
||||||
|
"last_message": last_msg.content if last_msg else None,
|
||||||
|
"last_message_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp else None),
|
||||||
|
"unread_count": unread_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@usersRouter.get("/{user_id}", response_model=schemas.UserProfile)
|
||||||
def get_user_by_id(
|
def get_user_by_id(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: models.User = Depends(get_current_user)
|
current_user: models.User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Получить публичную информацию о пользователе, включая его публичный ключ.
|
Получить информацию о пользователе с учетом настроек конфиденциальности.
|
||||||
"""
|
"""
|
||||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
|
|
||||||
return user
|
# Возвращаем информацию с учетом настроек конфиденциальности
|
||||||
|
profile_data = {
|
||||||
|
"id": user.id,
|
||||||
|
"public_key": user.public_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверяем настройки конфиденциальности
|
||||||
|
if user.show_username:
|
||||||
|
profile_data["username"] = user.username
|
||||||
|
|
||||||
|
if user.show_avatar:
|
||||||
|
# Для аватара пока просто передаем имя, клиент сам сгенерирует аватар
|
||||||
|
profile_data["first_name"] = user.first_name
|
||||||
|
profile_data["last_name"] = user.last_name
|
||||||
|
|
||||||
|
if user.show_about:
|
||||||
|
profile_data["about"] = user.about
|
||||||
|
|
||||||
|
if user.show_phone:
|
||||||
|
profile_data["phone"] = user.phone
|
||||||
|
|
||||||
|
if user.show_email:
|
||||||
|
profile_data["email"] = user.email
|
||||||
|
|
||||||
|
return profile_data
|
||||||
|
|
|
||||||
|
|
@ -22,3 +22,39 @@ class UserPublic(BaseModel):
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateMe(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
about: Optional[str] = None
|
||||||
|
|
||||||
|
class UpdateEncryptedPrivateKey(BaseModel):
|
||||||
|
encrypted_private_key: str
|
||||||
|
|
||||||
|
class ChangePassword(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
class UpdatePrivacySettings(BaseModel):
|
||||||
|
show_email: Optional[bool] = None
|
||||||
|
show_phone: Optional[bool] = None
|
||||||
|
show_avatar: Optional[bool] = None
|
||||||
|
show_about: Optional[bool] = None
|
||||||
|
show_username: Optional[bool] = None
|
||||||
|
|
||||||
|
class UserProfile(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: Optional[str] = None
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
about: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
public_key: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime
|
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./chepuhagram.db"
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./chepuhagram.db"
|
||||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
|
@ -18,12 +19,20 @@ class User(Base):
|
||||||
username = Column(String, unique=True, index=True)
|
username = Column(String, unique=True, index=True)
|
||||||
about = Column(String, nullable=True)
|
about = Column(String, nullable=True)
|
||||||
phone = Column(String(20), unique=True, nullable=True)
|
phone = Column(String(20), unique=True, nullable=True)
|
||||||
|
email = Column(String(255), unique=True, nullable=True)
|
||||||
totp_secret = Column(String(32), nullable=True)
|
totp_secret = Column(String(32), nullable=True)
|
||||||
hashed_password = Column(String)
|
hashed_password = Column(String)
|
||||||
public_key = Column(String, nullable=True)
|
public_key = Column(String, nullable=True)
|
||||||
encrypted_private_key = Column(String, nullable=True)
|
encrypted_private_key = Column(String, nullable=True)
|
||||||
fcm_token = Column(String, nullable=True)
|
fcm_token = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Privacy settings
|
||||||
|
show_email = Column(Integer, nullable=False, server_default="1") # 1 = true, 0 = false
|
||||||
|
show_phone = Column(Integer, nullable=False, server_default="1")
|
||||||
|
show_avatar = Column(Integer, nullable=False, server_default="1")
|
||||||
|
show_about = Column(Integer, nullable=False, server_default="1")
|
||||||
|
show_username = Column(Integer, nullable=False, server_default="1")
|
||||||
|
|
||||||
class Message(Base):
|
class Message(Base):
|
||||||
__tablename__ = "messages"
|
__tablename__ = "messages"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
@ -31,5 +40,57 @@ class Message(Base):
|
||||||
receiver_id = Column(Integer, ForeignKey("users.id"))
|
receiver_id = Column(Integer, ForeignKey("users.id"))
|
||||||
content = Column(Text)
|
content = Column(Text)
|
||||||
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
delivered_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
read_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True)
|
||||||
|
reply_to_text = Column(Text, nullable=True)
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_sqlite_message_columns():
|
||||||
|
# Простая авто-миграция для SQLite без Alembic.
|
||||||
|
# Добавляет колонки, если приложение обновилось на уже существующей БД.
|
||||||
|
with engine.connect() as conn:
|
||||||
|
cols = conn.execute(text("PRAGMA table_info(messages)")).fetchall()
|
||||||
|
existing = {row[1] for row in cols} # row[1] = name
|
||||||
|
|
||||||
|
if "delivered_at" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE messages ADD COLUMN delivered_at DATETIME"))
|
||||||
|
if "read_at" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE messages ADD COLUMN read_at DATETIME"))
|
||||||
|
if "reply_to_id" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_id INTEGER REFERENCES messages(id)"))
|
||||||
|
if "reply_to_text" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
_ensure_sqlite_message_columns()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_sqlite_user_columns():
|
||||||
|
with engine.connect() as conn:
|
||||||
|
cols = conn.execute(text("PRAGMA table_info(users)")).fetchall()
|
||||||
|
existing = {row[1] for row in cols}
|
||||||
|
|
||||||
|
if "about" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN about TEXT"))
|
||||||
|
if "phone" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN phone VARCHAR(20)"))
|
||||||
|
if "email" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR(255)"))
|
||||||
|
if "show_email" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN show_email INTEGER DEFAULT 1"))
|
||||||
|
if "show_phone" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN show_phone INTEGER DEFAULT 1"))
|
||||||
|
if "show_avatar" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN show_avatar INTEGER DEFAULT 1"))
|
||||||
|
if "show_about" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN show_about INTEGER DEFAULT 1"))
|
||||||
|
if "show_username" not in existing:
|
||||||
|
conn.execute(text("ALTER TABLE users ADD COLUMN show_username INTEGER DEFAULT 1"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
_ensure_sqlite_user_columns()
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
||||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
user_id = await test_token(token=token)
|
user_id = int(await test_token(token=token))
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
||||||
return
|
return
|
||||||
|
|
@ -51,18 +51,47 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
||||||
|
|
||||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
receiver_id = message_data.get("receiver_id")
|
receiver_id = message_data.get("receiver_id")
|
||||||
|
temp_id = message_data.get("temp_id")
|
||||||
content = message_data.get("content")
|
content = message_data.get("content")
|
||||||
content50 = message_data.get("content50")
|
content50 = message_data.get("content50")
|
||||||
|
|
||||||
|
if receiver_id is None or content is None:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"detail": "receiver_id/content required",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
receiver_id = int(receiver_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"detail": "receiver_id must be int",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
new_msg = models.Message(
|
new_msg = models.Message(
|
||||||
sender_id=user_id,
|
sender_id=user_id,
|
||||||
receiver_id=receiver_id,
|
receiver_id=receiver_id,
|
||||||
content=content
|
content=content,
|
||||||
|
reply_to_id=message_data.get("reply_to_id"),
|
||||||
|
reply_to_text=message_data.get("reply_to_text")
|
||||||
)
|
)
|
||||||
db.add(new_msg)
|
db.add(new_msg)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_msg)
|
db.refresh(new_msg)
|
||||||
|
|
||||||
if receiver_id not in manager.active_connections and user.public_key != '':
|
# ACK отправителю: сервер принял и сохранил сообщение (нужно для статусов клиента).
|
||||||
|
await manager.send_personal_message({
|
||||||
|
"type": "message_sent",
|
||||||
|
"temp_id": temp_id,
|
||||||
|
"server_id": new_msg.id,
|
||||||
|
"timestamp": (new_msg.timestamp or datetime.now()).isoformat(),
|
||||||
|
}, str(user_id))
|
||||||
|
|
||||||
|
# Если получатель оффлайн — отправим пуш (если есть токен и ключи).
|
||||||
|
if user.public_key:
|
||||||
receiver = db.query(models.User).filter(
|
receiver = db.query(models.User).filter(
|
||||||
models.User.id == receiver_id).first()
|
models.User.id == receiver_id).first()
|
||||||
if receiver.fcm_token:
|
if receiver.fcm_token:
|
||||||
|
|
@ -79,13 +108,61 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
||||||
"id": new_msg.id,
|
"id": new_msg.id,
|
||||||
"type": "private_message",
|
"type": "private_message",
|
||||||
"sender_id": user_id,
|
"sender_id": user_id,
|
||||||
"reciever_id": receiver_id,
|
"receiver_id": receiver_id,
|
||||||
"content": message_data.get("content"),
|
"content": message_data.get("content"),
|
||||||
"timestamp": datetime.now().isoformat()
|
"timestamp": (new_msg.timestamp or datetime.now()).isoformat(),
|
||||||
|
"reply_to_id": new_msg.reply_to_id,
|
||||||
|
"reply_to_text": new_msg.reply_to_text,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Пересылаем получателю, если он в сети
|
# Пересылаем получателю, если он в сети
|
||||||
await manager.send_personal_message(outgoing_message, str(receiver_id))
|
sent_to_receiver = await manager.send_personal_message(outgoing_message, str(receiver_id))
|
||||||
|
|
||||||
|
# Если сообщение реально ушло по сокету получателю — отмечаем delivered_at.
|
||||||
|
if sent_to_receiver:
|
||||||
|
try:
|
||||||
|
delivered_at = datetime.now()
|
||||||
|
new_msg.delivered_at = delivered_at
|
||||||
|
db.add(new_msg)
|
||||||
|
db.commit()
|
||||||
|
await manager.send_personal_message({
|
||||||
|
"type": "message_delivered",
|
||||||
|
"message_id": new_msg.id,
|
||||||
|
"timestamp": delivered_at.isoformat(),
|
||||||
|
}, str(user_id))
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
elif message_data.get("type") == "read_receipt":
|
||||||
|
message_id = message_data.get("message_id")
|
||||||
|
try:
|
||||||
|
message_id = int(message_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg = db.query(models.Message).filter(models.Message.id == message_id).first()
|
||||||
|
if msg is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Безопасность: read_receipt может отправлять только получатель.
|
||||||
|
if int(msg.receiver_id) != int(user_id):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Сохраняем read_at в БД
|
||||||
|
try:
|
||||||
|
read_at = datetime.now()
|
||||||
|
msg.read_at = read_at
|
||||||
|
db.add(msg)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
sender_id = int(msg.sender_id)
|
||||||
|
await manager.send_personal_message({
|
||||||
|
"type": "message_read",
|
||||||
|
"message_id": message_id,
|
||||||
|
"timestamp": read_at.isoformat() if 'read_at' in locals() else datetime.now().isoformat(),
|
||||||
|
}, str(sender_id))
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -120,20 +197,27 @@ class ConnectionManager:
|
||||||
# Храним активные соединения: {user_id: websocket}
|
# Храним активные соединения: {user_id: websocket}
|
||||||
self.active_connections: Dict[str, WebSocket] = {}
|
self.active_connections: Dict[str, WebSocket] = {}
|
||||||
|
|
||||||
async def connect(self, user_id: str, websocket: WebSocket):
|
async def connect(self, user_id: int, websocket: WebSocket):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
self.active_connections[user_id] = websocket
|
self.active_connections[str(user_id)] = websocket
|
||||||
|
|
||||||
def disconnect(self, user_id: str):
|
def disconnect(self, user_id: int):
|
||||||
if user_id in self.active_connections:
|
key = str(user_id)
|
||||||
del self.active_connections[user_id]
|
if key in self.active_connections:
|
||||||
|
del self.active_connections[key]
|
||||||
|
|
||||||
async def send_personal_message(self, message: dict, user_id: str):
|
async def send_personal_message(self, message: dict, user_id: str) -> bool:
|
||||||
if str(user_id) in self.active_connections:
|
if str(user_id) in self.active_connections:
|
||||||
await self.active_connections[str(user_id)].send_json(message)
|
try:
|
||||||
print('Sent to socket')
|
await self.active_connections[str(user_id)].send_json(message)
|
||||||
|
print('Sent to socket')
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Failed to send to socket: {e}')
|
||||||
|
return False
|
||||||
else:
|
else:
|
||||||
print('User not active')
|
print('User not active')
|
||||||
|
return False
|
||||||
|
|
||||||
async def broadcast(self, message: dict):
|
async def broadcast(self, message: dict):
|
||||||
# Рассылка вообще всем (например, системное уведомление)
|
# Рассылка вообще всем (например, системное уведомление)
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,13 @@
|
||||||
|
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
#include <local_auth_windows/local_auth_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
LocalAuthPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
firebase_core
|
firebase_core
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
local_auth_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue