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