Медиа

This commit is contained in:
Artur 2026-05-22 18:18:55 +05:00
parent 51b11d9290
commit 4b306f3cee
68 changed files with 7693 additions and 1246 deletions

View File

@ -50,3 +50,12 @@ dependencies {
implementation(platform("com.google.firebase:firebase-bom:34.12.0"))
implementation("com.google.firebase:firebase-messaging")
}
configurations.all {
resolutionStrategy.eachDependency {
if (requested.group == "com.arthenica" && requested.name.startsWith("ffmpeg-kit")) {
useVersion("6.0.3")
because("Фикс падения сборки на версии 6.0.3+2-LTS")
}
}
}

View File

@ -4,14 +4,16 @@
<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" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<application
android:label="Chepuhagram"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:icon="@mipmap/launcher_icon"
android:usesCleartextTraffic="true"
android:enableOnBackInvokedCallback="true">
<activity
@ -51,6 +53,15 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="internal_files" path="." />
<cache-path name="internal_cache" path="." />
<external-path name="external_files" path="." />
</paths>

View File

@ -1,7 +1,17 @@
allprojects {
repositories {
google()
mavenCentral()
google()
maven { url = uri("https://jitpack.io") }
maven { url = uri("https://storage.googleapis.com/download.flutter.io") }
}
}
buildscript {
repositories {
mavenCentral()
google()
maven { url = uri("https://plugins.gradle.org/m2/") }
}
}

View File

@ -1,2 +1,8 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
kotlin.incremental=false
kotlin.incremental.useGradleBuilder=false

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -427,7 +427,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@ -484,7 +484,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

View File

@ -1,122 +1 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -1,7 +1,7 @@
import 'package:sqflite/sqflite.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:chepuhagram/data/models/message_model.dart';
import 'dart:typed_data';
class LocalDbService {
static final LocalDbService _instance = LocalDbService._internal();
@ -10,37 +10,51 @@ class LocalDbService {
factory LocalDbService() => _instance;
LocalDbService._internal();
static const int _dbVersion = 8;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDb();
return _database!;
}
Future<void> _createMessagesTable(Database db) async {
await db.execute('''
CREATE TABLE messages(
id INTEGER PRIMARY KEY,
sender_id INTEGER,
receiver_id INTEGER,
content TEXT,
timestamp TEXT,
delivered_at TEXT,
read_at TEXT,
reply_to_id INTEGER,
reply_to_text TEXT,
edited_at TEXT,
message_type TEXT DEFAULT 'text',
file_id TEXT,
encrypted_key TEXT,
file_name TEXT,
file_size INTEGER
)
''');
}
Future<Database> _initDb() async {
String path = join(await getDatabasesPath(), 'chat_app.db');
return await openDatabase(
path,
version: 7,
version: _dbVersion,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE messages(
id INTEGER PRIMARY KEY,
sender_id INTEGER,
receiver_id INTEGER,
content TEXT,
timestamp TEXT,
delivered_at TEXT,
read_at TEXT,
reply_to_id INTEGER,
reply_to_text TEXT,
edited_at TEXT,
message_type TEXT DEFAULT 'text',
file_id TEXT,
encrypted_key TEXT
)
''');
await _createMessagesTable(db);
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 8) {
// v8: stop storing media bytes in SQLite; rebuild messages table.
await db.execute('DROP TABLE IF EXISTS messages');
await _createMessagesTable(db);
return;
}
if (oldVersion < 2) {
await db.execute('ALTER TABLE messages ADD COLUMN delivered_at TEXT');
await db.execute('ALTER TABLE messages ADD COLUMN read_at TEXT');
@ -79,15 +93,7 @@ class LocalDbService {
print('encrypted_key column already exists: $e');
}
}
if (oldVersion < 7) {
try {
await db.execute(
'ALTER TABLE messages ADD COLUMN local_file_bytes BLOB',
);
} catch (e) {
print('local_file_bytes column already exists: $e');
}
}
// old migrations kept for safety, but v8 rebuild returns early.
},
);
}
@ -121,12 +127,11 @@ class LocalDbService {
'reply_to_id': msg.replyToId,
'reply_to_text': msg.replyToText,
'edited_at': msg.editedAt?.toIso8601String(),
'message_type': msg.messageType == MessageType.image
? 'image'
: 'text',
'message_type': msg.messageType.name,
'file_id': msg.fileId,
'encrypted_key': msg.encryptedFileKey,
'local_file_bytes': msg.localFileBytes,
'file_name': msg.fileName,
'file_size': msg.fileSize,
}, conflictAlgorithm: ConflictAlgorithm.replace);
} else {
// Если это Map из API
@ -145,6 +150,8 @@ class LocalDbService {
'message_type': msg['message_type'] ?? 'text',
'file_id': msg['file_id'],
'encrypted_key': msg['encrypted_key'],
'file_name': msg['file_name'],
'file_size': msg['file_size'],
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
}
@ -211,19 +218,6 @@ class LocalDbService {
);
}
Future<void> updateMessageLocalFileBytes(
int messageId,
Uint8List localFileBytes,
) async {
final db = await database;
await db.update(
'messages',
{'local_file_bytes': localFileBytes},
where: 'id = ?',
whereArgs: [messageId],
);
}
Future<void> updateMessageContent(
int messageId,
String content,

View File

@ -8,7 +8,6 @@ import 'package:chepuhagram/core/constants.dart';
import 'package:flutter/widgets.dart';
class SocketService with WidgetsBindingObserver {
static final SocketService _instance = SocketService._internal();
factory SocketService() => _instance;
@ -24,7 +23,7 @@ class SocketService with WidgetsBindingObserver {
Stream<Map<String, dynamic>> get messages => _messageController.stream;
bool allowConnect = true; // Флаг для контроля подключения
Timer? _connectTimer;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
@ -36,26 +35,39 @@ class SocketService with WidgetsBindingObserver {
}
}
Future<void> startConnect(ApiService apiService) async {
if (_connectTimer != null && _connectTimer!.isActive)
return; // Уже запущено
_connectTimer = Timer.periodic(const Duration(seconds: 15), (_) {
connect(apiService);
});
}
Future<void> connect(ApiService apiService) async {
final token = await apiService.getAccessToken();
if (_channel != null) return; // Уже подключены
if (token == null || token.isEmpty) {
print('❌ SocketService.connect: no access token, skipping connect');
return;
throw Exception('Нет токена доступа. Пожалуйста, войдите в систему.');
}
if (!allowConnect) return; // Не разрешаем подключение
// В FastAPI эндпоинт ожидает токен в URL-параметре
final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token");
// print("✅ Токен получен, устанавливаем WebSocket соединение...");
//_channel = WebSocketChannel.connect(uri);
_channel = IOWebSocketChannel.connect(
uri,
connectTimeout: Duration(seconds: 10),
);
startConnect(
apiService,
); // Запускаем таймер на случай, если соединение не установится
try {
// В FastAPI эндпоинт ожидает токен в URL-параметре
final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token");
//_channel = WebSocketChannel.connect(uri);
_channel = IOWebSocketChannel.connect(
uri,
connectTimeout: Duration(seconds: 10),
);
await _channel!.ready;
_channel!.stream.listen(
(data) {
@ -68,10 +80,10 @@ class SocketService with WidgetsBindingObserver {
);
} on TimeoutException catch (_) {
_channel = null;
throw Exception('timeout');
throw Exception('Превышено время ожидания. Пожалуйста, попробуйте позже.');
} catch (e) {
_channel = null;
throw Exception("Ошибка подключения: $e");
throw Exception('Ошибка при подключении к WebSocket: $e');
}
}
@ -85,7 +97,10 @@ class SocketService with WidgetsBindingObserver {
if (_channel == null) {
if (retryCnt < maxRetries) {
// Schedule retry with exponential backoff
Future.delayed(Duration(seconds: 1 << retryCnt), () => sendMessage(data, retryCnt: retryCnt + 1));
Future.delayed(
Duration(seconds: 1 << retryCnt),
() => sendMessage(data, retryCnt: retryCnt + 1),
);
}
return false;
}
@ -111,5 +126,7 @@ class SocketService with WidgetsBindingObserver {
void disconnect() {
_channel?.sink.close(status.normalClosure);
_channel = null;
_connectTimer?.cancel();
_connectTimer = null;
}
}

View File

@ -1,4 +1,5 @@
import '/core/constants.dart';
import 'package:chepuhagram/data/models/message_model.dart';
class Contact {
final int id;
@ -11,10 +12,17 @@ class Contact {
final DateTime? lastMessageTime;
final bool isOnline;
final int unreadCount;
final String? publicKey;
String? publicKey;
final bool isLastMsgDecrypted;
final int? lastMessageId;
final MessageType? lastMessageType;
String? get effectiveAvatarUrl => avatarUrl ?? (avatarFileId != null ? '${AppConstants.baseUrl}/media/$avatarFileId' : null);
String? get effectiveAvatarUrl {
if (avatarFileId != null && avatarFileId!.isNotEmpty) {
return '${AppConstants.baseUrl}/media/$avatarFileId';
}
return avatarUrl;
}
Contact({
required this.id,
@ -29,6 +37,8 @@ class Contact {
this.unreadCount = 0,
this.publicKey,
this.isLastMsgDecrypted = false,
this.lastMessageId,
this.lastMessageType,
});
Contact copyWith({
@ -44,6 +54,8 @@ class Contact {
int? unreadCount,
String? publicKey,
bool? isLastMsgDecrypted,
int? lastMessageId,
MessageType? lastMessageType,
}) {
return Contact(
id: id ?? this.id,
@ -51,13 +63,15 @@ class Contact {
name: name ?? this.name,
surname: surname ?? this.surname,
lastMessage: lastMessage ?? this.lastMessage,
avatarFileId: avatarFileId,
avatarUrl: avatarUrl,
avatarFileId: avatarFileId ?? this.avatarFileId,
avatarUrl: avatarUrl ?? this.avatarUrl,
lastMessageTime: lastMessageTime ?? this.lastMessageTime,
isOnline: isOnline ?? this.isOnline,
unreadCount: unreadCount ?? this.unreadCount,
publicKey: publicKey ?? this.publicKey,
isLastMsgDecrypted: isLastMsgDecrypted ?? this.isLastMsgDecrypted,
lastMessageId: lastMessageId ?? this.lastMessageId,
lastMessageType: lastMessageType ?? this.lastMessageType,
);
}
@ -78,10 +92,12 @@ class Contact {
avatarFileId: json['avatar_file_id'] ?? json['avatarFileId'],
avatarUrl: json['avatar_url'] ?? json['avatarUrl'],
lastMessageTime: parseTime(json['last_message_time'] ?? json['lastMessageTime']),
isOnline: (json['is_online'] ?? json['isOnline']) == true,
isOnline: (json['online'] ?? json['Online']) == true,
unreadCount: int.tryParse((json['unread_count'] ?? json['unreadCount'] ?? 0).toString()) ?? 0,
publicKey: json['public_key'],
isLastMsgDecrypted: json['is_last_msg_decrypted'] ?? false,
lastMessageId: int.tryParse((json['last_message_id'] ?? json['lastMessageId'] ?? 0).toString()) ?? 0,
lastMessageType: MessageModel.parseMessageType(json['last_message_type'] ?? json['lastMessageType'] ?? 'text'),
);
}
}

Binary file not shown.

View File

@ -1,8 +1,9 @@
import 'dart:typed_data';
import 'dart:io';
enum MessageStatus { sending, sent, delivered, read, failed }
enum MessageStatus { encrypting, sending, sent, delivered, read, failed }
enum MessageType { text, image }
enum MessageType { text, image, video, file, videoNote, voiceNote }
class MessageModel {
final int? id; // server id (null пока не подтверждено сервером)
@ -16,10 +17,12 @@ class MessageModel {
final int? replyToId; // ID сообщения, на которое отвечают
final String? replyToText; // текст сообщения, на которое отвечают (для отображения)
final DateTime? editedAt;
final Uint8List? localFileBytes;
final File? localFile;
final MessageType messageType;
final String? fileId;
final String? encryptedFileKey;
final String? fileName;
final int? fileSize;
MessageModel({
this.id,
@ -33,10 +36,12 @@ class MessageModel {
this.replyToId,
this.replyToText,
this.editedAt,
this.localFileBytes,
this.localFile,
this.messageType = MessageType.text,
this.fileId,
this.encryptedFileKey,
this.fileName,
this.fileSize
});
MessageModel copyWith({
@ -51,10 +56,12 @@ class MessageModel {
int? replyToId,
String? replyToText,
DateTime? editedAt,
Uint8List? localFileBytes,
File? localFile,
MessageType? messageType,
String? fileId,
String? encryptedFileKey,
String? fileName,
int? fileSize,
}) {
return MessageModel(
id: id ?? this.id,
@ -68,10 +75,12 @@ class MessageModel {
replyToId: replyToId ?? this.replyToId,
replyToText: replyToText ?? this.replyToText,
editedAt: editedAt ?? this.editedAt,
localFileBytes: localFileBytes ?? this.localFileBytes,
localFile: localFile ?? this.localFile,
messageType: messageType ?? this.messageType,
fileId: fileId ?? this.fileId,
encryptedFileKey: encryptedFileKey ?? this.encryptedFileKey,
fileName: fileName ?? this.fileName,
fileSize: fileSize ?? this.fileSize,
);
}
@ -79,6 +88,7 @@ class MessageModel {
final senderId = int.parse(json['sender_id'].toString());
final receiverId = int.parse((json['receiver_id'] ?? json['recipient_id']).toString());
final createdAtRaw = (json['created_at'] ?? json['timestamp']).toString();
final messageTypeStr = json['message_type']?.toString() ?? 'text';
return MessageModel(
id: json['id'] == null ? null : int.tryParse(json['id'].toString()),
@ -92,12 +102,52 @@ class MessageModel {
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()),
messageType: json['message_type'] == 'image' ? MessageType.image : MessageType.text,
messageType: MessageModel.parseMessageType(messageTypeStr),
fileId: json['file_id']?.toString(),
encryptedFileKey: json['encrypted_key']?.toString(),
fileName: json['file_name']?.toString(),
fileSize: json['file_size'] == null ? null : int.tryParse(json['file_size'].toString()),
);
}
static MessageType parseMessageType(String typeStr) {
switch (typeStr.toLowerCase()) {
case 'image':
return MessageType.image;
case 'video':
return MessageType.video;
case 'file':
return MessageType.file;
case 'video_note':
case 'videonote':
return MessageType.videoNote;
case 'voice_note':
case 'voicenote':
return MessageType.voiceNote;
case 'text':
default:
return MessageType.text;
}
}
static String getMediaPreview(MessageType type) {
switch (type) {
case MessageType.videoNote:
return '[Кружок]';
case MessageType.voiceNote:
return '[Голосовое]';
case MessageType.image:
return '[Фото]';
case MessageType.video:
return '[Видео]';
case MessageType.file:
return '[Файл]';
case MessageType.text:
return '';
}
}
Map<String, dynamic> toJson() {
return {
'id': id,
@ -110,9 +160,11 @@ class MessageModel {
'reply_to_id': replyToId,
'reply_to_text': replyToText,
'edited_at': editedAt?.toIso8601String(),
'message_type': messageType == MessageType.image ? 'image' : 'text',
'message_type': messageType.name,
'file_id': fileId,
'encrypted_key': encryptedFileKey,
'file_name': fileName,
'file_size': fileSize,
};
}
}

View File

@ -3,48 +3,98 @@ import 'package:http/http.dart' as http;
import '/core/constants.dart';
import '/data/models/contact_model.dart';
import '/domain/services/api_service.dart';
import 'package:flutter_http_cache/flutter_http_cache.dart';
class ContactRepository {
final http.Client _client = http.Client();
late final CachedHttpClient _client;
bool _isCacheInitialized = false;
final ApiService _apiService = ApiService();
Future<List<Contact>> fetchChatContacts() async {
final token = await _apiService.getAccessToken();
ContactRepository() {
_initCachedClient();
}
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
if (token == null) {
throw Exception('No access token');
}
final response = await _client.get(
Uri.parse('${AppConstants.baseUrl}/users/chats'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
// Единая инициализация кэша для всех запросов репозитория
void _initCachedClient() {
final cache = _apiService.cache;
_client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
}
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
print(data);
List<Contact> contacts = data.map((json) => Contact.fromJson(json)).toList();
for (var item in contacts) {
if (item.lastMessageTime != null) {
DateTime serverTime = item.lastMessageTime!;
item = item.copyWith(lastMessageTime: serverTime.add(offset));
}
}
return contacts;
} else {
throw Exception('Failed to load contacts');
Future<void> _ensureCacheReady() async {
if (!_isCacheInitialized) {
await _apiService.cache.initialize();
_isCacheInitialized = true;
}
}
Future<List<Contact>> fetchAllUsers() async {
Future<List<Contact>> fetchChatContacts({bool forceRefresh = false}) async {
final token = await _apiService.getAccessToken();
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
final Map<String, String> requestHeaders = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
if (forceRefresh) {
requestHeaders['Cache-Control'] = 'no-cache';
}
await _ensureCacheReady();
try {
final response = await _client.get(
Uri.parse('${AppConstants.baseUrl}/users/chats'),
headers: requestHeaders,
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
print(data);
return data.map((json) {
final contact = Contact.fromJson(json);
print(contact.lastMessageType);
if (contact.lastMessageTime != null) {
return contact.copyWith(
lastMessageTime: contact.lastMessageTime!.add(offset),
);
}
return contact;
}).toList();
} else {
throw Exception('Failed to load contacts');
}
} catch (e) {
print(
'⚠️ Ошибка сети при загрузке контактов: $e. Пробуем строгий кэш...',
);
// FALLBACK: Если сеть упала, принудительно создаем запрос с политикой cacheOnly
final offlineClient = CachedHttpClient(
cache: _apiService.cache,
defaultCachePolicy: CachePolicy.cacheOnly, // Читаем строго из кэша
);
try {
final response = await offlineClient.get(
Uri.parse('${AppConstants.baseUrl}/users/chats'),
headers: requestHeaders,
);
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
return data.map((json) => Contact.fromJson(json)).toList();
} catch (cacheError) {
throw Exception('Нет доступа к сети. Проверте подключение к интернету.');
}
}
}
Future<List<Contact>> fetchAllUsers({bool forceRefresh = false}) async {
final token = await _apiService.getAccessToken();
DateTime now = DateTime.now();
@ -54,37 +104,54 @@ class ContactRepository {
throw Exception('No access token');
}
final Map<String, String> requestHeaders = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
if (forceRefresh) {
requestHeaders['Cache-Control'] = 'no-cache';
}
await _ensureCacheReady();
final response = await _client.get(
Uri.parse('${AppConstants.baseUrl}/users/all'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
headers: requestHeaders,
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
List<Contact> contacts = data.map((json) => Contact.fromJson(json)).toList();
for (var contact in contacts) {
return data.map((json) {
final contact = Contact.fromJson(json);
if (contact.lastMessageTime != null) {
DateTime serverTime = contact.lastMessageTime!;
contact = contact.copyWith(lastMessageTime: serverTime.add(offset));
return contact.copyWith(
lastMessageTime: contact.lastMessageTime!.add(offset),
);
}
}
return contacts;
return contact;
}).toList();
} else {
throw Exception('Failed to load contacts');
}
}
Future<Contact> fetchContactById(int userId) async {
Future<Contact> fetchContactById(
int userId, {
bool forceRefresh = false,
}) async {
final token = await _apiService.getAccessToken();
final Map<String, String> requestHeaders = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
if (forceRefresh) {
requestHeaders['Cache-Control'] = 'no-cache';
}
await _ensureCacheReady();
final response = await _client.get(
Uri.parse('${AppConstants.baseUrl}/users/$userId'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
headers: requestHeaders,
);
if (response.statusCode == 200) {
@ -94,4 +161,39 @@ class ContactRepository {
throw Exception('Не удалось загрузить данные контакта');
}
}
Future<List<Map<String, dynamic>>> getLastMessagesForContact(
int contactId, {
int limit = 2,
bool forceRefresh = false,
}) async {
final token = await _apiService.getAccessToken();
if (token == null) {
throw Exception('No access token');
}
final Map<String, String> requestHeaders = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
if (forceRefresh) {
requestHeaders['Cache-Control'] = 'no-cache';
}
await _ensureCacheReady();
final response = await _client.get(
Uri.parse(
'${AppConstants.baseUrl}/messages/last?contact_id=$contactId&limit=$limit',
),
headers: requestHeaders,
);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
return data.map((item) => item as Map<String, dynamic>).toList();
} else {
throw Exception('Failed to load last messages');
}
}
}

View File

@ -1,49 +1,244 @@
import 'dart:math';
import 'package:chepuhagram/data/models/contact_model.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:chepuhagram/core/constants.dart';
import 'package:flutter_http_cache/flutter_http_cache.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'dart:io';
class ApiService extends ChangeNotifier {
final _client = http.Client();
final _storage = const FlutterSecureStorage();
bool _isRefreshing = false;
bool _isCacheInitialized = false;
Future<String?> uploadMedia(List<int> bytes, {String purpose = 'media'}) async {
final cache = HttpCache(
config: const CacheConfig(
maxMemorySize: 100 * 1024 * 1024, // 100MB
maxDiskSize: 500 * 1024 * 1024, // 500MB
),
);
Future<void> _ensureCacheReady() async {
if (!_isCacheInitialized) {
await cache.initialize();
_isCacheInitialized = true;
}
}
/// Получает данные пользователя (включая его публичный ключ E2EE) по username
Future<Contact?> getUserByUsername(String username) async {
try {
final token = await getAccessToken();
var request = http.MultipartRequest(
'POST',
Uri.parse('${AppConstants.baseUrl}/media/v2/upload'),
);
request.headers.addAll({
'Authorization': 'Bearer $token',
});
// Добавляем файл в запрос
request.files.add(
http.MultipartFile.fromBytes(
'file',
bytes,
filename: 'media.enc', // Имя файла для сервера
),
);
// Добавляем purpose
request.fields['purpose'] = purpose;
// Подставляй свой эндпоинт, например: /users/by-username/
final response = await Dio().get('/users/by-username/$username');
var streamedResponse = await request.send().timeout(Duration(seconds: 30));
var response = await http.Response.fromStream(streamedResponse);
if (response.statusCode == 200) {
// Предполагаем, что сервер возвращает JSON {"file_id": "..."}
final data = jsonDecode(response.body);
return data['file_id'];
if (response.statusCode == 200 && response.data != null) {
// Парсим полученные данные в модель контакта.
// Убедись, что метод Contact.fromJson или Contact.fromMap корректно обрабатывает поле public_key
return Contact.fromJson(response.data);
}
return null;
} catch (e) {
print("Ошибка API при загрузке: $e");
print("[ApiService] Ошибка при получении пользователя по username: $e");
return null;
}
}
Future<String?> copyMediaOnServer(String fileId, int receiverId) async {
try {
final token = await getAccessToken();
final response = await Dio().post(
'${AppConstants.baseUrl}/media/copy',
data: FormData.fromMap({'file_id': fileId, 'receiver_id': receiverId}),
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
if (response.statusCode == 200) {
return response.data['new_file_id'];
}
} catch (e) {
print("Ошибка копирования на сервере: $e");
}
return null;
}
Future<String?> uploadFile(
List<int> bytes, {
String purpose = 'media',
Function(double)? onProgress,
}) async {
final token = await getAccessToken();
final dio = Dio();
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: 'media.enc'),
'purpose': purpose,
});
final response = await dio.post(
'${AppConstants.baseUrl}/media/v2/upload',
data: formData,
onSendProgress: (sent, total) {
onProgress?.call(sent / total);
},
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
return response.data['file_id'];
}
Future<(int?, String?)> getRemoteFileSizeAndName(String fileId) async {
try {
final token = await getAccessToken();
final url =
'${AppConstants.baseUrl}/media/size/$fileId'; // Скорректируй путь согласно роутеру
final response = await Dio().get(
url,
options: Options(
headers: {
'Authorization': 'Bearer $token', // Твой токен, если требуется
},
),
);
if (response.statusCode == 200 && response.data != null) {
// Извлекаем размер из JSON: {"file_id": "...", "size": 123456}
final intSize = response.data['size'] as int?;
String? fileName = response.data['file_name'] as String?;
if (fileName != null) {
fileName = Uri.decodeComponent(fileName);
print("Имя файла, полученное от сервера: $fileName");
}
debugPrint(
'Успешно получен размер файла через API-size: $intSize байт',
);
return (intSize, fileName);
}
} catch (e) {
debugPrint('Ошибка при получении размера файла через API-size: $e');
}
return (null, null);
}
Future<(http.ByteStream, String)> downloadFileAsStream(String fileId) async {
final token = await getAccessToken(); // Получаем JWT токен авторизации
final url = Uri.parse(
'${AppConstants.baseUrl}/media/$fileId',
); // Замените на ваш эндпоинт скачивания
final request = http.Request('GET', url);
request.headers.addAll({'Authorization': 'Bearer $token'});
// Отправляем запрос
final http.StreamedResponse response = await _client.send(request);
final contentDisposition = response.headers['content-disposition'];
String serverFileName = 'media.enc';
if (contentDisposition != null) {
final match = RegExp(
r"filename\*=UTF-8''(.+)",
).firstMatch(contentDisposition);
if (match != null) {
serverFileName = Uri.decodeComponent(match.group(1)!);
print("Имя файла, полученное от сервера: $serverFileName");
}
}
if (response.statusCode == 200) {
return (response.stream, serverFileName);
} else {
throw Exception(
'Ошибка скачивания файла: сервер вернул статус ${response.statusCode}',
);
}
}
Future<File?> downloadFile(String fileId, String filePath) async {
final token = await getAccessToken();
try {
final response = await Dio().download(
'${AppConstants.baseUrl}/media/$fileId',
filePath,
options: Options(
headers: {'Authorization': 'Bearer $token'},
validateStatus: (status) => status == 200,
),
);
if (response.statusCode == 200) {
return File(filePath);
}
} catch (e) {
debugPrint('Ошибка при скачивании: $e');
final file = File(filePath);
if (await file.exists()) await file.delete();
}
return null;
}
Future<String?> uploadFileStream(
Stream<List<int>> stream,
int sourceLength, {
String purpose = 'media',
void Function(int processed, int total)? onProgress,
String? fileName,
}) async {
try {
final token = await getAccessToken();
final dio = Dio();
print(
'[DEBUG] uploadFileStream: работаем через поточное чтение. Размер=$sourceLength bytes, purpose=$purpose',
);
// БЕЗОПАСНО ДЛЯ RAM: Передаем стрим напрямую в Dio. Память не забивается!
final formData = FormData.fromMap({
'file': MultipartFile.fromStream(
() => stream,
sourceLength, // Передаем точную длину стрима, это важно для прогресса!
filename: fileName,
),
'purpose': purpose,
});
final response = await dio.post(
'${AppConstants.baseUrl}/media/v2/upload',
data: formData,
onSendProgress: (sent, total) {
// Твой print(sent) теперь будет вызываться динамически по мере ухода байт в сеть!
if (total > 0) {
onProgress?.call(sent, total);
}
},
options: Options(
headers: {'Authorization': 'Bearer $token'},
validateStatus: (status) => status != null && status < 500,
),
);
print('[DEBUG] uploadFileStream response status=${response.statusCode}');
print('[DEBUG] uploadFileStream response data=${response.data}');
if (response.statusCode == 200 || response.statusCode == 201) {
final fileId = response.data['file_id']?.toString();
print('[DEBUG] uploadFileStream: успешно загружен, file_id=$fileId');
return fileId;
} else {
print(
'[ERROR] uploadFileStream: ошибка ${response.statusCode} - ${response.data}',
);
return null;
}
} catch (e) {
print('[ERROR] uploadFileStream exception: $e');
return null;
}
}
@ -58,11 +253,13 @@ class ApiService extends ChangeNotifier {
try {
final refreshToken = await _storage.read(key: 'refresh_token');
final response = await _client.post(
Uri.parse('${AppConstants.baseUrl}/auth/refresh'),
body: jsonEncode({'refresh_token': refreshToken}),
headers: {'Content-Type': 'application/json'},
).timeout(Duration(seconds: 30));
final response = await _client
.post(
Uri.parse('${AppConstants.baseUrl}/auth/refresh'),
body: jsonEncode({'refresh_token': refreshToken}),
headers: {'Content-Type': 'application/json'},
)
.timeout(Duration(seconds: 30));
final decodedResponse =
jsonDecode(utf8.decode(response.bodyBytes)) as Map;
@ -112,25 +309,31 @@ class ApiService extends ChangeNotifier {
Future<bool> updateFcmToken(String fcmtoken) async {
notifyListeners();
try {
final token = await getAccessToken();
final response = await _client.post(
Uri.parse('${AppConstants.baseUrl}/auth/update-fcm?token=$fcmtoken'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
if (token == null) return false; // Нет токена прерываем выполнение
final response = await _client
.post(
Uri.parse(
'${AppConstants.baseUrl}/auth/update-fcm?token=$fcmtoken',
),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
)
.timeout(const Duration(seconds: 10)); // Ограничиваем время ожидания
if (response.statusCode == 200) {
return true;
} else {
print("Ошибка установки ключа: ${response.statusCode}");
print("Ошибка установки FCM ключа: ${response.statusCode}");
return false;
}
} catch (e) {
rethrow;
print(" Не удалось обновить FCM токен (нет сети): $e");
return false; // Возвращаем false вместо падения приложения
} finally {
notifyListeners();
}
@ -165,7 +368,12 @@ class ApiService extends ChangeNotifier {
Future<Map<String, dynamic>> getMe() async {
final token = await getAccessToken();
final response = await _client.get(
await cache.initialize();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final response = await client.get(
Uri.parse('${AppConstants.baseUrl}/users/me'),
headers: {
'Content-Type': 'application/json',
@ -214,9 +422,27 @@ class ApiService extends ChangeNotifier {
return response.statusCode == 200;
}
Future<List<dynamic>> getChatHistory(int contactId) async {
Future<List<dynamic>> getChatHistory(
int contactId, {
bool forceRefresh = false,
}) async {
final token = await getAccessToken();
final response = await _client.get(
await _ensureCacheReady();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final Map<String, String> requestHeaders = {
'Authorization': 'Bearer $token',
};
if (forceRefresh) {
requestHeaders['Cache-Control'] = 'no-cache';
}
final response = await client.get(
Uri.parse(
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}',
),
@ -225,25 +451,57 @@ class ApiService extends ChangeNotifier {
"Authorization": "Bearer $token",
},
);
return jsonDecode(response.body) as List<dynamic>;
return jsonDecode(utf8.decode(response.bodyBytes)) as List<dynamic>;
}
Future<Uint8List?> downloadMedia(String fileId) async {
Future<Uint8List?> downloadMedia(
String fileId, {
void Function(int received, int total)? onProgress,
}) async {
try {
final token = await getAccessToken();
final response = await _client.get(
Uri.parse('${AppConstants.baseUrl}/media/$fileId'),
headers: {
'Authorization': 'Bearer $token',
},
await _ensureCacheReady();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
if (response.statusCode == 200) {
return response.bodyBytes;
final uri = Uri.parse('${AppConstants.baseUrl}/media/$fileId');
if (onProgress == null) {
final response = await client.get(
uri,
headers: {'Authorization': 'Bearer $token'},
);
if (response.statusCode == 200) {
return response.bodyBytes;
}
print('?????? ???????? ?????: ${response.statusCode}');
return null;
}
print('Ошибка загрузки медиа: ${response.statusCode}');
final req = http.Request('GET', uri);
req.headers['Authorization'] = 'Bearer $token';
final streamed = await client.send(req);
final total =
int.tryParse(streamed.headers['content-length'] ?? '') ?? -1;
int received = 0;
final bytes = <int>[];
await for (final chunk in streamed.stream) {
bytes.addAll(chunk);
received += chunk.length;
onProgress(received, total);
}
if (streamed.statusCode == 200) {
return Uint8List.fromList(bytes);
}
print('?????? ???????? ?????: ${streamed.statusCode}');
return null;
} catch (e) {
print('Ошибка downloadMedia: $e');
print('?????? downloadMedia: $e');
return null;
}
}
@ -286,7 +544,12 @@ class ApiService extends ChangeNotifier {
Future<Map<String, dynamic>> getUserById(int userId) async {
final token = await getAccessToken();
final response = await _client.get(
await _ensureCacheReady();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final response = await client.get(
Uri.parse('${AppConstants.baseUrl}/users/$userId'),
headers: {
'Content-Type': 'application/json',
@ -331,7 +594,13 @@ class ApiService extends ChangeNotifier {
Future<Map<String, dynamic>> getPrivacySettings() async {
final token = await getAccessToken();
final response = await _client.get(
await _ensureCacheReady();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final response = await client.get(
Uri.parse('${AppConstants.baseUrl}/users/me/privacy'),
headers: {
'Content-Type': 'application/json',

View File

@ -2,7 +2,11 @@ import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:chepuhagram/data/models/contact_model.dart';
import 'dart:async';
import 'package:pointycastle/export.dart' as pc;
import 'dart:io';
class CryptoService {
final _storage = const FlutterSecureStorage();
@ -244,33 +248,173 @@ class CryptoService {
return result;
}
Future<(List<int>, String)?> encryptImage(
List<int> fileBytes,
Future<(Stream<List<int>>, String)> encryptFileStream(
Stream<List<int>> fileStream,
SecretKey sharedKey, {
void Function(int processed, int total)? onProgress,
int? totalSize,
}) async {
// 1. Генерируем уникальный ключ для конкретного файла
final SecretKey fileKey = await aesGcm.newSecretKey();
final List<int> fileKeyBytes = await fileKey.extractBytes();
// 2. Шифруем этот ключ файла на общем ключе чата (sharedKey)
final keyNonce = aesGcm.newNonce();
final encryptedKeyBox = await aesGcm.encrypt(
fileKeyBytes,
secretKey: sharedKey,
nonce: keyNonce,
);
// Кодируем зашифрованный ключ в Base64 для сервера
final String encryptedKeyForServer = base64Encode(
encryptedKeyBox.concatenation(),
);
int processedBytes = 0;
final int total = totalSize ?? 0;
// 3. Создаем асинхронный генератор для поблочного шифрования самого файла
Stream<List<int>> processEncryption() async* {
final List<int> buffer = [];
const int chunkSize = 64 * 1024; // 64 KB
await for (final chunk in fileStream) {
buffer.addAll(chunk);
while (buffer.length >= chunkSize) {
final plainBlock = Uint8List.fromList(buffer.sublist(0, chunkSize));
buffer.removeRange(0, chunkSize);
final blockNonce = aesGcm.newNonce();
final secretBox = await aesGcm.encrypt(
plainBlock,
secretKey: fileKey,
nonce: blockNonce,
);
final payload = secretBox.concatenation();
final header = ByteData(4)..setUint32(0, payload.length);
yield header.buffer.asUint8List();
yield payload;
processedBytes += chunkSize;
if (onProgress != null) {
onProgress(processedBytes, total);
}
}
}
// Дозаписываем остаток файла (хвост), если он есть
if (buffer.isNotEmpty) {
final plainBlock = Uint8List.fromList(buffer);
final blockNonce = aesGcm.newNonce();
final secretBox = await aesGcm.encrypt(
plainBlock,
secretKey: fileKey,
nonce: blockNonce,
);
final payload = secretBox.concatenation();
final header = ByteData(4)..setUint32(0, payload.length);
yield header.buffer.asUint8List();
yield payload;
}
}
// Возвращаем кортеж (Record): очищенный зашифрованный поток данных и ключ для сервера
return (processEncryption(), encryptedKeyForServer);
}
Stream<List<int>> decryptFileStream(
Stream<List<int>> encryptedStream,
SecretKey sharedKey,
) async {
String encryptedFileKey, {
int? totalBytes,
void Function(int processed, int total)? onProgress,
}) async* {
try {
final SecretKey fileSecretKey = await aesGcm.newSecretKey();
final List<int> fileSecretKeyBytes = await fileSecretKey.extractBytes();
final SecretBox secretBox = await aesGcm.encrypt(
fileBytes,
secretKey: fileSecretKey,
// 1. Дешифруем ключ файла с помощью общего ключа чата
final encryptedKeyBytes = base64Decode(encryptedFileKey);
final keySecretBox = SecretBox.fromConcatenation(
encryptedKeyBytes,
nonceLength: 12,
macLength: 16,
);
final List<int> dataToUpload = secretBox.concatenation();
final encryptedKeyBox = await aesGcm.encrypt(
fileSecretKeyBytes,
final fileKeyBytes = await aesGcm.decrypt(
keySecretBox,
secretKey: sharedKey,
);
final fileKey = SecretKey(fileKeyBytes);
final String encryptedKeyForServer = base64Encode(
encryptedKeyBox.concatenation(),
final List<int> buffer = [];
int blocksDecrypted = 0;
int totalProcessedBytes = 0;
// 2. Потоковая дешифровка блоков файла
await for (final chunk in encryptedStream) {
buffer.addAll(chunk);
while (true) {
if (buffer.length < 4) break;
final headerBytes = Uint8List.fromList(buffer.sublist(0, 4));
final int payloadLength = ByteData.sublistView(
headerBytes,
).getUint32(0);
// Проверяем: если длина чанка подозрительно огромная (из-за неверного формата файла)
if (payloadLength > 500 * 1024 || payloadLength <= 0) {
print(
"ОШИБКА: Неверный заголовок длины чанка: $payloadLength байт. Возможно, файл зашифрован старым методом!",
);
throw Exception("Неверный формат зашифрованного блока");
}
if (buffer.length < 4 + payloadLength) break;
final encryptedBlockBytes = Uint8List.fromList(
buffer.sublist(4, 4 + payloadLength),
);
buffer.removeRange(0, 4 + payloadLength);
final blockSecretBox = SecretBox.fromConcatenation(
encryptedBlockBytes,
nonceLength: 12,
macLength: 16,
);
final decryptedBlock = await aesGcm.decrypt(
blockSecretBox,
secretKey: fileKey,
);
blocksDecrypted++;
if (blocksDecrypted % 10 == 0 || payloadLength < 64 * 1024) {
print(
"Дешифровано блоков: $blocksDecrypted. Текущий размер: ${decryptedBlock.length} байт. Всего обработано $totalProcessedBytes. Всего $totalBytes",
);
}
// Увеличиваем счетчик обработанных зашифрованных байт
totalProcessedBytes += 4 + payloadLength;
// Вызываем колбэк прогресса
if (onProgress != null) {
// Передаем, сколько байт обработано, и общий размер (если totalBytes null, передаем -1)
onProgress(totalProcessedBytes, totalBytes ?? -1);
}
yield decryptedBlock;
}
}
print(
"ПОТОК ДЕШИФРАЦИИ ЗАВЕРШЕН ПОЛНОСТЬЮ. Всего блоков: $blocksDecrypted",
);
return (dataToUpload, encryptedKeyForServer);
} catch (e) {
print("Ошибка шифрования медиа: $e");
return null;
} catch (e, stack) {
print("КРИТИЧЕСКАЯ ОШИБКА ВНУТРИ КРИПТОСТРИМА: $e");
print(stack);
rethrow;
}
}
@ -305,7 +449,7 @@ class CryptoService {
}
}
Future<Uint8List?> decryptImage(
Future<Uint8List?> decryptMedia(
List<int> encryptedData,
String encryptedKey,
SecretKey sharedKey,
@ -338,6 +482,27 @@ class CryptoService {
}
}
Future<SecretKey?> _decryptFileKey(
String encryptedFileKey,
SecretKey sharedKey,
) async {
try {
final keyBytes = base64Decode(encryptedFileKey);
final nonce = keyBytes.sublist(0, 12);
final macBytes = keyBytes.sublist(keyBytes.length - 16);
final cipherText = keyBytes.sublist(12, keyBytes.length - 16);
final decrypted = await aesGcm.decrypt(
SecretBox(cipherText, nonce: nonce, mac: Mac(macBytes)),
secretKey: sharedKey,
);
return SecretKey(decrypted);
} catch (e) {
print('Error decrypting file key: $e');
return null;
}
}
Future<String> decryptMessage(String base64Data, SecretKey sharedKey) async {
final data = base64Decode(base64Data);
@ -369,4 +534,21 @@ class CryptoService {
Future<void> deletePrivateKey() async {
await _storage.delete(key: 'private_key');
}
SecretKey? _currentSharedKey;
// Метод для установки ключа (вызывается при входе в чат)
void setCurrentSharedKey(SecretKey key) {
_currentSharedKey = key;
}
// Тот самый метод, который ищет ChatScreen
Future<SecretKey> getSharedKey(String? chatId) async {
if (_currentSharedKey == null) {
// Если ключа нет, его нужно либо вычислить заново,
// либо выбросить ошибку. Для теста можно вернуть ошибку:
throw Exception("Shared key not initialized for chat $chatId");
}
return _currentSharedKey!;
}
}

View File

@ -358,7 +358,7 @@ class AuthProvider extends ChangeNotifier {
Future<bool> updateAvatar(String path) async {
try {
final bytes = await File(path).readAsBytes();
final fileId = await _apiService.uploadMedia(bytes, purpose: 'avatar');
final fileId = await _apiService.uploadFile(bytes, purpose: 'avatar');
if (fileId != null) {
final success = await _apiService.updateAvatar(fileId);
if (success) {

View File

@ -5,6 +5,7 @@ import '/domain/services/crypto_service.dart';
import 'dart:isolate';
import 'package:cryptography/cryptography.dart';
import 'package:flutter/foundation.dart';
import 'package:chepuhagram/data/models/message_model.dart';
class ContactProvider extends ChangeNotifier {
final ContactRepository _repository = ContactRepository();
@ -94,41 +95,64 @@ class ContactProvider extends ChangeNotifier {
}
}
String _getMediaPreview(MessageType type) {
switch (type) {
case MessageType.videoNote:
return '[Кружок]';
case MessageType.voiceNote:
return '[Голосовое]';
case MessageType.image:
return '[Фото]';
case MessageType.video:
return '[Видео]';
case MessageType.file:
return '[Файл]';
case MessageType.text:
default:
return '';
}
}
Future<void> _enrichContactsWithLastMessages() async {
final myPrivKeyBase64 = await _cryptoService.getPrivateKey();
if (myPrivKeyBase64 == null) return;
final myPrivKeyBase64 = await _cryptoService.getPrivateKey();
if (myPrivKeyBase64 == null) return;
// Создаем локальные копии для передачи
final contactsToProcess = List<Contact>.from(_contacts);
final cacheCopy = Map<int, SecretKey>.from(_sharedKeysCache);
// Создаем локальные копии для передачи
final contactsToProcess = List<Contact>.from(_contacts);
final cacheCopy = Map<int, SecretKey>.from(_sharedKeysCache);
print('Avialable cache for contacts: ${cacheCopy.length}');
print('Avialable cache for contacts: ${cacheCopy.length}');
try {
final updatedContacts = await compute(
CryptoService.bulkDecryptContacts,
{
try {
final updatedContacts = await compute(CryptoService.bulkDecryptContacts, {
'contacts': contactsToProcess,
'privKey': myPrivKeyBase64,
'cache': cacheCopy,
},
);
for (var contact in updatedContacts) {
print('Decrypted contact: ${contact.name} ${contact.surname}, lastMessage: ${contact.lastMessage}, isDecrypted: ${contact.isLastMsgDecrypted}');
});
for (var contact in updatedContacts) {
print(
'Decrypted contact: ${contact.name} ${contact.surname}, lastMessage: ${contact.lastMessage}, isDecrypted: ${contact.isLastMsgDecrypted}',
);
}
_contacts = updatedContacts;
notifyListeners();
} catch (e) {
print("Ошибка дешифровки: $e");
}
_contacts = updatedContacts;
notifyListeners();
} catch (e) {
print("Ошибка дешифровки: $e");
}
}
Future<void> updateContact(int userId) async {
Future<void> updateContact(
int userId, {
String? lastMessage,
DateTime? lastMessageTime,
bool? isLastMsgDecrypted,
int? unreadCount,
}) async {
try {
final updatedContact = await _repository.fetchContactById(userId);
final index = _contacts.indexWhere((c) => c.id == userId);
if (index != -1) {
// Обновляем только поля профиля, сохраняя lastMessage и т.д.
final existing = _contacts[index];
_contacts[index] = existing.copyWith(
username: updatedContact.username,
@ -138,8 +162,15 @@ class ContactProvider extends ChangeNotifier {
avatarFileId: updatedContact.avatarFileId,
isOnline: updatedContact.isOnline,
publicKey: updatedContact.publicKey,
lastMessage: lastMessage ?? existing.lastMessage,
lastMessageTime: lastMessageTime ?? existing.lastMessageTime,
isLastMsgDecrypted: isLastMsgDecrypted ?? existing.isLastMsgDecrypted,
unreadCount: unreadCount ?? existing.unreadCount,
lastMessageId: updatedContact.lastMessageId,
);
print(
"Контакт ${updatedContact.name} ${updatedContact.surname} ${updatedContact.id} ${updatedContact.avatarFileId} ${updatedContact.avatarUrl} обновлен",
);
print("Контакт ${updatedContact.name} ${updatedContact.surname} ${updatedContact.id} ${updatedContact.avatarFileId} ${updatedContact.avatarUrl} обновлен");
notifyListeners();
}
} catch (e) {
@ -147,4 +178,117 @@ class ContactProvider extends ChangeNotifier {
}
}
Future<void> updateContactOnlineStatus(int userId, bool isOnline) async {
try {
final index = _contacts.indexWhere((c) => c.id == userId);
if (index != -1) {
final existing = _contacts[index];
_contacts[index] = existing.copyWith(
isOnline: isOnline,
username: existing.username,
name: existing.name,
surname: existing.surname,
avatarUrl: existing.avatarUrl,
avatarFileId: existing.avatarFileId,
publicKey: existing.publicKey,
);
print("Контакт ${existing.name} ${existing.surname} онлайн обновлен");
notifyListeners();
}
} catch (e) {
print("Error updating contact: $e");
}
}
Future<void> updateContactLastMessage(int contactId, {String? lastMessage, DateTime? lastMessageTime, bool? isLastMsgDecrypted, int? lastMessageId, bool isEdited = false}) async {
try {
final index = _contacts.indexWhere((c) => c.id == contactId);
if (index != -1) {
final existing = _contacts[index];
String displayMessage;
if (isEdited) {
final baseMessage = lastMessage ?? existing.lastMessage;
final rawMessage = baseMessage != null && baseMessage.isNotEmpty
? baseMessage
: 'Сообщение изменено';
displayMessage = rawMessage.endsWith('(изменено)')
? rawMessage
: '$rawMessage (изменено)';
} else {
displayMessage = lastMessage ?? existing.lastMessage ?? '';
}
_contacts[index] = existing.copyWith(
lastMessage: displayMessage.isNotEmpty ? displayMessage : null,
lastMessageTime: lastMessageTime,
isLastMsgDecrypted: isLastMsgDecrypted ?? existing.isLastMsgDecrypted,
lastMessageId: lastMessageId,
);
print("Последнее сообщение контакта ${existing.name} обновлено: $displayMessage");
notifyListeners();
}
} catch (e) {
print("Error updating contact last message: $e");
}
}
Future<void> refreshContactLastMessage(int contactId) async {
try {
// Получить предпоследнее сообщение из базы данных
final lastMessages = await _repository.getLastMessagesForContact(contactId, limit: 2);
if (lastMessages.isNotEmpty) {
final lastMsg = lastMessages.first;
final contact = _contacts.firstWhere((c) => c.id == contactId);
final messageId = int.tryParse(lastMsg['id'].toString());
final timestamp = DateTime.tryParse(lastMsg['timestamp']?.toString() ?? '');
final myPrivKeyBase64 = await _cryptoService.getPrivateKey();
if (myPrivKeyBase64 != null && contact.publicKey != null) {
try {
final sharedSecret = await _cryptoService.deriveSharedSecret(
myPrivKeyBase64,
contact.publicKey!,
);
final decryptedText = await _cryptoService.decryptMessage(
lastMsg['content'],
sharedSecret,
);
await updateContactLastMessage(
contactId,
lastMessage: decryptedText,
lastMessageTime: timestamp,
isLastMsgDecrypted: true,
lastMessageId: messageId,
);
} catch (e) {
print("Error decrypting last message: $e");
await updateContactLastMessage(
contactId,
lastMessage: lastMsg['content50'] ?? 'Зашифрованное сообщение',
lastMessageTime: timestamp,
isLastMsgDecrypted: false,
lastMessageId: messageId,
);
}
} else {
await updateContactLastMessage(
contactId,
lastMessage: lastMsg['content50'] ?? 'Зашифрованное сообщение',
lastMessageTime: timestamp,
isLastMsgDecrypted: false,
lastMessageId: messageId,
);
}
} else {
// Нет сообщений
await updateContactLastMessage(
contactId,
lastMessage: null,
lastMessageTime: null,
lastMessageId: null,
);
}
} catch (e) {
print("Error refreshing contact last message: $e");
}
}
}

View File

@ -0,0 +1,412 @@
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'dart:async';
import 'media_preview_screen.dart';
class CameraScreen extends StatefulWidget {
const CameraScreen({super.key});
@override
State<CameraScreen> createState() => _CameraScreenState();
}
enum FlashModeType { off, autoCapture, alwaysCapture, torch }
class _CameraScreenState extends State<CameraScreen> {
CameraController? _controller;
List<CameraDescription> _cameras = [];
int _cameraIndex = 0;
bool _isRecording = false;
FlashModeType _flashMode = FlashModeType.off;
double _minZoom = 1.0;
double _maxZoom = 1.0;
double _currentZoom = 1.0;
bool _showZoomSlider = false;
Future<void>? _initFuture;
@override
void initState() {
super.initState();
_initFuture = _init();
}
Future<void> _init() async {
_cameras = await availableCameras();
await _initCamera();
}
Future<void> _initCamera() async {
final camera = _cameras[_cameraIndex];
final controller = CameraController(
camera,
ResolutionPreset.high,
enableAudio: true,
);
await controller.initialize();
_minZoom = await controller.getMinZoomLevel();
_maxZoom = await controller.getMaxZoomLevel();
_currentZoom = _minZoom;
if (!mounted) return;
setState(() {
_controller = controller;
});
}
Future<void> _switchCamera() async {
if (_cameras.length < 2) return;
await _controller?.dispose();
_cameraIndex = (_cameraIndex + 1) % _cameras.length;
setState(() => _controller = null);
await _initCamera();
}
Future<void> _cycleFlashMode() async {
if (_controller == null) return;
switch (_flashMode) {
case FlashModeType.off:
_flashMode = FlashModeType.autoCapture;
await _controller!.setFlashMode(FlashMode.off);
break;
case FlashModeType.autoCapture:
_flashMode = FlashModeType.alwaysCapture;
await _controller!.setFlashMode(FlashMode.off);
break;
case FlashModeType.alwaysCapture:
_flashMode = FlashModeType.torch;
await _controller!.setFlashMode(FlashMode.torch);
break;
case FlashModeType.torch:
_flashMode = FlashModeType.off;
await _controller!.setFlashMode(FlashMode.off);
break;
}
setState(() {});
}
Future<void> _takePhoto() async {
if (_controller == null) return;
bool usedTorch = false;
if (_flashMode == FlashModeType.alwaysCapture) {
await _controller!.setFlashMode(FlashMode.torch);
usedTorch = true;
await Future.delayed(const Duration(milliseconds: 120));
}
if (_flashMode == FlashModeType.autoCapture) {
await _controller!.setFlashMode(FlashMode.torch);
usedTorch = true;
await Future.delayed(const Duration(milliseconds: 120));
}
final file = await _controller!.takePicture();
if (usedTorch) {
await _controller!.setFlashMode(FlashMode.off);
}
WidgetsBinding.instance.addPostFrameCallback((_) async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MediaPreviewScreen(path: file.path, isVideo: false),
),
);
if (result == true && mounted) {
Navigator.pop(context, (file, 'image'));
}
});
}
bool usedTorch = false;
Future<void> _startVideo() async {
if (_controller == null || _isRecording) return;
if (_flashMode == FlashModeType.alwaysCapture) {
await _controller!.setFlashMode(FlashMode.torch);
usedTorch = true;
await Future.delayed(const Duration(milliseconds: 120));
}
if (_flashMode == FlashModeType.autoCapture) {
await _controller!.setFlashMode(FlashMode.torch);
usedTorch = true;
await Future.delayed(const Duration(milliseconds: 120));
}
await _controller!.startVideoRecording();
setState(() => _isRecording = true);
}
Future<void> _stopVideo() async {
if (_controller == null || !_isRecording) return;
if (usedTorch) {
await _controller!.setFlashMode(FlashMode.off);
}
final file = await _controller!.stopVideoRecording();
setState(() => _isRecording = false);
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MediaPreviewScreen(path: file.path, isVideo: true),
),
);
if (result == true && mounted) {
Navigator.pop(context, (file, 'video'));
}
}
Future<void> _setZoom(double zoom) async {
if (_controller == null) return;
final clamped = zoom.clamp(_minZoom, _maxZoom);
await _controller!.setZoomLevel(clamped);
setState(() {
_currentZoom = clamped;
});
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: FutureBuilder(
future: _initFuture,
builder: (context, snapshot) {
if (_controller == null || !_controller!.value.isInitialized) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
// 📷 Camera preview (full screen, Telegram style crop)
Positioned.fill(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _controller!.value.previewSize!.height,
height: _controller!.value.previewSize!.width,
child: GestureDetector(
onScaleStart: (_) {
setState(() {
_showZoomSlider = true;
});
},
onScaleUpdate: (details) {
final zoom = (_currentZoom * details.scale).clamp(
_minZoom,
_maxZoom,
);
_setZoom(zoom);
},
child: CameraPreview(_controller!),
),
),
),
),
// 🌑 top gradient (Telegram feel)
Positioned(
top: 0,
left: 0,
right: 0,
height: 120,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black87, Colors.transparent],
),
),
),
),
// 🔘 top controls
Positioned(
top: 50,
left: 20,
right: 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Flash (left)
IconButton(
onPressed: _cycleFlashMode,
icon: Icon(switch (_flashMode) {
FlashModeType.off => Icons.flash_off,
FlashModeType.autoCapture => Icons.flash_auto,
FlashModeType.alwaysCapture => Icons.flash_on,
FlashModeType.torch => Icons.highlight,
}, color: Colors.white),
),
// Camera switch (right)
IconButton(
onPressed: _switchCamera,
icon: const Icon(Icons.cameraswitch, color: Colors.white),
),
],
),
),
// 🔘 capture button (center bottom)
Positioned(
bottom: 90,
left: 0,
right: 0,
child: Column(
children: [
GestureDetector(
onTap: _takePhoto,
onLongPressStart: (_) => _startVideo(),
onLongPressEnd: (_) => _stopVideo(),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: _isRecording ? 80 : 72,
height: _isRecording ? 80 : 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _isRecording ? Colors.red : Colors.white,
border: Border.all(color: Colors.white, width: 4),
),
),
),
const SizedBox(height: 16),
const Text(
"Нажмите для фото, удерживайте для съемки",
style: TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
),
// 🔴 recording indicator
if (_isRecording)
const Positioned(
top: 50,
left: 0,
right: 0,
child: Center(
child: Text(
"REC",
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
),
if (_showZoomSlider)
Positioned(
bottom: 200,
left: 20,
right: 20,
child: Center(
child: Container(
child: Row(
children: [
GestureDetector(
onTap: () {
final newZoom = (_currentZoom - 0.5).clamp(
_minZoom,
_maxZoom,
);
_setZoom(newZoom);
},
child: const Text(
'',
style: TextStyle(
color: Colors.white,
fontSize: 18,
),
),
),
const SizedBox(width: 8),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2,
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white24,
thumbColor: Colors.white,
overlayColor: Colors.white24,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 6,
),
),
child: Slider(
value: _currentZoom,
min: _minZoom,
max: _maxZoom,
onChanged: (value) {
_setZoom(value);
},
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {
final newZoom = (_currentZoom + 0.5).clamp(
_minZoom,
_maxZoom,
);
_setZoom(newZoom);
},
child: const Text(
'+',
style: TextStyle(
color: Colors.white,
fontSize: 18,
),
),
),
],
),
),
),
),
],
);
},
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import 'dart:convert';
import 'package:chepuhagram/core/constants.dart';
import 'package:chepuhagram/domain/services/aPI_service.dart';
import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../widgets/contact_tile.dart';
@ -44,6 +44,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
CancelToken? _cancelToken = CancelToken();
String? _latestApkUrl;
bool _showUpdateBanner = false;
bool _contactsLoaded = false;
Timer? _contactLoadTimer;
@override
void initState() {
@ -61,26 +63,38 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
'Setting current user ID in ContactProvider: ${authProvider.currentUserId}',
);
contactProvider.setCurrentUserId(authProvider.currentUserId);
_initContacts();
_startContactsLoadTimer();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAppUpdate();
}
Future<void> _startContactsLoadTimer() async {
if (_contactLoadTimer != null && _contactLoadTimer!.isActive) return;
_contactLoadTimer = Timer(const Duration(seconds: 2), () {
_initContacts();
});
}
Future<void> _initContacts() async {
if (_contactsLoaded) return; // Предотвращаем повторную загрузку
final contactProvider = context.read<ContactProvider>();
// Ждем завершения загрузки контактов
await contactProvider.loadContacts();
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAppUpdate();
});
// Дальнейшая логика выполнится только после того, как loadContacts завершится
if (widget.targetChatId != null) {
_navigateToTargetChat();
} else {
_checkSavedNotificationTarget();
}
_contactLoadTimer?.cancel();
_contactLoadTimer = null;
_contactsLoaded = true;
}
@override
@ -290,6 +304,81 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
contactProvider.updateContact(userId);
}
}
if (data['type'] == 'user_online') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {
final contactProvider = context.read<ContactProvider>();
contactProvider.updateContactOnlineStatus(userId, true);
}
}
if (data['type'] == 'user_offline') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {
final contactProvider = context.read<ContactProvider>();
contactProvider.updateContactOnlineStatus(userId, false);
}
}
if (data['type'] == 'message_edited') {
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
if (messageId != null && senderId != null) {
final contactProvider = context.read<ContactProvider>();
final contact = contactProvider.contacts
.where((c) => c.id == senderId)
.firstOrNull;
if (contact != null) {
final editedAt = DateTime.tryParse(
data['edited_at']?.toString() ?? '',
);
// Дефолтные значения на случай ошибки расшифровки
String lastMessageText = contact.lastMessage ?? '';
bool isDecrypted = false;
final myPrivKey = await CryptoService().getPrivateKey();
if (myPrivKey != null && contact.publicKey != null) {
try {
final sharedSecret = await CryptoService().deriveSharedSecret(
myPrivKey,
contact.publicKey!,
);
lastMessageText = await CryptoService().decryptMessage(
data['content']?.toString() ?? '',
sharedSecret,
);
isDecrypted = true;
} catch (e) {
print('Error decrypting edited message for contacts list: $e');
}
}
// Единая точка обновления состояния
await contactProvider.updateContactLastMessage(
contact.id,
lastMessage: lastMessageText,
lastMessageTime: editedAt,
isLastMsgDecrypted: isDecrypted,
lastMessageId: messageId,
isEdited: true,
);
}
}
}
if (data['type'] == 'message_deleted') {
final messageId = int.tryParse(data['message_id']?.toString() ?? '');
if (messageId != null) {
final contactProvider = context.read<ContactProvider>();
final contactIndex = contactProvider.contacts.indexWhere(
(c) => c.lastMessageId == messageId,
);
if (contactIndex != -1) {
final contactId = contactProvider.contacts[contactIndex].id;
await contactProvider.refreshContactLastMessage(contactId);
}
}
}
}
}
@ -391,8 +480,19 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
);
if (message.data['type'] == 'enc_message') {
print('Received private message FCM, updating contact $senderId');
final contactProvider = context.read<ContactProvider>();
contactProvider.loadContacts();
contactProvider.updateContact(
senderId,
lastMessage: decryptedText,
lastMessageTime: DateTime.tryParse(
message.data['timestamp'] ?? DateTime.now().toIso8601String(),
),
isLastMsgDecrypted: true,
unreadCount: message.data['unread_count'] != null
? int.tryParse(message.data['unread_count'].toString()) ?? 1
: null,
);
}
} catch (e) {
print('Error processing foreground FCM message: $e');
@ -401,7 +501,13 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
@override
Widget build(BuildContext context) {
final double fabBottomPadding = _showUpdateBanner ? 120.0 : 16.0;
double bannerHeight = 0.0;
if (_showUpdateBanner) {
bannerHeight = _isDownloading ? 152.0 : 96.0;
}
final double fabBottomPadding = _showUpdateBanner
? (bannerHeight + 20.0)
: 16.0;
return Scaffold(
appBar: AppBar(
title: Text(
@ -420,7 +526,15 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
return const Center(child: CircularProgressIndicator());
}
if (contactProvider.error != null) {
return Center(child: Text('Error: ${contactProvider.error}'));
return Center(
child: Text(
'${contactProvider.error?.replaceAll('Exception: ', '')}',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
);
}
return ListView.separated(
itemCount: contactProvider.contacts.length,
@ -459,7 +573,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
],
),
floatingActionButton: AnimatedPadding(
duration: const Duration(milliseconds: 300),
duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut,
padding: EdgeInsets.only(bottom: fabBottomPadding),
child: FloatingActionButton(

View File

@ -0,0 +1,300 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '/core/constants.dart';
import '/data/models/message_model.dart';
import '/data/models/contact_model.dart';
import '/logic/contact_provider.dart';
import '/domain/services/api_service.dart';
class ForwardContactPickerScreen extends StatefulWidget {
final MessageModel message;
const ForwardContactPickerScreen({
super.key,
required this.message,
});
@override
State<ForwardContactPickerScreen> createState() => _ForwardContactPickerScreenState();
}
class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen> {
Contact? _selectedContact;
bool _isInitLoading = true;
SharedPreferences? _prefs;
String? token;
@override
void initState() {
super.initState();
_loadActiveChats();
}
Future<void> _loadActiveChats() async {
try {
final contactProvider = context.read<ContactProvider>();
await contactProvider.loadContacts();
final apiService = ApiService();
final accessToken = await apiService.getAccessToken();
final shared = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_prefs = shared;
token = accessToken;
});
}
} catch (e) {
debugPrint("Ошибка при загрузке данных для пересылки: $e");
} finally {
if (mounted) {
setState(() {
_isInitLoading = false;
});
}
}
}
String _getDisplayName(Contact contact) {
if (_prefs == null) return contact.name;
final id = contact.id;
final savedName = _prefs!.getString('firstname_$id');
if (savedName != null && savedName.isNotEmpty) {
return savedName;
}
return contact.name;
}
String _formatTime(DateTime? time) {
if (time == null) return '';
final localTime = time.toLocal();
final hour = localTime.hour.toString().padLeft(2, '0');
final minute = localTime.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
@override
Widget build(BuildContext context) {
final contactProvider = context.watch<ContactProvider>();
final contacts = contactProvider.contacts;
final isLoading = _isInitLoading || contactProvider.isLoading;
final primaryColor = Theme.of(context).colorScheme.primary;
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text(
'Переслать...',
style: TextStyle(fontWeight: FontWeight.w600),
),
actions: [
AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _selectedContact != null ? 1.0 : 0.4,
child: TextButton(
onPressed: _selectedContact != null
? () => Navigator.of(context).pop(_selectedContact)
: null,
child: const Text(
'Продолжить',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
const SizedBox(width: 8),
],
),
body: () {
if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (contactProvider.error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
'Ошибка: ${contactProvider.error}',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
),
);
}
if (contacts.isEmpty) {
return const Center(
child: Text(
'Нет активных чатов для пересылки.',
style: TextStyle(color: Colors.grey, fontSize: 15),
),
);
}
return ListView.builder(
itemCount: contacts.length,
itemBuilder: (context, index) {
final contact = contacts[index];
final isSelected = _selectedContact?.id == contact.id;
// Логика формирования текста сообщения (1-в-1 как в твоем ContactTile)
final bool isDecrypted = contact.isLastMsgDecrypted ?? false;
final String subtitleText = isDecrypted
? (contact.lastMessage == null
? "Нет сообщений"
: "${contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : ''} ${contact.lastMessage}".trim())
: (contact.lastMessage != null
? "Ожидание дешифровки..."
: "Нет сообщений");
// Логика формирования URL аватарки
final avatarUrl = contact.effectiveAvatarUrl;
final bool hasAvatar = avatarUrl != null && avatarUrl.isNotEmpty;
return InkWell(
onTap: () {
setState(() {
if (isSelected) {
_selectedContact = null;
} else {
_selectedContact = contact;
}
});
},
child: Container(
color: isSelected ? primaryColor.withOpacity(0.08) : Colors.transparent,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
// 1. АВАТАРКА
leading: Stack(
children: [
if (hasAvatar)
CircleAvatar(
radius: 24,
backgroundColor: Colors.grey[200],
child: ClipOval(
child: CachedNetworkImage(
imageUrl: avatarUrl,
width: 48,
height: 48,
fit: BoxFit.cover,
httpHeaders: token != null ? {'Authorization': 'Bearer $token'} : null,
placeholder: (context, url) => const CircularProgressIndicator(strokeWidth: 2),
errorWidget: (context, url, error) => CircleAvatar(
radius: 24,
backgroundColor: primaryColor.withOpacity(0.1),
child: Text(
_getDisplayName(contact).isNotEmpty ? _getDisplayName(contact)[0].toUpperCase() : '?',
style: TextStyle(color: primaryColor, fontWeight: FontWeight.bold),
),
),
),
),
)
else
CircleAvatar(
radius: 24,
backgroundColor: primaryColor.withOpacity(0.1),
child: Text(
_getDisplayName(contact).isNotEmpty ? _getDisplayName(contact)[0].toUpperCase() : '?',
style: TextStyle(color: primaryColor, fontWeight: FontWeight.bold),
),
),
if (contact.isOnline == true)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Theme.of(context).scaffoldBackgroundColor, width: 2),
),
),
),
],
),
// 2. ИМЯ
title: Text(
_getDisplayName(contact),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
// 3. ПОСЛЕДНЕЕ СООБЩЕНИЕ
subtitle: Text(
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey),
),
// 4. ПРАВАЯ ЧАСТЬ (Анимация переключения Время <-> Галочка)
trailing: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
child: isSelected
? Container(
key: const ValueKey('checkmark'),
width: 24,
height: 24,
decoration: BoxDecoration(
color: primaryColor,
shape: BoxShape.circle,
),
child: const Icon(Icons.check_rounded, color: Colors.white, size: 16),
)
: Column(
key: const ValueKey('time_and_badge'),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(contact.lastMessageTime),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
if (contact.unreadCount > 0) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: primaryColor.withAlpha((0.5 * 255).round()),
shape: BoxShape.circle,
),
child: Text(
'${contact.unreadCount}',
style: const TextStyle(color: Colors.white, fontSize: 10),
),
),
],
],
),
),
),
),
);
},
);
}(),
);
}
}

View File

@ -0,0 +1,235 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:flutter/services.dart';
import 'dart:math';
import 'package:open_filex/open_filex.dart';
class MediaPreviewScreen extends StatefulWidget {
final String path;
final bool isVideo;
const MediaPreviewScreen({
super.key,
required this.path,
required this.isVideo,
});
@override
State<MediaPreviewScreen> createState() => _MediaPreviewScreenState();
}
class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
VideoPlayerController? _videoController;
bool _isPlaying = true;
String? _videoInitError;
@override
void initState() {
super.initState();
if (widget.isVideo) {
_videoController = VideoPlayerController.file(File(widget.path))
..initialize()
.then((_) {
_videoInitError = null;
if (!mounted) return;
setState(() {});
_videoController!.setLooping(false);
_videoController!.play();
})
.catchError((e) {
_videoInitError = e.toString();
_videoController?.dispose().catchError((_) {});
_videoController = null;
if (mounted) setState(() {});
});
_videoController!.addListener(() {
if (mounted) setState(() {});
});
}
}
@override
void dispose() {
_videoController?.dispose();
super.dispose();
}
String _formatDuration(Duration d) {
String two(int n) => n.toString().padLeft(2, '0');
final m = two(d.inMinutes.remainder(60));
final s = two(d.inSeconds.remainder(60));
return "$m:$s";
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: null,
body: Stack(
children: [
// MEDIA
Center(
child: widget.isVideo
? (_videoInitError != null)
? _buildVideoInitErrorFallback()
: (_videoController != null &&
_videoController!.value.isInitialized)
? Stack(
alignment: Alignment.center,
children: [
AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: VideoPlayer(_videoController!),
),
// overlay controls
Positioned(
bottom: 40,
left: 16,
right: 16,
child: _buildVideoControls(),
),
],
)
: const CircularProgressIndicator()
: Image.file(File(widget.path)),
),
// BOTTOM ACTIONS (как Telegram)
Positioned(
bottom: 40,
left: 16,
right: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// переснять
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white10,
),
onPressed: () {
Navigator.pop(context, false);
},
icon: const Icon(Icons.refresh),
label: const Text("Переснять"),
),
// отправить
ElevatedButton.icon(
onPressed: () {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.pop(context, true);
});
},
icon: const Icon(Icons.send),
label: const Text("Отправить"),
),
],
),
),
],
),
);
}
Widget _buildVideoControls() {
final c = _videoController!;
final duration = c.value.duration;
final position = c.value.position;
final posMs = position.inMilliseconds.toDouble();
final maxMs = duration.inMilliseconds
.toDouble()
.clamp(1, double.infinity)
.toDouble();
return Container(
child: Row(
children: [
// / слева
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: Icon(
c.value.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
size: 26,
),
onPressed: () {
setState(() {
if (c.value.isPlaying) {
c.pause();
_isPlaying = false;
} else {
c.play();
_isPlaying = true;
}
});
},
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2, // ТОНКИЙ как в Telegram
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white24,
thumbColor: Colors.white,
overlayColor: Colors.transparent,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5),
),
child: Slider(
value: posMs.clamp(0, maxMs).toDouble(),
min: 0,
max: maxMs,
onChanged: (v) {
c.seekTo(Duration(milliseconds: v.toInt()));
},
),
),
),
const SizedBox(width: 8),
Text(
"${_formatDuration(position)} / ${_formatDuration(duration)}",
style: const TextStyle(color: Colors.white, fontSize: 12),
),
],
),
);
}
Widget _buildVideoInitErrorFallback() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_disabled, color: Colors.white70, size: 48),
const SizedBox(height: 10),
const Text(
'Видео не воспроизводится на этом устройстве',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: () async {
try {
await OpenFilex.open(widget.path);
} catch (_) {}
},
icon: const Icon(Icons.open_in_new, color: Colors.white70),
label: const Text(
'Открыть внешним плеером',
style: TextStyle(color: Colors.white70),
),
),
],
);
}
}

View File

@ -0,0 +1,330 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
import 'package:open_filex/open_filex.dart';
class MediaItem {
final String path;
final bool isVideo;
MediaItem({
required this.path,
required this.isVideo,
});
}
class MediaViewer extends StatefulWidget {
final List<MediaItem> items;
final int initialIndex;
const MediaViewer({
super.key,
required this.items,
this.initialIndex = 0,
});
@override
State<MediaViewer> createState() => _MediaViewerState();
}
class _MediaViewerState extends State<MediaViewer> {
late PageController _pageController;
VideoPlayerController? _videoController;
String? _videoInitError;
int _index = 0;
bool _uiVisible = true;
bool _isLandscape = false;
@override
void initState() {
super.initState();
_index = widget.initialIndex;
_pageController = PageController(initialPage: _index);
// 1. Скрываем строку состояния и панель навигации при входе в плеер
_hideSystemUI();
_initVideoIfNeeded(_index);
}
Future<void> _initVideoIfNeeded(int index) async {
_videoController?.removeListener(_videoListener);
_videoController?.dispose();
_videoController = null;
_videoInitError = null;
final item = widget.items[index];
if (!item.isVideo) return;
final controller = VideoPlayerController.file(File(item.path));
_videoController = controller;
try {
await controller.initialize();
_videoController!.addListener(_videoListener);
controller.setLooping(false);
controller.play();
_videoInitError = null;
} catch (e) {
_videoInitError = e.toString();
_videoController?.removeListener(_videoListener);
await _videoController?.dispose().catchError((_) {});
_videoController = null;
} finally {
if (mounted) setState(() {});
}
}
void _videoListener() {
if (mounted) {
setState(() {});
}
}
// Метод скрытия системного UI (Status bar и Navigation bar)
void _hideSystemUI() {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
}
// Метод показа системного UI при выходе из полноэкранного режима
void _showSystemUI() {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values, // Возвращает статус-бар и нижний бар
);
}
void _toggleOrientation() {
setState(() {
_isLandscape = !_isLandscape;
});
if (_isLandscape) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
} else {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
}
}
@override
void dispose() {
_videoController?.removeListener(_videoListener);
_videoController?.dispose();
_pageController.dispose();
// 2. Обязательно возвращаем системный UI и портретный режим при выходе
_showSystemUI();
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
super.dispose();
}
void _toggleUI() {
setState(() => _uiVisible = !_uiVisible);
}
String _format(Duration d) {
String two(int n) => n.toString().padLeft(2, '0');
return "${two(d.inMinutes.remainder(60))}:${two(d.inSeconds.remainder(60))}";
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
// SafeArea гарантирует, что даже если системные бары скрыты/показаны,
// интерактивные элементы интерфейса (кнопки закрытия, плеер)
// никогда не уйдут под физические вырезы экрана (челку, скругления)
body: SafeArea(
child: GestureDetector(
onTap: _toggleUI,
behavior: HitTestBehavior.opaque,
child: Stack(
children: [
// MEDIA PAGES (Контент растягивается на весь экран)
Positioned.fill(
child: PageView.builder(
controller: _pageController,
onPageChanged: (i) async {
setState(() => _index = i);
await _initVideoIfNeeded(i);
},
itemCount: widget.items.length,
itemBuilder: (context, i) {
final item = widget.items[i];
if (item.isVideo) {
if (_videoInitError != null) {
return _buildVideoInitErrorFallback(item.path);
}
if (_videoController == null ||
!_videoController!.value.isInitialized) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
return Center(
child: AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: VideoPlayer(_videoController!),
),
);
}
return Center(
child: InteractiveViewer(
maxScale: 4.0,
child: Image.file(
File(item.path),
fit: BoxFit.contain,
),
),
);
},
),
),
// TOP BAR (Кнопки управления сверху)
if (_uiVisible)
Positioned(
top: 10, // Маленький фиксированный отступ, т.к. SafeArea уже защищает сверху
left: 16,
right: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.close, color: Colors.white, size: 28),
onPressed: () => Navigator.pop(context),
),
IconButton(
icon: Icon(
Icons.screen_rotation,
color: Colors.white,
size: 26,
),
onPressed: _toggleOrientation,
),
],
),
),
// VIDEO CONTROLS (Нижняя панель управления видео)
if (_uiVisible &&
widget.items[_index].isVideo &&
_videoController != null &&
_videoController!.value.isInitialized)
Positioned(
bottom: 10, // Прижато к низу безопасной зоны SafeArea
left: 16,
right: 16,
child: _buildVideoControls(),
),
],
),
),
),
);
}
Widget _buildVideoControls() {
final c = _videoController!;
final pos = c.value.position;
final dur = c.value.duration;
final posMs = pos.inMilliseconds.toDouble();
final maxMs = dur.inMilliseconds.toDouble().clamp(1, double.infinity).toDouble();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(30),
),
child: Row(
children: [
IconButton(
icon: Icon(
c.value.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
size: 28,
),
onPressed: () {
setState(() {
c.value.isPlaying ? c.pause() : c.play();
});
},
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 4,
activeTrackColor: Colors.white60,
inactiveTrackColor: Colors.white30,
thumbColor: Colors.white,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayColor: Colors.transparent,
),
child: Slider(
value: posMs.clamp(0, maxMs),
min: 0,
max: maxMs,
onChanged: (v) {
c.seekTo(Duration(milliseconds: v.toInt()));
},
),
),
),
const SizedBox(width: 8),
Text(
"${_format(pos)} / ${_format(dur)}",
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
],
),
);
}
Widget _buildVideoInitErrorFallback(String path) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.play_disabled, color: Colors.white70, size: 56),
const SizedBox(height: 10),
const Text(
'Видео не воспроизводится на этом устройстве',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: () async {
try {
await OpenFilex.open(path);
} catch (_) {}
},
icon: const Icon(Icons.open_in_new, color: Colors.white70),
label: const Text(
'Открыть внешним плеером',
style: TextStyle(color: Colors.white70),
),
),
],
),
);
}
}

View File

@ -14,14 +14,12 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
static const _showPhoneKey = 'privacy_show_phone';
static const _showAvatarKey = 'privacy_show_avatar';
static const _showAboutKey = 'privacy_show_about';
static const _showUsernameKey = 'privacy_show_username';
static const _showLastOnlineKey = 'privacy_show_last_online';
bool _showEmail = true;
bool _showPhone = true;
bool _showAvatar = true;
bool _showAbout = true;
bool _showUsername = true;
bool _showLastOnline = true;
bool _isSaving = false;
@ -39,7 +37,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
_showPhone = prefs.getBool(_showPhoneKey) ?? true;
_showAvatar = prefs.getBool(_showAvatarKey) ?? true;
_showAbout = prefs.getBool(_showAboutKey) ?? true;
_showUsername = prefs.getBool(_showUsernameKey) ?? true;
_showLastOnline = prefs.getBool(_showLastOnlineKey) ?? true;
});
}
@ -53,7 +50,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
_showPhone = data['show_phone'] ?? true;
_showAvatar = data['show_avatar'] ?? true;
_showAbout = data['show_about'] ?? true;
_showUsername = data['show_username'] ?? true;
_showLastOnline = data['show_last_online'] ?? true;
});
// Сохраняем локально для быстрого доступа
@ -61,7 +57,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
await _savePreference(_showPhoneKey, _showPhone);
await _savePreference(_showAvatarKey, _showAvatar);
await _savePreference(_showAboutKey, _showAbout);
await _savePreference(_showUsernameKey, _showUsername);
await _savePreference(_showLastOnlineKey, _showLastOnline);
} catch (e) {
// Если не удалось загрузить с сервера, используем локальные настройки
@ -86,7 +81,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
showPhone: _showPhone,
showAvatar: _showAvatar,
showAbout: _showAbout,
showUsername: _showUsername,
showLastOnline: _showLastOnline,
);
@ -96,7 +90,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
await _savePreference(_showPhoneKey, _showPhone);
await _savePreference(_showAvatarKey, _showAvatar);
await _savePreference(_showAboutKey, _showAbout);
await _savePreference(_showUsernameKey, _showUsername);
await _savePreference(_showLastOnlineKey, _showLastOnline);
if (mounted) {
@ -150,13 +143,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
children: [
const Text('Настройки видимости', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
SwitchListTile(
title: const Text('Показывать имя пользователя (@username)'),
value: _showUsername,
onChanged: (value) {
setState(() => _showUsername = value);
},
),
SwitchListTile(
title: const Text('Показывать почту другим'),
value: _showEmail,

View File

@ -54,6 +54,8 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
}
Future<void> _loadUserData() async {
_error = null;
_isLoading = true;
try {
final api = ApiService();
final data = await api.getUserById(widget.userId);
@ -74,10 +76,18 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString().replaceAll('Exception: ', '');
if (e.toString().contains('SocketFailed')) {
_error =
'Ошибка соединения с сервером. Проверьте интернет соединение.';
} else {
_error = e.toString().replaceAll('Exception: ', '');
}
_isLoading = false;
});
}
Future.delayed(Duration(seconds: 2), () {
_loadUserData();
});
}
}
@ -195,7 +205,8 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
textAlign: TextAlign.center,
)
else if (DateTime.tryParse(_userData!['last_online']) != null)
else if (_userData!['last_online'] != null &&
DateTime.tryParse(_userData!['last_online']) != null)
Text(
'Был(а) в сети ${_formatLastOnline(DateTime.tryParse(_userData!['last_online'])!.add(offset != null ? offset! : Duration.zero))}',
style: const TextStyle(
@ -365,6 +376,9 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
return '${difference.inHours} час${_pluralize(difference.inHours, "", "а", "ов")} назад';
} else if (difference.inDays < 7) {
return '${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
} else if (difference.inDays < 30) {
final weeks = (difference.inDays / 7).floor();
return '$weeks ${_pluralize(weeks, "неделю", "недели", "недель")} назад';
} else {
return 'давно';
}

View File

@ -1,13 +1,15 @@
import 'package:chepuhagram/domain/services/aPI_service.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '/data/models/contact_model.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:chepuhagram/data/models/message_model.dart';
class ContactTile extends StatefulWidget {
final Contact contact;
final VoidCallback? onTap;
const ContactTile({super.key, required this.contact, this.onTap});
ContactTile({super.key, required this.contact, this.onTap});
@override
State<ContactTile> createState() => _ContactTileState();
@ -15,24 +17,22 @@ class ContactTile extends StatefulWidget {
class _ContactTileState extends State<ContactTile> {
SharedPreferences? _prefs;
Duration? offset;
String? token;
@override
void initState() {
super.initState();
DateTime now = DateTime.now();
offset = now.timeZoneOffset;
_initPrefs();
}
Future<void> _initPrefs() async {
final apiService = ApiService();
final accessToken = await apiService.getAccessToken();
final shared = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_prefs = shared;
token = accessToken;
});
}
}
@ -59,8 +59,8 @@ class _ContactTileState extends State<ContactTile> {
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
final username = widget.contact.username; //
final username = widget.contact.username;
final initials =
(displayName.isNotEmpty
? displayName
@ -70,60 +70,108 @@ class _ContactTileState extends State<ContactTile> {
.where((p) => p.isNotEmpty)
.take(2)
.map((p) => p[0].toUpperCase())
.join();
.join(); //
debugPrint(
'=== CONTACT DEBUG: ${widget.contact.name} -> URL: ${widget.contact.effectiveAvatarUrl}',
);
return ListTile(
onTap: widget.onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: CircleAvatar(
radius: 28,
backgroundColor: primary.withAlpha((0.1 * 255).round()),
backgroundImage: widget.contact.effectiveAvatarUrl != null
? CachedNetworkImageProvider(widget.contact.effectiveAvatarUrl!)
: null,
child: widget.contact.effectiveAvatarUrl == null
? Text(
initials,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
onTap: widget.onTap, //
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
), //
// Переписываем ведущий виджет (аватарку)
leading: SizedBox(
width: 56, // Соответствует радиусу 28 * 2
height: 56,
child:
widget.contact.effectiveAvatarUrl !=
null //
? CachedNetworkImage(
imageUrl: widget.contact.effectiveAvatarUrl!, //
// Передаем токен для FastAPI, чтобы сервер разрешил скачивание файла
httpHeaders: {
if (token != null) 'Authorization': 'Bearer $token',
},
imageBuilder: (context, imageProvider) => Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
image: imageProvider,
fit: BoxFit.cover,
),
),
),
// Пока картинка качается показываем цветной круг с инициалами
placeholder: (context, url) => CircleAvatar(
radius: 28,
backgroundColor: primary.withAlpha((0.1 * 255).round()),
child: Text(
initials,
style: TextStyle(
color: primary,
fontWeight: FontWeight.bold,
),
),
),
// Ошибка 401, 404 или упал интернет? Без паники, плавно вернем инициалы
errorWidget: (context, url, error) => CircleAvatar(
radius: 28,
backgroundColor: primary.withAlpha((0.1 * 255).round()),
child: Text(
initials,
style: TextStyle(
color: primary,
fontWeight: FontWeight.bold,
),
),
),
)
: null,
: CircleAvatar(
radius: 28,
backgroundColor: primary.withAlpha((0.1 * 255).round()), //
child: Text(
initials,
style: TextStyle(color: primary, fontWeight: FontWeight.bold),
),
),
),
title: Text(
displayName,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
displayName, //
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), //
),
subtitle: Text(
widget.contact.isLastMsgDecrypted
? widget.contact.lastMessage ?? "Нет сообщений"
? widget.contact.lastMessage == null
? "Нет сообщений"
: "${widget.contact.lastMessageType != null ? MessageModel.getMediaPreview(widget.contact.lastMessageType!) : ''} ${widget.contact.lastMessage}"
: (widget.contact.lastMessage != null
? "Ожидание дешифровки..."
: "Нет сообщений"),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey),
style: TextStyle(color: Colors.grey), //
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatTime(widget.contact.lastMessageTime),
style: const TextStyle(color: Colors.grey, fontSize: 12),
_formatTime(widget.contact.lastMessageTime), //
style: TextStyle(color: Colors.grey, fontSize: 12), //
),
const SizedBox(height: 4),
if (widget.contact.unreadCount > 0)
SizedBox(height: 4), //
if (widget.contact.unreadCount > 0) //
Container(
padding: const EdgeInsets.all(6),
padding: EdgeInsets.all(6), //
decoration: BoxDecoration(
color: primary.withAlpha((0.5 * 255).round()),
shape: BoxShape.circle,
color: primary.withAlpha((0.5 * 255).round()), //
shape: BoxShape.circle, //
),
child: Text(
'${widget.contact.unreadCount}',
style: const TextStyle(color: Colors.white, fontSize: 10),
'${widget.contact.unreadCount}', //
style: TextStyle(color: Colors.white, fontSize: 10), //
),
),
],
@ -133,7 +181,6 @@ class _ContactTileState extends State<ContactTile> {
String _formatTime(DateTime? time) {
if (time == null) return "";
time = time.add(offset!);
return "${time.hour}:${time.minute.toString().padLeft(2, '0')}";
}
}

File diff suppressed because it is too large Load Diff

View File

@ -6,17 +6,25 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
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) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_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,8 +3,10 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux
flutter_secure_storage_linux
record_linux
url_launcher_linux
)

View File

@ -5,6 +5,9 @@
import FlutterMacOS
import Foundation
import audioplayers_darwin
import ffmpeg_kit_flutter_new_min_gpl
import file_picker
import file_selector_macos
import firebase_analytics
import firebase_core
@ -16,11 +19,18 @@ import gal
import local_auth_darwin
import package_info_plus
import path_provider_foundation
import photo_manager
import record_macos
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
import video_compress
import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
FFmpegKitFlutterPlugin.register(with: registry.registrar(forPlugin: "FFmpegKitFlutterPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
@ -32,7 +42,11 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "PhotoManagerPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
}

View File

@ -9,6 +9,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.35"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args:
dependency: transitive
description:
@ -25,6 +33,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
audioplayers:
dependency: "direct main"
description:
name: audioplayers
sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70
url: "https://pub.dev"
source: hosted
version: "6.6.0"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91
url: "https://pub.dev"
source: hosted
version: "6.4.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
url: "https://pub.dev"
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9
url: "https://pub.dev"
source: hosted
version: "5.2.0"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734
url: "https://pub.dev"
source: hosted
version: "4.3.0"
boolean_selector:
dependency: transitive
description:
@ -57,14 +121,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.1"
camera:
dependency: "direct main"
description:
name: camera
sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437"
url: "https://pub.dev"
source: hosted
version: "0.11.4"
camera_android_camerax:
dependency: transitive
description:
name: camera_android_camerax
sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577"
url: "https://pub.dev"
source: hosted
version: "0.6.30"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b"
url: "https://pub.dev"
source: hosted
version: "0.9.23+2"
camera_platform_interface:
dependency: transitive
description:
name: camera_platform_interface
sha256: "7ac852d77699acee79f0d438b793feee26721841e50973576419ff5c6d95e9b7"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
camera_web:
dependency: transitive
description:
name: camera_web
sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7"
url: "https://pub.dev"
source: hosted
version: "0.3.5+3"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@ -113,6 +233,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.9.0"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@ -145,6 +273,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
extended_image:
dependency: transitive
description:
name: extended_image
sha256: f6cbb1d798f51262ed1a3d93b4f1f2aa0d76128df39af18ecb77fa740f88b2e0
url: "https://pub.dev"
source: hosted
version: "10.0.1"
extended_image_library:
dependency: transitive
description:
name: extended_image_library
sha256: "1f9a24d3a00c2633891c6a7b5cab2807999eb2d5b597e5133b63f49d113811fe"
url: "https://pub.dev"
source: hosted
version: "5.0.1"
fake_async:
dependency: transitive
description:
@ -161,6 +305,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffmpeg_kit_flutter_new_min_gpl:
dependency: "direct main"
description:
name: ffmpeg_kit_flutter_new_min_gpl
sha256: "7009b1a8a75188b4f8c13ba4bbc399c8e57b13bab9ee172f4a5583774d850efd"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
ffmpeg_kit_flutter_platform_interface:
dependency: transitive
description:
name: ffmpeg_kit_flutter_platform_interface
sha256: addf046ae44e190ad0101b2fde2ad909a3cd08a2a109f6106d2f7048b7abedee
url: "https://pub.dev"
source: hosted
version: "0.2.1"
file:
dependency: transitive
description:
@ -169,6 +329,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
url: "https://pub.dev"
source: hosted
version: "11.0.2"
file_selector_linux:
dependency: transitive
description:
@ -294,6 +462,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_http_cache:
dependency: "direct main"
description:
name: flutter_http_cache
sha256: "2227f5694d730622d6dad580b0e4fdfec6b5884868148101d13c61a09661fa78"
url: "https://pub.dev"
source: hosted
version: "0.0.3"
flutter_image_compress:
dependency: "direct main"
description:
@ -342,6 +518,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.1.5"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_linkify:
dependency: "direct main"
description:
@ -456,6 +640,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: "direct main"
description:
@ -464,6 +656,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_client_helper:
dependency: transitive
description:
name: http_client_helper
sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
http_parser:
dependency: transitive
description:
@ -472,14 +672,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.2.2"
image_picker_android:
dependency: transitive
description:
@ -568,6 +776,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
jwt_decoder:
dependency: "direct main"
description:
@ -660,26 +876,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: transitive
description:
@ -792,6 +1008,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
url: "https://pub.dev"
source: hosted
version: "12.0.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:
@ -800,6 +1064,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.2"
photo_manager:
dependency: "direct main"
description:
name: photo_manager
sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2
url: "https://pub.dev"
source: hosted
version: "3.9.0"
photo_manager_image_provider:
dependency: transitive
description:
name: photo_manager_image_provider
sha256: b6015b67b32f345f57cf32c126f871bced2501236c405aafaefa885f7c821e4f
url: "https://pub.dev"
source: hosted
version: "2.2.0"
platform:
dependency: transitive
description:
@ -816,6 +1096,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pointycastle:
dependency: "direct main"
description:
name: pointycastle
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
url: "https://pub.dev"
source: hosted
version: "3.9.1"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
provider:
dependency: "direct main"
description:
@ -824,6 +1120,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
record:
dependency: "direct main"
description:
name: record
sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
url: "https://pub.dev"
source: hosted
version: "6.2.0"
record_android:
dependency: transitive
description:
name: record_android
sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
record_ios:
dependency: transitive
description:
name: record_ios
sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
record_linux:
dependency: transitive
description:
name: record_linux
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
record_platform_interface:
dependency: transitive
description:
name: record_platform_interface
sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
record_web:
dependency: transitive
description:
name: record_web
sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_windows:
dependency: transitive
description:
name: record_windows
sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
rxdart:
dependency: transitive
description:
@ -957,6 +1317,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@ -985,10 +1353,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.11"
timezone:
dependency: transitive
description:
@ -1085,6 +1453,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
video_compress:
dependency: "direct main"
description:
name: video_compress
sha256: "31bc5cdb9a02ba666456e5e1907393c28e6e0e972980d7d8d619a7beda0d4f20"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0"
url: "https://pub.dev"
source: hosted
version: "2.9.5"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
url: "https://pub.dev"
source: hosted
version: "2.9.4"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0"
url: "https://pub.dev"
source: hosted
version: "6.7.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
video_thumbnail:
dependency: "direct main"
description:
name: video_thumbnail
sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b"
url: "https://pub.dev"
source: hosted
version: "0.5.6"
visibility_detector:
dependency: "direct main"
description:
name: visibility_detector
sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420
url: "https://pub.dev"
source: hosted
version: "0.4.0+2"
vm_service:
dependency: transitive
description:
@ -1117,6 +1549,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
wechat_assets_picker:
dependency: "direct main"
description:
name: wechat_assets_picker
sha256: c307e50394c1e6dfcd5c4701e84efb549fce71444fedcf2e671c50d809b3e2a1
url: "https://pub.dev"
source: hosted
version: "9.8.0"
wechat_picker_library:
dependency: transitive
description:
name: wechat_picker_library
sha256: "5cb61b9aa935b60da5b043f8446fbb9c5077419f20ccc4856bf444aec4f44bc1"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
win32:
dependency: transitive
description:
@ -1141,6 +1589,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0"

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: 2.0.1+1
version: 2.0.2+1
environment:
sdk: ^3.10.0
@ -50,16 +50,30 @@ dependencies:
shared_preferences: ^2.5.5
flutter_linkify: ^6.0.0
url_launcher: ^6.3.2
image_picker: ^1.0.4
gal: ^2.3.2
flutter_image_compress: ^2.1.0
dio: ^5.9.2
package_info_plus: ^9.0.1
open_filex: ^4.3.2
open_filex: ^4.7.0
convert: ^3.1.2
cached_network_image: ^3.3.1
flutter_cache_manager: ^3.0.2
path_provider: ^2.1.3
file_picker: ^11.0.2
video_compress: ^3.1.0
video_player: ^2.11.1
flutter_http_cache: ^0.0.3
image_picker: ^1.2.2
permission_handler: ^12.0.1
wechat_assets_picker: ^9.0.0
photo_manager: ^3.0.0
camera: ^0.11.0
pointycastle: ^3.9.1
visibility_detector: ^0.4.0+2
video_thumbnail: ^0.5.3
record: ^6.2.0
audioplayers: ^6.6.0
ffmpeg_kit_flutter_new_min_gpl: ^2.1.1
dev_dependencies:
flutter_test:
@ -72,6 +86,14 @@ dev_dependencies:
# rules and activating additional ones.
flutter_lints: ^6.0.0
flutter_launcher_icons: "^0.14.0"
flutter_launcher_icons:
android: "launcher_icon"
ios: true
image_path: "assets/images/icon.png"
remove_alpha_channel_ios: true
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@ -1,3 +1,4 @@
import shutil
from fastapi import Depends, FastAPI, HTTPException, status, APIRouter, File, UploadFile, Request, Form
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
@ -49,13 +50,16 @@ def _parse_multipart_body(body: bytes):
if not disposition_match:
continue
field_name = disposition_match.group(1).decode('utf-8', errors='ignore')
field_name = disposition_match.group(
1).decode('utf-8', errors='ignore')
filename = disposition_match.group(2)
if field_name != 'file':
continue
filename = filename.decode('utf-8', errors='ignore') if filename else 'upload.bin'
content_type_match = re.search(br'Content-Type:\s*([\w\-\/]+)', headers, re.IGNORECASE)
filename = filename.decode(
'utf-8', errors='ignore') if filename else 'upload.bin'
content_type_match = re.search(
br'Content-Type:\s*([\w\-\/]+)', headers, re.IGNORECASE)
content_type = (
content_type_match.group(1).decode('utf-8', errors='ignore')
if content_type_match
@ -86,14 +90,16 @@ def _encode_multipart_formdata(fields, files):
for name, value in fields.items():
body.write(f"--{boundary}\r\n".encode('utf-8'))
body.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode('utf-8'))
body.write(
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode('utf-8'))
body.write(str(value).encode('utf-8'))
body.write(b"\r\n")
for field_name, filename, content_type, file_bytes in files:
body.write(f"--{boundary}\r\n".encode('utf-8'))
body.write(
f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'.encode('utf-8')
f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'.encode(
'utf-8')
)
body.write(f"Content-Type: {content_type}\r\n\r\n".encode('utf-8'))
body.write(file_bytes)
@ -130,9 +136,11 @@ def _stream_response_from_remote(url: str):
except urllib.error.HTTPError as exc:
if exc.code == 404:
raise HTTPException(status_code=404, detail='File not found')
raise HTTPException(status_code=502, detail=f'Error fetching media from home server: {exc.code}')
raise HTTPException(
status_code=502, detail=f'Error fetching media from home server: {exc.code}')
except Exception as exc:
raise HTTPException(status_code=502, detail=f'Could not reach home server: {exc}')
raise HTTPException(
status_code=502, detail=f'Could not reach home server: {exc}')
headers = {k.lower(): v for k, v in response.getheaders()}
content_type = headers.get('content-type', 'application/octet-stream')
@ -146,7 +154,8 @@ def _stream_response_from_remote(url: str):
def _post_file_to_home(item: models.CloudMediaItem) -> tuple[bool, str]:
file_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
file_path = os.path.join(
config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if not os.path.exists(file_path):
return False, 'Local cache file not found'
@ -159,7 +168,8 @@ def _post_file_to_home(item: models.CloudMediaItem) -> tuple[bool, str]:
'original_filename': item.original_filename or item.local_filename,
}
files = [
('file', item.original_filename or item.local_filename, item.content_type or 'application/octet-stream', content),
('file', item.original_filename or item.local_filename,
item.content_type or 'application/octet-stream', content),
]
body, boundary = _encode_multipart_formdata(fields, files)
request = urllib.request.Request(
@ -201,7 +211,8 @@ def _cleanup_home_quota(db: Session, owner_id: int | None):
for file_record in files:
if total <= config.HOME_USER_QUOTA_BYTES:
break
path = os.path.join(config.HOME_MEDIA_FOLDER, file_record.storage_filename)
path = os.path.join(config.HOME_MEDIA_FOLDER,
file_record.storage_filename)
if os.path.exists(path):
os.remove(path)
total -= file_record.size_bytes
@ -212,7 +223,8 @@ def _cleanup_home_quota(db: Session, owner_id: int | None):
def _cleanup_all_home_storage():
db = models.SessionLocal()
try:
owner_ids = db.query(models.HomeMediaFile.owner_id).filter(models.HomeMediaFile.owner_id.isnot(None)).distinct().all()
owner_ids = db.query(models.HomeMediaFile.owner_id).filter(
models.HomeMediaFile.owner_id.isnot(None)).distinct().all()
for owner_id_tuple in owner_ids:
_cleanup_home_quota(db, owner_id_tuple[0])
finally:
@ -248,7 +260,8 @@ async def forward_pending_media_loop():
item.sent_at = func.now()
item.error_message = None
db.commit()
cache_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
cache_path = os.path.join(
config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if os.path.exists(cache_path):
os.remove(cache_path)
else:
@ -293,7 +306,8 @@ async def upload_file(
content = await uploaded_file.read()
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
raise HTTPException(status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
raise HTTPException(
status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
file_id = uuid.uuid4().hex
filename = f"{file_id}.enc"
@ -315,7 +329,8 @@ async def upload_file_v2(
current_user: models.User = Depends(get_current_user),
):
if config.SERVER_ROLE != 'cloud':
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Upload endpoint is available only on cloud server')
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Upload endpoint is available only on cloud server')
uploaded_file = await _get_upload_file(request, file)
if uploaded_file is None or not uploaded_file.filename:
@ -323,7 +338,8 @@ async def upload_file_v2(
content = await uploaded_file.read()
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
raise HTTPException(status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
raise HTTPException(
status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
db = models.SessionLocal()
try:
@ -337,7 +353,8 @@ async def upload_file_v2(
file_id = uuid.uuid4().hex
local_filename = f"{file_id}.enc"
storage_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, local_filename)
storage_path = os.path.join(
config.CLOUD_MEDIA_CACHE_FOLDER, local_filename)
with open(storage_path, 'wb') as f:
f.write(content)
@ -369,7 +386,8 @@ async def receive_media(
):
secret = request.headers.get('X-Media-Forwarding-Secret')
if secret != config.MEDIA_FORWARDING_SECRET:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid forwarding secret')
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid forwarding secret')
uploaded_file = await _get_upload_file(request, file)
if uploaded_file is None or not uploaded_file.filename:
@ -377,7 +395,8 @@ async def receive_media(
content = await uploaded_file.read()
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
raise HTTPException(status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
raise HTTPException(
status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
file_id = cloud_file_id or uuid.uuid4().hex
storage_filename = f"{file_id}.enc"
@ -404,13 +423,162 @@ async def receive_media(
return {'status': 'ok', 'file_id': file_id}
@mediaRouter.get('/size/{file_id}')
async def get_file_size(file_id: str):
db = models.SessionLocal()
db_file = None
try:
db_file = db.query(models.HomeMediaFile).filter(
models.HomeMediaFile.file_id == file_id).first()
finally:
db.close()
# 1. Проверяем наличие файла локально на этом сервере
local_path = _find_local_media_path(file_id)
if local_path and os.path.exists(local_path):
file_size = os.path.getsize(local_path)
filename = db_file.original_filename if db_file else f"file_{file_id}"
content_type = db_file.content_type if db_file else 'application/octet-stream'
encoded_filename = urllib.parse.quote(filename)
return {"file_id": file_id, "size": file_size, "file_name": encoded_filename, "content_type": content_type}
# 2. Если роль сервера 'cloud', запрашиваем размер у домашнего сервера
if config.SERVER_ROLE == 'cloud':
remote_url = f"{config.HOME_SERVER_URL}/media/size/{file_id}"
try:
# Выполняем синхронный легковесный подзапрос к домашнему серверу в треде,
# чтобы не блокировать асинхронный цикл FastAPI (по аналогии с деплоем стримов)
def _fetch_remote_size():
req = urllib.request.Request(remote_url, method='GET')
with urllib.request.urlopen(req, timeout=5.0) as response:
if response.status == 200:
import json
return json.loads(response.read().decode('utf-8'))
return None
remote_data = await asyncio.to_thread(_fetch_remote_size)
if remote_data:
return remote_data
except urllib.error.HTTPError as e:
if e.code == 404:
raise HTTPException(
status_code=404, detail='File not found on home server')
raise HTTPException(status_code=e.code, detail='Home server error')
except Exception as e:
print(f"Ошибка подключения к домашнему серверу: {e}")
raise HTTPException(
status_code=502, detail='Home server is unavailable')
# 3. Если файл не найден ни локально, ни на удаленном сервере
raise HTTPException(status_code=404, detail='File not found')
@mediaRouter.get('/{file_id}')
async def get_file(file_id: str):
db = models.SessionLocal()
db_file = None
try:
db_file = db.query(models.HomeMediaFile).filter(
models.HomeMediaFile.file_id == file_id).first()
finally:
db.close()
local_path = _find_local_media_path(file_id)
if local_path:
return FileResponse(local_path, media_type='application/octet-stream')
filename = db_file.original_filename if db_file else f"file_{file_id}"
content_type = db_file.content_type if db_file else 'application/octet-stream'
encoded_filename = urllib.parse.quote(filename)
headers = {
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
return FileResponse(
local_path,
media_type=content_type,
headers=headers
)
if config.SERVER_ROLE == 'cloud':
return _stream_response_from_remote(f"{config.HOME_SERVER_URL}/media/{file_id}")
raise HTTPException(status_code=404, detail='File not found')
@mediaRouter.post('/copy_internal')
async def copy_file_internal(
request: Request,
file_id: str = Form(...),
owner_id: int = Form(...), # ID нового владельца (получателя)
):
# Проверка секрета
secret = request.headers.get('X-Media-Forwarding-Secret')
if secret != config.MEDIA_FORWARDING_SECRET:
raise HTTPException(status_code=401, detail='Unauthorized')
# 1. Находим файл
source_path = _find_local_media_path(file_id)
if not source_path:
raise HTTPException(status_code=404, detail='Source file not found')
# 2. Создаем новый ID и путь
new_file_id = uuid.uuid4().hex
new_storage_filename = f"{new_file_id}.enc"
dest_path = os.path.join(config.HOME_MEDIA_FOLDER, new_storage_filename)
# 3. Физическое копирование
shutil.copyfile(source_path, dest_path)
# 4. Обновляем БД
db = models.SessionLocal()
try:
old_record = db.query(models.HomeMediaFile).filter(
models.HomeMediaFile.file_id == file_id).first()
new_record = models.HomeMediaFile(
file_id=new_file_id,
owner_id=owner_id,
original_filename=old_record.original_filename if old_record else "copy.enc",
content_type=old_record.content_type if old_record else 'application/octet-stream',
storage_filename=new_storage_filename,
size_bytes=os.path.getsize(dest_path),
)
db.add(new_record)
db.commit()
finally:
db.close()
return {"status": "ok", "new_file_id": new_file_id}
@mediaRouter.post('/copy')
async def copy(
file_id: str = Form(...),
current_user: models.User = Depends(get_current_user),
):
if config.SERVER_ROLE != 'cloud':
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Upload endpoint is available only on cloud server')
# Делаем запрос к домашнему серверу
url = f"{config.HOME_SERVER_URL}/media/copy_internal"
# Используем FormData для передачи параметров на домашний сервер
body_data = f"file_id={file_id}&owner_id={current_user.id}".encode('utf-8')
request = urllib.request.Request(
url,
data=body_data,
headers={
'X-Media-Forwarding-Secret': config.MEDIA_FORWARDING_SECRET,
'Content-Type': 'application/x-www-form-urlencoded'
},
method='POST'
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
if response.status == 200:
import json
return json.loads(response.read().decode('utf-8'))
except Exception as e:
raise HTTPException(
status_code=502, detail=f'Failed to copy on home server: {e}')
raise HTTPException(status_code=500, detail='Copying failed')

View File

@ -37,6 +37,21 @@ async def get_chat_history(
return jsonable_encoder(messages)
@messagesRouter.get("/last")
async def get_last_messages(
contact_id: int,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
limit: int = 2
):
messages = db.query(models.Message).filter(
(models.Message.sender_id == current_user.id) & (models.Message.receiver_id == contact_id) |
(models.Message.sender_id == contact_id) & (models.Message.receiver_id == current_user.id)
).order_by(models.Message.timestamp.desc()).limit(limit).all()
return jsonable_encoder(messages)
@messagesRouter.delete("/all")
async def delete_all_messages(
current_user: models.User = Depends(get_current_user),

View File

@ -1,7 +1,7 @@
import os
from fastapi import Depends, APIRouter, HTTPException, Depends, Request
from fastapi import Depends, APIRouter, HTTPException, Depends, Request, status
from sqlalchemy.orm import Session
from app.db import models
from app.core.security import get_current_user
@ -34,7 +34,8 @@ def _delete_old_avatar_file(file_id: str, db: Session):
models.CloudMediaItem.file_id == file_id,
).all()
for item in cloud_item:
cloud_path = os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
cloud_path = os.path.join(
config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if os.path.exists(cloud_path):
try:
os.remove(cloud_path)
@ -175,8 +176,8 @@ async def update_privacy_settings(
user_to_update.show_avatar = 1 if data.show_avatar else 0
if data.show_about is not None:
user_to_update.show_about = 1 if data.show_about else 0
if data.show_username is not None:
user_to_update.show_username = 1 if data.show_username else 0
# Настройка show_username удалена, всегда сохраняем 1
user_to_update.show_username = 1
if data.show_last_online is not None:
user_to_update.show_last_online = 1 if data.show_last_online else 0
try:
@ -201,11 +202,12 @@ async def get_privacy_settings(current_user: models.User = Depends(get_current_u
"show_phone": bool(current_user.show_phone),
"show_avatar": bool(current_user.show_avatar),
"show_about": bool(current_user.show_about),
"show_username": bool(current_user.show_username),
"show_username": True, # Настройка show_username удалена, всегда возвращаем True
"show_last_online": bool(current_user.show_last_online),
}
@usersRouter.get("/all")
async def read_users_all(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
users = db.query(models.User).all()
@ -282,11 +284,14 @@ async def read_users_chats(
"username": user.username,
"name": f"{user.first_name} {user.last_name or ''}".strip(),
"public_key": user.public_key,
"avatar_file_id": user.avatar_file_id if user.show_avatar else None,
"avatar_url": str(request.url_for("get_file", file_id=user.avatar_file_id)) if user.show_avatar and user.avatar_file_id else None,
"avatar_file_id": user.avatar_file_id if (user.show_avatar or current_user.id == 1) else None,
"avatar_url": str(request.url_for("get_file", file_id=user.avatar_file_id)) if (user.show_avatar or current_user.id == 1) and user.avatar_file_id else None,
"last_message": last_msg.content if last_msg else None,
"last_message_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp else None),
"unread_count": unread_count,
"online": str(user.id) in connection_manager.manager.active_connections,
"last_message_id": last_msg.id if last_msg else None,
"last_message_type": last_msg.message_type if last_msg else None,
}
)
@ -294,6 +299,51 @@ async def read_users_chats(
return result
@usersRouter.get("/by-username/{username}", response_model=schemas.UserContactResponse)
def get_user_by_username(username: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user)):
user = db.query(models.User).filter(models.User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден")
profile_data = {
"id": user.id,
"public_key": user.public_key,
}
profile_data["first_name"] = user.first_name
profile_data["last_name"] = user.last_name
profile_data["username"] = user.username
if user.show_avatar or current_user.id == 1:
profile_data["avatar_url"] = str(request.url_for(
"get_file", file_id=user.avatar_file_id)) if (user.show_avatar or current_user.id == 1) and user.avatar_file_id else None
profile_data["show_avatar"] = bool(user.show_avatar)
profile_data["totp_enabled"] = bool(user.totp_secret)
if user.show_about or current_user.id == 1:
profile_data["about"] = user.about
if user.show_phone or current_user.id == 1:
profile_data["phone"] = user.phone
if user.show_email or current_user.id == 1:
profile_data["email"] = user.email
if str(user.id) in connection_manager.manager.active_connections:
profile_data["online"] = True
else:
profile_data["online"] = False
if user.show_last_online or current_user.id == 1:
profile_data["last_online"] = user.last_online.isoformat(
) if user.last_online else None
return profile_data
@usersRouter.get("/{user_id}", response_model=schemas.UserProfile)
def get_user_by_id(
user_id: int,
@ -317,33 +367,30 @@ def get_user_by_id(
profile_data["first_name"] = user.first_name
profile_data["last_name"] = user.last_name
profile_data["username"] = user.username
# Проверяем настройки конфиденциальности
if user.show_username:
profile_data["username"] = user.username
if user.show_avatar:
if user.show_avatar or current_user.id == 1:
profile_data["avatar_url"] = str(request.url_for(
"get_file", file_id=user.avatar_file_id)) if user.avatar_file_id else None
"get_file", file_id=user.avatar_file_id)) if (user.show_avatar or current_user.id == 1) and user.avatar_file_id else None
profile_data["show_avatar"] = bool(user.show_avatar)
profile_data["totp_enabled"] = bool(user.totp_secret)
if user.show_about:
if user.show_about or current_user.id == 1:
profile_data["about"] = user.about
if user.show_phone:
if user.show_phone or current_user.id == 1:
profile_data["phone"] = user.phone
if user.show_email:
if user.show_email or current_user.id == 1:
profile_data["email"] = user.email
if str(user.id) in connection_manager.manager.active_connections:
profile_data["online"] = True
else:
profile_data["online"] = False
if user.show_last_online:
if user.show_last_online or current_user.id == 1:
profile_data["last_online"] = user.last_online.isoformat(
) if user.last_online else None

View File

@ -72,3 +72,12 @@ class UserProfile(BaseModel):
class Config:
from_attributes = True
class UserContactResponse(BaseModel):
id: str
name: str
username: str
public_key: Optional[str] = None
class Config:
from_attributes = True

View File

@ -110,15 +110,15 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
f"DEBUG saved message: id={new_msg.id}, sender={new_msg.sender_id}, receiver={new_msg.receiver_id}, message_type={new_msg.message_type}, file_id={new_msg.file_id}, encrypted_key_present={new_msg.encrypted_key is not None}",
)
# ACK отправителю: сервер принял и сохранил сообщение (нужно для статусов клиента).
# ACK отправителю: сервер принял и сохранил сообщение.
await manager.send_personal_message({
"type": "message_sent",
"temp_id": temp_id,
"server_id": new_msg.id,
"timestamp": (new_msg.timestamp or datetime.now()).isoformat(),
"timestamp": (new_msg.timestamp or datetime.utcnow()).isoformat(),
}, str(user_id))
# Если получатель оффлайн — отправим пуш (если есть токен и ключи).
# отправим пуш.
if user.public_key:
receiver = db.query(models.User).filter(
models.User.id == receiver_id).first()
@ -129,7 +129,12 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
user.first_name,
user.public_key,
content50 if content50 else content,
datetime.now(),
datetime.utcnow(),
unread_count=db.query(models.Message).filter(
models.Message.receiver_id == receiver_id,
models.Message.read_at == None
).count(),
message_id=new_msg.id,
)
# Формируем пакет для получателя
outgoing_message = {
@ -141,7 +146,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"message_type": message_type,
"file_id": file_id,
"encrypted_key": message_data.get("encrypted_key"),
"timestamp": (new_msg.timestamp or datetime.now()).isoformat(),
"timestamp": (new_msg.timestamp or datetime.utcnow()).isoformat(),
"reply_to_id": new_msg.reply_to_id,
"reply_to_text": new_msg.reply_to_text,
}
@ -157,7 +162,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
# Если сообщение реально ушло по сокету получателю — отмечаем delivered_at.
if sent_to_receiver:
try:
delivered_at = datetime.now()
delivered_at = datetime.utcnow()
new_msg.delivered_at = delivered_at
db.add(new_msg)
db.commit()
@ -192,7 +197,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
continue
try:
msg.content = content
msg.edited_at = datetime.now()
msg.edited_at = datetime.utcnow()
db.add(msg)
db.commit()
except Exception:
@ -201,6 +206,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
event = {
"type": "message_edited",
"message_id": msg.id,
"sender_id": msg.sender_id,
"content": msg.content,
"edited_at": msg.edited_at.isoformat() if msg.edited_at else None,
}
@ -259,7 +265,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
# Сохраняем read_at в БД
try:
read_at = datetime.now()
read_at = datetime.utcnow()
msg.read_at = read_at
db.add(msg)
db.commit()
@ -270,7 +276,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
await manager.send_personal_message({
"type": "message_read",
"message_id": message_id,
"timestamp": read_at.isoformat() if 'read_at' in locals() else datetime.now().isoformat(),
"timestamp": read_at.isoformat() if 'read_at' in locals() else datetime.utcnow().isoformat(),
}, str(sender_id))
elif message_data.get("type") == "typing":
receiver_id = message_data.get("receiver_id")
@ -311,7 +317,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
})
def send_fcm_notification(token, user_id, username, public_key, encrypted_text, timestamp):
def send_fcm_notification(token, user_id, username, public_key, encrypted_text, timestamp, unread_count='1', message_id='0'):
print(
f"DEBUG: Отправляем FCM уведомление пользователю {user_id} с токеном {token}")
message = messaging.Message(
@ -322,6 +328,8 @@ def send_fcm_notification(token, user_id, username, public_key, encrypted_text,
"public_key": public_key,
"content": encrypted_text, # Зашифрованный текст
"timestamp": timestamp.isoformat(),
"unread_count": str(unread_count),
"message_id": str(message_id),
},
android=messaging.AndroidConfig(
priority='high',

Binary file not shown.

View File

@ -6,14 +6,19 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.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 <gal/gal_plugin_c_api.h>
#include <local_auth_windows/local_auth_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseCorePluginCApiRegisterWithRegistrar(
@ -24,6 +29,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("GalPluginCApi"));
LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@ -3,11 +3,14 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
file_selector_windows
firebase_core
flutter_secure_storage_windows
gal
local_auth_windows
permission_handler_windows
record_windows
url_launcher_windows
)