Встроеный апдейтер
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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
<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
|
<application
|
||||||
android:label="Chepuhagram"
|
android:label="Chepuhagram"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
@ -57,5 +60,13 @@
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</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>
|
</queries>
|
||||||
</manifest>
|
</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 {
|
class AppConstants {
|
||||||
//static const baseUrl = '192.168.0.180:8000';
|
//static const baseUrl = '192.168.0.180:8000';
|
||||||
static const baseUrl = 'https://api.chepuhagram.ru';
|
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;
|
ThemeMode get themeMode => _themeMode;
|
||||||
Color get accentColor => _accentColor;
|
Color get accentColor => _accentColor;
|
||||||
|
|
||||||
|
bool isLight = false;
|
||||||
|
|
||||||
ThemeProvider() {
|
ThemeProvider() {
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
}
|
}
|
||||||
|
|
@ -21,6 +23,7 @@ class ThemeProvider extends ChangeNotifier {
|
||||||
|
|
||||||
if (mode != null) {
|
if (mode != null) {
|
||||||
_themeMode = mode == 'dark' ? ThemeMode.dark : ThemeMode.light;
|
_themeMode = mode == 'dark' ? ThemeMode.dark : ThemeMode.light;
|
||||||
|
isLight = mode == 'light';
|
||||||
}
|
}
|
||||||
if (color != null) _accentColor = Color(int.parse(color));
|
if (color != null) _accentColor = Color(int.parse(color));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
@ -28,6 +31,7 @@ class ThemeProvider extends ChangeNotifier {
|
||||||
|
|
||||||
void toggleTheme(bool isDark) {
|
void toggleTheme(bool isDark) {
|
||||||
_themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
|
_themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
|
||||||
|
isLight = !isDark;
|
||||||
_storage.write(key: 'theme_mode', value: isDark ? 'dark' : 'light');
|
_storage.write(key: 'theme_mode', value: isDark ? 'dark' : 'light');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class LocalDbService {
|
||||||
String path = join(await getDatabasesPath(), 'chat_app.db');
|
String path = join(await getDatabasesPath(), 'chat_app.db');
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 3,
|
version: 4,
|
||||||
onCreate: (db, version) async {
|
onCreate: (db, version) async {
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE messages(
|
CREATE TABLE messages(
|
||||||
|
|
@ -31,7 +31,8 @@ class LocalDbService {
|
||||||
delivered_at TEXT,
|
delivered_at TEXT,
|
||||||
read_at TEXT,
|
read_at TEXT,
|
||||||
reply_to_id INTEGER,
|
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');
|
await db.execute('ALTER TABLE messages ADD COLUMN read_at TEXT');
|
||||||
}
|
}
|
||||||
if (oldVersion < 3) {
|
if (oldVersion < 3) {
|
||||||
await db.execute('ALTER TABLE messages ADD COLUMN reply_to_id INTEGER');
|
await db.execute(
|
||||||
await db.execute('ALTER TABLE messages ADD COLUMN reply_to_text TEXT');
|
'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(),
|
'timestamp': msg.createdAt.toIso8601String(),
|
||||||
'delivered_at': null,
|
'delivered_at': null,
|
||||||
'read_at': null,
|
'read_at': null,
|
||||||
|
'reply_to_id': msg.replyToId,
|
||||||
|
'reply_to_text': msg.replyToText,
|
||||||
|
'edited_at': msg.editedAt?.toIso8601String(),
|
||||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
} else {
|
} else {
|
||||||
// Если это Map из API
|
// Если это Map из API
|
||||||
batch.insert('messages', {
|
batch.insert('messages', {
|
||||||
'id': msg['id'],
|
'id': msg['id'],
|
||||||
'sender_id': msg['sender_id'],
|
'sender_id': msg['sender_id'],
|
||||||
'receiver_id': msg['receiver_id'], // Убедись, что ключ совпадает с API
|
'receiver_id':
|
||||||
|
msg['receiver_id'], // Убедись, что ключ совпадает с API
|
||||||
'content': msg['content'],
|
'content': msg['content'],
|
||||||
'timestamp': msg['timestamp'],
|
'timestamp': msg['timestamp'],
|
||||||
'delivered_at': msg['delivered_at'],
|
'delivered_at': msg['delivered_at'],
|
||||||
'read_at': msg['read_at'],
|
'read_at': msg['read_at'],
|
||||||
'reply_to_id': msg['reply_to_id'],
|
'reply_to_id': msg['reply_to_id'],
|
||||||
'reply_to_text': msg['reply_to_text'],
|
'reply_to_text': msg['reply_to_text'],
|
||||||
|
'edited_at': msg['edited_at'],
|
||||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
}, 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 {
|
Future<Map<String, dynamic>?> getLastMessage(int contactId, int myId) async {
|
||||||
final db = await database;
|
final db = await database;
|
||||||
final rows = await db.query(
|
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;
|
final db = await database;
|
||||||
await db.delete(
|
await db.update(
|
||||||
'messages',
|
'messages',
|
||||||
|
{'content': content, 'edited_at': editedAt?.toIso8601String()},
|
||||||
where: 'id = ?',
|
where: 'id = ?',
|
||||||
whereArgs: [messageId],
|
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:chepuhagram/domain/services/api_service.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import 'package:web_socket_channel/status.dart' as status;
|
import 'package:web_socket_channel/status.dart' as status;
|
||||||
|
import 'package:web_socket_channel/io.dart';
|
||||||
import 'package:chepuhagram/core/constants.dart';
|
import 'package:chepuhagram/core/constants.dart';
|
||||||
|
|
||||||
class SocketService {
|
class SocketService {
|
||||||
|
|
@ -29,10 +30,17 @@ class SocketService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке
|
// В 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 = IOWebSocketChannel.connect(
|
||||||
|
uri,
|
||||||
|
connectTimeout: Duration(seconds: 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _channel!.ready;
|
||||||
_channel!.stream.listen(
|
_channel!.stream.listen(
|
||||||
(data) {
|
(data) {
|
||||||
final decoded = jsonDecode(data);
|
final decoded = jsonDecode(data);
|
||||||
|
|
@ -42,6 +50,13 @@ class SocketService {
|
||||||
onError: (error) => _reconnect(apiService),
|
onError: (error) => _reconnect(apiService),
|
||||||
onDone: () => _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 {
|
Future<void> _reconnect(ApiService apiService) async {
|
||||||
|
|
@ -71,14 +86,11 @@ class SocketService {
|
||||||
}
|
}
|
||||||
|
|
||||||
bool sendReadReceipt(int messageId) {
|
bool sendReadReceipt(int messageId) {
|
||||||
return sendMessage({
|
return sendMessage({'type': 'read_receipt', 'message_id': messageId});
|
||||||
'type': 'read_receipt',
|
|
||||||
'message_id': messageId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void disconnect() {
|
void disconnect() {
|
||||||
_channel?.sink.close(status.goingAway);
|
_channel?.sink.close(status.normalClosure);
|
||||||
_channel = null;
|
_channel = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,8 @@ class Contact {
|
||||||
return Contact(
|
return Contact(
|
||||||
id: json['id'],
|
id: json['id'],
|
||||||
username: json['username'] ?? 'Unknown',
|
username: json['username'] ?? 'Unknown',
|
||||||
name: json['name'] ?? 'Unknown',
|
name: json['name'] ?? json['first_name'] ?? 'Unknown',
|
||||||
surname: json['surname'] ?? 'Unknown',
|
surname: json['surname'] ?? json['last_name'] ?? 'Unknown',
|
||||||
lastMessage: json['last_message'] ?? json['lastMessage'],
|
lastMessage: json['last_message'] ?? json['lastMessage'],
|
||||||
avatarUrl: json['avatar_url'] ?? json['avatarUrl'],
|
avatarUrl: json['avatar_url'] ?? json['avatarUrl'],
|
||||||
lastMessageTime: parseTime(json['last_message_time'] ?? json['lastMessageTime']),
|
lastMessageTime: parseTime(json['last_message_time'] ?? json['lastMessageTime']),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
enum MessageStatus { sending, sent, delivered, read, failed }
|
enum MessageStatus { sending, sent, delivered, read, failed }
|
||||||
|
|
||||||
class MessageModel {
|
class MessageModel {
|
||||||
|
|
@ -11,6 +13,8 @@ class MessageModel {
|
||||||
final MessageStatus status;
|
final MessageStatus status;
|
||||||
final int? replyToId; // ID сообщения, на которое отвечают
|
final int? replyToId; // ID сообщения, на которое отвечают
|
||||||
final String? replyToText; // текст сообщения, на которое отвечают (для отображения)
|
final String? replyToText; // текст сообщения, на которое отвечают (для отображения)
|
||||||
|
final DateTime? editedAt;
|
||||||
|
final Uint8List? localFileBytes;
|
||||||
|
|
||||||
MessageModel({
|
MessageModel({
|
||||||
this.id,
|
this.id,
|
||||||
|
|
@ -23,6 +27,8 @@ class MessageModel {
|
||||||
this.status = MessageStatus.sent,
|
this.status = MessageStatus.sent,
|
||||||
this.replyToId,
|
this.replyToId,
|
||||||
this.replyToText,
|
this.replyToText,
|
||||||
|
this.editedAt,
|
||||||
|
this.localFileBytes
|
||||||
});
|
});
|
||||||
|
|
||||||
MessageModel copyWith({
|
MessageModel copyWith({
|
||||||
|
|
@ -36,6 +42,8 @@ class MessageModel {
|
||||||
MessageStatus? status,
|
MessageStatus? status,
|
||||||
int? replyToId,
|
int? replyToId,
|
||||||
String? replyToText,
|
String? replyToText,
|
||||||
|
DateTime? editedAt,
|
||||||
|
Uint8List? localFileBytes,
|
||||||
}) {
|
}) {
|
||||||
return MessageModel(
|
return MessageModel(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
|
|
@ -48,6 +56,8 @@ class MessageModel {
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
replyToId: replyToId ?? this.replyToId,
|
replyToId: replyToId ?? this.replyToId,
|
||||||
replyToText: replyToText ?? this.replyToText,
|
replyToText: replyToText ?? this.replyToText,
|
||||||
|
editedAt: editedAt ?? this.editedAt,
|
||||||
|
localFileBytes: localFileBytes ?? this.localFileBytes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,6 +77,7 @@ class MessageModel {
|
||||||
status: MessageStatus.sent,
|
status: MessageStatus.sent,
|
||||||
replyToId: json['reply_to_id'] == null ? null : int.tryParse(json['reply_to_id'].toString()),
|
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(),
|
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,
|
'status': status.name,
|
||||||
'reply_to_id': replyToId,
|
'reply_to_id': replyToId,
|
||||||
'reply_to_text': replyToText,
|
'reply_to_text': replyToText,
|
||||||
|
'edited_at': editedAt?.toIso8601String(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,44 @@ class ApiService extends ChangeNotifier {
|
||||||
final _client = http.Client();
|
final _client = http.Client();
|
||||||
final _storage = const FlutterSecureStorage();
|
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 {
|
Future<bool> refreshToken() async {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
|
@ -128,7 +166,8 @@ class ApiService extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
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('Не удалось получить данные пользователя');
|
throw Exception('Не удалось получить данные пользователя');
|
||||||
}
|
}
|
||||||
|
|
@ -147,7 +186,10 @@ class ApiService extends ChangeNotifier {
|
||||||
return response.statusCode == 200;
|
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 token = await getAccessToken();
|
||||||
final response = await _client.put(
|
final response = await _client.put(
|
||||||
Uri.parse('${AppConstants.baseUrl}/users/me/password'),
|
Uri.parse('${AppConstants.baseUrl}/users/me/password'),
|
||||||
|
|
@ -167,7 +209,9 @@ class ApiService extends ChangeNotifier {
|
||||||
Future<List<dynamic>> getChatHistory(int contactId) async {
|
Future<List<dynamic>> getChatHistory(int contactId) async {
|
||||||
final token = await getAccessToken();
|
final token = await getAccessToken();
|
||||||
final response = await _client.get(
|
final response = await _client.get(
|
||||||
Uri.parse('${AppConstants.baseUrl}/messages/history/${contactId.toString()}'),
|
Uri.parse(
|
||||||
|
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}',
|
||||||
|
),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
"Authorization": "Bearer $token",
|
"Authorization": "Bearer $token",
|
||||||
|
|
@ -205,7 +249,11 @@ class ApiService extends ChangeNotifier {
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
return decoded as Map<String, dynamic>;
|
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 {
|
Future<Map<String, dynamic>> getUserById(int userId) async {
|
||||||
|
|
@ -219,7 +267,8 @@ class ApiService extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
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('Не удалось получить информацию о пользователе');
|
throw Exception('Не удалось получить информацию о пользователе');
|
||||||
}
|
}
|
||||||
|
|
@ -261,7 +310,8 @@ class ApiService extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
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('Не удалось получить настройки конфиденциальности');
|
throw Exception('Не удалось получить настройки конфиденциальности');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
@ -97,7 +98,9 @@ class CryptoService {
|
||||||
String myPrivateKeyBase64,
|
String myPrivateKeyBase64,
|
||||||
String theirPublicKeyBase64,
|
String theirPublicKeyBase64,
|
||||||
) async {
|
) async {
|
||||||
final myKeyPair = await algorithm.newKeyPairFromSeed(base64Decode(myPrivateKeyBase64));
|
final myKeyPair = await algorithm.newKeyPairFromSeed(
|
||||||
|
base64Decode(myPrivateKeyBase64),
|
||||||
|
);
|
||||||
final theirPublicKey = SimplePublicKey(
|
final theirPublicKey = SimplePublicKey(
|
||||||
base64Decode(theirPublicKeyBase64),
|
base64Decode(theirPublicKeyBase64),
|
||||||
type: KeyPairType.x25519,
|
type: KeyPairType.x25519,
|
||||||
|
|
@ -120,6 +123,36 @@ class CryptoService {
|
||||||
return base64Encode(nonce + encrypted.mac.bytes + encrypted.cipherText);
|
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 {
|
Future<String> decryptMessage(String base64Data, SecretKey sharedKey) async {
|
||||||
final data = base64Decode(base64Data);
|
final data = base64Decode(base64Data);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,11 @@ class AuthProvider extends ChangeNotifier {
|
||||||
final CryptoService _cryptoService = CryptoService();
|
final CryptoService _cryptoService = CryptoService();
|
||||||
|
|
||||||
Future<void> initRealtime() async {
|
Future<void> initRealtime() async {
|
||||||
|
try {
|
||||||
await _socketService.connect(_apiService);
|
await _socketService.connect(_apiService);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void closeRealtime() {
|
void closeRealtime() {
|
||||||
|
|
@ -153,7 +157,7 @@ class AuthProvider extends ChangeNotifier {
|
||||||
if (token == null) return false;
|
if (token == null) return false;
|
||||||
|
|
||||||
// Загружаем currentUserId из хранилища
|
// Загружаем currentUserId из хранилища
|
||||||
final userIdStr = await _storage.read(key: 'user_id');
|
/*final userIdStr = await _storage.read(key: 'user_id');
|
||||||
if (userIdStr != null) {
|
if (userIdStr != null) {
|
||||||
_currentUserId = int.tryParse(userIdStr);
|
_currentUserId = int.tryParse(userIdStr);
|
||||||
}
|
}
|
||||||
|
|
@ -183,7 +187,8 @@ class AuthProvider extends ChangeNotifier {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Если сервер недоступен, позволяем offline mode
|
// Если сервер недоступен, позволяем offline mode
|
||||||
return true;
|
return true;
|
||||||
}
|
}*/
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> updateProfileAndSecurity({
|
Future<bool> updateProfileAndSecurity({
|
||||||
|
|
@ -241,6 +246,7 @@ class AuthProvider extends ChangeNotifier {
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map;
|
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map;
|
||||||
|
|
||||||
|
_currentUserId = data['id'] as int?;
|
||||||
_username = data['username']?.toString();
|
_username = data['username']?.toString();
|
||||||
_firstName = data['first_name']?.toString();
|
_firstName = data['first_name']?.toString();
|
||||||
_lastName = data['last_name']?.toString();
|
_lastName = data['last_name']?.toString();
|
||||||
|
|
@ -249,10 +255,12 @@ class AuthProvider extends ChangeNotifier {
|
||||||
_about = data['about']?.toString();
|
_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) {
|
if (!_hasPublicKeyOnServer) {
|
||||||
// Путь А: Первая настройка - нужно создать ключи и профиль
|
// Путь А: Первая настройка - нужно создать ключи и профиль
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,10 @@ class ContactProvider extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _enrichContactsWithLastMessages() async {
|
Future<void> _enrichContactsWithLastMessages() async {
|
||||||
|
print("Начинаем обогащать контакты последними сообщениями из локальной БД... Для текущего пользователя ID: $_currentUserId");
|
||||||
final myId = _currentUserId;
|
final myId = _currentUserId;
|
||||||
if (myId == null) return;
|
if (myId == null) return;
|
||||||
|
print("Текущий пользователь ID: $myId");
|
||||||
|
|
||||||
final myPrivKey = await _cryptoService.getPrivateKey();
|
final myPrivKey = await _cryptoService.getPrivateKey();
|
||||||
|
|
||||||
|
|
@ -93,6 +95,7 @@ class ContactProvider extends ChangeNotifier {
|
||||||
final contact = updated[i];
|
final contact = updated[i];
|
||||||
|
|
||||||
// 1) Если сервер уже прислал lastMessage — попробуем расшифровать превью.
|
// 1) Если сервер уже прислал lastMessage — попробуем расшифровать превью.
|
||||||
|
print(contact.lastMessage);
|
||||||
if (contact.lastMessage != null &&
|
if (contact.lastMessage != null &&
|
||||||
contact.lastMessage!.isNotEmpty &&
|
contact.lastMessage!.isNotEmpty &&
|
||||||
myPrivKey != null &&
|
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;
|
_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 'package:provider/provider.dart';
|
||||||
import 'presentation/screens/splash_screen.dart';
|
import 'presentation/screens/splash_screen.dart';
|
||||||
|
|
||||||
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||||
|
FlutterLocalNotificationsPlugin();
|
||||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
|
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||||||
|
|
||||||
// Глобальная переменная для отслеживания текущего активного контакта в чате
|
// Глобальная переменная для отслеживания текущего активного контакта в чате
|
||||||
int? currentActiveChatContactId;
|
int? currentActiveChatContactId;
|
||||||
|
|
||||||
|
|
@ -28,9 +31,12 @@ RemoteMessage? initialMessage;
|
||||||
// Ключ для SharedPreferences
|
// Ключ для SharedPreferences
|
||||||
const String _notificationLaunchKey = 'notification_launch_data';
|
const String _notificationLaunchKey = 'notification_launch_data';
|
||||||
// Защита от повторной обработки одного и того же payload при следующих запусках по иконке
|
// Защита от повторной обработки одного и того же 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;
|
final payload = notificationResponse.payload;
|
||||||
if (payload != null) {
|
if (payload != null) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -47,12 +53,19 @@ Future<void> _onSelectNotification(NotificationResponse notificationResponse) as
|
||||||
// Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение
|
// Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение
|
||||||
// будет снова автопереходить в чат.
|
// будет снова автопереходить в чат.
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
|
final lastHandled = prefs.getString(
|
||||||
|
_lastHandledNotificationLaunchPayloadKey,
|
||||||
|
);
|
||||||
if (lastHandled != canonicalPayload) {
|
if (lastHandled != canonicalPayload) {
|
||||||
await prefs.setString(_notificationLaunchKey, 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 {
|
} else {
|
||||||
await prefs.remove(_notificationLaunchKey);
|
await prefs.remove(_notificationLaunchKey);
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +73,9 @@ Future<void> _onSelectNotification(NotificationResponse notificationResponse) as
|
||||||
// Navigate to chat with this contact (if context is ready)
|
// Navigate to chat with this contact (if context is ready)
|
||||||
_navigateToChat(senderId);
|
_navigateToChat(senderId);
|
||||||
} else {
|
} else {
|
||||||
print('Notification payload has invalid sender_id: ${data['sender_id']}');
|
print(
|
||||||
|
'Notification payload has invalid sender_id: ${data['sender_id']}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error parsing notification payload: $e');
|
print('Error parsing notification payload: $e');
|
||||||
|
|
@ -72,7 +87,10 @@ void _navigateToChat(int senderId) {
|
||||||
print('Navigating to chat with senderId: $senderId');
|
print('Navigating to chat with senderId: $senderId');
|
||||||
final context = navigatorKey.currentContext;
|
final context = navigatorKey.currentContext;
|
||||||
if (context != null) {
|
if (context != null) {
|
||||||
final contactProvider = Provider.of<ContactProvider>(context, listen: false);
|
final contactProvider = Provider.of<ContactProvider>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
);
|
||||||
|
|
||||||
// Check if contacts are loaded
|
// Check if contacts are loaded
|
||||||
if (contactProvider.contacts.isEmpty) {
|
if (contactProvider.contacts.isEmpty) {
|
||||||
|
|
@ -96,18 +114,16 @@ void _navigateToChat(int senderId) {
|
||||||
currentActiveChatContactId = senderId; // Устанавливаем активный чат
|
currentActiveChatContactId = senderId; // Устанавливаем активный чат
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
|
||||||
builder: (_) => ChatScreen(contact: contact),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} 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
|
// Contact not found, go to contacts screen
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||||
builder: (_) => const ContactsScreen(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -132,11 +148,16 @@ void main() async {
|
||||||
print('Sender ID: ${initialMessage!.data['sender_id']}');
|
print('Sender ID: ${initialMessage!.data['sender_id']}');
|
||||||
|
|
||||||
final payloadString = jsonEncode(initialMessage!.data);
|
final payloadString = jsonEncode(initialMessage!.data);
|
||||||
final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
|
final lastHandled = prefs.getString(
|
||||||
|
_lastHandledNotificationLaunchPayloadKey,
|
||||||
|
);
|
||||||
if (lastHandled != payloadString) {
|
if (lastHandled != payloadString) {
|
||||||
// Сохраняем данные уведомления
|
// Сохраняем данные уведомления
|
||||||
await prefs.setString(_notificationLaunchKey, payloadString);
|
await prefs.setString(_notificationLaunchKey, payloadString);
|
||||||
await prefs.setString(_lastHandledNotificationLaunchPayloadKey, payloadString);
|
await prefs.setString(
|
||||||
|
_lastHandledNotificationLaunchPayloadKey,
|
||||||
|
payloadString,
|
||||||
|
);
|
||||||
print('Saved notification data to SharedPreferences');
|
print('Saved notification data to SharedPreferences');
|
||||||
} else {
|
} else {
|
||||||
print('InitialMessage payload already handled earlier, skipping');
|
print('InitialMessage payload already handled earlier, skipping');
|
||||||
|
|
@ -148,25 +169,34 @@ void main() async {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize local notifications
|
// Initialize local notifications
|
||||||
const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher');
|
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||||
final InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid);
|
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
|
final InitializationSettings initializationSettings = InitializationSettings(
|
||||||
|
android: initializationSettingsAndroid,
|
||||||
|
);
|
||||||
await flutterLocalNotificationsPlugin.initialize(
|
await flutterLocalNotificationsPlugin.initialize(
|
||||||
initializationSettings,
|
initializationSettings,
|
||||||
onDidReceiveNotificationResponse: _onSelectNotification,
|
onDidReceiveNotificationResponse: _onSelectNotification,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Если приложение было запущено из локального уведомления, сохраним payload
|
// Если приложение было запущено из локального уведомления, сохраним payload
|
||||||
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails();
|
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
|
||||||
|
.getNotificationAppLaunchDetails();
|
||||||
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
|
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
|
||||||
final payload = notificationAppLaunchDetails?.notificationResponse?.payload;
|
final payload = notificationAppLaunchDetails?.notificationResponse?.payload;
|
||||||
print('App launched from local notification, payload: $payload');
|
print('App launched from local notification, payload: $payload');
|
||||||
if (payload != null && payload.isNotEmpty) {
|
if (payload != null && payload.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
|
final lastHandled = prefs.getString(
|
||||||
|
_lastHandledNotificationLaunchPayloadKey,
|
||||||
|
);
|
||||||
if (lastHandled != payload) {
|
if (lastHandled != payload) {
|
||||||
final data = jsonDecode(payload);
|
final data = jsonDecode(payload);
|
||||||
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
|
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
|
||||||
await prefs.setString(_lastHandledNotificationLaunchPayloadKey, payload);
|
await prefs.setString(
|
||||||
|
_lastHandledNotificationLaunchPayloadKey,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
print('Saved local notification launch payload to SharedPreferences');
|
print('Saved local notification launch payload to SharedPreferences');
|
||||||
} else {
|
} else {
|
||||||
print('Local notification payload already handled earlier, skipping');
|
print('Local notification payload already handled earlier, skipping');
|
||||||
|
|
@ -185,7 +215,11 @@ void main() async {
|
||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
);
|
);
|
||||||
|
|
||||||
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
|
await flutterLocalNotificationsPlugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>()
|
||||||
|
?.createNotificationChannel(channel);
|
||||||
|
|
||||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||||
|
|
||||||
|
|
@ -208,8 +242,10 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
if (message.data['type'] == 'enc_message') {
|
if (message.data['type'] == 'enc_message') {
|
||||||
try {
|
try {
|
||||||
// Initialize notifications for background
|
// Initialize notifications for background
|
||||||
const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher');
|
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||||
const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid);
|
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
|
const InitializationSettings initializationSettings =
|
||||||
|
InitializationSettings(android: initializationSettingsAndroid);
|
||||||
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
||||||
|
|
||||||
// Create notification channel
|
// Create notification channel
|
||||||
|
|
@ -220,7 +256,11 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
importance: Importance.high,
|
importance: Importance.high,
|
||||||
);
|
);
|
||||||
|
|
||||||
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
|
await flutterLocalNotificationsPlugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>()
|
||||||
|
?.createNotificationChannel(channel);
|
||||||
|
|
||||||
// Try to decrypt
|
// Try to decrypt
|
||||||
String notificationText = 'New encrypted message';
|
String notificationText = 'New encrypted message';
|
||||||
|
|
@ -233,11 +273,18 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
print('Private key retrieved: ${myPrivKey != null}');
|
print('Private key retrieved: ${myPrivKey != null}');
|
||||||
if (myPrivKey == null) {
|
if (myPrivKey == null) {
|
||||||
print('Private key not found, showing encrypted message');
|
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 {
|
} else {
|
||||||
// 3. Расшифровываем
|
// 3. Расшифровываем
|
||||||
final sharedSecret = await crypto.deriveSharedSecret(myPrivKey, message.data['public_key']);
|
final sharedSecret = await crypto.deriveSharedSecret(
|
||||||
final decryptedText = await crypto.decryptMessage(message.data['content'], sharedSecret);
|
myPrivKey,
|
||||||
|
message.data['public_key'],
|
||||||
|
);
|
||||||
|
final decryptedText = await crypto.decryptMessage(
|
||||||
|
message.data['content'],
|
||||||
|
sharedSecret,
|
||||||
|
);
|
||||||
notificationText = decryptedText;
|
notificationText = decryptedText;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -250,11 +297,14 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
message.hashCode,
|
message.hashCode,
|
||||||
message.data['username'] ?? 'Unknown',
|
message.data['username'] ?? 'Unknown',
|
||||||
notificationText,
|
notificationText,
|
||||||
const NotificationDetails(android: AndroidNotificationDetails('chat_id', 'Messages')),
|
const NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails('chat_id', 'Messages'),
|
||||||
|
),
|
||||||
payload: jsonEncode({
|
payload: jsonEncode({
|
||||||
'type': 'enc_message',
|
'type': 'enc_message',
|
||||||
'sender_id': message.data['sender_id'],
|
'sender_id': message.data['sender_id'],
|
||||||
'timestamp': message.data['timestamp'] ?? DateTime.now().toIso8601String(),
|
'timestamp':
|
||||||
|
message.data['timestamp'] ?? DateTime.now().toIso8601String(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
print('Notification shown successfully');
|
print('Notification shown successfully');
|
||||||
|
|
@ -317,6 +367,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||||
theme: themeProvider.themeData,
|
theme: themeProvider.themeData,
|
||||||
themeMode: themeProvider.themeMode,
|
themeMode: themeProvider.themeMode,
|
||||||
navigatorKey: navigatorKey,
|
navigatorKey: navigatorKey,
|
||||||
|
navigatorObservers: [routeObserver],
|
||||||
|
|
||||||
// Начальный экран
|
// Начальный экран
|
||||||
home: const SplashScreen(),
|
home: const SplashScreen(),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '/data/models/message_model.dart';
|
import '/data/models/message_model.dart';
|
||||||
import '/data/models/contact_model.dart';
|
import '/data/models/contact_model.dart';
|
||||||
|
|
@ -16,6 +17,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'contacts_screen.dart';
|
import 'contacts_screen.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'user_profile_screen.dart';
|
import 'user_profile_screen.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
|
|
@ -40,6 +42,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
StreamSubscription<dynamic>? _socketSubscription;
|
StreamSubscription<dynamic>? _socketSubscription;
|
||||||
final Set<int> _sentReadReceipts = <int>{};
|
final Set<int> _sentReadReceipts = <int>{};
|
||||||
final LocalDbService _localDbService = LocalDbService();
|
final LocalDbService _localDbService = LocalDbService();
|
||||||
|
Uint8List? _pendingImageBytes;
|
||||||
MessageModel? _replyTo;
|
MessageModel? _replyTo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -80,6 +83,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text("Не удалось получить ключ шифрования собеседника"),
|
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(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (Navigator.canPop(context)) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
} else {
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
title: GestureDetector(
|
title: GestureDetector(
|
||||||
|
|
@ -163,6 +177,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
_inputFocusNode.requestFocus();
|
_inputFocusNode.requestFocus();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (msg.isMe)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit),
|
||||||
|
title: const Text('Изменить'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
_editMessage(msg);
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.copy),
|
leading: const Icon(Icons.copy),
|
||||||
title: const Text('Скопировать'),
|
title: const Text('Скопировать'),
|
||||||
|
|
@ -171,7 +194,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
await Clipboard.setData(ClipboardData(text: msg.text));
|
await Clipboard.setData(ClipboardData(text: msg.text));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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,11 +215,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
title: const Text('Переслать'),
|
title: const Text('Переслать'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(ctx).pop();
|
Navigator.of(ctx).pop();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_showForwardContactPicker(msg);
|
||||||
const SnackBar(content: Text('Пересылка пока не реализована')),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (msg.isMe)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_outline),
|
leading: const Icon(Icons.delete_outline),
|
||||||
title: const Text('Удалить'),
|
title: const Text('Удалить'),
|
||||||
|
|
@ -192,18 +226,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
iconColor: Colors.red,
|
iconColor: Colors.red,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.of(ctx).pop();
|
Navigator.of(ctx).pop();
|
||||||
setState(() {
|
await _deleteMessage(msg);
|
||||||
messages.removeWhere(
|
|
||||||
(m) => (m.id != null && m.id == msg.id) || (m.tempId != null && m.tempId == msg.tempId),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
final id = msg.id;
|
|
||||||
if (id != null) {
|
|
||||||
try {
|
|
||||||
await _localDbService.deleteMessage(id);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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() {
|
Widget _buildMessageInput() {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
// Добавляем SafeArea здесь
|
// Добавляем SafeArea здесь
|
||||||
|
|
@ -225,7 +518,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
children: [
|
children: [
|
||||||
if (_replyTo != null)
|
if (_replyTo != null)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
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(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.photo),
|
||||||
|
onPressed: _pickImage,
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
|
|
@ -259,6 +588,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
maxLines: 5,
|
maxLines: 5,
|
||||||
textInputAction: TextInputAction.newline,
|
textInputAction: TextInputAction.newline,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: "Напиши сообщение...",
|
hintText: "Напиши сообщение...",
|
||||||
),
|
),
|
||||||
|
|
@ -278,84 +608,148 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage() async {
|
Future<void> _pickImage() async {
|
||||||
final rawText = _controller.text.trim();
|
final ImagePicker _picker = ImagePicker();
|
||||||
if (rawText.isEmpty) return;
|
final XFile? image = await _picker.pickImage(
|
||||||
_controller.clear();
|
source: ImageSource.gallery,
|
||||||
|
maxWidth: 1280,
|
||||||
if (_currentContact.publicKey == null) {
|
maxHeight: 1280,
|
||||||
await _loadContactKey();
|
imageQuality: 80,
|
||||||
if (_currentContact.publicKey == null) return;
|
);
|
||||||
|
if (image != null) {
|
||||||
|
final Uint8List fileBytes = await image.readAsBytes();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pendingImageBytes = fileBytes;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
Future<void> _sendMessage() async {
|
||||||
final myPrivKey = await _cryptoService.getPrivateKey();
|
final rawText = _controller.text.trim();
|
||||||
|
final hasImage = _pendingImageBytes != null;
|
||||||
|
|
||||||
|
// Если и текст пустой, и картинки нет — выходим
|
||||||
|
if (rawText.isEmpty && !hasImage) return;
|
||||||
|
|
||||||
|
// Блокируем UI на время загрузки
|
||||||
|
_controller.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Подготовка ключей
|
||||||
|
final myPrivKey = await _cryptoService.getPrivateKey();
|
||||||
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
||||||
myPrivKey!,
|
myPrivKey!,
|
||||||
_currentContact.publicKey!,
|
_currentContact.publicKey!,
|
||||||
);
|
);
|
||||||
|
|
||||||
final encryptedText = await _cryptoService.encryptMessage(
|
String? fileId;
|
||||||
rawText,
|
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,
|
sharedSecret,
|
||||||
);
|
);
|
||||||
|
|
||||||
final encryptedText50 = await _cryptoService.encryptMessage(
|
String previewText = rawText.isNotEmpty ? rawText : "[Фото]";
|
||||||
rawText.length > 50 ? rawText.substring(0, 50) : rawText,
|
if (previewText.length > 50) previewText = previewText.substring(0, 50);
|
||||||
|
encryptedContent50 = await _cryptoService.encryptMessage(
|
||||||
|
previewText,
|
||||||
sharedSecret,
|
sharedSecret,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 4. Создаем локальную модель для мгновенного отображения
|
||||||
final tempId = DateTime.now().microsecondsSinceEpoch;
|
final tempId = DateTime.now().microsecondsSinceEpoch;
|
||||||
final localMessage = MessageModel(
|
final localMessage = MessageModel(
|
||||||
tempId: tempId,
|
tempId: tempId,
|
||||||
text: rawText,
|
text: rawText.isNotEmpty ? rawText : "[Фото]",
|
||||||
isMe: true,
|
isMe: true,
|
||||||
senderId: myId,
|
senderId: myId,
|
||||||
receiverId: _currentContact.id,
|
receiverId: _currentContact.id,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
status: MessageStatus.sending,
|
status: MessageStatus.sending,
|
||||||
|
localFileBytes: _pendingImageBytes,
|
||||||
replyToId: _replyTo?.id,
|
replyToId: _replyTo?.id,
|
||||||
replyToText: _replyTo?.text,
|
replyToText: _replyTo?.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
messages.add(localMessage);
|
messages.add(localMessage);
|
||||||
|
_pendingImageBytes = null; // Очищаем черновик
|
||||||
});
|
});
|
||||||
|
|
||||||
// Формируем payload для сервера
|
// 5. Формируем финальный payload для сокета
|
||||||
final payload = {
|
final payload = {
|
||||||
"type": "private_message",
|
"type": "private_message",
|
||||||
"receiver_id": _currentContact.id,
|
"receiver_id": _currentContact.id,
|
||||||
"content": encryptedText,
|
"message_type": hasImage ? "image" : "text",
|
||||||
"content50": encryptedText50,
|
"content": encryptedContent, // Шифрованный текст (подпись)
|
||||||
|
"content50": encryptedContent50, // Шифрованное превью
|
||||||
"temp_id": tempId,
|
"temp_id": tempId,
|
||||||
|
if (hasImage) ...{
|
||||||
|
"file_id": fileId,
|
||||||
|
"encrypted_key": encryptedFileKey, // Зашифрованный AES-ключ файла
|
||||||
|
},
|
||||||
if (_replyTo?.id != null) ...{
|
if (_replyTo?.id != null) ...{
|
||||||
"reply_to_id": _replyTo!.id,
|
"reply_to_id": _replyTo!.id,
|
||||||
"reply_to_text": _replyTo!.text,
|
"reply_to_text": _replyTo!.text,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Отправляем
|
// 6. Отправка через сокет
|
||||||
print("ОТПРАВКА: $payload");
|
final ok = Provider.of<SocketService>(
|
||||||
final ok = Provider.of<SocketService>(context, listen: false).sendMessage(payload);
|
context,
|
||||||
|
listen: false,
|
||||||
|
).sendMessage(payload);
|
||||||
|
|
||||||
if (!mounted) return;
|
// Обновляем статус
|
||||||
setState(() {
|
setState(() {
|
||||||
final idx = messages.indexWhere((m) => m.tempId == tempId);
|
final idx = messages.indexWhere((m) => m.tempId == tempId);
|
||||||
if (idx == -1) return;
|
if (idx != -1) {
|
||||||
messages[idx] = messages[idx].copyWith(
|
messages[idx] = messages[idx].copyWith(
|
||||||
status: ok ? MessageStatus.sent : MessageStatus.failed,
|
status: ok ? MessageStatus.sent : MessageStatus.failed,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
_replyTo = null;
|
_replyTo = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
_controller.clear();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// В случае ошибки возвращаем текст в контроллер
|
||||||
_controller.text = rawText;
|
_controller.text = rawText;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(
|
||||||
).showSnackBar(SnackBar(content: Text("Ошибка шифрования: $e")));
|
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;
|
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') {
|
if (data['type'] == 'message_read') {
|
||||||
final messageId = int.tryParse(data['message_id'].toString());
|
final messageId = int.tryParse(data['message_id'].toString());
|
||||||
if (messageId == null) return;
|
if (messageId == null) return;
|
||||||
|
|
@ -449,14 +892,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
|
|
||||||
if (data['type'] == 'private_message') {
|
if (data['type'] == 'private_message') {
|
||||||
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся
|
// 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся
|
||||||
final isFromPartnerToMe = senderId == widget.contact.id && receiverId == myId;
|
final isFromPartnerToMe =
|
||||||
|
senderId == widget.contact.id && receiverId == myId;
|
||||||
if (isFromPartnerToMe) {
|
if (isFromPartnerToMe) {
|
||||||
try {
|
try {
|
||||||
final myPrivKey = await _cryptoService.getPrivateKey();
|
final myPrivKey = await _cryptoService.getPrivateKey();
|
||||||
|
|
@ -478,8 +926,12 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final serverMessageId = int.tryParse(data['id']?.toString() ?? '');
|
final serverMessageId = int.tryParse(data['id']?.toString() ?? '');
|
||||||
if (serverMessageId != null && !_sentReadReceipts.contains(serverMessageId)) {
|
if (serverMessageId != null &&
|
||||||
Provider.of<SocketService>(context, listen: false).sendReadReceipt(serverMessageId);
|
!_sentReadReceipts.contains(serverMessageId)) {
|
||||||
|
Provider.of<SocketService>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
).sendReadReceipt(serverMessageId);
|
||||||
_sentReadReceipts.add(serverMessageId);
|
_sentReadReceipts.add(serverMessageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -493,8 +945,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
receiverId: myId,
|
receiverId: myId,
|
||||||
createdAt: DateTime.parse(data['timestamp']),
|
createdAt: DateTime.parse(data['timestamp']),
|
||||||
status: MessageStatus.delivered,
|
status: MessageStatus.delivered,
|
||||||
replyToId: data['reply_to_id'] == null ? null : int.tryParse(data['reply_to_id'].toString()),
|
replyToId: data['reply_to_id'] == null
|
||||||
replyToText: data['reply_to_text'] == null ? null : data['reply_to_text'].toString(),
|
? 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!,
|
myPrivKey!,
|
||||||
widget.contact.publicKey!,
|
widget.contact.publicKey!,
|
||||||
);
|
);
|
||||||
final cached = await _localDbService.getChatHistory(widget.contact.id, myId);
|
final cached = await _localDbService.getChatHistory(
|
||||||
|
widget.contact.id,
|
||||||
|
myId,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
List<MessageModel> loadedLocalMessages = [];
|
List<MessageModel> loadedLocalMessages = [];
|
||||||
|
|
@ -557,8 +1017,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
receiverId: msg['receiver_id'],
|
receiverId: msg['receiver_id'],
|
||||||
createdAt: DateTime.parse(msg['timestamp']),
|
createdAt: DateTime.parse(msg['timestamp']),
|
||||||
status: status,
|
status: status,
|
||||||
replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()),
|
replyToId: msg['reply_to_id'] == null
|
||||||
replyToText: msg['reply_to_text'] == null ? null : msg['reply_to_text'].toString(),
|
? 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'],
|
receiverId: msg['receiver_id'],
|
||||||
createdAt: DateTime.parse(msg['timestamp']),
|
createdAt: DateTime.parse(msg['timestamp']),
|
||||||
status: status,
|
status: status,
|
||||||
replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()),
|
replyToId: msg['reply_to_id'] == null
|
||||||
replyToText: msg['reply_to_text'] == null ? null : msg['reply_to_text'].toString(),
|
? 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);
|
Provider.of<SocketService>(context, listen: false).sendReadReceipt(id);
|
||||||
_sentReadReceipts.add(id);
|
_sentReadReceipts.add(id);
|
||||||
}
|
}
|
||||||
|
await _localDbService.deleteChatHistory(widget.contact.id, myId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Ошибка загрузки истории: $e");
|
print("Ошибка загрузки истории: $e");
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'package:chepuhagram/core/constants.dart';
|
||||||
import 'package:chepuhagram/domain/services/aPI_service.dart';
|
import 'package:chepuhagram/domain/services/aPI_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.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:chepuhagram/domain/services/crypto_service.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:chepuhagram/main.dart';
|
import 'package:chepuhagram/main.dart';
|
||||||
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
|
||||||
import 'dart:async';
|
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 {
|
class ContactsScreen extends StatefulWidget {
|
||||||
final int? targetChatId;
|
final int? targetChatId;
|
||||||
|
|
@ -25,9 +32,14 @@ class ContactsScreen extends StatefulWidget {
|
||||||
State<ContactsScreen> createState() => _ContactsScreenState();
|
State<ContactsScreen> createState() => _ContactsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ContactsScreenState extends State<ContactsScreen> {
|
class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
||||||
static const String _notificationLaunchKey = 'notification_launch_data';
|
static const String _notificationLaunchKey = 'notification_launch_data';
|
||||||
StreamSubscription<dynamic>? _socketSubscription;
|
StreamSubscription<dynamic>? _socketSubscription;
|
||||||
|
bool _isDownloading = false;
|
||||||
|
double _downloadProgress = 0.0;
|
||||||
|
CancelToken? _cancelToken = CancelToken();
|
||||||
|
String? _latestApkUrl;
|
||||||
|
bool _showUpdateBanner = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -39,6 +51,9 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
final contactProvider = context.read<ContactProvider>();
|
final contactProvider = context.read<ContactProvider>();
|
||||||
|
|
||||||
// Установить текущего пользователя и загрузить контакты с сообщениями
|
// Установить текущего пользователя и загрузить контакты с сообщениями
|
||||||
|
print(
|
||||||
|
'Setting current user ID in ContactProvider: ${authProvider.currentUserId}',
|
||||||
|
);
|
||||||
contactProvider.setCurrentUserId(authProvider.currentUserId);
|
contactProvider.setCurrentUserId(authProvider.currentUserId);
|
||||||
contactProvider.loadContacts().then((_) {
|
contactProvider.loadContacts().then((_) {
|
||||||
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
|
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 {
|
Future<void> _checkSavedNotificationTarget() async {
|
||||||
|
|
@ -89,7 +133,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
_navigateToTargetChatWithId(widget.targetChatId!);
|
_navigateToTargetChatWithId(widget.targetChatId!);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToTargetChatWithId(int targetChatId) {
|
void _navigateToTargetChatWithId(int targetChatId) async {
|
||||||
print('_navigateToTargetChat called with targetChatId: $targetChatId');
|
print('_navigateToTargetChat called with targetChatId: $targetChatId');
|
||||||
final contactProvider = context.read<ContactProvider>();
|
final contactProvider = context.read<ContactProvider>();
|
||||||
try {
|
try {
|
||||||
|
|
@ -98,15 +142,45 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
);
|
);
|
||||||
print('Auto-navigating to chat with contact: ${contact.username}');
|
print('Auto-navigating to chat with contact: ${contact.username}');
|
||||||
currentActiveChatContactId = targetChatId; // Устанавливаем активный чат
|
currentActiveChatContactId = targetChatId; // Устанавливаем активный чат
|
||||||
Navigator.push(
|
final result = await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
|
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
|
||||||
);
|
);
|
||||||
|
if (result != null) {
|
||||||
|
_refreshData(); // Обновляем данные при возвращении с чата, если нужно
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Target contact with id $targetChatId not found: $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 {
|
Future<void> _setupPushNotifications() async {
|
||||||
// Request permissions
|
// Request permissions
|
||||||
await FirebaseMessaging.instance.requestPermission();
|
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>();
|
final contactProvider = context.read<ContactProvider>();
|
||||||
print('Navigate to chat from notification with senderId: $senderId');
|
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}');
|
print('Navigating to chat from notification: ${contact.username}');
|
||||||
currentActiveChatContactId = senderId; // Устанавливаем активный чат
|
currentActiveChatContactId = senderId; // Устанавливаем активный чат
|
||||||
Navigator.push(
|
final result = await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
|
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
|
||||||
);
|
);
|
||||||
|
if (result != null) {
|
||||||
|
_refreshData(); // Обновляем данные при возвращении с чата, если нужно
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Contact not found, stay on contacts screen
|
// Contact not found, stay on contacts screen
|
||||||
print('Contact not found for notification: $senderId');
|
print('Contact not found for notification: $senderId');
|
||||||
|
|
@ -249,14 +326,9 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_socketSubscription?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final double fabBottomPadding = _showUpdateBanner ? 120.0 : 16.0;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
|
|
@ -267,7 +339,9 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})],
|
actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})],
|
||||||
),
|
),
|
||||||
body: Consumer<ContactProvider>(
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Consumer<ContactProvider>(
|
||||||
builder: (context, contactProvider, child) {
|
builder: (context, contactProvider, child) {
|
||||||
if (contactProvider.isLoading) {
|
if (contactProvider.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
|
@ -286,27 +360,44 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
final contact = contactProvider.contacts[index];
|
final contact = contactProvider.contacts[index];
|
||||||
return ContactTile(
|
return ContactTile(
|
||||||
contact: contact,
|
contact: contact,
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
Navigator.push(
|
final result = await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ChatScreen(contact: contact),
|
builder: (_) => ChatScreen(contact: contact),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (result != null) {
|
||||||
|
_refreshData(); // Обновляем данные при возвращении с чата, если нужно
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
if (_showUpdateBanner)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 40,
|
||||||
|
child: _buildUpdateBanner(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: AnimatedPadding(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
padding: EdgeInsets.only(bottom: fabBottomPadding),
|
||||||
|
child: FloatingActionButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const NewChatScreen()),
|
MaterialPageRoute(builder: (_) => const NewChatScreen()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurface),
|
child: const Icon(Icons.edit),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
drawer: Drawer(
|
drawer: Drawer(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
|
@ -327,9 +418,17 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
.join();
|
.join();
|
||||||
|
|
||||||
return UserAccountsDrawerHeader(
|
return UserAccountsDrawerHeader(
|
||||||
accountName: Text(displayName),
|
accountName: Text(
|
||||||
|
displayName,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
accountEmail: Text(
|
accountEmail: Text(
|
||||||
username == null || username.isEmpty ? '' : '@$username',
|
username == null || username.isEmpty ? '' : '@$username',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
currentAccountPicture: CircleAvatar(
|
currentAccountPicture: CircleAvatar(
|
||||||
backgroundColor: Theme.of(context).colorScheme.onSurface,
|
backgroundColor: Theme.of(context).colorScheme.onSurface,
|
||||||
|
|
@ -343,7 +442,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
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.pop(context);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
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(
|
final response = await http.get(
|
||||||
Uri.http(AppConstants.baseUrl, 'users/me'),
|
Uri.parse('${AppConstants.baseUrl}/users/me'),
|
||||||
headers: {'Authorization': 'Bearer $token'},
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,33 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '/logic/auth_provider.dart';
|
import '/logic/auth_provider.dart';
|
||||||
import '/core/theme_manager.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});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final themeProv = context.watch<ThemeProvider>();
|
final themeProv = context.watch<ThemeProvider>();
|
||||||
|
|
@ -20,16 +43,39 @@ class SettingsScreen extends StatelessWidget {
|
||||||
? '@${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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text("Настройки")),
|
appBar: AppBar(title: const Text("Настройки")),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// Секция Профиля
|
// Секция Профиля
|
||||||
UserAccountsDrawerHeader(
|
UserAccountsDrawerHeader(
|
||||||
accountName: Text(authProv.displayName),
|
accountName: Text(
|
||||||
accountEmail: Text(accountEmail),
|
authProv.displayName,
|
||||||
currentAccountPicture: const CircleAvatar(
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
child: Icon(Icons.person, size: 40),
|
),
|
||||||
|
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),
|
decoration: const BoxDecoration(color: Colors.transparent),
|
||||||
),
|
),
|
||||||
|
|
@ -82,7 +128,10 @@ class SettingsScreen extends StatelessWidget {
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.palette_outlined),
|
Icon(
|
||||||
|
Icons.palette_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
SizedBox(width: 10),
|
SizedBox(width: 10),
|
||||||
const Text("Цвет темы"),
|
const Text("Цвет темы"),
|
||||||
Spacer(),
|
Spacer(),
|
||||||
|
|
@ -117,9 +166,9 @@ class SettingsScreen extends StatelessWidget {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
const Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
"Chepuhagram for Android v1.0.0",
|
"Chepuhagram for Android v$versionCode",
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -129,7 +178,7 @@ class SettingsScreen extends StatelessWidget {
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import '../../logic/auth_provider.dart';
|
import '../../logic/auth_provider.dart';
|
||||||
import '../../logic/contact_provider.dart';
|
import '../../logic/contact_provider.dart';
|
||||||
import 'login_screen.dart';
|
import 'login_screen.dart';
|
||||||
|
|
@ -21,6 +25,7 @@ class SplashScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _SplashScreenState extends State<SplashScreen> {
|
class _SplashScreenState extends State<SplashScreen> {
|
||||||
int? _targetChatId;
|
int? _targetChatId;
|
||||||
|
String? connectError;
|
||||||
|
|
||||||
// Ключ для SharedPreferences
|
// Ключ для SharedPreferences
|
||||||
static const String _notificationLaunchKey = 'notification_launch_data';
|
static const String _notificationLaunchKey = 'notification_launch_data';
|
||||||
|
|
@ -39,7 +44,9 @@ class _SplashScreenState extends State<SplashScreen> {
|
||||||
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
|
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
|
||||||
print('App opened from notification: ${message.data}');
|
print('App opened from notification: ${message.data}');
|
||||||
if (message.data['type'] == 'enc_message') {
|
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) {
|
if (senderId != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_targetChatId = senderId;
|
_targetChatId = senderId;
|
||||||
|
|
@ -61,10 +68,36 @@ class _SplashScreenState extends State<SplashScreen> {
|
||||||
final isLoggedIn = await authProvider.tryAutoLogin();
|
final isLoggedIn = await authProvider.tryAutoLogin();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
bool connected = false;
|
||||||
|
int connectAttempt = 0;
|
||||||
// 3. Навигация в зависимости от результата и статуса аккаунта
|
// 3. Навигация в зависимости от результата и статуса аккаунта
|
||||||
if (isLoggedIn) {
|
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) {
|
if (authProvider.needsSetup) {
|
||||||
|
|
@ -82,7 +115,8 @@ class _SplashScreenState extends State<SplashScreen> {
|
||||||
} else {
|
} else {
|
||||||
// Путь Б: Нормальный вход в контакты
|
// Путь Б: Нормальный вход в контакты
|
||||||
// Проверяем, было ли приложение запущено из уведомления
|
// Проверяем, было ли приложение запущено из уведомления
|
||||||
int? targetChatId = _targetChatId; // Сначала проверяем из onMessageOpenedApp
|
int? targetChatId =
|
||||||
|
_targetChatId; // Сначала проверяем из onMessageOpenedApp
|
||||||
|
|
||||||
// Если не установлено, проверяем SharedPreferences
|
// Если не установлено, проверяем SharedPreferences
|
||||||
if (targetChatId == null) {
|
if (targetChatId == null) {
|
||||||
|
|
@ -93,13 +127,17 @@ class _SplashScreenState extends State<SplashScreen> {
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(savedData) as Map<String, dynamic>;
|
final data = jsonDecode(savedData) as Map<String, dynamic>;
|
||||||
print('Found saved notification data: $data');
|
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();
|
final type = data['type']?.toString();
|
||||||
|
|
||||||
// Поддерживаем старый payload (только sender_id) и новый (type+sender_id)
|
// Поддерживаем старый payload (только sender_id) и новый (type+sender_id)
|
||||||
if (senderId != null && (type == null || type == 'enc_message')) {
|
if (senderId != null && (type == null || type == 'enc_message')) {
|
||||||
targetChatId = senderId;
|
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) {
|
if (initialMessage != null) {
|
||||||
print('Initial message data: ${initialMessage!.data}');
|
print('Initial message data: ${initialMessage!.data}');
|
||||||
if (initialMessage!.data['type'] == 'enc_message') {
|
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');
|
print('Set target chat from initialMessage: $targetChatId');
|
||||||
} else {
|
} else {
|
||||||
print('Initial message type is not enc_message: ${initialMessage!.data['type']}');
|
print(
|
||||||
|
'Initial message type is not enc_message: ${initialMessage!.data['type']}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print('No initial message found');
|
print('No initial message found');
|
||||||
|
|
@ -130,29 +172,45 @@ class _SplashScreenState extends State<SplashScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetChatId != null) {
|
if (targetChatId != null) {
|
||||||
print('Notification targetChatId resolved: $targetChatId, trying to open chat directly');
|
print(
|
||||||
|
'Notification targetChatId resolved: $targetChatId, trying to open chat directly',
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
final contactProvider = context.read<ContactProvider>();
|
final contactProvider = context.read<ContactProvider>();
|
||||||
contactProvider.setCurrentUserId(authProvider.currentUserId);
|
contactProvider.setCurrentUserId(authProvider.currentUserId);
|
||||||
await contactProvider.loadContacts();
|
await contactProvider.loadContacts();
|
||||||
|
|
||||||
final contact = contactProvider.contacts.firstWhere((c) => c.id == targetChatId);
|
final contact = contactProvider.contacts.firstWhere(
|
||||||
|
(c) => c.id == targetChatId,
|
||||||
|
);
|
||||||
currentActiveChatContactId = 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(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
|
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} 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');
|
print('Navigating to ContactsScreen, targetChatId: $targetChatId');
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_notificationLaunchKey);
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => ContactsScreen(targetChatId: targetChatId)),
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ContactsScreen(targetChatId: targetChatId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -193,6 +251,15 @@ class _SplashScreenState extends State<SplashScreen> {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
color: Theme.of(context).colorScheme.primary,
|
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(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
'Made by ArturKarasevich',
|
'Made by ArturKarasevich',
|
||||||
|
|
@ -201,7 +268,7 @@ class _SplashScreenState extends State<SplashScreen> {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 80),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../core/app_colors.dart';
|
|
||||||
import '/data/models/contact_model.dart';
|
import '/data/models/contact_model.dart';
|
||||||
|
|
||||||
class ContactTile extends StatelessWidget {
|
class ContactTile extends StatelessWidget {
|
||||||
|
|
@ -8,10 +7,26 @@ class ContactTile extends StatelessWidget {
|
||||||
|
|
||||||
const ContactTile({super.key, required this.contact, this.onTap});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final primary = Theme.of(context).colorScheme.primary;
|
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(
|
return ListTile(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
|
@ -19,8 +34,11 @@ class ContactTile extends StatelessWidget {
|
||||||
radius: 28,
|
radius: 28,
|
||||||
backgroundColor: primary.withAlpha((0.1 * 255).round()),
|
backgroundColor: primary.withAlpha((0.1 * 255).round()),
|
||||||
child: Text(
|
child: Text(
|
||||||
contact.name[0],
|
initials,
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
|
|
@ -31,7 +49,7 @@ class ContactTile extends StatelessWidget {
|
||||||
contact.lastMessage ?? "Нет сообщений",
|
contact.lastMessage ?? "Нет сообщений",
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(color: AppColors.textSecondary),
|
style: const TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
trailing: Column(
|
trailing: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
|
@ -39,13 +57,19 @@ class ContactTile extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_formatTime(contact.lastMessageTime),
|
_formatTime(contact.lastMessageTime),
|
||||||
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12),
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
if (contact.unreadCount > 0)
|
if (contact.unreadCount > 0)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(6),
|
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(
|
child: Text(
|
||||||
'${contact.unreadCount}',
|
'${contact.unreadCount}',
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '/data/models/message_model.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 {
|
class MessageBubble extends StatelessWidget {
|
||||||
final MessageModel message;
|
final MessageModel message;
|
||||||
|
|
@ -14,6 +18,7 @@ class MessageBubble extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isMe = message.isMe;
|
final isMe = message.isMe;
|
||||||
|
final themeProv = context.watch<ThemeProvider>();
|
||||||
return Align(
|
return Align(
|
||||||
// Выравниваем вправо, если это мое сообщение, и влево — если чужое
|
// Выравниваем вправо, если это мое сообщение, и влево — если чужое
|
||||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
|
@ -39,7 +44,7 @@ class MessageBubble extends StatelessWidget {
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isMe
|
color: isMe
|
||||||
? Theme.of(context).colorScheme.primary
|
? Theme.of(context).colorScheme.primaryFixedDim
|
||||||
: Colors.grey[300],
|
: Colors.grey[300],
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
topLeft: const Radius.circular(16),
|
topLeft: const Radius.circular(16),
|
||||||
|
|
@ -61,7 +66,7 @@ class MessageBubble extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border(
|
border: Border(
|
||||||
left: BorderSide(
|
left: BorderSide(
|
||||||
color: isMe ? Colors.white70 : Colors.black38,
|
color: isMe ? Colors.black54 : Colors.black38,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -72,7 +77,7 @@ class MessageBubble extends StatelessWidget {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.reply,
|
Icons.reply,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: isMe ? Colors.white70 : Colors.black54,
|
color: isMe ? Colors.black54 : Colors.black54,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -81,7 +86,7 @@ class MessageBubble extends StatelessWidget {
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
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,
|
fontSize: 12,
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
),
|
),
|
||||||
|
|
@ -91,12 +96,16 @@ class MessageBubble extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
Text(
|
Linkify(
|
||||||
message.text,
|
onOpen: (link) async {
|
||||||
style: TextStyle(
|
final Uri url = Uri.parse(link.url);
|
||||||
color: isMe ? Colors.white : Colors.black87,
|
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||||
fontSize: 16,
|
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),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -105,10 +114,21 @@ class MessageBubble extends StatelessWidget {
|
||||||
Text(
|
Text(
|
||||||
_formatTime(message.createdAt),
|
_formatTime(message.createdAt),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isMe ? Colors.white70 : Colors.black54,
|
color: isMe ? Colors.black87 : Colors.black54,
|
||||||
fontSize: 10,
|
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) ...[
|
if (isMe) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Icon(
|
Icon(
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,18 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#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 <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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 =
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
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
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_linux
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,32 @@
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import file_selector_macos
|
||||||
import firebase_analytics
|
import firebase_analytics
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
|
import flutter_image_compress_macos
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
import local_auth_darwin
|
import local_auth_darwin
|
||||||
|
import package_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin"))
|
FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin"))
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
|
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
280
pubspec.lock
280
pubspec.lock
|
|
@ -57,6 +57,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -89,6 +97,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -113,6 +137,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
firebase_analytics:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -190,6 +246,62 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -304,6 +416,70 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
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:
|
intl:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -368,6 +544,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.2"
|
version: "3.0.2"
|
||||||
|
linkify:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: linkify
|
||||||
|
sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.0"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -440,6 +624,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.17.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -448,6 +640,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
package_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -456,6 +656,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -717,6 +933,70 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|
@ -48,6 +48,13 @@ dependencies:
|
||||||
flutter_local_notifications: ^17.2.2
|
flutter_local_notifications: ^17.2.2
|
||||||
firebase_analytics: ^10.10.7
|
firebase_analytics: ^10.10.7
|
||||||
shared_preferences: ^2.5.5
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,11 @@ authRouter = APIRouter(
|
||||||
|
|
||||||
|
|
||||||
@authRouter.post("/register")
|
@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:
|
if len(password.encode('utf-8')) > 72:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="Пароль слишком длинный (макс. 72 байта)")
|
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)
|
new_user = models.User(username=username, hashed_password=hashed_pwd)
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "ok", "message": "User created"}
|
return {"status": "ok", "message": "User created", "id": new_user.id}
|
||||||
|
|
||||||
|
|
||||||
@authRouter.post("/hash")
|
@authRouter.post("/hash")
|
||||||
|
|
@ -106,6 +110,7 @@ async def setup_account(data: schemas.SetupAccount, current_user: models.User =
|
||||||
db.refresh(user_to_update)
|
db.refresh(user_to_update)
|
||||||
return {"status": "ok", "message": "Account setup completed"}
|
return {"status": "ok", "message": "Account setup completed"}
|
||||||
|
|
||||||
|
|
||||||
@authRouter.post("/update-fcm")
|
@authRouter.post("/update-fcm")
|
||||||
async def update_fcm(token: str, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
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)
|
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.db import models
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.api import schemas
|
from app.api import schemas
|
||||||
from sqlalchemy import or_, and_
|
from sqlalchemy import or_, and_, exists
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -65,7 +65,8 @@ async def update_users_me(
|
||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
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)
|
db.refresh(user_to_update)
|
||||||
return {
|
return {
|
||||||
|
|
@ -95,7 +96,8 @@ async def update_encrypted_private_key(
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=500, detail="Не удалось сохранить ключ шифрования")
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Не удалось сохранить ключ шифрования")
|
||||||
|
|
||||||
db.refresh(user_to_update)
|
db.refresh(user_to_update)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
@ -119,7 +121,8 @@ async def change_password(
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=500, detail="Не удалось изменить пароль")
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Не удалось изменить пароль")
|
||||||
|
|
||||||
db.refresh(user_to_update)
|
db.refresh(user_to_update)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
@ -148,7 +151,8 @@ async def update_privacy_settings(
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=500, detail="Не удалось сохранить настройки конфиденциальности")
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Не удалось сохранить настройки конфиденциальности")
|
||||||
|
|
||||||
db.refresh(user_to_update)
|
db.refresh(user_to_update)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
@ -185,9 +189,19 @@ async def read_users_chats(
|
||||||
last_message возвращается в том виде, как хранится в БД (зашифрованный content).
|
last_message возвращается в том виде, как хранится в БД (зашифрованный content).
|
||||||
Клиент должен расшифровать превью локально.
|
Клиент должен расшифровать превью локально.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
users = (
|
users = (
|
||||||
db.query(models.User)
|
db.query(models.User)
|
||||||
.filter(models.User.id != current_user.id)
|
.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()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -233,6 +247,7 @@ async def read_users_chats(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result.sort(key=lambda x: x['last_message_time'] or '', reverse=True)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,19 @@ from fastapi import FastAPI, Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
from passlib.context import CryptContext
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.db import models
|
from app.db import models
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
import os
|
import os
|
||||||
|
import bcrypt
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
SECRET_KEY = os.getenv("JWT_KEY").strip()
|
SECRET_KEY = os.getenv("JWT_KEY").strip()
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60
|
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
|
||||||
|
|
||||||
# бд
|
# бд
|
||||||
|
|
@ -28,10 +26,13 @@ def get_db():
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def verify_password(plain_password, hashed_password):
|
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):
|
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):
|
def create_access_token(data: dict):
|
||||||
to_encode = data.copy()
|
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.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime
|
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime
|
||||||
|
|
@ -13,7 +13,7 @@ Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__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")
|
first_name = Column(String(50), nullable=False, server_default="User")
|
||||||
last_name = Column(String(50), nullable=True)
|
last_name = Column(String(50), nullable=True)
|
||||||
username = Column(String, unique=True, index=True)
|
username = Column(String, unique=True, index=True)
|
||||||
|
|
@ -44,6 +44,7 @@ class Message(Base):
|
||||||
read_at = Column(DateTime(timezone=True), nullable=True)
|
read_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True)
|
reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True)
|
||||||
reply_to_text = Column(Text, nullable=True)
|
reply_to_text = Column(Text, nullable=True)
|
||||||
|
edited_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
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)"))
|
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_id INTEGER REFERENCES messages(id)"))
|
||||||
if "reply_to_text" not in existing:
|
if "reply_to_text" not in existing:
|
||||||
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT"))
|
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()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,76 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
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":
|
elif message_data.get("type") == "read_receipt":
|
||||||
message_id = message_data.get("message_id")
|
message_id = message_data.get("message_id")
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
37
srv/main.py
37
srv/main.py
|
|
@ -1,13 +1,16 @@
|
||||||
from fastapi import FastAPI
|
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 app.websocket.connection_manager import wsRouter
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
import os
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
app.include_router(auth.authRouter)
|
app.include_router(auth.authRouter)
|
||||||
app.include_router(users.usersRouter)
|
app.include_router(users.usersRouter)
|
||||||
app.include_router(messages.messagesRouter)
|
app.include_router(messages.messagesRouter)
|
||||||
|
app.include_router(media.mediaRouter)
|
||||||
app.include_router(wsRouter)
|
app.include_router(wsRouter)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|
@ -18,6 +21,38 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8587)
|
uvicorn.run(app, host="0.0.0.0", port=8587)
|
||||||
|
|
@ -6,15 +6,21 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
#include <local_auth_windows/local_auth_plugin.h>
|
#include <local_auth_windows/local_auth_plugin.h>
|
||||||
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
LocalAuthPluginRegisterWithRegistrar(
|
LocalAuthPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||||
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_windows
|
||||||
firebase_core
|
firebase_core
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
local_auth_windows
|
local_auth_windows
|
||||||
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue