Встроеный апдейтер

This commit is contained in:
Artur 2026-04-30 23:07:36 +05:00
parent 7ea3d8dc28
commit 1a36cbccd3
35 changed files with 2530 additions and 344 deletions

View File

@ -3,12 +3,15 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:label="Chepuhagram"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="true"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:exported="true"
@ -57,5 +60,13 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" />
</intent>
</queries>
</manifest>

File diff suppressed because one or more lines are too long

View File

@ -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);// Белый (сообщения собеседника)
}

View File

@ -1,4 +1,5 @@
class AppConstants {
//static const baseUrl = '192.168.0.180:8000';
static const baseUrl = 'https://api.chepuhagram.ru';
}
static const wsUrl = 'wss://api.chepuhagram.ru';
}

View File

@ -10,6 +10,8 @@ class ThemeProvider extends ChangeNotifier {
ThemeMode get themeMode => _themeMode;
Color get accentColor => _accentColor;
bool isLight = false;
ThemeProvider() {
_loadSettings();
}
@ -21,6 +23,7 @@ class ThemeProvider extends ChangeNotifier {
if (mode != null) {
_themeMode = mode == 'dark' ? ThemeMode.dark : ThemeMode.light;
isLight = mode == 'light';
}
if (color != null) _accentColor = Color(int.parse(color));
notifyListeners();
@ -28,6 +31,7 @@ class ThemeProvider extends ChangeNotifier {
void toggleTheme(bool isDark) {
_themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
isLight = !isDark;
_storage.write(key: 'theme_mode', value: isDark ? 'dark' : 'light');
notifyListeners();
}

View File

@ -19,7 +19,7 @@ class LocalDbService {
String path = join(await getDatabasesPath(), 'chat_app.db');
return await openDatabase(
path,
version: 3,
version: 4,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE messages(
@ -31,7 +31,8 @@ class LocalDbService {
delivered_at TEXT,
read_at TEXT,
reply_to_id INTEGER,
reply_to_text TEXT
reply_to_text TEXT,
edited_at TEXT
)
''');
},
@ -41,8 +42,15 @@ class LocalDbService {
await db.execute('ALTER TABLE messages ADD COLUMN read_at TEXT');
}
if (oldVersion < 3) {
await db.execute('ALTER TABLE messages ADD COLUMN reply_to_id INTEGER');
await db.execute('ALTER TABLE messages ADD COLUMN reply_to_text TEXT');
await db.execute(
'ALTER TABLE messages ADD COLUMN reply_to_id INTEGER',
);
await db.execute(
'ALTER TABLE messages ADD COLUMN reply_to_text TEXT',
);
}
if (oldVersion < 4) {
await db.execute('ALTER TABLE messages ADD COLUMN edited_at TEXT');
}
},
);
@ -62,19 +70,24 @@ class LocalDbService {
'timestamp': msg.createdAt.toIso8601String(),
'delivered_at': null,
'read_at': null,
'reply_to_id': msg.replyToId,
'reply_to_text': msg.replyToText,
'edited_at': msg.editedAt?.toIso8601String(),
}, conflictAlgorithm: ConflictAlgorithm.replace);
} else {
// Если это Map из API
batch.insert('messages', {
'id': msg['id'],
'sender_id': msg['sender_id'],
'receiver_id': msg['receiver_id'], // Убедись, что ключ совпадает с API
'receiver_id':
msg['receiver_id'], // Убедись, что ключ совпадает с API
'content': msg['content'],
'timestamp': msg['timestamp'],
'delivered_at': msg['delivered_at'],
'read_at': msg['read_at'],
'reply_to_id': msg['reply_to_id'],
'reply_to_text': msg['reply_to_text'],
'edited_at': msg['edited_at'],
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
}
@ -96,6 +109,16 @@ class LocalDbService {
);
}
Future<int> deleteChatHistory(int contactId, int myId) async {
final db = await database;
return await db.delete(
'messages',
where:
'(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)',
whereArgs: [contactId, myId, myId, contactId],
);
}
Future<Map<String, dynamic>?> getLastMessage(int contactId, int myId) async {
final db = await database;
final rows = await db.query(
@ -131,12 +154,22 @@ class LocalDbService {
);
}
Future<void> deleteMessage(int messageId) async {
Future<void> updateMessageContent(
int messageId,
String content,
DateTime? editedAt,
) async {
final db = await database;
await db.delete(
await db.update(
'messages',
{'content': content, 'edited_at': editedAt?.toIso8601String()},
where: 'id = ?',
whereArgs: [messageId],
);
}
Future<void> deleteMessage(int messageId) async {
final db = await database;
await db.delete('messages', where: 'id = ?', whereArgs: [messageId]);
}
}

View File

@ -3,11 +3,12 @@ import 'dart:convert';
import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status;
import 'package:web_socket_channel/io.dart';
import 'package:chepuhagram/core/constants.dart';
class SocketService {
static final SocketService _instance = SocketService._internal();
factory SocketService() {
return _instance;
}
@ -29,19 +30,33 @@ class SocketService {
}
// В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке
final uri = Uri.parse("ws://${AppConstants.baseUrl.split('//')[1]}/ws?token=$token");
final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token");
_channel = WebSocketChannel.connect(uri);
//_channel = WebSocketChannel.connect(uri);
_channel!.stream.listen(
(data) {
final decoded = jsonDecode(data);
print("🚀 СООБЩЕНИЕ ПОЛУЧЕНО ИЗ SINK: $decoded");
_messageController.add(decoded);
},
onError: (error) => _reconnect(apiService),
onDone: () => _reconnect(apiService),
_channel = IOWebSocketChannel.connect(
uri,
connectTimeout: Duration(seconds: 10),
);
try {
await _channel!.ready;
_channel!.stream.listen(
(data) {
final decoded = jsonDecode(data);
print("🚀 СООБЩЕНИЕ ПОЛУЧЕНО ИЗ SINK: $decoded");
_messageController.add(decoded);
},
onError: (error) => _reconnect(apiService),
onDone: () => _reconnect(apiService),
);
} on TimeoutException catch (_) {
_channel = null;
throw Exception('timeout');
} catch (e) {
_channel = null;
throw Exception("Ошибка подключения: $e");
}
}
Future<void> _reconnect(ApiService apiService) async {
@ -71,14 +86,11 @@ class SocketService {
}
bool sendReadReceipt(int messageId) {
return sendMessage({
'type': 'read_receipt',
'message_id': messageId,
});
return sendMessage({'type': 'read_receipt', 'message_id': messageId});
}
void disconnect() {
_channel?.sink.close(status.goingAway);
_channel?.sink.close(status.normalClosure);
_channel = null;
}
}

View File

@ -60,8 +60,8 @@ class Contact {
return Contact(
id: json['id'],
username: json['username'] ?? 'Unknown',
name: json['name'] ?? 'Unknown',
surname: json['surname'] ?? 'Unknown',
name: json['name'] ?? json['first_name'] ?? 'Unknown',
surname: json['surname'] ?? json['last_name'] ?? 'Unknown',
lastMessage: json['last_message'] ?? json['lastMessage'],
avatarUrl: json['avatar_url'] ?? json['avatarUrl'],
lastMessageTime: parseTime(json['last_message_time'] ?? json['lastMessageTime']),

View File

@ -1,3 +1,5 @@
import 'dart:typed_data';
enum MessageStatus { sending, sent, delivered, read, failed }
class MessageModel {
@ -11,6 +13,8 @@ class MessageModel {
final MessageStatus status;
final int? replyToId; // ID сообщения, на которое отвечают
final String? replyToText; // текст сообщения, на которое отвечают (для отображения)
final DateTime? editedAt;
final Uint8List? localFileBytes;
MessageModel({
this.id,
@ -23,6 +27,8 @@ class MessageModel {
this.status = MessageStatus.sent,
this.replyToId,
this.replyToText,
this.editedAt,
this.localFileBytes
});
MessageModel copyWith({
@ -36,6 +42,8 @@ class MessageModel {
MessageStatus? status,
int? replyToId,
String? replyToText,
DateTime? editedAt,
Uint8List? localFileBytes,
}) {
return MessageModel(
id: id ?? this.id,
@ -48,6 +56,8 @@ class MessageModel {
status: status ?? this.status,
replyToId: replyToId ?? this.replyToId,
replyToText: replyToText ?? this.replyToText,
editedAt: editedAt ?? this.editedAt,
localFileBytes: localFileBytes ?? this.localFileBytes,
);
}
@ -67,6 +77,7 @@ class MessageModel {
status: MessageStatus.sent,
replyToId: json['reply_to_id'] == null ? null : int.tryParse(json['reply_to_id'].toString()),
replyToText: json['reply_to_text'] == null ? null : json['reply_to_text'].toString(),
editedAt: json['edited_at'] == null ? null : DateTime.tryParse(json['edited_at'].toString()),
);
}
@ -81,6 +92,7 @@ class MessageModel {
'status': status.name,
'reply_to_id': replyToId,
'reply_to_text': replyToText,
'edited_at': editedAt?.toIso8601String(),
};
}
}

View File

@ -9,6 +9,44 @@ class ApiService extends ChangeNotifier {
final _client = http.Client();
final _storage = const FlutterSecureStorage();
Future<String?> uploadMedia(List<int> bytes) async {
try {
final token = getAccessToken();
var request = http.MultipartRequest(
'POST',
Uri.parse('${AppConstants.baseUrl}/media/upload'),
);
request.headers.addAll({
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
});
// Добавляем файл в запрос
request.files.add(
http.MultipartFile.fromBytes(
'file',
bytes,
filename: 'media.enc', // Имя файла для сервера
),
);
// Добавь заголовки авторизации, если они у тебя есть (JWT и т.д.)
// request.headers.addAll({'Authorization': 'Bearer $token'});
var streamedResponse = await request.send();
var response = await http.Response.fromStream(streamedResponse);
if (response.statusCode == 200) {
// Предполагаем, что сервер возвращает JSON {"file_id": "..."}
final data = jsonDecode(response.body);
return data['file_id'];
}
return null;
} catch (e) {
print("Ошибка API при загрузке: $e");
return null;
}
}
Future<bool> refreshToken() async {
notifyListeners();
@ -128,7 +166,8 @@ class ApiService extends ChangeNotifier {
);
if (response.statusCode == 200) {
return jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
return jsonDecode(utf8.decode(response.bodyBytes))
as Map<String, dynamic>;
}
throw Exception('Не удалось получить данные пользователя');
}
@ -147,7 +186,10 @@ class ApiService extends ChangeNotifier {
return response.statusCode == 200;
}
Future<bool> changePassword(String currentPassword, String newPassword) async {
Future<bool> changePassword(
String currentPassword,
String newPassword,
) async {
final token = await getAccessToken();
final response = await _client.put(
Uri.parse('${AppConstants.baseUrl}/users/me/password'),
@ -167,7 +209,9 @@ class ApiService extends ChangeNotifier {
Future<List<dynamic>> getChatHistory(int contactId) async {
final token = await getAccessToken();
final response = await _client.get(
Uri.parse('${AppConstants.baseUrl}/messages/history/${contactId.toString()}'),
Uri.parse(
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}',
),
headers: {
'Content-Type': 'application/json',
"Authorization": "Bearer $token",
@ -205,7 +249,11 @@ class ApiService extends ChangeNotifier {
if (response.statusCode == 200) {
return decoded as Map<String, dynamic>;
}
throw Exception((decoded is Map && decoded['detail'] != null) ? decoded['detail'] : 'Failed to update profile');
throw Exception(
(decoded is Map && decoded['detail'] != null)
? decoded['detail']
: 'Failed to update profile',
);
}
Future<Map<String, dynamic>> getUserById(int userId) async {
@ -219,7 +267,8 @@ class ApiService extends ChangeNotifier {
);
if (response.statusCode == 200) {
return jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
return jsonDecode(utf8.decode(response.bodyBytes))
as Map<String, dynamic>;
}
throw Exception('Не удалось получить информацию о пользователе');
}
@ -261,7 +310,8 @@ class ApiService extends ChangeNotifier {
);
if (response.statusCode == 200) {
return jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
return jsonDecode(utf8.decode(response.bodyBytes))
as Map<String, dynamic>;
}
throw Exception('Не удалось получить настройки конфиденциальности');
}

View File

@ -1,3 +1,4 @@
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'dart:convert';
@ -97,7 +98,9 @@ class CryptoService {
String myPrivateKeyBase64,
String theirPublicKeyBase64,
) async {
final myKeyPair = await algorithm.newKeyPairFromSeed(base64Decode(myPrivateKeyBase64));
final myKeyPair = await algorithm.newKeyPairFromSeed(
base64Decode(myPrivateKeyBase64),
);
final theirPublicKey = SimplePublicKey(
base64Decode(theirPublicKeyBase64),
type: KeyPairType.x25519,
@ -119,10 +122,40 @@ class CryptoService {
// Сохраняем Nonce + MAC + 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 {
final data = base64Decode(base64Data);
final nonce = data.sublist(0, 12);
final mac = data.sublist(12, 28);
final cipherText = data.sublist(28);
@ -131,7 +164,7 @@ class CryptoService {
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
secretKey: sharedKey,
);
return utf8.decode(decrypted);
}

View File

@ -72,7 +72,11 @@ class AuthProvider extends ChangeNotifier {
final CryptoService _cryptoService = CryptoService();
Future<void> initRealtime() async {
await _socketService.connect(_apiService);
try {
await _socketService.connect(_apiService);
} catch (e) {
throw Exception(e);
}
}
void closeRealtime() {
@ -153,7 +157,7 @@ class AuthProvider extends ChangeNotifier {
if (token == null) return false;
// Загружаем currentUserId из хранилища
final userIdStr = await _storage.read(key: 'user_id');
/*final userIdStr = await _storage.read(key: 'user_id');
if (userIdStr != null) {
_currentUserId = int.tryParse(userIdStr);
}
@ -183,7 +187,8 @@ class AuthProvider extends ChangeNotifier {
} catch (e) {
// Если сервер недоступен, позволяем offline mode
return true;
}
}*/
return true;
}
Future<bool> updateProfileAndSecurity({
@ -241,18 +246,21 @@ class AuthProvider extends ChangeNotifier {
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes)) as Map;
_currentUserId = data['id'] as int?;
_username = data['username']?.toString();
_firstName = data['first_name']?.toString();
_lastName = data['last_name']?.toString();
_phone = data['phone']?.toString();
_email = data['email']?.toString();
_about = data['about']?.toString();
// Проверяем наличие публичного ключа на сервере
_hasPublicKeyOnServer = data['public_key'] != null && data['public_key'].isNotEmpty;
_hasPublicKeyOnServer =
data['public_key'] != null && data['public_key'].isNotEmpty;
// Проверяем наличие приватного ключа локально
final hasLocalPrivateKey = await _storage.read(key: 'private_key') != null;
final hasLocalPrivateKey =
await _storage.read(key: 'private_key') != null;
if (!_hasPublicKeyOnServer) {
// Путь А: Первая настройка - нужно создать ключи и профиль

View File

@ -82,8 +82,10 @@ class ContactProvider extends ChangeNotifier {
}
Future<void> _enrichContactsWithLastMessages() async {
print("Начинаем обогащать контакты последними сообщениями из локальной БД... Для текущего пользователя ID: $_currentUserId");
final myId = _currentUserId;
if (myId == null) return;
print("Текущий пользователь ID: $myId");
final myPrivKey = await _cryptoService.getPrivateKey();
@ -93,6 +95,7 @@ class ContactProvider extends ChangeNotifier {
final contact = updated[i];
// 1) Если сервер уже прислал lastMessage попробуем расшифровать превью.
print(contact.lastMessage);
if (contact.lastMessage != null &&
contact.lastMessage!.isNotEmpty &&
myPrivKey != null &&
@ -111,58 +114,6 @@ class ContactProvider extends ChangeNotifier {
// Если расшифровать не удалось оставляем как есть, дальше попробуем локальную БД.
}
}
// Если сервер уже отдал и сообщение, и время не трогаем (контакты уже обогащены).
final contactAfterServer = updated[i];
if (contactAfterServer.lastMessage != null &&
contactAfterServer.lastMessage!.isNotEmpty &&
contactAfterServer.publicKey == null) {
// Чтобы не показывать в списке контактов "ciphertext", если ключа нет.
updated[i] = contactAfterServer.copyWith(
lastMessage: 'Новое сообщение',
);
}
final contactAfterServer2 = updated[i];
if (contactAfterServer2.lastMessage != null &&
contactAfterServer2.lastMessageTime != null) {
continue;
}
final last = await _localDbService.getLastMessage(contact.id, myId);
if (last == null) continue;
final rawContent = last['content']?.toString();
final rawTimestamp = last['timestamp']?.toString();
final lastTime = rawTimestamp == null
? null
: DateTime.tryParse(rawTimestamp);
String? preview;
if (rawContent != null && rawContent.isNotEmpty) {
// Пытаемся расшифровать превью, если есть ключи.
try {
if (myPrivKey != null && contact.publicKey != null) {
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey,
contact.publicKey!,
);
preview = await _cryptoService.decryptMessage(
rawContent,
sharedSecret,
);
} else {
preview = 'Новое сообщение';
}
} catch (_) {
preview = 'Новое сообщение';
}
}
updated[i] = contactAfterServer2.copyWith(
lastMessage: preview,
lastMessageTime: contactAfterServer2.lastMessageTime ?? lastTime,
);
}
_contacts = updated;

View File

@ -16,9 +16,12 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'presentation/screens/splash_screen.dart';
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
// Глобальная переменная для отслеживания текущего активного контакта в чате
int? currentActiveChatContactId;
@ -28,9 +31,12 @@ RemoteMessage? initialMessage;
// Ключ для SharedPreferences
const String _notificationLaunchKey = 'notification_launch_data';
// Защита от повторной обработки одного и того же payload при следующих запусках по иконке
const String _lastHandledNotificationLaunchPayloadKey = 'notification_last_handled_payload';
const String _lastHandledNotificationLaunchPayloadKey =
'notification_last_handled_payload';
Future<void> _onSelectNotification(NotificationResponse notificationResponse) async {
Future<void> _onSelectNotification(
NotificationResponse notificationResponse,
) async {
final payload = notificationResponse.payload;
if (payload != null) {
try {
@ -47,12 +53,19 @@ Future<void> _onSelectNotification(NotificationResponse notificationResponse) as
// Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение
// будет снова автопереходить в чат.
if (context == null) {
final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey,
);
if (lastHandled != canonicalPayload) {
await prefs.setString(_notificationLaunchKey, canonicalPayload);
await prefs.setString(_lastHandledNotificationLaunchPayloadKey, canonicalPayload);
await prefs.setString(
_lastHandledNotificationLaunchPayloadKey,
canonicalPayload,
);
}
print('Navigator context is null, saved notification payload to SharedPreferences');
print(
'Navigator context is null, saved notification payload to SharedPreferences',
);
} else {
await prefs.remove(_notificationLaunchKey);
}
@ -60,7 +73,9 @@ Future<void> _onSelectNotification(NotificationResponse notificationResponse) as
// Navigate to chat with this contact (if context is ready)
_navigateToChat(senderId);
} else {
print('Notification payload has invalid sender_id: ${data['sender_id']}');
print(
'Notification payload has invalid sender_id: ${data['sender_id']}',
);
}
} catch (e) {
print('Error parsing notification payload: $e');
@ -72,8 +87,11 @@ void _navigateToChat(int senderId) {
print('Navigating to chat with senderId: $senderId');
final context = navigatorKey.currentContext;
if (context != null) {
final contactProvider = Provider.of<ContactProvider>(context, listen: false);
final contactProvider = Provider.of<ContactProvider>(
context,
listen: false,
);
// Check if contacts are loaded
if (contactProvider.contacts.isEmpty) {
print('Contacts not loaded yet, navigating to contacts screen first');
@ -86,7 +104,7 @@ void _navigateToChat(int senderId) {
);
return;
}
try {
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == senderId,
@ -96,18 +114,16 @@ void _navigateToChat(int senderId) {
currentActiveChatContactId = senderId; // Устанавливаем активный чат
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatScreen(contact: contact),
),
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
} catch (e) {
print('Contact with id $senderId not found, navigating to contacts screen');
print(
'Contact with id $senderId not found, navigating to contacts screen',
);
// Contact not found, go to contacts screen
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ContactsScreen(),
),
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
}
} else {
@ -132,11 +148,16 @@ void main() async {
print('Sender ID: ${initialMessage!.data['sender_id']}');
final payloadString = jsonEncode(initialMessage!.data);
final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey,
);
if (lastHandled != payloadString) {
// Сохраняем данные уведомления
await prefs.setString(_notificationLaunchKey, payloadString);
await prefs.setString(_lastHandledNotificationLaunchPayloadKey, payloadString);
await prefs.setString(
_lastHandledNotificationLaunchPayloadKey,
payloadString,
);
print('Saved notification data to SharedPreferences');
} else {
print('InitialMessage payload already handled earlier, skipping');
@ -148,25 +169,34 @@ void main() async {
}
// Initialize local notifications
const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher');
final InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid);
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
final InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
);
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: _onSelectNotification,
);
// Если приложение было запущено из локального уведомления, сохраним payload
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails();
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
final payload = notificationAppLaunchDetails?.notificationResponse?.payload;
print('App launched from local notification, payload: $payload');
if (payload != null && payload.isNotEmpty) {
try {
final lastHandled = prefs.getString(_lastHandledNotificationLaunchPayloadKey);
final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey,
);
if (lastHandled != payload) {
final data = jsonDecode(payload);
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
await prefs.setString(_lastHandledNotificationLaunchPayloadKey, payload);
await prefs.setString(
_lastHandledNotificationLaunchPayloadKey,
payload,
);
print('Saved local notification launch payload to SharedPreferences');
} else {
print('Local notification payload already handled earlier, skipping');
@ -185,7 +215,11 @@ void main() async {
importance: Importance.high,
);
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
@ -208,8 +242,10 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
if (message.data['type'] == 'enc_message') {
try {
// Initialize notifications for background
const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher');
const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid);
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const InitializationSettings initializationSettings =
InitializationSettings(android: initializationSettingsAndroid);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
// Create notification channel
@ -220,24 +256,35 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
importance: Importance.high,
);
await flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()?.createNotificationChannel(channel);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
// Try to decrypt
String notificationText = 'New encrypted message';
try {
// 1. Инициализируем крипто-сервис
final crypto = CryptoService();
// 2. Достаем ключи (они должны быть в SecureStorage)
final myPrivKey = await crypto.getPrivateKey();
print('Private key retrieved: ${myPrivKey != null}');
if (myPrivKey == null) {
print('Private key not found, showing encrypted message');
notificationText = 'Encrypted message: ${message.data['content']?.substring(0, 50) ?? 'N/A'}...';
notificationText =
'Encrypted message: ${message.data['content']?.substring(0, 50) ?? 'N/A'}...';
} else {
// 3. Расшифровываем
final sharedSecret = await crypto.deriveSharedSecret(myPrivKey, message.data['public_key']);
final decryptedText = await crypto.decryptMessage(message.data['content'], sharedSecret);
final sharedSecret = await crypto.deriveSharedSecret(
myPrivKey,
message.data['public_key'],
);
final decryptedText = await crypto.decryptMessage(
message.data['content'],
sharedSecret,
);
notificationText = decryptedText;
}
} catch (e) {
@ -250,11 +297,14 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
message.hashCode,
message.data['username'] ?? 'Unknown',
notificationText,
const NotificationDetails(android: AndroidNotificationDetails('chat_id', 'Messages')),
const NotificationDetails(
android: AndroidNotificationDetails('chat_id', 'Messages'),
),
payload: jsonEncode({
'type': 'enc_message',
'sender_id': message.data['sender_id'],
'timestamp': message.data['timestamp'] ?? DateTime.now().toIso8601String(),
'timestamp':
message.data['timestamp'] ?? DateTime.now().toIso8601String(),
}),
);
print('Notification shown successfully');
@ -317,6 +367,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
theme: themeProvider.themeData,
themeMode: themeProvider.themeMode,
navigatorKey: navigatorKey,
navigatorObservers: [routeObserver],
// Начальный экран
home: const SplashScreen(),

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '/data/models/message_model.dart';
import '/data/models/contact_model.dart';
@ -16,6 +17,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'contacts_screen.dart';
import 'package:flutter/services.dart';
import 'user_profile_screen.dart';
import 'package:image_picker/image_picker.dart';
class ChatScreen extends StatefulWidget {
final Contact contact;
@ -40,6 +42,7 @@ class _ChatScreenState extends State<ChatScreen> {
StreamSubscription<dynamic>? _socketSubscription;
final Set<int> _sentReadReceipts = <int>{};
final LocalDbService _localDbService = LocalDbService();
Uint8List? _pendingImageBytes;
MessageModel? _replyTo;
@override
@ -80,6 +83,13 @@ class _ChatScreenState extends State<ChatScreen> {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Не удалось получить ключ шифрования собеседника"),
behavior: SnackBarBehavior.floating, // Обязательно для margin
margin: EdgeInsets.only(
bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию)
left: 10.0,
right: 10.0,
),
duration: Duration(seconds: 3),
),
);
}
@ -101,9 +111,13 @@ class _ChatScreenState extends State<ChatScreen> {
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
}
},
),
title: GestureDetector(
@ -163,6 +177,15 @@ class _ChatScreenState extends State<ChatScreen> {
_inputFocusNode.requestFocus();
},
),
if (msg.isMe)
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Изменить'),
onTap: () {
Navigator.of(ctx).pop();
_editMessage(msg);
},
),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Скопировать'),
@ -171,7 +194,19 @@ class _ChatScreenState extends State<ChatScreen> {
await Clipboard.setData(ClipboardData(text: msg.text));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Скопировано')),
const SnackBar(
content: Text('Скопировано'),
behavior:
SnackBarBehavior.floating, // Обязательно для margin
margin: EdgeInsets.only(
bottom:
80.0 +
10.0, // 20px + стандартный отступ (по желанию)
left: 10.0,
right: 10.0,
),
duration: Duration(seconds: 2),
),
);
},
),
@ -180,32 +215,20 @@ class _ChatScreenState extends State<ChatScreen> {
title: const Text('Переслать'),
onTap: () {
Navigator.of(ctx).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Пересылка пока не реализована')),
);
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Удалить'),
textColor: Colors.red,
iconColor: Colors.red,
onTap: () async {
Navigator.of(ctx).pop();
setState(() {
messages.removeWhere(
(m) => (m.id != null && m.id == msg.id) || (m.tempId != null && m.tempId == msg.tempId),
);
});
final id = msg.id;
if (id != null) {
try {
await _localDbService.deleteMessage(id);
} catch (_) {}
}
_showForwardContactPicker(msg);
},
),
if (msg.isMe)
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Удалить'),
textColor: Colors.red,
iconColor: Colors.red,
onTap: () async {
Navigator.of(ctx).pop();
await _deleteMessage(msg);
},
),
const SizedBox(height: 8),
],
),
@ -214,6 +237,276 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
Future<void> _editMessage(MessageModel msg) async {
final controller = TextEditingController(text: msg.text);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Изменить сообщение'),
content: TextField(
controller: controller,
minLines: 1,
maxLines: 5,
autofocus: true,
decoration: const InputDecoration(hintText: 'Новый текст сообщения'),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Сохранить'),
),
],
),
);
if (result != true || controller.text.trim().isEmpty) return;
final newText = controller.text.trim();
final myPrivKey = await _cryptoService.getPrivateKey();
if (myPrivKey == null) return;
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey,
_currentContact.publicKey!,
);
final encryptedContent = await _cryptoService.encryptMessage(
newText,
sharedSecret,
);
final content50 = newText.length > 50 ? newText.substring(0, 50) : newText;
final encryptedContent50 = await _cryptoService.encryptMessage(
content50,
sharedSecret,
);
setState(() {
messages = messages.map((m) {
if (m.id != null && m.id == msg.id) {
return m.copyWith(text: newText, editedAt: DateTime.now());
}
return m;
}).toList();
});
if (msg.id != null) {
try {
await _localDbService.updateMessageContent(
msg.id!,
encryptedContent,
DateTime.now(),
);
} catch (_) {}
Provider.of<SocketService>(context, listen: false).sendMessage({
'type': 'edit_message',
'message_id': msg.id,
'content': encryptedContent,
'content50': encryptedContent50,
});
}
}
Future<void> _deleteMessage(MessageModel msg) async {
setState(() {
messages.removeWhere(
(m) =>
(m.id != null && m.id == msg.id) ||
(m.tempId != null && m.tempId == msg.tempId),
);
});
final id = msg.id;
if (id != null) {
try {
await _localDbService.deleteMessage(id);
} catch (_) {}
Provider.of<SocketService>(
context,
listen: false,
).sendMessage({'type': 'delete_message', 'message_id': id});
}
}
Future<void> _showForwardContactPicker(MessageModel msg) async {
final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(myId);
await contactProvider.loadAllContactsForNewChat();
if (!mounted) return;
final selectedContact = await showModalBottomSheet<Contact?>(
context: context,
isScrollControlled: true,
builder: (ctx) {
final provider = context.watch<ContactProvider>();
if (provider.isLoading) {
return const SizedBox(
height: 150,
child: Center(child: CircularProgressIndicator()),
);
}
if (provider.error != null) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text('Ошибка загрузки контактов: ${provider.error}'),
);
}
if (provider.allContacts.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Text('Нет доступных контактов для пересылки.'),
);
}
return SafeArea(
child: ListView.builder(
shrinkWrap: true,
itemCount: provider.allContacts.length,
itemBuilder: (ctx2, index) {
final contact = provider.allContacts[index];
return ListTile(
leading: CircleAvatar(
child: Text(contact.name.isNotEmpty ? contact.name[0] : '?'),
),
title: Text(contact.name),
subtitle: Text(contact.username),
onTap: () => Navigator.of(ctx).pop(contact),
);
},
),
);
},
);
if (selectedContact != null) {
await _forwardMessage(msg, selectedContact);
}
}
Future<void> _forwardMessage(MessageModel msg, Contact targetContact) async {
final forwardText = msg.text.trim();
if (forwardText.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Нечего пересылать.'),
behavior: SnackBarBehavior.floating, // Обязательно для margin
margin: EdgeInsets.only(
bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию)
left: 10.0,
right: 10.0,
),
duration: Duration(seconds: 5),
),
);
return;
}
if (targetContact.publicKey == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Публичный ключ контакта ${targetContact.name} не найден.',
),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
duration: Duration(seconds: 3),
),
);
return;
}
try {
final myPrivKey = await _cryptoService.getPrivateKey();
if (myPrivKey == null) {
throw Exception('Не найден приватный ключ.');
}
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey,
targetContact.publicKey!,
);
final encryptedContent = await _cryptoService.encryptMessage(
forwardText,
sharedSecret,
);
final previewText = forwardText.length > 50
? forwardText.substring(0, 50)
: forwardText;
final encryptedContent50 = await _cryptoService.encryptMessage(
previewText,
sharedSecret,
);
final tempId = DateTime.now().microsecondsSinceEpoch;
final localMessage = MessageModel(
tempId: tempId,
text: forwardText.isNotEmpty ? forwardText : "[Фото]",
isMe: true,
senderId: myId,
receiverId: targetContact.id,
createdAt: DateTime.now(),
status: MessageStatus.sending,
localFileBytes: _pendingImageBytes,
);
if (_currentContact.id == targetContact.id) {
setState(() {
messages.add(localMessage);
_pendingImageBytes = null;
});
}
final ok = Provider.of<SocketService>(context, listen: false)
.sendMessage({
'type': 'private_message',
'receiver_id': targetContact.id,
'message_type': 'text',
'content': encryptedContent,
'content50': encryptedContent50,
'temp_id': tempId,
});
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ok
? 'Сообщение переслано контакту ${targetContact.name}.'
: 'Не удалось переслать сообщение.',
),
behavior: SnackBarBehavior.floating, // Обязательно для margin
margin: EdgeInsets.only(
bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию)
left: 10.0,
right: 10.0,
),
duration: Duration(seconds: 3),
),
);
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx != -1) {
messages[idx] = messages[idx].copyWith(
status: ok ? MessageStatus.sent : MessageStatus.failed,
);
}
_replyTo = null;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка пересылки: $e'),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
duration: Duration(seconds: 5),
),
);
}
}
Widget _buildMessageInput() {
return SafeArea(
// Добавляем SafeArea здесь
@ -225,7 +518,10 @@ class _ChatScreenState extends State<ChatScreen> {
children: [
if (_replyTo != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
@ -249,9 +545,42 @@ class _ChatScreenState extends State<ChatScreen> {
],
),
),
if (_pendingImageBytes != null)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.memory(
_pendingImageBytes!,
fit: BoxFit.cover,
height: 120,
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () =>
setState(() => _pendingImageBytes = null),
),
],
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.photo),
onPressed: _pickImage,
),
Expanded(
child: TextField(
controller: _controller,
@ -259,6 +588,7 @@ class _ChatScreenState extends State<ChatScreen> {
minLines: 1,
maxLines: 5,
textInputAction: TextInputAction.newline,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: "Напиши сообщение...",
),
@ -278,84 +608,148 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
Future<void> _pickImage() async {
final ImagePicker _picker = ImagePicker();
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1280,
maxHeight: 1280,
imageQuality: 80,
);
if (image != null) {
final Uint8List fileBytes = await image.readAsBytes();
if (!mounted) return;
setState(() {
_pendingImageBytes = fileBytes;
});
}
}
Future<void> _sendMessage() async {
final rawText = _controller.text.trim();
if (rawText.isEmpty) return;
final hasImage = _pendingImageBytes != null;
// Если и текст пустой, и картинки нет выходим
if (rawText.isEmpty && !hasImage) return;
// Блокируем UI на время загрузки
_controller.clear();
if (_currentContact.publicKey == null) {
await _loadContactKey();
if (_currentContact.publicKey == null) return;
}
try {
// 1. Подготовка ключей
final myPrivKey = await _cryptoService.getPrivateKey();
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey!,
_currentContact.publicKey!,
);
final encryptedText = await _cryptoService.encryptMessage(
rawText,
String? fileId;
String? encryptedFileKey;
String encryptedContent;
String encryptedContent50;
// 2. Если есть изображение сначала загружаем его
if (hasImage) {
final encryptionResult = await _cryptoService.encryptImage(
_pendingImageBytes!,
sharedSecret,
);
if (encryptionResult == null) {
throw Exception("Ошибка шифрования медиа");
}
final encryptedFileData = encryptionResult.$1;
final fileKeyForServer = encryptionResult.$2;
fileId = await apiService.uploadMedia(encryptedFileData);
if (fileId == null) throw Exception("Ошибка загрузки файла на сервер");
encryptedFileKey = fileKeyForServer;
}
// 3. Шифруем текст сообщения (даже если там пусто, или есть подпись к фото)
// Если текста нет, но есть фото, отправим пустую строку или "[Фото]"
final String textToEncrypt = rawText.isNotEmpty
? rawText
: (hasImage ? "" : "");
encryptedContent = await _cryptoService.encryptMessage(
textToEncrypt,
sharedSecret,
);
final encryptedText50 = await _cryptoService.encryptMessage(
rawText.length > 50 ? rawText.substring(0, 50) : rawText,
String previewText = rawText.isNotEmpty ? rawText : "[Фото]";
if (previewText.length > 50) previewText = previewText.substring(0, 50);
encryptedContent50 = await _cryptoService.encryptMessage(
previewText,
sharedSecret,
);
// 4. Создаем локальную модель для мгновенного отображения
final tempId = DateTime.now().microsecondsSinceEpoch;
final localMessage = MessageModel(
tempId: tempId,
text: rawText,
text: rawText.isNotEmpty ? rawText : "[Фото]",
isMe: true,
senderId: myId,
receiverId: _currentContact.id,
createdAt: DateTime.now(),
status: MessageStatus.sending,
localFileBytes: _pendingImageBytes,
replyToId: _replyTo?.id,
replyToText: _replyTo?.text,
);
setState(() {
messages.add(localMessage);
_pendingImageBytes = null; // Очищаем черновик
});
// Формируем payload для сервера
// 5. Формируем финальный payload для сокета
final payload = {
"type": "private_message",
"receiver_id": _currentContact.id,
"content": encryptedText,
"content50": encryptedText50,
"message_type": hasImage ? "image" : "text",
"content": encryptedContent, // Шифрованный текст (подпись)
"content50": encryptedContent50, // Шифрованное превью
"temp_id": tempId,
if (hasImage) ...{
"file_id": fileId,
"encrypted_key": encryptedFileKey, // Зашифрованный AES-ключ файла
},
if (_replyTo?.id != null) ...{
"reply_to_id": _replyTo!.id,
"reply_to_text": _replyTo!.text,
},
};
// Отправляем
print("ОТПРАВКА: $payload");
final ok = Provider.of<SocketService>(context, listen: false).sendMessage(payload);
// 6. Отправка через сокет
final ok = Provider.of<SocketService>(
context,
listen: false,
).sendMessage(payload);
if (!mounted) return;
// Обновляем статус
setState(() {
final idx = messages.indexWhere((m) => m.tempId == tempId);
if (idx == -1) return;
messages[idx] = messages[idx].copyWith(
status: ok ? MessageStatus.sent : MessageStatus.failed,
);
if (idx != -1) {
messages[idx] = messages[idx].copyWith(
status: ok ? MessageStatus.sent : MessageStatus.failed,
);
}
_replyTo = null;
});
_controller.clear();
} catch (e) {
// В случае ошибки возвращаем текст в контроллер
_controller.text = rawText;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Ошибка шифрования: $e")));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Ошибка отправки: $e"),
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
duration: Duration(seconds: 5),
),
);
}
}
@ -425,6 +819,55 @@ class _ChatScreenState extends State<ChatScreen> {
return;
}
if (data['type'] == 'message_edited') {
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
final ts = DateTime.tryParse(data['edited_at']?.toString() ?? '');
if (messageId == null) return;
final myPrivKey = await _cryptoService.getPrivateKey();
if (myPrivKey == null) return;
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKey,
_currentContact.publicKey!,
);
final decryptedText = await _cryptoService.decryptMessage(
data['content'],
sharedSecret,
);
if (!mounted) return;
setState(() {
messages = messages.map((m) {
if (m.id != null && m.id == messageId) {
return m.copyWith(text: decryptedText, editedAt: ts);
}
return m;
}).toList();
});
try {
await _localDbService.updateMessageContent(
messageId,
data['content'].toString(),
ts,
);
} catch (_) {}
return;
}
if (data['type'] == 'message_deleted') {
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
if (messageId == null) return;
if (!mounted) return;
setState(() {
messages.removeWhere((m) => m.id != null && m.id == messageId);
});
try {
await _localDbService.deleteMessage(messageId);
} catch (_) {}
return;
}
if (data['type'] == 'message_read') {
final messageId = int.tryParse(data['message_id'].toString());
if (messageId == null) return;
@ -449,14 +892,19 @@ class _ChatScreenState extends State<ChatScreen> {
if (data['type'] == 'private_message') {
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
final receiverId = int.tryParse((data['receiver_id'] ?? data['recipient_id'])?.toString() ?? '');
final receiverId = int.tryParse(
(data['receiver_id'] ?? data['recipient_id'])?.toString() ?? '',
);
if (senderId == null || receiverId == null) {
print('Invalid private_message ids: sender_id=${data['sender_id']} receiver_id=${data['receiver_id'] ?? data['recipient_id']}');
print(
'Invalid private_message ids: sender_id=${data['sender_id']} receiver_id=${data['receiver_id'] ?? data['recipient_id']}',
);
return;
}
// 1. Проверяем, что сообщение именно от того, с кем мы сейчас общаемся
final isFromPartnerToMe = senderId == widget.contact.id && receiverId == myId;
final isFromPartnerToMe =
senderId == widget.contact.id && receiverId == myId;
if (isFromPartnerToMe) {
try {
final myPrivKey = await _cryptoService.getPrivateKey();
@ -478,8 +926,12 @@ class _ChatScreenState extends State<ChatScreen> {
if (!mounted) return;
final serverMessageId = int.tryParse(data['id']?.toString() ?? '');
if (serverMessageId != null && !_sentReadReceipts.contains(serverMessageId)) {
Provider.of<SocketService>(context, listen: false).sendReadReceipt(serverMessageId);
if (serverMessageId != null &&
!_sentReadReceipts.contains(serverMessageId)) {
Provider.of<SocketService>(
context,
listen: false,
).sendReadReceipt(serverMessageId);
_sentReadReceipts.add(serverMessageId);
}
@ -493,8 +945,13 @@ class _ChatScreenState extends State<ChatScreen> {
receiverId: myId,
createdAt: DateTime.parse(data['timestamp']),
status: MessageStatus.delivered,
replyToId: data['reply_to_id'] == null ? null : int.tryParse(data['reply_to_id'].toString()),
replyToText: data['reply_to_text'] == null ? null : data['reply_to_text'].toString(),
replyToId: data['reply_to_id'] == null
? null
: int.tryParse(data['reply_to_id'].toString()),
replyToText:
data['reply_to_text'] != null
? data['reply_to_text'].toString()
: null,
),
);
});
@ -520,7 +977,10 @@ class _ChatScreenState extends State<ChatScreen> {
myPrivKey!,
widget.contact.publicKey!,
);
final cached = await _localDbService.getChatHistory(widget.contact.id, myId);
final cached = await _localDbService.getChatHistory(
widget.contact.id,
myId,
);
try {
List<MessageModel> loadedLocalMessages = [];
@ -557,8 +1017,15 @@ class _ChatScreenState extends State<ChatScreen> {
receiverId: msg['receiver_id'],
createdAt: DateTime.parse(msg['timestamp']),
status: status,
replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()),
replyToText: msg['reply_to_text'] == null ? null : msg['reply_to_text'].toString(),
replyToId: msg['reply_to_id'] == null
? null
: int.tryParse(msg['reply_to_id'].toString()),
replyToText: msg['reply_to_text'] != null
? msg['reply_to_text'].toString()
: null,
editedAt: msg['edited_at'] != null
? DateTime.tryParse(msg['edited_at'].toString())
: null,
),
);
}
@ -618,8 +1085,15 @@ class _ChatScreenState extends State<ChatScreen> {
receiverId: msg['receiver_id'],
createdAt: DateTime.parse(msg['timestamp']),
status: status,
replyToId: msg['reply_to_id'] == null ? null : int.tryParse(msg['reply_to_id'].toString()),
replyToText: msg['reply_to_text'] == null ? null : msg['reply_to_text'].toString(),
replyToId: msg['reply_to_id'] == null
? null
: int.tryParse(msg['reply_to_id'].toString()),
replyToText: msg['reply_to_text'] != null
? msg['reply_to_text'].toString()
: null,
editedAt: msg['edited_at'] != null
? DateTime.tryParse(msg['edited_at'].toString())
: null,
),
);
}
@ -645,6 +1119,7 @@ class _ChatScreenState extends State<ChatScreen> {
Provider.of<SocketService>(context, listen: false).sendReadReceipt(id);
_sentReadReceipts.add(id);
}
await _localDbService.deleteChatHistory(widget.contact.id, myId);
} catch (e) {
print("Ошибка загрузки истории: $e");
if (!mounted) return;

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:chepuhagram/core/constants.dart';
import 'package:chepuhagram/domain/services/aPI_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -13,8 +14,14 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chepuhagram/main.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:open_filex/open_filex.dart';
class ContactsScreen extends StatefulWidget {
final int? targetChatId;
@ -25,9 +32,14 @@ class ContactsScreen extends StatefulWidget {
State<ContactsScreen> createState() => _ContactsScreenState();
}
class _ContactsScreenState extends State<ContactsScreen> {
class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
static const String _notificationLaunchKey = 'notification_launch_data';
StreamSubscription<dynamic>? _socketSubscription;
bool _isDownloading = false;
double _downloadProgress = 0.0;
CancelToken? _cancelToken = CancelToken();
String? _latestApkUrl;
bool _showUpdateBanner = false;
@override
void initState() {
@ -39,6 +51,9 @@ class _ContactsScreenState extends State<ContactsScreen> {
final contactProvider = context.read<ContactProvider>();
// Установить текущего пользователя и загрузить контакты с сообщениями
print(
'Setting current user ID in ContactProvider: ${authProvider.currentUserId}',
);
contactProvider.setCurrentUserId(authProvider.currentUserId);
contactProvider.loadContacts().then((_) {
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
@ -50,6 +65,35 @@ class _ContactsScreenState extends State<ContactsScreen> {
}
});
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAppUpdate();
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
}
@override
void didPopNext() {
print("Пользователь вернулся на этот экран!");
_refreshData();
}
@override
void dispose() {
routeObserver.unsubscribe(this);
_socketSubscription?.cancel();
super.dispose();
}
void _refreshData() {
print("Обновляем данные контактов и сообщений...");
final contactProvider = context.read<ContactProvider>();
contactProvider.loadContacts();
}
Future<void> _checkSavedNotificationTarget() async {
@ -89,7 +133,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
_navigateToTargetChatWithId(widget.targetChatId!);
}
void _navigateToTargetChatWithId(int targetChatId) {
void _navigateToTargetChatWithId(int targetChatId) async {
print('_navigateToTargetChat called with targetChatId: $targetChatId');
final contactProvider = context.read<ContactProvider>();
try {
@ -98,15 +142,45 @@ class _ContactsScreenState extends State<ContactsScreen> {
);
print('Auto-navigating to chat with contact: ${contact.username}');
currentActiveChatContactId = targetChatId; // Устанавливаем активный чат
Navigator.push(
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
if (result != null) {
_refreshData(); // Обновляем данные при возвращении с чата, если нужно
}
} catch (e) {
print('Target contact with id $targetChatId not found: $e');
}
}
Future<void> _checkAppUpdate() async {
print('Проверка обновлений');
PackageInfo packageInfo = await PackageInfo.fromPlatform();
try {
// 1. Запрос к вашему FastAPI
final response = await http.get(
Uri.parse('${AppConstants.baseUrl}/check-update'),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final String latestVersion = data['latest_version'];
print('444444');
print(latestVersion);
print(packageInfo.version);
// Сравнение версий (предположим, у вас есть способ получить текущую версию)
if (latestVersion != packageInfo.version) {
setState(() {
_showUpdateBanner = true;
_latestApkUrl = data['apk_url'];
});
}
}
} catch (e) {
print("Ошибка проверки обновлений: $e");
}
}
Future<void> _setupPushNotifications() async {
// Request permissions
await FirebaseMessaging.instance.requestPermission();
@ -151,7 +225,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
});
}
void _navigateToChatFromNotification(int senderId) {
void _navigateToChatFromNotification(int senderId) async {
final contactProvider = context.read<ContactProvider>();
print('Navigate to chat from notification with senderId: $senderId');
@ -173,10 +247,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
);
print('Navigating to chat from notification: ${contact.username}');
currentActiveChatContactId = senderId; // Устанавливаем активный чат
Navigator.push(
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
if (result != null) {
_refreshData(); // Обновляем данные при возвращении с чата, если нужно
}
} catch (e) {
// Contact not found, stay on contacts screen
print('Contact not found for notification: $senderId');
@ -249,14 +326,9 @@ class _ContactsScreenState extends State<ContactsScreen> {
}
}
@override
void dispose() {
_socketSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final double fabBottomPadding = _showUpdateBanner ? 120.0 : 16.0;
return Scaffold(
appBar: AppBar(
title: Text(
@ -267,46 +339,65 @@ class _ContactsScreenState extends State<ContactsScreen> {
elevation: 0,
actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})],
),
body: Consumer<ContactProvider>(
builder: (context, contactProvider, child) {
if (contactProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (contactProvider.error != null) {
return Center(child: Text('Error: ${contactProvider.error}'));
}
return ListView.separated(
itemCount: contactProvider.contacts.length,
separatorBuilder: (context, index) => Divider(
height: 1,
indent: 80,
color: Theme.of(context).colorScheme.primaryContainer,
),
itemBuilder: (context, index) {
final contact = contactProvider.contacts[index];
return ContactTile(
contact: contact,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatScreen(contact: contact),
),
body: Stack(
children: [
Consumer<ContactProvider>(
builder: (context, contactProvider, child) {
if (contactProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (contactProvider.error != null) {
return Center(child: Text('Error: ${contactProvider.error}'));
}
return ListView.separated(
itemCount: contactProvider.contacts.length,
separatorBuilder: (context, index) => Divider(
height: 1,
indent: 80,
color: Theme.of(context).colorScheme.primaryContainer,
),
itemBuilder: (context, index) {
final contact = contactProvider.contacts[index];
return ContactTile(
contact: contact,
onTap: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatScreen(contact: contact),
),
);
if (result != null) {
_refreshData(); // Обновляем данные при возвращении с чата, если нужно
}
},
);
},
);
},
);
},
),
if (_showUpdateBanner)
Positioned(
left: 0,
right: 0,
bottom: 40,
child: _buildUpdateBanner(),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const NewChatScreen()),
);
},
child: Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurface),
floatingActionButton: AnimatedPadding(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
padding: EdgeInsets.only(bottom: fabBottomPadding),
child: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const NewChatScreen()),
);
},
child: const Icon(Icons.edit),
),
),
drawer: Drawer(
child: ListView(
@ -327,9 +418,17 @@ class _ContactsScreenState extends State<ContactsScreen> {
.join();
return UserAccountsDrawerHeader(
accountName: Text(displayName),
accountName: Text(
displayName,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
accountEmail: Text(
username == null || username.isEmpty ? '' : '@$username',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
currentAccountPicture: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.onSurface,
@ -343,7 +442,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
),
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
color: Theme.of(context).colorScheme.inversePrimary,
),
);
},
@ -356,7 +455,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
MaterialPageRoute(builder: (_) => SettingsScreen()),
);
},
),
@ -372,4 +471,157 @@ class _ContactsScreenState extends State<ContactsScreen> {
),
);
}
Future<void> _startDownload() async {
if (_latestApkUrl == null) return;
// Показываем индикатор
setState(() => _isDownloading = true);
final dir = await getExternalStorageDirectory();
final path = '${dir!.path}/update.apk';
final file = File(path);
// Удаляем старый файл, если он есть, чтобы гарантировать чистоту
if (await file.exists()) {
await file.delete();
}
try {
// Скачиваем файл «в лоб»
await Dio().download(
_latestApkUrl!,
path,
cancelToken: _cancelToken,
onReceiveProgress: (rec, total) {
if (total != -1) {
if (mounted) {
setState(() => _downloadProgress = rec / total);
}
}
},
);
// После успешного скачивания установка
final result = await OpenFilex.open(path);
if (result.type != ResultType.done) {
print("Ошибка при установке: ${result.message}");
}
} on DioException catch (e) {
if (e.type != DioExceptionType.cancel) {
print("Ошибка скачивания: $e");
}
} catch (e) {
print("Ошибка: $e");
} finally {
if (mounted) {
setState(() => _isDownloading = false);
}
}
}
void _cancelDownload() {
_cancelToken?.cancel("Отменено");
setState(() {
_isDownloading = false;
_downloadProgress = 0.0;
});
}
Widget _buildUpdateBanner() {
return Container(
margin: const EdgeInsets.fromLTRB(
12,
0,
12,
16,
), // Отступы от краев и снизу
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.orange.shade600, Colors.deepOrange.shade400],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(
Icons.system_update_alt,
color: Colors.white,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_isDownloading
? 'Скачивание ${(_downloadProgress * 100).toStringAsFixed(0)}%'
: "Доступно новое обновление!",
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
TextButton(
onPressed: () async {
if (_isDownloading) {
// Если уже качаем отменяем
_cancelToken?.cancel("Пользователь отменил загрузку");
setState(() {
_isDownloading = false;
_cancelToken = null; // Обязательно обнуляем токен!
_downloadProgress = 0.0;
});
} else {
// Если не качаем запускаем
setState(() {
_isDownloading = true;
_cancelToken =
CancelToken(); // Создаем новый токен перед началом
});
// ВАЖНО: вызываем саму функцию скачивания
await _startDownload();
}
},
style: TextButton.styleFrom(
backgroundColor: Colors.white24,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
_isDownloading ? "Отмена" : "Обновить",
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
if (_isDownloading) ...[
const SizedBox(height: 12),
LinearProgressIndicator(
value: _downloadProgress,
color: Colors.white,
backgroundColor: Colors.white24,
),
],
],
),
),
),
);
}
}

View File

@ -70,7 +70,7 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
// Скачиваем зашифрованный приватный ключ с сервера
final response = await http.get(
Uri.http(AppConstants.baseUrl, 'users/me'),
Uri.parse('${AppConstants.baseUrl}/users/me'),
headers: {'Authorization': 'Bearer $token'},
);

View File

@ -5,10 +5,33 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '/logic/auth_provider.dart';
import '/core/theme_manager.dart';
import 'package:package_info_plus/package_info_plus.dart';
class SettingsScreen extends StatelessWidget {
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
String? versionCode;
@override
void initState() {
super.initState();
_loadVersion();
}
void _loadVersion() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
versionCode = packageInfo.version;
});
}
}
@override
Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>();
@ -17,8 +40,18 @@ class SettingsScreen extends StatelessWidget {
final accountEmail = authProv.email?.isNotEmpty == true
? authProv.email!
: authProv.username?.isNotEmpty == true
? '@${authProv.username!}'
: 'Не указано';
? '@${authProv.username!}'
: 'Не указано';
final username = authProv.username;
final displayName = authProv.displayName;
final initials = (displayName.isNotEmpty ? displayName : (username ?? 'U'))
.trim()
.split(RegExp(r'\s+'))
.where((p) => p.isNotEmpty)
.take(2)
.map((p) => p[0].toUpperCase())
.join();
return Scaffold(
appBar: AppBar(title: const Text("Настройки")),
@ -26,10 +59,23 @@ class SettingsScreen extends StatelessWidget {
children: [
// Секция Профиля
UserAccountsDrawerHeader(
accountName: Text(authProv.displayName),
accountEmail: Text(accountEmail),
currentAccountPicture: const CircleAvatar(
child: Icon(Icons.person, size: 40),
accountName: Text(
authProv.displayName,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
accountEmail: Text(
accountEmail,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
currentAccountPicture: CircleAvatar(
child: Text(
initials.isEmpty ? 'U' : initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
decoration: const BoxDecoration(color: Colors.transparent),
),
@ -82,7 +128,10 @@ class SettingsScreen extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Icon(Icons.palette_outlined),
Icon(
Icons.palette_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
SizedBox(width: 10),
const Text("Цвет темы"),
Spacer(),
@ -117,9 +166,9 @@ class SettingsScreen extends StatelessWidget {
},
),
const Spacer(),
const Center(
Center(
child: Text(
"Chepuhagram for Android v1.0.0",
"Chepuhagram for Android v$versionCode",
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
@ -129,7 +178,7 @@ class SettingsScreen extends StatelessWidget {
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
SizedBox(height: 10,)
const Spacer(),
],
),
);

View File

@ -1,5 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../../logic/auth_provider.dart';
import '../../logic/contact_provider.dart';
import 'login_screen.dart';
@ -21,7 +25,8 @@ class SplashScreen extends StatefulWidget {
class _SplashScreenState extends State<SplashScreen> {
int? _targetChatId;
String? connectError;
// Ключ для SharedPreferences
static const String _notificationLaunchKey = 'notification_launch_data';
@ -39,7 +44,9 @@ class _SplashScreenState extends State<SplashScreen> {
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
print('App opened from notification: ${message.data}');
if (message.data['type'] == 'enc_message') {
final senderId = int.tryParse(message.data['sender_id']?.toString() ?? '');
final senderId = int.tryParse(
message.data['sender_id']?.toString() ?? '',
);
if (senderId != null) {
setState(() {
_targetChatId = senderId;
@ -61,11 +68,37 @@ class _SplashScreenState extends State<SplashScreen> {
final isLoggedIn = await authProvider.tryAutoLogin();
if (!mounted) return;
bool connected = false;
int connectAttempt = 0;
// 3. Навигация в зависимости от результата и статуса аккаунта
if (isLoggedIn) {
await authProvider.initRealtime(); // Запускаем WebSocket сразу
while (!connected) {
try {
await authProvider.initRealtime();
connected = true;
} catch (e) {
connectAttempt++;
if (e.toString().contains('timeout')) {
setState(() {
connectError =
'Превышено время ожидания.\n Проверьте интернет соеденение.\n Попытка соеденения: $connectAttempt';
});
} else if (e.toString().contains('Failed host lookup')) {
setState(() {
connectError =
'Сервер недоступен. Проверьте интернет соеденение.\n Попытка соеденения: $connectAttempt';
});
} else {
setState(() {
connectError = e.toString().replaceAll('Exception: ', '');
});
}
await Future.delayed(Duration(seconds: 2));
}
}
await authProvider.refreshMe();
// Определяем путь пользователя
if (authProvider.needsSetup) {
// Путь А: Первичная настройка
@ -82,24 +115,29 @@ class _SplashScreenState extends State<SplashScreen> {
} else {
// Путь Б: Нормальный вход в контакты
// Проверяем, было ли приложение запущено из уведомления
int? targetChatId = _targetChatId; // Сначала проверяем из onMessageOpenedApp
int? targetChatId =
_targetChatId; // Сначала проверяем из onMessageOpenedApp
// Если не установлено, проверяем SharedPreferences
if (targetChatId == null) {
final prefs = await SharedPreferences.getInstance();
final savedData = prefs.getString(_notificationLaunchKey);
if (savedData != null) {
try {
final data = jsonDecode(savedData) as Map<String, dynamic>;
print('Found saved notification data: $data');
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
final senderId = int.tryParse(
data['sender_id']?.toString() ?? '',
);
final type = data['type']?.toString();
// Поддерживаем старый payload (только sender_id) и новый (type+sender_id)
if (senderId != null && (type == null || type == 'enc_message')) {
targetChatId = senderId;
print('App launched from saved notification, target chat: $targetChatId');
print(
'App launched from saved notification, target chat: $targetChatId',
);
}
// Очищаем сохраненные данные после использования
@ -109,17 +147,21 @@ class _SplashScreenState extends State<SplashScreen> {
await prefs.remove(_notificationLaunchKey);
}
}
// Также проверяем initialMessage как fallback
if (targetChatId == null) {
print('Checking initialMessage: $initialMessage');
if (initialMessage != null) {
print('Initial message data: ${initialMessage!.data}');
if (initialMessage!.data['type'] == 'enc_message') {
targetChatId = int.tryParse(initialMessage!.data['sender_id']?.toString() ?? '');
targetChatId = int.tryParse(
initialMessage!.data['sender_id']?.toString() ?? '',
);
print('Set target chat from initialMessage: $targetChatId');
} else {
print('Initial message type is not enc_message: ${initialMessage!.data['type']}');
print(
'Initial message type is not enc_message: ${initialMessage!.data['type']}',
);
}
} else {
print('No initial message found');
@ -130,29 +172,45 @@ class _SplashScreenState extends State<SplashScreen> {
}
if (targetChatId != null) {
print('Notification targetChatId resolved: $targetChatId, trying to open chat directly');
print(
'Notification targetChatId resolved: $targetChatId, trying to open chat directly',
);
try {
final contactProvider = context.read<ContactProvider>();
contactProvider.setCurrentUserId(authProvider.currentUserId);
await contactProvider.loadContacts();
final contact = contactProvider.contacts.firstWhere((c) => c.id == targetChatId);
final contact = contactProvider.contacts.firstWhere(
(c) => c.id == targetChatId,
);
currentActiveChatContactId = targetChatId;
print('Directly navigating to ChatScreen for contact: ${contact.username}');
print(
'Directly navigating to ChatScreen for contact: ${contact.username}',
);
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)),
);
return;
} catch (e) {
print('Failed to open chat directly, falling back to ContactsScreen: $e');
print(
'Failed to open chat directly, falling back to ContactsScreen: $e',
);
}
}
print('Navigating to ContactsScreen, targetChatId: $targetChatId');
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_notificationLaunchKey);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => ContactsScreen(targetChatId: targetChatId)),
MaterialPageRoute(
builder: (_) => ContactsScreen(targetChatId: targetChatId),
),
);
}
} else {
@ -193,6 +251,15 @@ class _SplashScreenState extends State<SplashScreen> {
CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 40),
Text(
connectError ?? '',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 14,
),
textAlign: TextAlign.center,
),
const Spacer(),
Text(
'Made by ArturKarasevich',
@ -201,7 +268,7 @@ class _SplashScreenState extends State<SplashScreen> {
fontSize: 12,
),
),
const SizedBox(height: 40),
const SizedBox(height: 80),
],
),
),

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '/data/models/contact_model.dart';
class ContactTile extends StatelessWidget {
@ -8,10 +7,26 @@ class ContactTile extends StatelessWidget {
const ContactTile({super.key, required this.contact, this.onTap});
String get displayName {
final full = '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim();
if (full.isNotEmpty) return full;
if ((contact.username != 'Unknown' ? contact.username : '').isNotEmpty) return contact.username!;
return 'User';
}
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
final username = contact.username;
final initials = (displayName.isNotEmpty ? displayName : (username != 'Unknown' ? username : 'U'))
.trim()
.split(RegExp(r'\s+'))
.where((p) => p.isNotEmpty)
.take(2)
.map((p) => p[0].toUpperCase())
.join();
return ListTile(
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
@ -19,8 +34,11 @@ class ContactTile extends StatelessWidget {
radius: 28,
backgroundColor: primary.withAlpha((0.1 * 255).round()),
child: Text(
contact.name[0],
style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)
initials,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
title: Text(
@ -31,7 +49,7 @@ class ContactTile extends StatelessWidget {
contact.lastMessage ?? "Нет сообщений",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.textSecondary),
style: const TextStyle(color: Colors.grey),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -39,13 +57,19 @@ class ContactTile extends StatelessWidget {
children: [
Text(
_formatTime(contact.lastMessageTime),
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
const SizedBox(height: 4),
if (contact.unreadCount > 0)
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(color: primary.withAlpha((0.5 * 255).round()), shape: BoxShape.circle),
decoration: BoxDecoration(
color: primary.withAlpha((0.5 * 255).round()),
shape: BoxShape.circle,
),
child: Text(
'${contact.unreadCount}',
style: const TextStyle(color: Colors.white, fontSize: 10),
@ -60,4 +84,4 @@ class ContactTile extends StatelessWidget {
if (time == null) return "";
return "${time.hour}:${time.minute.toString().padLeft(2, '0')}";
}
}
}

View File

@ -1,5 +1,9 @@
import 'package:flutter/material.dart';
import '/data/models/message_model.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:provider/provider.dart';
import '/core/theme_manager.dart';
class MessageBubble extends StatelessWidget {
final MessageModel message;
@ -14,6 +18,7 @@ class MessageBubble extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isMe = message.isMe;
final themeProv = context.watch<ThemeProvider>();
return Align(
// Выравниваем вправо, если это мое сообщение, и влево если чужое
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
@ -39,7 +44,7 @@ class MessageBubble extends StatelessWidget {
),
decoration: BoxDecoration(
color: isMe
? Theme.of(context).colorScheme.primary
? Theme.of(context).colorScheme.primaryFixedDim
: Colors.grey[300],
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
@ -61,7 +66,7 @@ class MessageBubble extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
border: Border(
left: BorderSide(
color: isMe ? Colors.white70 : Colors.black38,
color: isMe ? Colors.black54 : Colors.black38,
width: 2,
),
),
@ -72,7 +77,7 @@ class MessageBubble extends StatelessWidget {
Icon(
Icons.reply,
size: 14,
color: isMe ? Colors.white70 : Colors.black54,
color: isMe ? Colors.black54 : Colors.black54,
),
const SizedBox(width: 4),
Expanded(
@ -81,7 +86,7 @@ class MessageBubble extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isMe ? Colors.white70 : Colors.black54,
color: isMe ? const Color.fromARGB(221, 21, 21, 21) : const Color.fromARGB(221, 21, 21, 21),
fontSize: 12,
fontStyle: FontStyle.italic,
),
@ -91,12 +96,16 @@ class MessageBubble extends StatelessWidget {
),
),
],
Text(
message.text,
style: TextStyle(
color: isMe ? Colors.white : Colors.black87,
fontSize: 16,
),
Linkify(
onOpen: (link) async {
final Uri url = Uri.parse(link.url);
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
throw Exception('Could not launch $url');
}
},
text: message.text,
style: TextStyle(color: isMe ? (themeProv.isLight ? Colors.black : Colors.black) : Colors.black),
linkStyle: TextStyle(color: const Color.fromARGB(255, 10, 87, 123), fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Row(
@ -105,10 +114,21 @@ class MessageBubble extends StatelessWidget {
Text(
_formatTime(message.createdAt),
style: TextStyle(
color: isMe ? Colors.white70 : Colors.black54,
color: isMe ? Colors.black87 : Colors.black54,
fontSize: 10,
),
),
if (message.editedAt != null) ...[
const SizedBox(width: 6),
Text(
'(изменено)',
style: TextStyle(
color: isMe ? Colors.black54 : Colors.black54,
fontSize: 10,
fontStyle: FontStyle.italic,
),
),
],
if (isMe) ...[
const SizedBox(width: 6),
Icon(

View File

@ -6,10 +6,18 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View File

@ -3,7 +3,9 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@ -5,24 +5,32 @@
import FlutterMacOS
import Foundation
import file_selector_macos
import firebase_analytics
import firebase_core
import firebase_messaging
import flutter_image_compress_macos
import flutter_local_notifications
import flutter_secure_storage_darwin
import local_auth_darwin
import package_info_plus
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@ -57,6 +57,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@ -89,6 +97,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.12"
dio:
dependency: "direct main"
description:
name: dio
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.dev"
source: hosted
version: "5.9.2"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
fake_async:
dependency: transitive
description:
@ -113,6 +137,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
firebase_analytics:
dependency: "direct main"
description:
@ -190,6 +246,62 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_image_compress:
dependency: "direct main"
description:
name: flutter_image_compress
sha256: "51d23be39efc2185e72e290042a0da41aed70b14ef97db362a6b5368d0523b27"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
flutter_image_compress_common:
dependency: transitive
description:
name: flutter_image_compress_common
sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb
url: "https://pub.dev"
source: hosted
version: "1.0.6"
flutter_image_compress_macos:
dependency: transitive
description:
name: flutter_image_compress_macos
sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
flutter_image_compress_ohos:
dependency: transitive
description:
name: flutter_image_compress_ohos
sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51
url: "https://pub.dev"
source: hosted
version: "0.0.3"
flutter_image_compress_platform_interface:
dependency: transitive
description:
name: flutter_image_compress_platform_interface
sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
flutter_image_compress_web:
dependency: transitive
description:
name: flutter_image_compress_web
sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96
url: "https://pub.dev"
source: hosted
version: "0.1.5"
flutter_linkify:
dependency: "direct main"
description:
name: flutter_linkify
sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_lints:
dependency: "direct dev"
description:
@ -304,6 +416,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622"
url: "https://pub.dev"
source: hosted
version: "0.8.13+16"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
intl:
dependency: transitive
description:
@ -368,6 +544,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
linkify:
dependency: transitive
description:
name: linkify
sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
lints:
dependency: transitive
description:
@ -440,6 +624,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nested:
dependency: transitive
description:
@ -448,6 +640,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
open_filex:
dependency: "direct main"
description:
name: open_filex
sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900"
url: "https://pub.dev"
source: hosted
version: "4.7.0"
package_config:
dependency: transitive
description:
@ -456,6 +656,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev"
source: hosted
version: "9.0.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: "direct main"
description:
@ -717,6 +933,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev"
source: hosted
version: "2.4.2"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_math:
dependency: transitive
description:

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
version: 2.0.0+1
environment:
sdk: ^3.10.0
@ -48,6 +48,13 @@ dependencies:
flutter_local_notifications: ^17.2.2
firebase_analytics: ^10.10.7
shared_preferences: ^2.5.5
flutter_linkify: ^6.0.0
url_launcher: ^6.3.2
image_picker: ^1.0.4
flutter_image_compress: ^2.1.0
dio: ^5.9.2
package_info_plus: ^9.0.1
open_filex: ^4.3.2
dev_dependencies:
flutter_test:

View File

@ -26,7 +26,11 @@ authRouter = APIRouter(
@authRouter.post("/register")
async def register(username: str, password: str, db: Session = Depends(get_db)):
async def register(username: str, password: str, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
if current_user.id != 1:
raise HTTPException(
status_code=403, detail='Forbidden'
)
if len(password.encode('utf-8')) > 72:
raise HTTPException(
status_code=400, detail="Пароль слишком длинный (макс. 72 байта)")
@ -41,7 +45,7 @@ async def register(username: str, password: str, db: Session = Depends(get_db)):
new_user = models.User(username=username, hashed_password=hashed_pwd)
db.add(new_user)
db.commit()
return {"status": "ok", "message": "User created"}
return {"status": "ok", "message": "User created", "id": new_user.id}
@authRouter.post("/hash")
@ -106,10 +110,11 @@ async def setup_account(data: schemas.SetupAccount, current_user: models.User =
db.refresh(user_to_update)
return {"status": "ok", "message": "Account setup completed"}
@authRouter.post("/update-fcm")
async def update_fcm(token: str, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
user_to_update = db.merge(current_user)
user_to_update.fcm_token = token
db.commit()
db.refresh(user_to_update)
return {"status": "ok"}
return {"status": "ok"}

View File

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

View File

@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from app.db import models
from app.core.security import get_current_user
from app.api import schemas
from sqlalchemy import or_, and_
from sqlalchemy import or_, and_, exists
from sqlalchemy.exc import IntegrityError
@ -47,7 +47,7 @@ async def update_users_me(
db: Session = Depends(get_db),
):
user_to_update = db.merge(current_user)
if data.username is not None:
user_to_update.username = data.username
if data.first_name is not None:
@ -65,7 +65,8 @@ async def update_users_me(
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="phone/email already in use")
raise HTTPException(
status_code=400, detail="phone/email already in use")
db.refresh(user_to_update)
return {
@ -95,7 +96,8 @@ async def update_encrypted_private_key(
db.commit()
except Exception:
db.rollback()
raise HTTPException(status_code=500, detail="Не удалось сохранить ключ шифрования")
raise HTTPException(
status_code=500, detail="Не удалось сохранить ключ шифрования")
db.refresh(user_to_update)
return {"status": "ok"}
@ -119,7 +121,8 @@ async def change_password(
db.commit()
except Exception:
db.rollback()
raise HTTPException(status_code=500, detail="Не удалось изменить пароль")
raise HTTPException(
status_code=500, detail="Не удалось изменить пароль")
db.refresh(user_to_update)
return {"status": "ok"}
@ -132,7 +135,7 @@ async def update_privacy_settings(
db: Session = Depends(get_db),
):
user_to_update = db.merge(current_user)
if data.show_email is not None:
user_to_update.show_email = 1 if data.show_email else 0
if data.show_phone is not None:
@ -148,7 +151,8 @@ async def update_privacy_settings(
db.commit()
except Exception:
db.rollback()
raise HTTPException(status_code=500, detail="Не удалось сохранить настройки конфиденциальности")
raise HTTPException(
status_code=500, detail="Не удалось сохранить настройки конфиденциальности")
db.refresh(user_to_update)
return {"status": "ok"}
@ -185,9 +189,19 @@ async def read_users_chats(
last_message возвращается в том виде, как хранится в БД (зашифрованный content).
Клиент должен расшифровать превью локально.
"""
users = (
db.query(models.User)
.filter(models.User.id != current_user.id)
.filter(exists().where(
or_(
and_(models.Message.sender_id == current_user.id,
models.Message.receiver_id == models.User.id),
and_(models.Message.sender_id == models.User.id,
models.Message.receiver_id == current_user.id)
)
))
.all()
)
@ -233,6 +247,7 @@ async def read_users_chats(
}
)
result.sort(key=lambda x: x['last_message_time'] or '', reverse=True)
return result
@ -259,7 +274,7 @@ def get_user_by_id(
# Проверяем настройки конфиденциальности
if user.show_username:
profile_data["username"] = user.username
if user.show_avatar:
# Для аватара пока просто передаем имя, клиент сам сгенерирует аватар
profile_data["first_name"] = user.first_name

View File

@ -2,21 +2,19 @@ from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from datetime import datetime, timedelta
from jose import jwt
from passlib.context import CryptContext
import hashlib
from sqlalchemy.orm import Session
from app.db import models
from dotenv import load_dotenv
from jose import JWTError, jwt
import os
import bcrypt
load_dotenv()
SECRET_KEY = os.getenv("JWT_KEY").strip()
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
# бд
@ -28,10 +26,13 @@ def get_db():
db.close()
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
try:
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password)
except TypeError:
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
def get_password_hash(password):
return pwd_context.hash(password)
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
def create_access_token(data: dict):
to_encode = data.copy()

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy import Column, Integer, String, Sequence, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime
@ -13,7 +13,7 @@ Base.metadata.create_all(bind=engine)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, Sequence('user_id_seq', start=100), primary_key=True, index=True)
first_name = Column(String(50), nullable=False, server_default="User")
last_name = Column(String(50), nullable=True)
username = Column(String, unique=True, index=True)
@ -44,6 +44,7 @@ class Message(Base):
read_at = Column(DateTime(timezone=True), nullable=True)
reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True)
reply_to_text = Column(Text, nullable=True)
edited_at = Column(DateTime(timezone=True), nullable=True)
Base.metadata.create_all(bind=engine)
@ -63,6 +64,8 @@ def _ensure_sqlite_message_columns():
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_id INTEGER REFERENCES messages(id)"))
if "reply_to_text" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT"))
if "edited_at" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN edited_at DATETIME"))
conn.commit()

View File

@ -133,6 +133,76 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
except Exception:
db.rollback()
elif message_data.get("type") == "edit_message":
message_id = message_data.get("message_id")
content = message_data.get("content")
if message_id is None or content is None:
await websocket.send_json({
"type": "error",
"detail": "message_id/content required",
})
continue
try:
message_id = int(message_id)
except (TypeError, ValueError):
await websocket.send_json({
"type": "error",
"detail": "message_id must be int",
})
continue
msg = db.query(models.Message).filter(models.Message.id == message_id).first()
if msg is None or msg.sender_id != user_id:
continue
try:
msg.content = content
msg.edited_at = datetime.now()
db.add(msg)
db.commit()
except Exception:
db.rollback()
continue
event = {
"type": "message_edited",
"message_id": msg.id,
"content": msg.content,
"edited_at": msg.edited_at.isoformat() if msg.edited_at else None,
}
await manager.send_personal_message(event, str(msg.receiver_id))
await manager.send_personal_message(event, str(msg.sender_id))
elif message_data.get("type") == "delete_message":
message_id = message_data.get("message_id")
if message_id is None:
await websocket.send_json({
"type": "error",
"detail": "message_id required",
})
continue
try:
message_id = int(message_id)
except (TypeError, ValueError):
await websocket.send_json({
"type": "error",
"detail": "message_id must be int",
})
continue
msg = db.query(models.Message).filter(models.Message.id == message_id).first()
if msg is None or msg.sender_id != user_id:
continue
receiver_id = msg.receiver_id
try:
db.delete(msg)
db.commit()
except Exception:
db.rollback()
continue
event = {
"type": "message_deleted",
"message_id": message_id,
}
await manager.send_personal_message(event, str(receiver_id))
await manager.send_personal_message(event, str(user_id))
elif message_data.get("type") == "read_receipt":
message_id = message_data.get("message_id")
try:

View File

@ -1,13 +1,16 @@
from fastapi import FastAPI
from app.api.endpoints import users, auth, messages
from fastapi.responses import FileResponse
from app.api.endpoints import users, auth, messages, media
from app.websocket.connection_manager import wsRouter
from fastapi.middleware.cors import CORSMiddleware
import os
app = FastAPI()
app.include_router(auth.authRouter)
app.include_router(users.usersRouter)
app.include_router(messages.messagesRouter)
app.include_router(media.mediaRouter)
app.include_router(wsRouter)
app.add_middleware(
@ -18,6 +21,38 @@ app.add_middleware(
allow_headers=["*"],
)
@app.get("/check-update")
async def check_update():
return {
"latest_version": "2.0.0",
"apk_url": "https://api.chepuhagram.ru/get-update",
"force_update": False
}
@app.get("/get-update")
async def get_image():
file_path = "app-release.apk"
if not os.path.exists(file_path):
return {"error": "Файл не найден"}
return FileResponse(path=file_path, filename="chepuhagram-release.apk",
media_type="application/vnd.android.package-archive",)
@app.head("/get-update")
async def head_image():
file_path = "app-release.apk"
if not os.path.exists(file_path):
return {"error": "Файл не найден"}
return FileResponse(
path=file_path,
filename="chepuhagram-release.apk",
media_type="application/vnd.android.package-archive"
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8587)
uvicorn.run(app, host="0.0.0.0", port=8587)

View File

@ -6,15 +6,21 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_auth_windows/local_auth_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@ -3,9 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_core
flutter_secure_storage_windows
local_auth_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST