Медиа
|
|
@ -49,4 +49,13 @@ dependencies {
|
|||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
|
@ -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>
|
||||
|
|
@ -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/") }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 42 KiB |
|
|
@ -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++";
|
||||
|
|
|
|||
|
|
@ -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"}}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 917 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 23 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -8,10 +8,9 @@ import 'package:chepuhagram/core/constants.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SocketService with WidgetsBindingObserver {
|
||||
|
||||
static final SocketService _instance = SocketService._internal();
|
||||
factory SocketService() => _instance;
|
||||
|
||||
|
||||
SocketService._internal() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
|
@ -24,8 +23,8 @@ class SocketService with WidgetsBindingObserver {
|
|||
Stream<Map<String, dynamic>> get messages => _messageController.stream;
|
||||
|
||||
bool allowConnect = true; // Флаг для контроля подключения
|
||||
Timer? _connectTimer;
|
||||
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
|
|
@ -33,29 +32,42 @@ class SocketService with WidgetsBindingObserver {
|
|||
} else {
|
||||
allowConnect = false;
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,88 +3,155 @@ 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();
|
||||
|
||||
|
||||
DateTime now = DateTime.now();
|
||||
ContactRepository() {
|
||||
_initCachedClient();
|
||||
}
|
||||
|
||||
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();
|
||||
Duration offset = now.timeZoneOffset;
|
||||
|
||||
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}/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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
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'];
|
||||
// Подставляй свой эндпоинт, например: /users/by-username/
|
||||
final response = await Dio().get('/users/by-username/$username');
|
||||
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 'давно';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
record_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
480
pubspec.lock
|
|
@ -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"
|
||||
|
|
|
|||
28
pubspec.yaml
|
|
@ -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:
|
||||
|
|
@ -71,6 +85,14 @@ dev_dependencies:
|
|||
# package. See that file for information about deactivating specific lint
|
||||
# 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
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 139 KiB |
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -217,7 +219,7 @@ async def read_users_all(current_user: models.User = Depends(get_current_user),
|
|||
else:
|
||||
users_for_return = users
|
||||
return [{"id": user.id, "username": user.username, "name": f"{user.first_name} {user.last_name or ''}".strip(), "public_key": user.public_key} for user in users_for_return]
|
||||
|
||||
|
||||
|
||||
@usersRouter.get("/chats")
|
||||
async def read_users_chats(
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||