Compare commits
11 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
966b1a6b84 | |
|
|
3b5be5f4d9 | |
|
|
9bd91b7711 | |
|
|
4b306f3cee | |
|
|
51b11d9290 | |
|
|
cf8c4fa0d6 | |
|
|
981d322e1d | |
|
|
ee7d325856 | |
|
|
a7fe16954f | |
|
|
d33c41010d | |
|
|
15af40fc64 |
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "chepuhagram",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
},
|
||||
{
|
||||
"name": "chepuhagram (profile mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "profile"
|
||||
},
|
||||
{
|
||||
"name": "chepuhagram (release mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "release"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -34,6 +34,8 @@ android {
|
|||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
# Защита ffmpeg-kit от R8
|
||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||
-keep interface com.arthenica.ffmpegkit.** { *; }
|
||||
|
||||
# Защита от удаления нативных методов JNI
|
||||
-keepclassmembers class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Если используете конкретно ваш форк, добавим и его:
|
||||
-keep class com.antonkarpenko.ffmpegkit.** { *; }
|
||||
|
||||
# Защита Firebase (если падает Firebase, когда включен R8)
|
||||
-keep class com.google.firebase.** { *; }
|
||||
-keep class com.google.android.gms.** { *; }
|
||||
|
|
@ -4,12 +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.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
|
||||
|
|
@ -49,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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,45 @@
|
|||
package ru.chepuhagram.app
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.os.Bundle
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterFragmentActivity()
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
try {
|
||||
super.onCreate(savedInstanceState)
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
handleFatalStartupError("Ошибка загрузки нативных библиотек: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
handleFatalStartupError("Произошла системная ошибка при запуске: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Дополнительная защита при создании движка, если инициализация происходит там
|
||||
override fun provideFlutterEngine(context: android.content.Context): FlutterEngine? {
|
||||
return try {
|
||||
super.provideFlutterEngine(context)
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
handleFatalStartupError("Ошибка инициализации движка (FFmpegKit): ${e.message}")
|
||||
null // Возвращаем null, чтобы предотвратить дальнейший краш
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFatalStartupError(message: String) {
|
||||
// Мы не можем использовать стандартные диалоги из темы Activity,
|
||||
// так как они могут быть повреждены, используем чистый Android AlertDialog
|
||||
runOnUiThread {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Критическая ошибка")
|
||||
.setMessage("Приложение не может быть запущено на данном устройстве.\n\nТехническая информация: $message")
|
||||
.setPositiveButton("Закрыть") { _, _ ->
|
||||
finishAffinity() // Полностью закрывает приложение
|
||||
}
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
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 |
|
|
@ -1,3 +1,5 @@
|
|||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
- provider: true
|
||||
- shared_preferences: true
|
||||
|
|
@ -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 |
|
|
@ -6,9 +6,11 @@ class ThemeProvider extends ChangeNotifier {
|
|||
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
Color _accentColor = const Color(0xFF24A1DE);
|
||||
String? _wallpaperPath;
|
||||
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
Color get accentColor => _accentColor;
|
||||
String? get wallpaperPath => _wallpaperPath;
|
||||
|
||||
bool isLight = false;
|
||||
|
||||
|
|
@ -20,12 +22,14 @@ class ThemeProvider extends ChangeNotifier {
|
|||
Future<void> _loadSettings() async {
|
||||
final mode = await _storage.read(key: 'theme_mode');
|
||||
final color = await _storage.read(key: 'accent_color');
|
||||
final wallpaper = await _storage.read(key: 'wallpaper_path');
|
||||
|
||||
if (mode != null) {
|
||||
_themeMode = mode == 'dark' ? ThemeMode.dark : ThemeMode.light;
|
||||
isLight = mode == 'light';
|
||||
}
|
||||
if (color != null) _accentColor = Color(int.parse(color));
|
||||
_wallpaperPath = wallpaper;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +46,16 @@ class ThemeProvider extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateWallpaper(String? path) {
|
||||
_wallpaperPath = path;
|
||||
if (path != null) {
|
||||
_storage.write(key: 'wallpaper_path', value: path);
|
||||
} else {
|
||||
_storage.delete(key: 'wallpaper_path');
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
ThemeData get themeData => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: _themeMode == ThemeMode.dark
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:chepuhagram/data/models/message_model.dart';
|
||||
|
||||
|
|
@ -9,34 +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: 4,
|
||||
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
|
||||
)
|
||||
''');
|
||||
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');
|
||||
|
|
@ -52,27 +70,68 @@ class LocalDbService {
|
|||
if (oldVersion < 4) {
|
||||
await db.execute('ALTER TABLE messages ADD COLUMN edited_at TEXT');
|
||||
}
|
||||
if (oldVersion < 5) {
|
||||
try {
|
||||
await db.execute(
|
||||
'ALTER TABLE messages ADD COLUMN message_type TEXT',
|
||||
);
|
||||
} catch (e) {
|
||||
print('message_type column already exists: $e');
|
||||
}
|
||||
try {
|
||||
await db.execute('ALTER TABLE messages ADD COLUMN file_id TEXT');
|
||||
} catch (e) {
|
||||
print('file_id column already exists: $e');
|
||||
}
|
||||
}
|
||||
if (oldVersion < 6) {
|
||||
try {
|
||||
await db.execute(
|
||||
'ALTER TABLE messages ADD COLUMN encrypted_key TEXT',
|
||||
);
|
||||
} catch (e) {
|
||||
print('encrypted_key column already exists: $e');
|
||||
}
|
||||
}
|
||||
// old migrations kept for safety, but v8 rebuild returns early.
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Сохранение списка сообщений (из истории)
|
||||
Future<void> clearDatabase() async {
|
||||
final db = await database;
|
||||
await db.delete('messages');
|
||||
}
|
||||
|
||||
Future<void> saveMessages(List<dynamic> messages) async {
|
||||
final db = await database;
|
||||
final List<int> incomingIds = messages.map((msg) {
|
||||
return (msg is MessageModel) ? msg.id! : (msg['id'] as int);
|
||||
}).toList();
|
||||
|
||||
Batch batch = db.batch();
|
||||
|
||||
if (incomingIds.isNotEmpty) {
|
||||
batch.delete('messages', where: 'id NOT IN (${incomingIds.join(',')})');
|
||||
}
|
||||
for (var msg in messages) {
|
||||
if (msg is MessageModel) {
|
||||
batch.insert('messages', {
|
||||
'id': msg.id,
|
||||
'sender_id': msg.senderId,
|
||||
'receiver_id': msg.receiverId,
|
||||
'content': msg.text, // ВАЖНО: сохраняй зашифрованный текст!
|
||||
'content': msg.text,
|
||||
'timestamp': msg.createdAt.toIso8601String(),
|
||||
'delivered_at': null,
|
||||
'read_at': null,
|
||||
'reply_to_id': msg.replyToId,
|
||||
'reply_to_text': msg.replyToText,
|
||||
'edited_at': msg.editedAt?.toIso8601String(),
|
||||
'message_type': msg.messageType.name,
|
||||
'file_id': msg.fileId,
|
||||
'encrypted_key': msg.encryptedFileKey,
|
||||
'file_name': msg.fileName,
|
||||
'file_size': msg.fileSize,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
} else {
|
||||
// Если это Map из API
|
||||
|
|
@ -88,6 +147,11 @@ class LocalDbService {
|
|||
'reply_to_id': msg['reply_to_id'],
|
||||
'reply_to_text': msg['reply_to_text'],
|
||||
'edited_at': msg['edited_at'],
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
|||
import 'package:web_socket_channel/status.dart' as status;
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:chepuhagram/core/constants.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SocketService {
|
||||
class SocketService with WidgetsBindingObserver {
|
||||
static final SocketService _instance = SocketService._internal();
|
||||
factory SocketService() => _instance;
|
||||
|
||||
factory SocketService() {
|
||||
return _instance;
|
||||
SocketService._internal() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
SocketService._internal();
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
final StreamController<Map<String, dynamic>> _messageController =
|
||||
|
|
@ -21,25 +22,52 @@ class SocketService {
|
|||
// Поток, который будут слушать провайдеры
|
||||
Stream<Map<String, dynamic>> get messages => _messageController.stream;
|
||||
|
||||
bool allowConnect = true; // Флаг для контроля подключения
|
||||
Timer? _connectTimer;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
allowConnect = true;
|
||||
} 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) {
|
||||
|
|
@ -52,10 +80,10 @@ class SocketService {
|
|||
);
|
||||
} on TimeoutException catch (_) {
|
||||
_channel = null;
|
||||
throw Exception('timeout');
|
||||
throw Exception('Превышено время ожидания. Пожалуйста, попробуйте позже.');
|
||||
} catch (e) {
|
||||
_channel = null;
|
||||
throw Exception("Ошибка подключения: $e");
|
||||
throw Exception('Ошибка при подключении к WebSocket: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -65,9 +93,15 @@ class SocketService {
|
|||
}
|
||||
|
||||
bool sendMessage(Map<String, dynamic> data, {int retryCnt = 0}) {
|
||||
const maxRetries = 5;
|
||||
if (_channel == null) {
|
||||
//print("❌ ОШИБКА: Попытка отправить сообщение через NULL канал.");
|
||||
sendMessage(data, retryCnt: retryCnt + 1);
|
||||
if (retryCnt < maxRetries) {
|
||||
// Schedule retry with exponential backoff
|
||||
Future.delayed(
|
||||
Duration(seconds: 1 << retryCnt),
|
||||
() => sendMessage(data, retryCnt: retryCnt + 1),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
|
|
@ -92,5 +126,7 @@ class SocketService {
|
|||
void disconnect() {
|
||||
_channel?.sink.close(status.normalClosure);
|
||||
_channel = null;
|
||||
_connectTimer?.cancel();
|
||||
_connectTimer = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,28 @@
|
|||
import '/core/constants.dart';
|
||||
import 'package:chepuhagram/data/models/message_model.dart';
|
||||
|
||||
class Contact {
|
||||
final int id;
|
||||
final String username;
|
||||
final String name;
|
||||
final String surname;
|
||||
String name;
|
||||
String surname;
|
||||
final String? lastMessage;
|
||||
final String? avatarUrl;
|
||||
String? avatarFileId;
|
||||
String? avatarUrl;
|
||||
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 {
|
||||
if (avatarFileId != null && avatarFileId!.isNotEmpty) {
|
||||
return '${AppConstants.baseUrl}/media/$avatarFileId';
|
||||
}
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
Contact({
|
||||
required this.id,
|
||||
|
|
@ -16,11 +30,15 @@ class Contact {
|
|||
required this.name,
|
||||
required this.surname,
|
||||
this.lastMessage,
|
||||
this.avatarFileId,
|
||||
this.avatarUrl,
|
||||
this.lastMessageTime,
|
||||
this.isOnline = false,
|
||||
this.unreadCount = 0,
|
||||
this.publicKey,
|
||||
this.isLastMsgDecrypted = false,
|
||||
this.lastMessageId,
|
||||
this.lastMessageType,
|
||||
});
|
||||
|
||||
Contact copyWith({
|
||||
|
|
@ -29,11 +47,15 @@ class Contact {
|
|||
String? name,
|
||||
String? surname,
|
||||
String? lastMessage,
|
||||
String? avatarFileId,
|
||||
String? avatarUrl,
|
||||
DateTime? lastMessageTime,
|
||||
bool? isOnline,
|
||||
int? unreadCount,
|
||||
String? publicKey,
|
||||
bool? isLastMsgDecrypted,
|
||||
int? lastMessageId,
|
||||
MessageType? lastMessageType,
|
||||
}) {
|
||||
return Contact(
|
||||
id: id ?? this.id,
|
||||
|
|
@ -41,11 +63,15 @@ class Contact {
|
|||
name: name ?? this.name,
|
||||
surname: surname ?? this.surname,
|
||||
lastMessage: lastMessage ?? this.lastMessage,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -63,11 +89,15 @@ class Contact {
|
|||
name: json['name'] ?? json['first_name'] ?? 'Unknown',
|
||||
surname: json['surname'] ?? json['last_name'] ?? 'Unknown',
|
||||
lastMessage: json['last_message'] ?? json['lastMessage'],
|
||||
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,6 +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, video, file, videoNote, voiceNote }
|
||||
|
||||
class MessageModel {
|
||||
final int? id; // server id (null пока не подтверждено сервером)
|
||||
|
|
@ -14,7 +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,
|
||||
|
|
@ -28,7 +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({
|
||||
|
|
@ -43,7 +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,
|
||||
|
|
@ -57,7 +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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -65,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()),
|
||||
|
|
@ -78,9 +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: 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,
|
||||
|
|
@ -93,6 +160,11 @@ class MessageModel {
|
|||
'reply_to_id': replyToId,
|
||||
'reply_to_text': replyToText,
|
||||
'edited_at': editedAt?.toIso8601String(),
|
||||
'message_type': messageType.name,
|
||||
'file_id': fileId,
|
||||
'encrypted_key': encryptedFileKey,
|
||||
'file_name': fileName,
|
||||
'file_size': fileSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,63 +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();
|
||||
if (token == null) {
|
||||
throw Exception('No access token');
|
||||
}
|
||||
ContactRepository() {
|
||||
_initCachedClient();
|
||||
}
|
||||
|
||||
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));
|
||||
return data.map((json) => Contact.fromJson(json)).toList();
|
||||
} 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));
|
||||
return data.map((json) => Contact.fromJson(json)).toList();
|
||||
return data.map((json) {
|
||||
final contact = Contact.fromJson(json);
|
||||
if (contact.lastMessageTime != null) {
|
||||
return contact.copyWith(
|
||||
lastMessageTime: contact.lastMessageTime!.add(offset),
|
||||
);
|
||||
}
|
||||
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) {
|
||||
|
|
@ -69,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,62 +1,265 @@
|
|||
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 'dart:convert';
|
||||
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) 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 = getAccessToken();
|
||||
var request = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('${AppConstants.baseUrl}/media/upload'),
|
||||
);
|
||||
request.headers.addAll({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
});
|
||||
// Добавляем файл в запрос
|
||||
request.files.add(
|
||||
http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
bytes,
|
||||
filename: 'media.enc', // Имя файла для сервера
|
||||
),
|
||||
);
|
||||
// Подставляй свой эндпоинт, например: /users/by-username/
|
||||
final response = await Dio().get('/users/by-username/$username');
|
||||
|
||||
// Добавь заголовки авторизации, если они у тебя есть (JWT и т.д.)
|
||||
// request.headers.addAll({'Authorization': 'Bearer $token'});
|
||||
|
||||
var streamedResponse = await request.send();
|
||||
var response = await http.Response.fromStream(streamedResponse);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Предполагаем, что сервер возвращает JSON {"file_id": "..."}
|
||||
final data = jsonDecode(response.body);
|
||||
return data['file_id'];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> refreshToken() async {
|
||||
if (_isRefreshing) {
|
||||
// Already refreshing, wait for completion or return true assuming it will succeed
|
||||
return true;
|
||||
}
|
||||
_isRefreshing = true;
|
||||
notifyListeners();
|
||||
|
||||
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'},
|
||||
);
|
||||
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;
|
||||
|
|
@ -78,12 +281,21 @@ class ApiService extends ChangeNotifier {
|
|||
}
|
||||
} catch (e) {
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
return false;
|
||||
} finally {
|
||||
_isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getAccessToken() async {
|
||||
String? token = await _storage.read(key: 'access_token');
|
||||
String? token;
|
||||
try {
|
||||
token = await _storage.read(key: 'access_token');
|
||||
} catch (_) {
|
||||
throw Exception(
|
||||
'Критическая ошибка инициализации внутренних библиотек.\n Приложение не может продолжить работу. \n Обратитесь к разработчику. \n Код ошибки: _apis_gat_1',
|
||||
);
|
||||
}
|
||||
|
||||
if (token != null) {
|
||||
bool isExpiredSoon =
|
||||
|
|
@ -104,25 +316,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();
|
||||
}
|
||||
|
|
@ -157,7 +375,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',
|
||||
|
|
@ -206,9 +429,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()}',
|
||||
),
|
||||
|
|
@ -217,7 +458,59 @@ 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, {
|
||||
void Function(int received, int total)? onProgress,
|
||||
}) async {
|
||||
try {
|
||||
final token = await getAccessToken();
|
||||
await _ensureCacheReady();
|
||||
|
||||
final client = CachedHttpClient(
|
||||
cache: cache,
|
||||
defaultCachePolicy: CachePolicy.networkFirst,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> updateMe({
|
||||
|
|
@ -258,7 +551,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',
|
||||
|
|
@ -279,6 +577,7 @@ class ApiService extends ChangeNotifier {
|
|||
bool? showAvatar,
|
||||
bool? showAbout,
|
||||
bool? showUsername,
|
||||
bool? showLastOnline,
|
||||
}) async {
|
||||
final token = await getAccessToken();
|
||||
final response = await _client.put(
|
||||
|
|
@ -293,6 +592,7 @@ class ApiService extends ChangeNotifier {
|
|||
if (showAvatar != null) 'show_avatar': showAvatar,
|
||||
if (showAbout != null) 'show_about': showAbout,
|
||||
if (showUsername != null) 'show_username': showUsername,
|
||||
if (showLastOnline != null) 'show_last_online': showLastOnline,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -301,7 +601,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',
|
||||
|
|
@ -315,4 +621,74 @@ class ApiService extends ChangeNotifier {
|
|||
}
|
||||
throw Exception('Не удалось получить настройки конфиденциальности');
|
||||
}
|
||||
|
||||
Future<bool> updateAvatar(String fileId) async {
|
||||
final token = await getAccessToken();
|
||||
final response = await _client.put(
|
||||
Uri.parse('${AppConstants.baseUrl}/users/me/avatar'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode({'avatar_file_id': fileId}),
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> enableTotp() async {
|
||||
final token = await getAccessToken();
|
||||
final response = await _client.post(
|
||||
Uri.parse('${AppConstants.baseUrl}/auth/totp/enable'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(utf8.decode(response.bodyBytes))
|
||||
as Map<String, dynamic>;
|
||||
}
|
||||
throw Exception(
|
||||
(jsonDecode(response.body) as Map<String, dynamic>)['detail'] ??
|
||||
'Failed to enable TOTP',
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> verifyTotp(String code) async {
|
||||
final token = await getAccessToken();
|
||||
final response = await _client.post(
|
||||
Uri.parse('${AppConstants.baseUrl}/auth/totp/verify'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode({'code': code}),
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<bool> disableTotp() async {
|
||||
final token = await getAccessToken();
|
||||
final response = await _client.post(
|
||||
Uri.parse('${AppConstants.baseUrl}/auth/totp/disable'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<bool> deleteAllMessages() async {
|
||||
final token = await getAccessToken();
|
||||
final response = await _client.delete(
|
||||
Uri.parse('${AppConstants.baseUrl}/messages/all'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import 'dart:typed_data';
|
||||
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();
|
||||
|
|
@ -123,32 +128,377 @@ class CryptoService {
|
|||
return base64Encode(nonce + encrypted.mac.bytes + encrypted.cipherText);
|
||||
}
|
||||
|
||||
Future<(List<int>, String)?> encryptImage(
|
||||
List<int> fileBytes,
|
||||
static Future<String> decryptInIsolate(
|
||||
String base64Data,
|
||||
SecretKey sharedKey,
|
||||
) async {
|
||||
final data = base64Decode(base64Data);
|
||||
final aesGcm = AesGcm.with256bits();
|
||||
|
||||
final nonce = data.sublist(0, 12);
|
||||
final mac = data.sublist(12, 28);
|
||||
final cipherText = data.sublist(28);
|
||||
|
||||
final decrypted = await aesGcm.decrypt(
|
||||
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
|
||||
secretKey: sharedKey,
|
||||
);
|
||||
|
||||
return utf8.decode(decrypted);
|
||||
}
|
||||
|
||||
static Future<List<Contact>> bulkDecryptContacts(
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
final List<Contact> contacts = data['contacts'];
|
||||
final String privKey = data['privKey'];
|
||||
final Map<int, SecretKey> cache = data['cache'];
|
||||
|
||||
final x25519 = X25519();
|
||||
final aesGcm = AesGcm.with256bits();
|
||||
final List<Contact> result = [];
|
||||
|
||||
// Вычисляем свою пару один раз
|
||||
final myKeyPair = await x25519.newKeyPairFromSeed(base64Decode(privKey));
|
||||
|
||||
for (var contact in contacts) {
|
||||
if (contact.lastMessage == null || contact.publicKey == null) {
|
||||
result.add(contact);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
SecretKey sharedKey;
|
||||
if (cache.containsKey(contact.id)) {
|
||||
sharedKey = cache[contact.id]!;
|
||||
} else {
|
||||
final theirPubKey = SimplePublicKey(
|
||||
base64Decode(contact.publicKey!),
|
||||
type: KeyPairType.x25519,
|
||||
);
|
||||
sharedKey = await x25519.sharedSecretKey(
|
||||
keyPair: myKeyPair,
|
||||
remotePublicKey: theirPubKey,
|
||||
);
|
||||
}
|
||||
|
||||
// Дешифровка AES-GCM
|
||||
final msgData = base64Decode(contact.lastMessage!);
|
||||
final decrypted = await aesGcm.decrypt(
|
||||
SecretBox(
|
||||
msgData.sublist(28),
|
||||
nonce: msgData.sublist(0, 12),
|
||||
mac: Mac(msgData.sublist(12, 28)),
|
||||
),
|
||||
secretKey: sharedKey,
|
||||
);
|
||||
|
||||
result.add(
|
||||
contact.copyWith(
|
||||
lastMessage: utf8.decode(decrypted),
|
||||
isLastMsgDecrypted: true,
|
||||
avatarFileId: contact.avatarFileId,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
result.add(
|
||||
contact.copyWith(
|
||||
lastMessage: '[не удалось расшифровать: $e]',
|
||||
isLastMsgDecrypted: true,
|
||||
avatarFileId: contact.avatarFileId,
|
||||
avatarUrl: contact.avatarUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Future<Map<int, List<int>>> computeSharedKeysTask(
|
||||
Map<String, dynamic> params,
|
||||
) async {
|
||||
final Map<int, String> isolateKeysMap = params['keysMap'];
|
||||
final String isolatePrivKey = params['privKey'];
|
||||
|
||||
final x25519 = X25519();
|
||||
final Map<int, List<int>> result = {};
|
||||
|
||||
final myKeyPair = await x25519.newKeyPairFromSeed(
|
||||
base64Decode(isolatePrivKey),
|
||||
);
|
||||
|
||||
for (var entry in isolateKeysMap.entries) {
|
||||
try {
|
||||
final theirPubKey = SimplePublicKey(
|
||||
base64Decode(entry.value),
|
||||
type: KeyPairType.x25519,
|
||||
);
|
||||
|
||||
final sharedKey = await x25519.sharedSecretKey(
|
||||
keyPair: myKeyPair,
|
||||
remotePublicKey: theirPubKey,
|
||||
);
|
||||
|
||||
result[entry.key] = await sharedKey.extractBytes();
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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,
|
||||
String encryptedFileKey, {
|
||||
int? totalBytes,
|
||||
void Function(int processed, int total)? onProgress,
|
||||
}) async* {
|
||||
try {
|
||||
// 1. Дешифруем ключ файла с помощью общего ключа чата
|
||||
final encryptedKeyBytes = base64Decode(encryptedFileKey);
|
||||
final keySecretBox = SecretBox.fromConcatenation(
|
||||
encryptedKeyBytes,
|
||||
nonceLength: 12,
|
||||
macLength: 16,
|
||||
);
|
||||
final fileKeyBytes = await aesGcm.decrypt(
|
||||
keySecretBox,
|
||||
secretKey: sharedKey,
|
||||
);
|
||||
final fileKey = SecretKey(fileKeyBytes);
|
||||
|
||||
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",
|
||||
);
|
||||
} catch (e, stack) {
|
||||
print("КРИТИЧЕСКАЯ ОШИБКА ВНУТРИ КРИПТОСТРИМА: $e");
|
||||
print(stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> decryptAesKey(
|
||||
String encryptedKey,
|
||||
SecretKey sharedKey,
|
||||
) async {
|
||||
try {
|
||||
final SecretKey fileSecretKey = await aesGcm.newSecretKey();
|
||||
final List<int> fileSecretKeyBytes = await fileSecretKey.extractBytes();
|
||||
final keyBytes = base64Decode(encryptedKey);
|
||||
final nonce = keyBytes.sublist(0, 12);
|
||||
final cipherText = keyBytes.sublist(12, keyBytes.length - 16);
|
||||
final mac = keyBytes.sublist(keyBytes.length - 16);
|
||||
|
||||
final SecretBox secretBox = await aesGcm.encrypt(
|
||||
fileBytes,
|
||||
secretKey: fileSecretKey,
|
||||
final decrypted = await aesGcm.decrypt(
|
||||
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
|
||||
secretKey: sharedKey,
|
||||
);
|
||||
final List<int> dataToUpload = secretBox.concatenation();
|
||||
return Uint8List.fromList(decrypted);
|
||||
} catch (e) {
|
||||
print('Ошибка дешифровки AES ключа: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final encryptedKeyBox = await aesGcm.encrypt(
|
||||
fileSecretKeyBytes,
|
||||
Future<String?> encryptAesKey(List<int> keyBytes, SecretKey sharedKey) async {
|
||||
try {
|
||||
final encrypted = await aesGcm.encrypt(keyBytes, secretKey: sharedKey);
|
||||
return base64Encode(encrypted.concatenation());
|
||||
} catch (e) {
|
||||
print('Ошибка шифрования AES ключа: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> decryptMedia(
|
||||
List<int> encryptedData,
|
||||
String encryptedKey,
|
||||
SecretKey sharedKey,
|
||||
) async {
|
||||
try {
|
||||
final keyBytes = base64Decode(encryptedKey);
|
||||
final keyNonce = keyBytes.sublist(0, 12);
|
||||
final keyCipher = keyBytes.sublist(12, keyBytes.length - 16);
|
||||
final keyMac = keyBytes.sublist(keyBytes.length - 16);
|
||||
|
||||
final decryptedFileKey = await aesGcm.decrypt(
|
||||
SecretBox(keyCipher, nonce: keyNonce, mac: Mac(keyMac)),
|
||||
secretKey: sharedKey,
|
||||
);
|
||||
|
||||
final String encryptedKeyForServer = base64Encode(
|
||||
encryptedKeyBox.concatenation(),
|
||||
final fileSecretKey = SecretKey(decryptedFileKey);
|
||||
final nonce = encryptedData.sublist(0, 12);
|
||||
final cipherText = encryptedData.sublist(12, encryptedData.length - 16);
|
||||
final mac = encryptedData.sublist(encryptedData.length - 16);
|
||||
|
||||
final decryptedBytes = await aesGcm.decrypt(
|
||||
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
|
||||
secretKey: fileSecretKey,
|
||||
);
|
||||
|
||||
return (dataToUpload, encryptedKeyForServer);
|
||||
return Uint8List.fromList(decryptedBytes);
|
||||
} catch (e) {
|
||||
print("Ошибка шифрования медиа: $e");
|
||||
print('Ошибка дешифровки медиа: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -184,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!;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import '/core/constants.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||
|
|
@ -32,6 +35,12 @@ class AuthProvider extends ChangeNotifier {
|
|||
String? _about;
|
||||
String? get about => _about;
|
||||
|
||||
String? _avatarPath;
|
||||
String? get avatarPath => _avatarPath;
|
||||
|
||||
String? _avatarUrl;
|
||||
String? get avatarUrl => _avatarUrl;
|
||||
|
||||
// Privacy settings
|
||||
bool? _showEmail;
|
||||
bool? get showEmail => _showEmail;
|
||||
|
|
@ -85,14 +94,23 @@ class AuthProvider extends ChangeNotifier {
|
|||
|
||||
SocketService get socketService => _socketService;
|
||||
|
||||
Future<bool> login(String username, String password) async {
|
||||
Future<bool> login(
|
||||
String username,
|
||||
String password, {
|
||||
String? totpCode,
|
||||
}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final body = {'username': username, 'password': password};
|
||||
if (totpCode != null) {
|
||||
body['totp_code'] = totpCode;
|
||||
}
|
||||
final response = await _client.post(
|
||||
Uri.parse('${AppConstants.baseUrl}/auth/login'),
|
||||
body: {'username': username, 'password': password},
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
final decodedResponse =
|
||||
|
|
@ -135,7 +153,12 @@ class AuthProvider extends ChangeNotifier {
|
|||
Future<void> logout() async {
|
||||
final mode = await _storage.read(key: 'theme_mode');
|
||||
final color = await _storage.read(key: 'accent_color');
|
||||
final wallpaper = await _storage.read(key: 'wallpaper_path');
|
||||
final avatar = await _storage.read(key: 'avatar_path');
|
||||
await _storage.deleteAll();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.clear();
|
||||
await LocalDbService().clearDatabase();
|
||||
_currentUserId = null;
|
||||
_username = null;
|
||||
_firstName = null;
|
||||
|
|
@ -143,17 +166,32 @@ class AuthProvider extends ChangeNotifier {
|
|||
_phone = null;
|
||||
_email = null;
|
||||
_about = null;
|
||||
_avatarPath = null;
|
||||
_avatarUrl = null;
|
||||
if (mode != null) {
|
||||
await _storage.write(key: 'theme_mode', value: mode);
|
||||
}
|
||||
if (color != null) {
|
||||
await _storage.write(key: 'accent_color', value: color);
|
||||
}
|
||||
if (wallpaper != null) {
|
||||
await _storage.write(key: 'wallpaper_path', value: wallpaper);
|
||||
}
|
||||
if (avatar != null) {
|
||||
await _storage.write(key: 'avatar_path', value: avatar);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> tryAutoLogin() async {
|
||||
final token = await _apiService.getAccessToken();
|
||||
String? token;
|
||||
try {
|
||||
token = await _apiService.getAccessToken();
|
||||
} catch (e) {
|
||||
throw Exception(
|
||||
'$e+_aup_tal_1',
|
||||
);
|
||||
}
|
||||
if (token == null) return false;
|
||||
|
||||
// Загружаем currentUserId из хранилища
|
||||
|
|
@ -253,6 +291,13 @@ class AuthProvider extends ChangeNotifier {
|
|||
_phone = data['phone']?.toString();
|
||||
_email = data['email']?.toString();
|
||||
_about = data['about']?.toString();
|
||||
final avatarFileId = data['avatar_file_id']?.toString();
|
||||
_avatarUrl = avatarFileId != null
|
||||
? '${AppConstants.baseUrl}/media/$avatarFileId'
|
||||
: null;
|
||||
|
||||
// Загружаем локальные настройки
|
||||
_avatarPath = await _storage.read(key: 'avatar_path');
|
||||
|
||||
// Проверяем наличие публичного ключа на сервере
|
||||
_hasPublicKeyOnServer =
|
||||
|
|
@ -312,4 +357,33 @@ class AuthProvider extends ChangeNotifier {
|
|||
_needsKeyRecovery = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateAvatarPath(String? path) {
|
||||
_avatarPath = path;
|
||||
if (path != null) {
|
||||
_storage.write(key: 'avatar_path', value: path);
|
||||
} else {
|
||||
_storage.delete(key: 'avatar_path');
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> updateAvatar(String path) async {
|
||||
try {
|
||||
final bytes = await File(path).readAsBytes();
|
||||
final fileId = await _apiService.uploadFile(bytes, purpose: 'avatar');
|
||||
if (fileId != null) {
|
||||
final success = await _apiService.updateAvatar(fileId);
|
||||
if (success) {
|
||||
updateAvatarPath(path);
|
||||
await refreshMe(); // Обновить данные профиля, включая avatarUrl
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
print('Ошибка обновления аватарки: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '/data/models/contact_model.dart';
|
||||
import '/data/repositories/contact_repository.dart';
|
||||
import '/data/datasources/local_db_service.dart';
|
||||
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();
|
||||
final LocalDbService _localDbService = LocalDbService();
|
||||
final CryptoService _cryptoService = CryptoService();
|
||||
final CryptoService _cryptoService;
|
||||
|
||||
ContactProvider(this._cryptoService);
|
||||
final Map<int, SecretKey> _sharedKeysCache = {};
|
||||
List<Contact> _contacts = [];
|
||||
List<Contact> _allContacts = [];
|
||||
bool _isLoading = false;
|
||||
|
|
@ -19,6 +24,11 @@ class ContactProvider extends ChangeNotifier {
|
|||
List<Contact> get allContacts => _allContacts;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
Map<int, SecretKey> get sharedKeysCache => _sharedKeysCache;
|
||||
|
||||
void setSharedKey(int contactId, SecretKey key) {
|
||||
_sharedKeysCache[contactId] = key;
|
||||
}
|
||||
|
||||
void setCurrentUserId(int? id) {
|
||||
_currentUserId = id;
|
||||
|
|
@ -29,7 +39,7 @@ class ContactProvider extends ChangeNotifier {
|
|||
return _currentUserId;
|
||||
}
|
||||
|
||||
Future<void> loadContacts() async {
|
||||
Future<void> loadContacts({bool enrichContacts = true}) async {
|
||||
if (_isFirstLoad) {
|
||||
_isFirstLoad = false;
|
||||
_isLoading = true;
|
||||
|
|
@ -39,21 +49,27 @@ class ContactProvider extends ChangeNotifier {
|
|||
|
||||
try {
|
||||
final allContacts = await _repository.fetchChatContacts();
|
||||
// Фильтруем: исключаем себя (для основного списка - только чаты)
|
||||
_contacts = allContacts
|
||||
.where((contact) => contact.id != _currentUserId)
|
||||
.toList();
|
||||
final userIdCopy = _currentUserId;
|
||||
_contacts = await Isolate.run(() {
|
||||
return allContacts
|
||||
.where((contact) => contact.id != userIdCopy)
|
||||
.toList();
|
||||
});
|
||||
// Check if user changed during isolate execution
|
||||
if (userIdCopy != _currentUserId) {
|
||||
return; // Discard stale data
|
||||
}
|
||||
_allContacts = _contacts;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
// Обогащаем превью последним сообщением из локальной БД, не блокируя UI.
|
||||
_enrichContactsWithLastMessages();
|
||||
if (enrichContacts) {
|
||||
await _enrichContactsWithLastMessages();
|
||||
}
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
print("❌ ОШИБКА ПРИ ЗАГРУЗКЕ КОНТАКТОВ: $_error");
|
||||
} finally {
|
||||
// Если ошибка — выходим из состояния загрузки тут.
|
||||
// Если всё ок — `_isLoading` уже сброшен выше, чтобы показать список быстрее.
|
||||
if (_error != null) {
|
||||
_isLoading = false;
|
||||
}
|
||||
|
|
@ -61,7 +77,6 @@ class ContactProvider extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
// Метод для получения всех контактов (исключая себя) для нового чата
|
||||
Future<void> loadAllContactsForNewChat() async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
|
|
@ -69,7 +84,6 @@ class ContactProvider extends ChangeNotifier {
|
|||
|
||||
try {
|
||||
final allContacts = await _repository.fetchAllUsers();
|
||||
// Фильтруем только исключение самого себя
|
||||
_allContacts = allContacts
|
||||
.where((contact) => contact.id != _currentUserId)
|
||||
.toList();
|
||||
|
|
@ -81,43 +95,200 @@ class ContactProvider extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _enrichContactsWithLastMessages() async {
|
||||
print("Начинаем обогащать контакты последними сообщениями из локальной БД... Для текущего пользователя ID: $_currentUserId");
|
||||
final myId = _currentUserId;
|
||||
if (myId == null) return;
|
||||
print("Текущий пользователь ID: $myId");
|
||||
|
||||
final myPrivKey = await _cryptoService.getPrivateKey();
|
||||
|
||||
final List<Contact> updated = List<Contact>.from(_contacts);
|
||||
|
||||
for (int i = 0; i < updated.length; i++) {
|
||||
final contact = updated[i];
|
||||
|
||||
// 1) Если сервер уже прислал lastMessage — попробуем расшифровать превью.
|
||||
print(contact.lastMessage);
|
||||
if (contact.lastMessage != null &&
|
||||
contact.lastMessage!.isNotEmpty &&
|
||||
myPrivKey != null &&
|
||||
contact.publicKey != null) {
|
||||
try {
|
||||
final sharedSecret = await _cryptoService.deriveSharedSecret(
|
||||
myPrivKey,
|
||||
contact.publicKey!,
|
||||
);
|
||||
final decrypted = await _cryptoService.decryptMessage(
|
||||
contact.lastMessage!,
|
||||
sharedSecret,
|
||||
);
|
||||
updated[i] = contact.copyWith(lastMessage: decrypted);
|
||||
} catch (_) {
|
||||
// Если расшифровать не удалось — оставляем как есть, дальше попробуем локальную БД.
|
||||
}
|
||||
}
|
||||
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
|
||||
_contacts = updated;
|
||||
_allContacts = updated;
|
||||
notifyListeners();
|
||||
Future<void> _enrichContactsWithLastMessages() async {
|
||||
final myPrivKeyBase64 = await _cryptoService.getPrivateKey();
|
||||
if (myPrivKeyBase64 == null) return;
|
||||
|
||||
// Создаем локальные копии для передачи
|
||||
final contactsToProcess = List<Contact>.from(_contacts);
|
||||
final cacheCopy = Map<int, SecretKey>.from(_sharedKeysCache);
|
||||
|
||||
print('Avialable cache for contacts: ${cacheCopy.length}');
|
||||
|
||||
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}',
|
||||
);
|
||||
}
|
||||
_contacts = updatedContacts;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print("Ошибка дешифровки: $e");
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
final existing = _contacts[index];
|
||||
_contacts[index] = existing.copyWith(
|
||||
username: updatedContact.username,
|
||||
name: updatedContact.name,
|
||||
surname: updatedContact.surname,
|
||||
avatarUrl: updatedContact.avatarUrl,
|
||||
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} обновлен",
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
print("Error updating contact: $e");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
208
lib/main.dart
|
|
@ -1,5 +1,3 @@
|
|||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'data/datasources/ws_client.dart';
|
||||
import 'logic/auth_provider.dart';
|
||||
import 'logic/contact_provider.dart';
|
||||
|
|
@ -22,15 +20,12 @@ final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
|||
|
||||
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||||
|
||||
// Глобальная переменная для отслеживания текущего активного контакта в чате
|
||||
int? currentActiveChatContactId;
|
||||
|
||||
// Глобальная переменная для хранения начального сообщения (при запуске из уведомления)
|
||||
RemoteMessage? initialMessage;
|
||||
|
||||
// Ключ для SharedPreferences
|
||||
const String _notificationLaunchKey = 'notification_launch_data';
|
||||
// Защита от повторной обработки одного и того же payload при следующих запусках по иконке
|
||||
const String _lastHandledNotificationLaunchPayloadKey =
|
||||
'notification_last_handled_payload';
|
||||
|
||||
|
|
@ -49,9 +44,6 @@ Future<void> _onSelectNotification(
|
|||
final prefs = await SharedPreferences.getInstance();
|
||||
final canonicalPayload = jsonEncode(data);
|
||||
|
||||
// Важно: не сохраняем payload в SharedPreferences, если можем сразу перейти в чат.
|
||||
// Иначе при следующем обычном запуске (по иконке) останется "хвост" и приложение
|
||||
// будет снова автопереходить в чат.
|
||||
if (context == null) {
|
||||
final lastHandled = prefs.getString(
|
||||
_lastHandledNotificationLaunchPayloadKey,
|
||||
|
|
@ -70,7 +62,6 @@ Future<void> _onSelectNotification(
|
|||
await prefs.remove(_notificationLaunchKey);
|
||||
}
|
||||
|
||||
// Navigate to chat with this contact (if context is ready)
|
||||
_navigateToChat(senderId);
|
||||
} else {
|
||||
print(
|
||||
|
|
@ -133,103 +124,113 @@ void _navigateToChat(int senderId) {
|
|||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Firebase.initializeApp();
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
|
||||
// Проверяем, было ли приложение запущено из уведомления
|
||||
// Добавляем небольшую задержку, чтобы Firebase полностью инициализировался
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
initialMessage = await FirebaseMessaging.instance.getInitialMessage();
|
||||
print('Initial message from main() after delay: $initialMessage');
|
||||
// Сохраняем информацию в SharedPreferences для надежности
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (initialMessage != null) {
|
||||
print('App launched from notification: ${initialMessage!.data}');
|
||||
print('Message type: ${initialMessage!.data['type']}');
|
||||
print('Sender ID: ${initialMessage!.data['sender_id']}');
|
||||
initialMessage = await FirebaseMessaging.instance.getInitialMessage();
|
||||
print('Initial message from main() after delay: $initialMessage');
|
||||
// Сохраняем информацию в SharedPreferences для надежности
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (initialMessage != null) {
|
||||
print('App launched from notification: ${initialMessage!.data}');
|
||||
print('Message type: ${initialMessage!.data['type']}');
|
||||
print('Sender ID: ${initialMessage!.data['sender_id']}');
|
||||
|
||||
final payloadString = jsonEncode(initialMessage!.data);
|
||||
final lastHandled = prefs.getString(
|
||||
_lastHandledNotificationLaunchPayloadKey,
|
||||
);
|
||||
if (lastHandled != payloadString) {
|
||||
// Сохраняем данные уведомления
|
||||
await prefs.setString(_notificationLaunchKey, payloadString);
|
||||
await prefs.setString(
|
||||
final payloadString = jsonEncode(initialMessage!.data);
|
||||
final lastHandled = prefs.getString(
|
||||
_lastHandledNotificationLaunchPayloadKey,
|
||||
payloadString,
|
||||
);
|
||||
print('Saved notification data to SharedPreferences');
|
||||
} else {
|
||||
print('InitialMessage payload already handled earlier, skipping');
|
||||
}
|
||||
} else {
|
||||
print('No initial message - app launched normally');
|
||||
// Очищаем сохраненные данные, если приложение запущено нормально
|
||||
await prefs.remove(_notificationLaunchKey);
|
||||
}
|
||||
|
||||
// Initialize local notifications
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
final InitializationSettings initializationSettings = InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
);
|
||||
await flutterLocalNotificationsPlugin.initialize(
|
||||
initializationSettings,
|
||||
onDidReceiveNotificationResponse: _onSelectNotification,
|
||||
);
|
||||
|
||||
// Если приложение было запущено из локального уведомления, сохраним payload
|
||||
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
|
||||
.getNotificationAppLaunchDetails();
|
||||
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
|
||||
final payload = notificationAppLaunchDetails?.notificationResponse?.payload;
|
||||
print('App launched from local notification, payload: $payload');
|
||||
if (payload != null && payload.isNotEmpty) {
|
||||
try {
|
||||
final lastHandled = prefs.getString(
|
||||
if (lastHandled != payloadString) {
|
||||
// Сохраняем данные уведомления
|
||||
await prefs.setString(_notificationLaunchKey, payloadString);
|
||||
await prefs.setString(
|
||||
_lastHandledNotificationLaunchPayloadKey,
|
||||
payloadString,
|
||||
);
|
||||
if (lastHandled != payload) {
|
||||
final data = jsonDecode(payload);
|
||||
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
|
||||
await prefs.setString(
|
||||
print('Saved notification data to SharedPreferences');
|
||||
} else {
|
||||
print('InitialMessage payload already handled earlier, skipping');
|
||||
}
|
||||
} else {
|
||||
print('No initial message - app launched normally');
|
||||
// Очищаем сохраненные данные, если приложение запущено нормально
|
||||
await prefs.remove(_notificationLaunchKey);
|
||||
}
|
||||
|
||||
// Initialize local notifications
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
final InitializationSettings initializationSettings =
|
||||
InitializationSettings(android: initializationSettingsAndroid);
|
||||
await flutterLocalNotificationsPlugin.initialize(
|
||||
initializationSettings,
|
||||
onDidReceiveNotificationResponse: _onSelectNotification,
|
||||
);
|
||||
|
||||
// Если приложение было запущено из локального уведомления, сохраним payload
|
||||
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
|
||||
.getNotificationAppLaunchDetails();
|
||||
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
|
||||
final payload =
|
||||
notificationAppLaunchDetails?.notificationResponse?.payload;
|
||||
print('App launched from local notification, payload: $payload');
|
||||
if (payload != null && payload.isNotEmpty) {
|
||||
try {
|
||||
final lastHandled = prefs.getString(
|
||||
_lastHandledNotificationLaunchPayloadKey,
|
||||
payload,
|
||||
);
|
||||
print('Saved local notification launch payload to SharedPreferences');
|
||||
} else {
|
||||
print('Local notification payload already handled earlier, skipping');
|
||||
if (lastHandled != payload) {
|
||||
final data = jsonDecode(payload);
|
||||
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
|
||||
await prefs.setString(
|
||||
_lastHandledNotificationLaunchPayloadKey,
|
||||
payload,
|
||||
);
|
||||
print(
|
||||
'Saved local notification launch payload to SharedPreferences',
|
||||
);
|
||||
} else {
|
||||
print(
|
||||
'Local notification payload already handled earlier, skipping',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Failed to save notification launch payload: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
print('Failed to save notification launch payload: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Create notification channel for Android 8+
|
||||
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||
'chat_id', // id
|
||||
'Messages', // title
|
||||
description: 'Chat messages notifications', // description
|
||||
importance: Importance.high,
|
||||
);
|
||||
|
||||
await flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(channel);
|
||||
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||
} catch (e) {
|
||||
print('Уведосления не были инициальзированы: $e');
|
||||
}
|
||||
|
||||
// Create notification channel for Android 8+
|
||||
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||
'chat_id', // id
|
||||
'Messages', // title
|
||||
description: 'Chat messages notifications', // description
|
||||
importance: Importance.high,
|
||||
);
|
||||
|
||||
await flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(channel);
|
||||
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider(create: (_) => CryptoService()),
|
||||
Provider(create: (_) => SocketService()),
|
||||
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
||||
ChangeNotifierProvider(create: (_) => ThemeProvider()),
|
||||
ChangeNotifierProvider(create: (_) => ContactProvider()),
|
||||
Provider(create: (_) => SocketService()),
|
||||
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => ContactProvider(context.read<CryptoService>()),
|
||||
),
|
||||
],
|
||||
child: const MyApp(),
|
||||
),
|
||||
|
|
@ -292,13 +293,42 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
|||
notificationText = 'Failed to decrypt: ${e.toString()}';
|
||||
}
|
||||
|
||||
final senderId = int.tryParse(
|
||||
message.data['sender_id']?.toString() ?? '',
|
||||
);
|
||||
|
||||
// 4. Показываем локальное уведомление
|
||||
final String groupKey = 'ru.chepuhagram.app.$senderId';
|
||||
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
senderId!,
|
||||
'',
|
||||
'',
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'Messages',
|
||||
'Новые сообщения',
|
||||
groupKey: groupKey,
|
||||
setAsGroupSummary: true,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
groupAlertBehavior: GroupAlertBehavior.all,
|
||||
),
|
||||
),
|
||||
);
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
message.hashCode,
|
||||
message.data['username'] ?? 'Unknown',
|
||||
notificationText,
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails('chat_id', 'Messages'),
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'chat_id',
|
||||
'Messages',
|
||||
groupKey: groupKey,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
showWhen: true,
|
||||
),
|
||||
),
|
||||
payload: jsonEncode({
|
||||
'type': 'enc_message',
|
||||
|
|
|
|||
|
|
@ -155,8 +155,8 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: 'О себе',
|
||||
),
|
||||
minLines: 2,
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
maxLines: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '/core/theme_manager.dart';
|
||||
import 'dart:io';
|
||||
|
||||
class AppearanceSettingsScreen extends StatefulWidget {
|
||||
const AppearanceSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AppearanceSettingsScreen> createState() => _AppearanceSettingsScreenState();
|
||||
}
|
||||
|
||||
class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
Future<void> _pickWallpaper() async {
|
||||
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
|
||||
if (image != null) {
|
||||
context.read<ThemeProvider>().updateWallpaper(image.path);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeProv = context.watch<ThemeProvider>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Оформление")),
|
||||
body: ListView(
|
||||
children: [
|
||||
// Ночной режим
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.dark_mode),
|
||||
title: const Text("Ночной режим"),
|
||||
value: themeProv.themeMode == ThemeMode.dark,
|
||||
onChanged: (val) => themeProv.toggleTheme(val),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Выбор цвета акцента
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.palette_outlined,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text("Цвет темы"),
|
||||
const Spacer(),
|
||||
_colorCircle(context, const Color(0xFF24A1DE), themeProv),
|
||||
_colorCircle(context, const Color(0xFF3E8E7E), themeProv),
|
||||
_colorCircle(context, const Color(0xFF8E3E7E), themeProv),
|
||||
_colorCircle(context, const Color(0xFFFF9800), themeProv),
|
||||
_colorCircle(context, const Color(0xFFF44336), themeProv),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Обои чата
|
||||
ListTile(
|
||||
leading: const Icon(Icons.wallpaper),
|
||||
title: const Text('Обои чата'),
|
||||
subtitle: const Text('Выбрать изображение из галереи'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _pickWallpaper,
|
||||
),
|
||||
|
||||
// Показать текущие обои, если есть
|
||||
if (themeProv.wallpaperPath != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Текущие обои:'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 150,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: DecorationImage(
|
||||
image: FileImage(File(themeProv.wallpaperPath!)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () => themeProv.updateWallpaper(null),
|
||||
child: const Text('Удалить обои'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _colorCircle(BuildContext context, Color color, ThemeProvider prov) {
|
||||
bool isSelected = prov.accentColor == color;
|
||||
return GestureDetector(
|
||||
onTap: () => prov.updateAccentColor(color),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected ? color : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: CircleAvatar(backgroundColor: color, radius: 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -8,6 +8,7 @@ import '../screens/settings_screen.dart';
|
|||
import '../screens/new_chat_screen.dart';
|
||||
import '../screens/chat_screen.dart';
|
||||
import '/logic/contact_provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '/logic/auth_provider.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
|
@ -20,8 +21,8 @@ import 'package:package_info_plus/package_info_plus.dart';
|
|||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import '/data/datasources/ws_client.dart';
|
||||
|
||||
class ContactsScreen extends StatefulWidget {
|
||||
final int? targetChatId;
|
||||
|
|
@ -37,15 +38,22 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
StreamSubscription<dynamic>? _socketSubscription;
|
||||
bool _isDownloading = false;
|
||||
double _downloadProgress = 0.0;
|
||||
int _downloadedBytes = 0;
|
||||
int _downloadTotalBytes = 0;
|
||||
int _apkFileSizeBytes = 0;
|
||||
CancelToken? _cancelToken = CancelToken();
|
||||
String? _latestApkUrl;
|
||||
bool _showUpdateBanner = false;
|
||||
bool _contactsLoaded = false;
|
||||
Timer? _contactLoadTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print('ContactsScreen initState, targetChatId: ${widget.targetChatId}');
|
||||
_setupPushNotifications();
|
||||
final socketService = Provider.of<SocketService>(context, listen: false);
|
||||
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
final contactProvider = context.read<ContactProvider>();
|
||||
|
|
@ -55,19 +63,38 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
'Setting current user ID in ContactProvider: ${authProvider.currentUserId}',
|
||||
);
|
||||
contactProvider.setCurrentUserId(authProvider.currentUserId);
|
||||
contactProvider.loadContacts().then((_) {
|
||||
print('Contacts loaded, checking targetChatId: ${widget.targetChatId}');
|
||||
// После загрузки контактов проверить, нужно ли перейти к чату
|
||||
if (widget.targetChatId != null) {
|
||||
_navigateToTargetChat();
|
||||
} else {
|
||||
_checkSavedNotificationTarget();
|
||||
}
|
||||
});
|
||||
_startContactsLoadTimer();
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -174,6 +201,14 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
_showUpdateBanner = true;
|
||||
_latestApkUrl = data['apk_url'];
|
||||
});
|
||||
if (_latestApkUrl != null) {
|
||||
final size = await _fetchApkSize(_latestApkUrl!);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_apkFileSizeBytes = size;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -200,12 +235,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
});
|
||||
|
||||
// Listen for foreground messages
|
||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||
print('Foreground message received: ${message.data}');
|
||||
if (message.data['type'] == 'enc_message') {
|
||||
_handleIncomingMessage(message);
|
||||
}
|
||||
});
|
||||
FirebaseMessaging.onMessage.listen(_handleIncomingMessage);
|
||||
|
||||
// Handle notification tap when app was terminated/backgrounded
|
||||
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
|
||||
|
|
@ -260,7 +290,99 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _handleIncomingMessage(RemoteMessage message) async {
|
||||
Future<void> _handleIncomingMessage(dynamic data) async {
|
||||
if (data is RemoteMessage) {
|
||||
// FCM message
|
||||
await _handleFCMMessage(data);
|
||||
} else if (data is Map<String, dynamic>) {
|
||||
// WebSocket message
|
||||
print('WebSocket message received: $data');
|
||||
if (data['type'] == 'user_updated') {
|
||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
||||
if (userId != null) {
|
||||
final contactProvider = context.read<ContactProvider>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleFCMMessage(RemoteMessage message) async {
|
||||
try {
|
||||
// Проверяем, не находимся ли мы уже в чате с отправителем
|
||||
final senderId = int.tryParse(
|
||||
|
|
@ -273,8 +395,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
|
||||
// Ensure notification channel exists
|
||||
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||
'chat_id',
|
||||
'Messages',
|
||||
'Новые сообщения',
|
||||
description: 'Chat messages notifications',
|
||||
importance: Importance.high,
|
||||
);
|
||||
|
|
@ -301,13 +423,53 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
sharedSecret,
|
||||
);
|
||||
|
||||
if (senderId == null) return;
|
||||
final String groupKey = 'ru.chepuhagram.app.$senderId';
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? firstName = prefs.getString(
|
||||
'firstname_${message.data['sender_id']}',
|
||||
);
|
||||
final String? lastName = prefs.getString(
|
||||
'lastname_${message.data['sender_id']}',
|
||||
);
|
||||
final String localFullName = '${firstName ?? ''} ${lastName ?? ''}'
|
||||
.trim();
|
||||
|
||||
final String title = localFullName.isNotEmpty
|
||||
? localFullName
|
||||
: (message.data['username'] ?? 'Unknown');
|
||||
// Show local notification
|
||||
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
senderId,
|
||||
'',
|
||||
'',
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'Messages',
|
||||
'Новые сообщения',
|
||||
groupKey: groupKey,
|
||||
setAsGroupSummary: true,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
groupAlertBehavior: GroupAlertBehavior.all,
|
||||
),
|
||||
),
|
||||
);
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
message.hashCode,
|
||||
message.data['username'] ?? 'Unknown',
|
||||
title,
|
||||
decryptedText,
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails('chat_id', 'Messages'),
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'Messages',
|
||||
'Новые сообщения',
|
||||
groupKey: groupKey,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
showWhen: true,
|
||||
),
|
||||
),
|
||||
payload: jsonEncode({
|
||||
'type': 'enc_message',
|
||||
|
|
@ -318,17 +480,34 @@ 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 message: $e');
|
||||
print('Error processing foreground FCM message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
|
|
@ -347,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,
|
||||
|
|
@ -386,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(
|
||||
|
|
@ -431,15 +618,30 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
),
|
||||
),
|
||||
currentAccountPicture: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
child: Text(
|
||||
initials.isEmpty ? 'U' : initials,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
backgroundColor:
|
||||
authProvider.avatarUrl == null &&
|
||||
authProvider.avatarPath == null
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: null,
|
||||
backgroundImage: authProvider.avatarUrl != null
|
||||
? CachedNetworkImageProvider(authProvider.avatarUrl!)
|
||||
: authProvider.avatarPath != null
|
||||
? FileImage(File(authProvider.avatarPath!))
|
||||
: null,
|
||||
child:
|
||||
(authProvider.avatarUrl == null &&
|
||||
authProvider.avatarPath == null)
|
||||
? Text(
|
||||
initials.isEmpty ? 'U' : initials,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.inversePrimary,
|
||||
|
|
@ -488,16 +690,24 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
}
|
||||
|
||||
try {
|
||||
setState(() {
|
||||
_downloadProgress = 0.0;
|
||||
_downloadedBytes = 0;
|
||||
_downloadTotalBytes = 0;
|
||||
});
|
||||
|
||||
// Скачиваем файл «в лоб»
|
||||
await Dio().download(
|
||||
_latestApkUrl!,
|
||||
path,
|
||||
cancelToken: _cancelToken,
|
||||
onReceiveProgress: (rec, total) {
|
||||
if (total != -1) {
|
||||
if (mounted) {
|
||||
setState(() => _downloadProgress = rec / total);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_downloadedBytes = rec;
|
||||
_downloadTotalBytes = total > 0 ? total : 0;
|
||||
_downloadProgress = total > 0 ? rec / total : 0.0;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -515,17 +725,34 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
print("Ошибка: $e");
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isDownloading = false);
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_downloadProgress = 0.0;
|
||||
_downloadedBytes = 0;
|
||||
_downloadTotalBytes = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelDownload() {
|
||||
_cancelToken?.cancel("Отменено");
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_downloadProgress = 0.0;
|
||||
});
|
||||
Future<int> _fetchApkSize(String url) async {
|
||||
try {
|
||||
final response = await http.head(Uri.parse(url));
|
||||
final lengthHeader = response.headers['content-length'];
|
||||
if (lengthHeader == null) return 0;
|
||||
return int.tryParse(lengthHeader) ?? 0;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
const kb = 1024;
|
||||
const mb = kb * 1024;
|
||||
if (bytes < kb) return '$bytes B';
|
||||
if (bytes < mb) return '${(bytes / kb).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / mb).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
Widget _buildUpdateBanner() {
|
||||
|
|
@ -564,7 +791,9 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
child: Text(
|
||||
_isDownloading
|
||||
? 'Скачивание ${(_downloadProgress * 100).toStringAsFixed(0)}%'
|
||||
: "Доступно новое обновление!",
|
||||
: _apkFileSizeBytes > 0
|
||||
? 'Доступно новое обновление: ${_formatBytes(_apkFileSizeBytes)}'
|
||||
: 'Доступно новое обновление!',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
@ -581,6 +810,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
_isDownloading = false;
|
||||
_cancelToken = null; // Обязательно обнуляем токен!
|
||||
_downloadProgress = 0.0;
|
||||
_downloadedBytes = 0;
|
||||
_downloadTotalBytes = 0;
|
||||
});
|
||||
} else {
|
||||
// Если не качаем — запускаем
|
||||
|
|
@ -617,6 +848,14 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
color: Colors.white,
|
||||
backgroundColor: Colors.white24,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'${_formatBytes(_downloadedBytes)} из ${_formatBytes(_downloadTotalBytes)}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,15 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
|
|||
|
||||
try {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
|
||||
// Удаляем все сообщения пользователя
|
||||
try {
|
||||
final api = ApiService();
|
||||
await api.deleteAllMessages();
|
||||
} catch (e) {
|
||||
print('Ошибка при удалении сообщений: $e');
|
||||
// Продолжаем даже если удаление сообщений не удалось
|
||||
}
|
||||
|
||||
// Удаляем старые ключи и создаем новые
|
||||
await authProvider.resetKeys();
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _totpController = TextEditingController();
|
||||
bool _showTotpField = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -85,6 +88,36 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
validator: (value) =>
|
||||
value!.length < 6 ? "Минимум 6 символов" : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Поле TOTP, если требуется
|
||||
if (_showTotpField)
|
||||
TextFormField(
|
||||
controller: _totpController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "TOTP код",
|
||||
prefixIcon: const Icon(Icons.security),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
fillColor: Theme.of(context).colorScheme.primary,
|
||||
iconColor: Theme.of(context).colorScheme.primary,
|
||||
hoverColor: Theme.of(context).colorScheme.primary,
|
||||
focusColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
validator: (value) => value!.isEmpty ? "Введите TOTP код" : null,
|
||||
),
|
||||
if (_showTotpField) const SizedBox(height: 16),
|
||||
|
||||
// Сообщение об ошибке
|
||||
if (_errorMessage != null)
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_errorMessage != null) const SizedBox(height: 16),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Кнопка Входа
|
||||
|
|
@ -120,6 +153,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
final success = await authProvider.login(
|
||||
_usernameController.text,
|
||||
_passwordController.text,
|
||||
totpCode: _showTotpField ? _totpController.text : null,
|
||||
);
|
||||
if (success && mounted) {
|
||||
await authProvider.initRealtime();
|
||||
|
|
@ -146,9 +180,25 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
);
|
||||
final error = e.toString().replaceAll('Exception: ', '');
|
||||
if (error.contains('TOTP код требуется')) {
|
||||
setState(() {
|
||||
_showTotpField = true;
|
||||
_errorMessage = error;
|
||||
});
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_totpController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '/data/models/contact_model.dart';
|
||||
import '/logic/contact_provider.dart';
|
||||
import '/logic/auth_provider.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ 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;
|
||||
|
||||
@override
|
||||
|
|
@ -37,7 +37,7 @@ 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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -50,14 +50,14 @@ 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;
|
||||
});
|
||||
// Сохраняем локально для быстрого доступа
|
||||
await _savePreference(_showEmailKey, _showEmail);
|
||||
await _savePreference(_showPhoneKey, _showPhone);
|
||||
await _savePreference(_showAvatarKey, _showAvatar);
|
||||
await _savePreference(_showAboutKey, _showAbout);
|
||||
await _savePreference(_showUsernameKey, _showUsername);
|
||||
await _savePreference(_showLastOnlineKey, _showLastOnline);
|
||||
} catch (e) {
|
||||
// Если не удалось загрузить с сервера, используем локальные настройки
|
||||
print('Ошибка загрузки настроек с сервера: $e');
|
||||
|
|
@ -81,7 +81,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
showPhone: _showPhone,
|
||||
showAvatar: _showAvatar,
|
||||
showAbout: _showAbout,
|
||||
showUsername: _showUsername,
|
||||
showLastOnline: _showLastOnline,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
|
|
@ -90,7 +90,7 @@ 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) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
@ -143,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,
|
||||
|
|
@ -178,6 +171,13 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
setState(() => _showAbout = value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Показывать последний онлайн'),
|
||||
value: _showLastOnline,
|
||||
onChanged: (value) {
|
||||
setState(() => _showLastOnline = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Эти настройки влияют на то, какую информацию о вас видят другие пользователи приложения.',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
class SecuritySettingsScreen extends StatefulWidget {
|
||||
const SecuritySettingsScreen({super.key});
|
||||
|
|
@ -13,7 +15,7 @@ class SecuritySettingsScreen extends StatefulWidget {
|
|||
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||
final _passwordFormKey = GlobalKey<FormState>();
|
||||
final _encryptionFormKey = GlobalKey<FormState>();
|
||||
final _totpFormKey = GlobalKey<FormState>();
|
||||
//final _totpFormKey = GlobalKey<FormState>();
|
||||
|
||||
final _currentPasswordController = TextEditingController();
|
||||
final _newPasswordController = TextEditingController();
|
||||
|
|
@ -28,11 +30,15 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
bool _isSavingPassword = false;
|
||||
bool _isSavingEncryption = false;
|
||||
bool _isSavingTotp = false;
|
||||
bool _isTotpEnabled = false;
|
||||
String? _totpSecret;
|
||||
String? _totpQrCode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkBiometricSupport();
|
||||
_loadTotpStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -53,7 +59,8 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
final availableBiometrics = await _localAuth.getAvailableBiometrics();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isBiometricAvailable = canCheckBiometrics && isSupported && availableBiometrics.isNotEmpty;
|
||||
_isBiometricAvailable =
|
||||
canCheckBiometrics && isSupported && availableBiometrics.isNotEmpty;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
|
|
@ -63,6 +70,24 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTotpStatus() async {
|
||||
try {
|
||||
final api = ApiService();
|
||||
final userData = await api.getMe();
|
||||
print('TOTP status from getMe: ${userData['totp_enabled']}');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isTotpEnabled = userData['totp_enabled'] ?? false;
|
||||
});
|
||||
print('TOTP status set to: $_isTotpEnabled');
|
||||
} catch (e) {
|
||||
print('Error loading TOTP status: $e');
|
||||
// Ignore errors, assume TOTP is disabled
|
||||
if (!mounted) return;
|
||||
setState(() => _isTotpEnabled = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _authenticateBiometric() async {
|
||||
try {
|
||||
return await _localAuth.authenticate(
|
||||
|
|
@ -96,9 +121,9 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Пароль успешно изменён')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Пароль успешно изменён')));
|
||||
_currentPasswordController.clear();
|
||||
_newPasswordController.clear();
|
||||
_confirmPasswordController.clear();
|
||||
|
|
@ -143,7 +168,8 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
} else {
|
||||
final api = ApiService();
|
||||
final userData = await api.getMe();
|
||||
final encryptedPrivateKey = userData['encrypted_private_key']?.toString();
|
||||
final encryptedPrivateKey = userData['encrypted_private_key']
|
||||
?.toString();
|
||||
|
||||
if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty) {
|
||||
throw Exception('Зашифрованный ключ не найден на сервере.');
|
||||
|
|
@ -156,12 +182,12 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
await cryptoService.savePrivateKey(privateKeyBase64);
|
||||
}
|
||||
|
||||
final updatedEncryptedPrivateKey = await cryptoService.encryptPrivateKeyWithPassword(
|
||||
privateKeyBase64,
|
||||
newPassword,
|
||||
);
|
||||
final updatedEncryptedPrivateKey = await cryptoService
|
||||
.encryptPrivateKeyWithPassword(privateKeyBase64, newPassword);
|
||||
|
||||
final success = await ApiService().updateEncryptedPrivateKey(updatedEncryptedPrivateKey);
|
||||
final success = await ApiService().updateEncryptedPrivateKey(
|
||||
updatedEncryptedPrivateKey,
|
||||
);
|
||||
if (!success) {
|
||||
throw Exception('Не удалось обновить пароль шифрования на сервере.');
|
||||
}
|
||||
|
|
@ -185,12 +211,221 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
}
|
||||
|
||||
Future<void> _setupTotp() async {
|
||||
if (_isTotpEnabled) {
|
||||
// Показываем диалог с опциями
|
||||
_showTotpOptionsDialog();
|
||||
} else {
|
||||
// Enable TOTP
|
||||
setState(() => _isSavingTotp = true);
|
||||
try {
|
||||
final api = ApiService();
|
||||
final data = await api.enableTotp();
|
||||
setState(() {
|
||||
_totpSecret = data['secret'];
|
||||
_totpQrCode = data['qr_code'];
|
||||
});
|
||||
// Show dialog to scan QR and enter code
|
||||
_showTotpSetupDialog();
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isSavingTotp = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showTotpOptionsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('TOTP'),
|
||||
content: const Text('TOTP включён. Выберите действие:'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_reissueTotp();
|
||||
},
|
||||
child: const Text('Перевыпустить ключ'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_disableTotp();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
child: const Text('Отключить TOTP'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _reissueTotp() async {
|
||||
setState(() => _isSavingTotp = true);
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (!mounted) return;
|
||||
setState(() => _isSavingTotp = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('TOTP пока не подключён на сервере')),
|
||||
try {
|
||||
final api = ApiService();
|
||||
final data = await api.enableTotp();
|
||||
setState(() {
|
||||
_totpSecret = data['secret'];
|
||||
_totpQrCode = data['qr_code'];
|
||||
});
|
||||
// Show dialog to scan QR and enter code
|
||||
_showTotpSetupDialog(isReissue: true);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isSavingTotp = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disableTotp() async {
|
||||
setState(() => _isSavingTotp = true);
|
||||
try {
|
||||
final api = ApiService();
|
||||
final success = await api.disableTotp();
|
||||
if (success) {
|
||||
setState(() {
|
||||
_isTotpEnabled = false;
|
||||
_totpSecret = null;
|
||||
_totpQrCode = null;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('TOTP отключён')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isSavingTotp = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showTotpSetupDialog({bool isReissue = false}) {
|
||||
final codeController = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(isReissue ? 'Перевыпуск ключа TOTP' : 'Настройка TOTP'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(isReissue
|
||||
? 'Отсканируйте новый QR-код в приложении аутентификатора:'
|
||||
: 'Отсканируйте QR-код в приложении аутентификатора:'),
|
||||
const SizedBox(height: 16),
|
||||
if (_totpQrCode != null)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final base64String = _totpQrCode!.split(',').last;
|
||||
final bytes = base64Decode(base64String);
|
||||
return Image.memory(bytes, width: 200, height: 200);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Ключ: ${_totpSecret ?? ''}',
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () {
|
||||
if (_totpSecret != null) {
|
||||
Clipboard.setData(ClipboardData(text: _totpSecret!));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ключ скопирован')),
|
||||
);
|
||||
}
|
||||
},
|
||||
tooltip: 'Скопировать ключ',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: codeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Введите код из приложения',
|
||||
helperText: 'Обычно это 6 цифр',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
setState(() {
|
||||
_totpSecret = null;
|
||||
_totpQrCode = null;
|
||||
});
|
||||
},
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final code = codeController.text.trim();
|
||||
if (code.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Введите код')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final api = ApiService();
|
||||
final success = await api.verifyTotp(code);
|
||||
if (success) {
|
||||
Navigator.of(context).pop();
|
||||
setState(() {
|
||||
_isTotpEnabled = true;
|
||||
_totpSecret = null;
|
||||
_totpQrCode = null;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(isReissue ? 'Ключ перевыпущен' : 'TOTP включён')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Неверный код')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Подтвердить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +445,10 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text('Смена пароля аккаунта', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const Text(
|
||||
'Смена пароля аккаунта',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Form(
|
||||
key: _passwordFormKey,
|
||||
|
|
@ -218,10 +456,13 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
children: [
|
||||
TextFormField(
|
||||
controller: _currentPasswordController,
|
||||
decoration: const InputDecoration(labelText: 'Текущий пароль'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Текущий пароль',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return 'Введите текущий пароль';
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Введите текущий пароль';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
|
@ -231,7 +472,8 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
decoration: const InputDecoration(labelText: 'Новый пароль'),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return 'Введите новый пароль';
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Введите новый пароль';
|
||||
if (value.length < 6) return 'Пароль слишком короткий';
|
||||
return null;
|
||||
},
|
||||
|
|
@ -239,23 +481,31 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
decoration: const InputDecoration(labelText: 'Повторите пароль'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Повторите пароль',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value != _newPasswordController.text) return 'Пароли не совпадают';
|
||||
if (value != _newPasswordController.text)
|
||||
return 'Пароли не совпадают';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ElevatedButton(
|
||||
onPressed: _isSavingPassword ? null : _savePassword,
|
||||
child: _isSavingPassword ? const CircularProgressIndicator(color: Colors.white) : const Text('Сохранить пароль'),
|
||||
child: _isSavingPassword
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('Сохранить пароль'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('Пароль шифрования сообщений', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const Text(
|
||||
'Пароль шифрования сообщений',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Form(
|
||||
key: _encryptionFormKey,
|
||||
|
|
@ -275,39 +525,54 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _newEncryptPasswordController,
|
||||
decoration: const InputDecoration(labelText: 'Новый пароль шифрования'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Новый пароль шифрования',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.length < 6) return 'Пароль слишком короткий';
|
||||
if (value == null || value.length < 6)
|
||||
return 'Пароль слишком короткий';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _confirmEncryptPasswordController,
|
||||
decoration: const InputDecoration(labelText: 'Повторите новый пароль'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Повторите новый пароль',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value != _newEncryptPasswordController.text) return 'Пароли не совпадают';
|
||||
if (value != _newEncryptPasswordController.text)
|
||||
return 'Пароли не совпадают';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ElevatedButton(
|
||||
onPressed: _isSavingEncryption ? null : _saveEncryptionPassword,
|
||||
child: _isSavingEncryption ? const CircularProgressIndicator(color: Colors.white) : const Text('Сохранить пароль шифрования'),
|
||||
onPressed: _isSavingEncryption
|
||||
? null
|
||||
: _saveEncryptionPassword,
|
||||
child: _isSavingEncryption
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('Сохранить пароль шифрования'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('TOTP', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const Text(
|
||||
'TOTP',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Настройка одноразового кода (TOTP) пока не подключена на сервере.'),
|
||||
Text(_isTotpEnabled ? 'TOTP включён' : 'TOTP отключён'),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _isSavingTotp ? null : _setupTotp,
|
||||
child: _isSavingTotp ? const CircularProgressIndicator(color: Colors.white) : const Text('Установить TOTP код'),
|
||||
child: _isSavingTotp
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: Text(_isTotpEnabled ? 'Отключить TOTP' : 'Включить TOTP'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import 'package:chepuhagram/presentation/screens/account_settings_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/login_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/privacy_settings_menu_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/appearance_settings_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '/logic/auth_provider.dart';
|
||||
import '/core/theme_manager.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'dart:io';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
|
@ -16,6 +18,7 @@ class SettingsScreen extends StatefulWidget {
|
|||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
String? versionCode;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -32,14 +35,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _pickAvatar() async {
|
||||
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
|
||||
if (image != null) {
|
||||
final success = await context.read<AuthProvider>().updateAvatar(image.path);
|
||||
if (!success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ошибка загрузки аватарки')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeProv = context.watch<ThemeProvider>();
|
||||
final authProv = context.watch<AuthProvider>();
|
||||
|
||||
final accountEmail = authProv.email?.isNotEmpty == true
|
||||
? authProv.email!
|
||||
: authProv.username?.isNotEmpty == true
|
||||
final accountUsername = authProv.username?.isNotEmpty == true
|
||||
? '@${authProv.username!}'
|
||||
: 'Не указано';
|
||||
|
||||
|
|
@ -64,16 +76,54 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
),
|
||||
accountEmail: Text(
|
||||
accountEmail,
|
||||
accountUsername,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
),
|
||||
currentAccountPicture: CircleAvatar(
|
||||
child: Text(
|
||||
initials.isEmpty ? 'U' : initials,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
currentAccountPicture: GestureDetector(
|
||||
onTap: _pickAvatar,
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: Stack(
|
||||
children: [
|
||||
authProv.avatarUrl != null
|
||||
? CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundImage: NetworkImage(authProv.avatarUrl!),
|
||||
)
|
||||
: authProv.avatarPath != null
|
||||
? CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundImage: FileImage(File(authProv.avatarPath!)),
|
||||
)
|
||||
: CircleAvatar(
|
||||
radius: 40,
|
||||
child: Text(
|
||||
initials.isEmpty ? 'U' : initials,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.camera_alt,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -111,40 +161,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
},
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.dark_mode),
|
||||
title: const Text("Ночной режим"),
|
||||
value: themeProv.themeMode == ThemeMode.dark,
|
||||
onChanged: (val) => themeProv.toggleTheme(val),
|
||||
),
|
||||
|
||||
// Выбор цвета акцента
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.palette_outlined,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
const Text("Цвет темы"),
|
||||
Spacer(),
|
||||
_colorCircle(context, const Color(0xFF24A1DE), themeProv),
|
||||
_colorCircle(context, const Color(0xFF3E8E7E), themeProv),
|
||||
_colorCircle(context, const Color(0xFF8E3E7E), themeProv),
|
||||
_colorCircle(context, const Color(0xFFFF9800), themeProv),
|
||||
_colorCircle(context, const Color(0xFFF44336), themeProv),
|
||||
],
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette),
|
||||
title: const Text('Оформление'),
|
||||
subtitle: const Text('Тема, цвета, обои'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const AppearanceSettingsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
const Divider(),
|
||||
|
||||
|
|
@ -183,22 +214,4 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _colorCircle(BuildContext context, Color color, ThemeProvider prov) {
|
||||
bool isSelected = prov.accentColor == color;
|
||||
return GestureDetector(
|
||||
onTap: () => prov.updateAccentColor(color),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected ? color : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: CircleAvatar(backgroundColor: color, radius: 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import '../../logic/auth_provider.dart';
|
||||
import '../../logic/contact_provider.dart';
|
||||
import 'login_screen.dart';
|
||||
|
|
@ -15,6 +12,9 @@ import 'package:firebase_messaging/firebase_messaging.dart';
|
|||
import 'package:chepuhagram/main.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
|
@ -29,6 +29,8 @@ class _SplashScreenState extends State<SplashScreen> {
|
|||
|
||||
// Ключ для SharedPreferences
|
||||
static const String _notificationLaunchKey = 'notification_launch_data';
|
||||
static const String _contactPublicKey = 'contact_public_key_';
|
||||
static const String _contactSharedKey = 'contact_shared_key_';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -65,7 +67,16 @@ class _SplashScreenState extends State<SplashScreen> {
|
|||
|
||||
// 2. Пытаемся выполнить автологин
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
final isLoggedIn = await authProvider.tryAutoLogin();
|
||||
bool? isLoggedIn;
|
||||
try {
|
||||
isLoggedIn = await authProvider.tryAutoLogin();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
connectError =
|
||||
'$e+_sps_init_1'.replaceAll('Exception: ', '');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
bool connected = false;
|
||||
|
|
@ -117,12 +128,58 @@ class _SplashScreenState extends State<SplashScreen> {
|
|||
// Проверяем, было ли приложение запущено из уведомления
|
||||
int? targetChatId =
|
||||
_targetChatId; // Сначала проверяем из onMessageOpenedApp
|
||||
|
||||
// Если не установлено, проверяем SharedPreferences
|
||||
if (targetChatId == null) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedData = prefs.getString(_notificationLaunchKey);
|
||||
|
||||
try {
|
||||
final contactProvider = context.read<ContactProvider>();
|
||||
contactProvider.setCurrentUserId(authProvider.currentUserId);
|
||||
|
||||
await contactProvider.loadContacts(enrichContacts: false);
|
||||
|
||||
final myPrivKeyBase64 = await context
|
||||
.read<CryptoService>()
|
||||
.getPrivateKey();
|
||||
|
||||
if (myPrivKeyBase64 != null) {
|
||||
final Map<int, String> keysToCompute = {};
|
||||
for (var c in contactProvider.contacts) {
|
||||
final savedKeyHex = prefs.getString(
|
||||
'$_contactSharedKey${c.id}',
|
||||
);
|
||||
final savedPubKey = prefs.getString(
|
||||
'$_contactPublicKey${c.id}',
|
||||
);
|
||||
if (savedKeyHex != null && savedPubKey == c.publicKey) {
|
||||
final bytes = base64Decode(savedKeyHex);
|
||||
contactProvider.setSharedKey(c.id, SecretKey(bytes));
|
||||
} else if (c.publicKey != null) {
|
||||
keysToCompute[c.id] = c.publicKey!;
|
||||
}
|
||||
}
|
||||
print(
|
||||
'Contacts with keys for isolate: ${keysToCompute.keys.toList()}',
|
||||
);
|
||||
|
||||
final String privKey = myPrivKeyBase64;
|
||||
final computedKeys = await compute(
|
||||
CryptoService.computeSharedKeysTask,
|
||||
{'keysMap': keysToCompute, 'privKey': privKey},
|
||||
);
|
||||
|
||||
computedKeys.forEach((id, bytes) {
|
||||
contactProvider.setSharedKey(id, SecretKey(bytes));
|
||||
prefs.setString('$_contactSharedKey$id', base64Encode(bytes));
|
||||
prefs.setString('$_contactPublicKey$id', keysToCompute[id]!);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
print("Ошибка при загрузке контактов или вычислении ключей: $e");
|
||||
}
|
||||
|
||||
// Если не установлено, проверяем SharedPreferences
|
||||
|
||||
if (savedData != null) {
|
||||
try {
|
||||
final data = jsonDecode(savedData) as Map<String, dynamic>;
|
||||
|
|
@ -178,7 +235,7 @@ class _SplashScreenState extends State<SplashScreen> {
|
|||
try {
|
||||
final contactProvider = context.read<ContactProvider>();
|
||||
contactProvider.setCurrentUserId(authProvider.currentUserId);
|
||||
await contactProvider.loadContacts();
|
||||
await contactProvider.loadContacts(enrichContacts: false);
|
||||
|
||||
final contact = contactProvider.contacts.firstWhere(
|
||||
(c) => c.id == targetChatId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '/core/constants.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
class UserProfileScreen extends StatefulWidget {
|
||||
final int userId;
|
||||
|
|
@ -19,19 +25,48 @@ class UserProfileScreen extends StatefulWidget {
|
|||
|
||||
class _UserProfileScreenState extends State<UserProfileScreen> {
|
||||
Map<String, dynamic>? _userData;
|
||||
StreamSubscription<dynamic>? _socketSubscription;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
Duration? offset;
|
||||
Timer? _onlineTimer;
|
||||
String? firstName;
|
||||
String? lastName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadUserData();
|
||||
startOnlineUpdates();
|
||||
|
||||
DateTime now = DateTime.now();
|
||||
|
||||
offset = now.timeZoneOffset;
|
||||
|
||||
final socketService = Provider.of<SocketService>(context, listen: false);
|
||||
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
|
||||
}
|
||||
|
||||
void startOnlineUpdates() {
|
||||
_onlineTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
_loadUserData();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadUserData() async {
|
||||
_error = null;
|
||||
_isLoading = true;
|
||||
try {
|
||||
final api = ApiService();
|
||||
final data = await api.getUserById(widget.userId);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
firstName = prefs.containsKey('firstname_${widget.userId}')
|
||||
? prefs.getString('firstname_${widget.userId}')
|
||||
: null;
|
||||
lastName = prefs.containsKey('lastname_${widget.userId}')
|
||||
? prefs.getString('lastname_${widget.userId}')
|
||||
: null;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_userData = data;
|
||||
|
|
@ -41,44 +76,66 @@ 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_onlineTimer?.cancel();
|
||||
_socketSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Информация о пользователе'),
|
||||
),
|
||||
appBar: AppBar(title: const Text('Информация о пользователе')),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadUserData,
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadUserData,
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
)
|
||||
: _buildUserInfo(),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _buildUserInfo(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserInfo() {
|
||||
if (_userData == null) return const SizedBox.shrink();
|
||||
|
||||
final String displayFN = firstName ?? _userData?['first_name'] ?? '';
|
||||
final String displayLN = lastName ?? _userData?['last_name'] ?? '';
|
||||
final String username = _userData?['username'] ?? '';
|
||||
|
||||
final rawAvatarUrl = _userData?['avatar_url']?.toString();
|
||||
final avatarUrl = rawAvatarUrl != null && rawAvatarUrl.startsWith('/')
|
||||
? '${AppConstants.baseUrl}$rawAvatarUrl'
|
||||
: rawAvatarUrl;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
|
|
@ -87,38 +144,84 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
child: CircleAvatar(
|
||||
radius: 50,
|
||||
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
child: Text(
|
||||
(_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty &&
|
||||
_userData!['last_name'] != null && _userData!['last_name'].isNotEmpty)
|
||||
? '${_userData!['first_name'][0]}${_userData!['last_name'][0]}'.toUpperCase()
|
||||
: (_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty)
|
||||
? _userData!['first_name'][0].toUpperCase()
|
||||
: (_userData!['username'] != null && _userData!['username'].isNotEmpty)
|
||||
? _userData!['username'][0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
|
||||
),
|
||||
backgroundImage:
|
||||
(avatarUrl != null && _userData?['show_avatar'] == true)
|
||||
? CachedNetworkImageProvider(avatarUrl)
|
||||
: null,
|
||||
child: (avatarUrl == null || _userData?['show_avatar'] != true)
|
||||
? Text(
|
||||
(displayFN.isNotEmpty && displayLN.isNotEmpty)
|
||||
? '${displayFN[0]}${displayLN[0]}'.toUpperCase()
|
||||
: (displayFN.isNotEmpty)
|
||||
? displayFN[0].toUpperCase()
|
||||
: (username.isNotEmpty)
|
||||
? username[0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Name
|
||||
if ((_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty) ||
|
||||
(_userData!['last_name'] != null && _userData!['last_name'].isNotEmpty))
|
||||
Text(
|
||||
'${_userData!['first_name'] ?? ''} ${_userData!['last_name'] ?? ''}'.trim(),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
GestureDetector(
|
||||
onTap: () => {_editUserName(displayFN, displayLN)},
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
if ((displayFN.isNotEmpty) || (displayLN.isNotEmpty))
|
||||
Text(
|
||||
'$displayFN $displayLN'.trim(),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurface),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Username
|
||||
if (_userData!['username'] != null && _userData!['username'].isNotEmpty)
|
||||
Text(
|
||||
'@${_userData!['username']}',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: Colors.grey[600]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Last online status
|
||||
if (_userData!['online'] == true)
|
||||
const Text(
|
||||
'Онлайн',
|
||||
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
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(
|
||||
fontSize: 12,
|
||||
color: Color.fromARGB(255, 161, 161, 161),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
else
|
||||
const Text(
|
||||
'Был(а) недавно',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color.fromARGB(255, 161, 161, 161),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
|
@ -128,7 +231,11 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
|
||||
// Public Key (if available)
|
||||
if (_userData!['public_key'] != null)
|
||||
_buildInfoTile('Публичный ключ', _userData!['public_key'], maxLines: 3),
|
||||
_buildInfoTile(
|
||||
'Публичный ключ',
|
||||
_userData!['public_key'],
|
||||
maxLines: 3,
|
||||
),
|
||||
|
||||
// About
|
||||
if (_userData!['about'] != null && _userData!['about'].isNotEmpty)
|
||||
|
|
@ -143,11 +250,14 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
_buildInfoTile('Почта', _userData!['email']),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
if ((_userData!['username'] == null || _userData!['username'].isEmpty) &&
|
||||
(_userData!['first_name'] == null || _userData!['first_name'].isEmpty) &&
|
||||
(_userData!['last_name'] == null || _userData!['last_name'].isEmpty) &&
|
||||
(_userData!['about'] == null || _userData!['about'].isEmpty) &&
|
||||
(_userData!['phone'] == null || _userData!['phone'].isEmpty) &&
|
||||
if ((_userData!['username'] == null ||
|
||||
_userData!['username'].isEmpty) &&
|
||||
(_userData!['first_name'] == null ||
|
||||
_userData!['first_name'].isEmpty) &&
|
||||
(_userData!['last_name'] == null ||
|
||||
_userData!['last_name'].isEmpty) &&
|
||||
(_userData!['about'] == null || _userData!['about'].isEmpty) &&
|
||||
(_userData!['phone'] == null || _userData!['phone'].isEmpty) &&
|
||||
(_userData!['email'] == null || _userData!['email'].isEmpty))
|
||||
const Text(
|
||||
'Пользователь скрыл дополнительную информацию',
|
||||
|
|
@ -158,6 +268,134 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _editUserName(String firstname, String lastname) async {
|
||||
final firstnameController = TextEditingController(text: firstname);
|
||||
final lastnameController = TextEditingController(text: lastname);
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Изменить имя пользователя'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: firstnameController,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(hintText: 'Имя'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: lastnameController,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(hintText: 'Фамилия'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Сбрость'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (result == true) {
|
||||
if (firstname != firstnameController.text) {
|
||||
prefs.setString('firstname_${widget.userId}', firstnameController.text);
|
||||
}
|
||||
if (lastname != lastnameController.text) {
|
||||
prefs.setString('lastname_${widget.userId}', lastnameController.text);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
_loadUserData();
|
||||
} else {
|
||||
prefs.remove('firstname_${widget.userId}');
|
||||
prefs.remove('lastname_${widget.userId}');
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
_loadUserData();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleIncomingMessage(Map<String, dynamic> data) async {
|
||||
if (data['type'] == 'user_online') {
|
||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
||||
if (userId == widget.userId) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_userData = _userData?..['online'] = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data['type'] == 'user_offline') {
|
||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
||||
if (userId == widget.userId) {
|
||||
setState(() {
|
||||
_userData = _userData?..['online'] = false;
|
||||
_userData = _userData
|
||||
?..['last_online'] = DateTime.now().toIso8601String();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data['type'] == 'user_updated') {
|
||||
print('User updated message received, refreshing contact list');
|
||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
||||
if (userId != null && userId == widget.userId) {
|
||||
_loadUserData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatLastOnline(DateTime lastOnline) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(lastOnline);
|
||||
|
||||
if (difference.inSeconds < 60) {
|
||||
return 'только что';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes} минут${_pluralize(difference.inMinutes, "у", "ы", "")} назад';
|
||||
} else if (difference.inHours < 24) {
|
||||
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 'давно';
|
||||
}
|
||||
}
|
||||
|
||||
String _pluralize(int count, String form1, String form2, String form5) {
|
||||
final mod10 = count % 10;
|
||||
final mod100 = count % 100;
|
||||
if (mod10 == 1 && mod100 != 11) {
|
||||
return form1;
|
||||
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
|
||||
return form2;
|
||||
} else {
|
||||
return form5;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildInfoTile(String label, String value, {int maxLines = 1}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
@ -184,4 +422,4 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +1,177 @@
|
|||
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 StatelessWidget {
|
||||
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();
|
||||
}
|
||||
|
||||
class _ContactTileState extends State<ContactTile> {
|
||||
SharedPreferences? _prefs;
|
||||
String? token;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String get displayName {
|
||||
final full = '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim();
|
||||
if (_prefs == null) return widget.contact.name;
|
||||
|
||||
final id = widget.contact.id;
|
||||
final savedName = _prefs!.getString('firstname_$id');
|
||||
final savedSurname = _prefs!.getString('lastname_$id');
|
||||
|
||||
final name = savedName ?? widget.contact.name;
|
||||
final surname = savedSurname ?? widget.contact.surname;
|
||||
|
||||
final full =
|
||||
'${name != 'Unknown' ? name : ''} ${surname != 'Unknown' ? surname : ''}'
|
||||
.trim();
|
||||
|
||||
if (full.isNotEmpty) return full;
|
||||
if ((contact.username != 'Unknown' ? contact.username : '').isNotEmpty) return contact.username!;
|
||||
if (widget.contact.username != 'Unknown') return widget.contact.username;
|
||||
return 'User';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final username = widget.contact.username; //
|
||||
|
||||
final username = contact.username;
|
||||
final initials = (displayName.isNotEmpty ? displayName : (username != 'Unknown' ? username : 'U'))
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((p) => p.isNotEmpty)
|
||||
.take(2)
|
||||
.map((p) => p[0].toUpperCase())
|
||||
.join();
|
||||
|
||||
final initials =
|
||||
(displayName.isNotEmpty
|
||||
? displayName
|
||||
: (username != 'Unknown' ? username : 'U'))
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((p) => p.isNotEmpty)
|
||||
.take(2)
|
||||
.map((p) => p[0].toUpperCase())
|
||||
.join(); //
|
||||
debugPrint(
|
||||
'=== CONTACT DEBUG: ${widget.contact.name} -> URL: ${widget.contact.effectiveAvatarUrl}',
|
||||
);
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
leading: CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: primary.withAlpha((0.1 * 255).round()),
|
||||
child: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: primary.withAlpha((0.1 * 255).round()), //
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(color: primary, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
title: Text(
|
||||
contact.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
displayName, //
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), //
|
||||
),
|
||||
subtitle: Text(
|
||||
contact.lastMessage ?? "Нет сообщений",
|
||||
widget.contact.isLastMsgDecrypted
|
||||
? 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(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 (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(
|
||||
'${contact.unreadCount}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||
'${widget.contact.unreadCount}', //
|
||||
style: TextStyle(color: Colors.white, fontSize: 10), //
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -12,14 +15,22 @@ import firebase_messaging
|
|||
import flutter_image_compress_macos
|
||||
import flutter_local_notifications
|
||||
import flutter_secure_storage_darwin
|
||||
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"))
|
||||
|
|
@ -27,10 +38,15 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
||||
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"))
|
||||
}
|
||||
|
|
|
|||
562
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:
|
||||
|
|
@ -33,14 +97,94 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
cached_network_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cached_network_image
|
||||
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
cached_network_image_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_platform_interface
|
||||
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
cached_network_image_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_web
|
||||
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
||||
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:
|
||||
|
|
@ -57,6 +201,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -81,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:
|
||||
|
|
@ -113,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:
|
||||
|
|
@ -129,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:
|
||||
|
|
@ -137,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:
|
||||
|
|
@ -241,11 +441,35 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.5.18"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_cache_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||
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:
|
||||
|
|
@ -294,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:
|
||||
|
|
@ -400,6 +632,22 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
gal:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: gal
|
||||
sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee"
|
||||
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:
|
||||
|
|
@ -408,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:
|
||||
|
|
@ -416,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:
|
||||
|
|
@ -512,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:
|
||||
|
|
@ -604,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:
|
||||
|
|
@ -640,6 +912,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: octo_image
|
||||
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
open_filex:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -681,7 +961,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
|
|
@ -728,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:
|
||||
|
|
@ -736,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:
|
||||
|
|
@ -752,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:
|
||||
|
|
@ -760,6 +1120,78 @@ 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:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -885,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:
|
||||
|
|
@ -913,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:
|
||||
|
|
@ -997,6 +1437,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1005,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:
|
||||
|
|
@ -1037,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:
|
||||
|
|
@ -1061,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"
|
||||
|
|
|
|||
33
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.0+1
|
||||
version: 2.0.2+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
|
@ -50,11 +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:
|
||||
|
|
@ -66,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 |
|
|
@ -6,6 +6,11 @@ from app.api import schemas
|
|||
from app.db import models
|
||||
from jose import JWTError, jwt
|
||||
from app.core.security import get_current_user
|
||||
import pyotp
|
||||
import qrcode
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from fastapi.responses import StreamingResponse
|
||||
# бд
|
||||
|
||||
|
||||
|
|
@ -61,17 +66,26 @@ async def register(password: str):
|
|||
|
||||
|
||||
@authRouter.post("/login")
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
async def login(data: schemas.LoginRequest, db: Session = Depends(get_db)):
|
||||
print(f"Login attempt: username={data.username}, totp_code provided={bool(data.totp_code)}")
|
||||
|
||||
user = db.query(models.User).filter(
|
||||
models.User.username == form_data.username).first()
|
||||
models.User.username == data.username).first()
|
||||
|
||||
if not user or not security.verify_password(form_data.password, user.hashed_password):
|
||||
if not user or not security.verify_password(data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверный логин или пароль",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if user.totp_secret:
|
||||
if not data.totp_code:
|
||||
raise HTTPException(status_code=400, detail="TOTP код требуется")
|
||||
totp = pyotp.TOTP(user.totp_secret)
|
||||
if not totp.verify(data.totp_code):
|
||||
raise HTTPException(status_code=400, detail="Неверный TOTP код")
|
||||
|
||||
access_token = security.create_access_token(data={"sub": str(user.id)})
|
||||
refresh_token = security.create_refresh_token(data={"sub": str(user.id)})
|
||||
return {
|
||||
|
|
@ -82,6 +96,73 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session =
|
|||
}
|
||||
|
||||
|
||||
@authRouter.post("/totp/enable")
|
||||
async def enable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
# Загружаем свежую копию user из БД
|
||||
user = db.query(models.User).filter(models.User.id == current_user.id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
||||
|
||||
#if user.totp_secret:
|
||||
#raise HTTPException(status_code=400, detail="TOTP уже включен")
|
||||
|
||||
secret = pyotp.random_base32()
|
||||
user.totp_temp_secret = secret
|
||||
db.commit()
|
||||
print(f"TOTP enabled for user {user.id}, secret saved")
|
||||
|
||||
# Генерировать QR
|
||||
totp = pyotp.TOTP(secret)
|
||||
uri = totp.provisioning_uri(name=user.username, issuer_name="Chepuhagram")
|
||||
img = qrcode.make(uri)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
qr_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||
qr_data_url = f"data:image/png;base64,{qr_base64}"
|
||||
|
||||
return {"secret": secret, "qr_code": qr_data_url}
|
||||
|
||||
|
||||
@authRouter.post("/totp/verify")
|
||||
async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
# Загружаем свежую копию user из БД
|
||||
user = db.query(models.User).filter(models.User.id == current_user.id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
||||
|
||||
if not user.totp_temp_secret:
|
||||
raise HTTPException(status_code=400, detail="TOTP не включен")
|
||||
|
||||
try:
|
||||
totp = pyotp.TOTP(user.totp_temp_secret)
|
||||
code_str = str(data.code).strip()
|
||||
is_valid = totp.verify(code_str)
|
||||
print(f"TOTP verify: user_id={user.id}, code={code_str}, secret_set={bool(user.totp_temp_secret)}, valid={is_valid}")
|
||||
|
||||
if is_valid:
|
||||
user.totp_secret = user.totp_temp_secret
|
||||
user.totp_temp_secret = None
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Неверный код")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"TOTP verify error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка верификации: {str(e)}")
|
||||
|
||||
|
||||
@authRouter.post("/totp/disable")
|
||||
async def disable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
user = db.query(models.User).filter(models.User.id == current_user.id).first()
|
||||
if user:
|
||||
user.totp_secret = None
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@authRouter.post("/refresh")
|
||||
async def refresh_token(data: schemas.RefreshRequest):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,53 +1,584 @@
|
|||
from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, File, UploadFile
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
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
|
||||
from app.core import security
|
||||
from app.api import schemas
|
||||
from app.db import models
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.security import get_current_user
|
||||
from app.db import models
|
||||
from app.core.config import config
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
# бд
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from io import BytesIO
|
||||
import asyncio
|
||||
|
||||
|
||||
def get_db():
|
||||
def _ensure_directory(path: str):
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
|
||||
UPLOAD_FOLDER = 'uploads'
|
||||
|
||||
|
||||
def _parse_multipart_body(body: bytes):
|
||||
try:
|
||||
if not body.startswith(b"--"):
|
||||
return None
|
||||
|
||||
boundary, _ = body.split(b"\r\n", 1)
|
||||
parts = body.split(boundary)
|
||||
for part in parts:
|
||||
if not part or part in (b"--", b"--\r\n"):
|
||||
continue
|
||||
|
||||
part = part.strip(b"\r\n")
|
||||
if not part:
|
||||
continue
|
||||
|
||||
headers, _, content = part.partition(b"\r\n\r\n")
|
||||
if not headers or content is None:
|
||||
continue
|
||||
|
||||
disposition_match = re.search(
|
||||
br'Content-Disposition:\s*form-data;\s*name="([^"]+)"(?:;\s*filename="([^"]+)")?',
|
||||
headers,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not disposition_match:
|
||||
continue
|
||||
|
||||
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)
|
||||
content_type = (
|
||||
content_type_match.group(1).decode('utf-8', errors='ignore')
|
||||
if content_type_match
|
||||
else 'application/octet-stream'
|
||||
)
|
||||
return filename, content.rstrip(b'\r\n'), content_type
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def _get_upload_file(request: Request, uploaded_file: UploadFile | None):
|
||||
if uploaded_file is not None:
|
||||
return uploaded_file
|
||||
|
||||
raw_body = await request.body()
|
||||
parsed = _parse_multipart_body(raw_body)
|
||||
if parsed is None:
|
||||
return None
|
||||
|
||||
filename, content, content_type = parsed
|
||||
return UploadFile(filename=filename, file=BytesIO(content), content_type=content_type)
|
||||
|
||||
|
||||
def _encode_multipart_formdata(fields, files):
|
||||
boundary = uuid.uuid4().hex
|
||||
body = BytesIO()
|
||||
|
||||
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(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')
|
||||
)
|
||||
body.write(f"Content-Type: {content_type}\r\n\r\n".encode('utf-8'))
|
||||
body.write(file_bytes)
|
||||
body.write(b"\r\n")
|
||||
|
||||
body.write(f"--{boundary}--\r\n".encode('utf-8'))
|
||||
return body.getvalue(), boundary
|
||||
|
||||
|
||||
def _get_cloud_cache_size_bytes(db: Session) -> int:
|
||||
total = db.query(func.sum(models.CloudMediaItem.size_bytes)).filter(
|
||||
models.CloudMediaItem.status.in_(['pending', 'sending']),
|
||||
models.CloudMediaItem.is_avatar == 0,
|
||||
).scalar()
|
||||
return int(total or 0)
|
||||
|
||||
|
||||
def _find_local_media_path(file_id: str) -> str | None:
|
||||
candidates = [
|
||||
os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, f"{file_id}.enc"),
|
||||
os.path.join('uploads', f"{file_id}.enc"),
|
||||
os.path.join(config.HOME_MEDIA_FOLDER, f"{file_id}.enc"),
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _stream_response_from_remote(url: str):
|
||||
try:
|
||||
request = urllib.request.Request(url)
|
||||
response = urllib.request.urlopen(request, timeout=45)
|
||||
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}')
|
||||
except Exception as 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')
|
||||
return StreamingResponse(
|
||||
iter(lambda: response.read(8192), b""),
|
||||
media_type=content_type,
|
||||
headers={
|
||||
'Content-Disposition': headers.get('content-disposition', f'attachment; filename="{os.path.basename(url)}"')
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _post_file_to_home(item: models.CloudMediaItem) -> tuple[bool, str]:
|
||||
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'
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
content = f.read()
|
||||
|
||||
fields = {
|
||||
'owner_id': item.owner_id or '',
|
||||
'cloud_file_id': item.file_id,
|
||||
'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),
|
||||
]
|
||||
body, boundary = _encode_multipart_formdata(fields, files)
|
||||
request = urllib.request.Request(
|
||||
f"{config.HOME_SERVER_URL}/media/receive",
|
||||
data=body,
|
||||
headers={
|
||||
'Content-Type': f'multipart/form-data; boundary={boundary}',
|
||||
'X-Media-Forwarding-Secret': config.MEDIA_FORWARDING_SECRET,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
if response.status == 200:
|
||||
return True, ''
|
||||
return False, f'Home server returned {response.status}'
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode(errors='ignore')
|
||||
return False, f'Home server HTTP error {exc.code}: {body}'
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def _cleanup_home_quota(db: Session, owner_id: int | None):
|
||||
if owner_id is None:
|
||||
return
|
||||
|
||||
total = db.query(func.sum(models.HomeMediaFile.size_bytes)).filter(
|
||||
models.HomeMediaFile.owner_id == owner_id
|
||||
).scalar() or 0
|
||||
total = int(total)
|
||||
if total <= config.HOME_USER_QUOTA_BYTES:
|
||||
return
|
||||
|
||||
files = db.query(models.HomeMediaFile).filter(
|
||||
models.HomeMediaFile.owner_id == owner_id
|
||||
).order_by(models.HomeMediaFile.created_at.asc()).all()
|
||||
|
||||
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)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
total -= file_record.size_bytes
|
||||
db.delete(file_record)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _cleanup_all_home_storage():
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
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:
|
||||
db.close()
|
||||
|
||||
|
||||
async def forward_pending_media_loop():
|
||||
while True:
|
||||
if config.SERVER_ROLE != 'cloud':
|
||||
await asyncio.sleep(10)
|
||||
continue
|
||||
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
total_cache = _get_cloud_cache_size_bytes(db)
|
||||
if total_cache >= config.CLOUD_CACHE_MAX_BYTES:
|
||||
await asyncio.sleep(config.MEDIA_FORWARD_INTERVAL_SECONDS)
|
||||
continue
|
||||
|
||||
pending_items = db.query(models.CloudMediaItem).filter(
|
||||
models.CloudMediaItem.status == 'pending',
|
||||
models.CloudMediaItem.is_avatar == 0,
|
||||
).order_by(models.CloudMediaItem.created_at.asc()).limit(5).all()
|
||||
|
||||
for item in pending_items:
|
||||
item.status = 'sending'
|
||||
item.attempts += 1
|
||||
db.commit()
|
||||
|
||||
success, error = _post_file_to_home(item)
|
||||
if success:
|
||||
item.status = 'sent'
|
||||
item.sent_at = func.now()
|
||||
item.error_message = None
|
||||
db.commit()
|
||||
cache_path = os.path.join(
|
||||
config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
|
||||
if os.path.exists(cache_path):
|
||||
os.remove(cache_path)
|
||||
else:
|
||||
item.status = 'failed'
|
||||
item.error_message = error
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
await asyncio.sleep(config.MEDIA_FORWARD_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
async def home_storage_maintenance_loop():
|
||||
while True:
|
||||
if config.SERVER_ROLE != 'home':
|
||||
await asyncio.sleep(10)
|
||||
continue
|
||||
_cleanup_all_home_storage()
|
||||
await asyncio.sleep(600)
|
||||
|
||||
|
||||
mediaRouter = APIRouter(
|
||||
prefix="/media",
|
||||
tags=[],
|
||||
prefix='/media',
|
||||
tags=['media'],
|
||||
)
|
||||
|
||||
UPLOAD_FOLDER = 'uploads'
|
||||
if not os.path.exists(UPLOAD_FOLDER):
|
||||
os.makedirs(UPLOAD_FOLDER)
|
||||
|
||||
_ensure_directory(UPLOAD_FOLDER)
|
||||
_ensure_directory(config.CLOUD_MEDIA_CACHE_FOLDER)
|
||||
_ensure_directory(config.HOME_MEDIA_FOLDER)
|
||||
|
||||
|
||||
@mediaRouter.post('/upload')
|
||||
async def upload_file(file: UploadFile = File(...)):
|
||||
# Проверяем, есть ли файл в запросе
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No selected file")
|
||||
async def upload_file(
|
||||
request: Request,
|
||||
file: UploadFile = File(None),
|
||||
):
|
||||
uploaded_file = await _get_upload_file(request, file)
|
||||
if uploaded_file is None or not uploaded_file.filename:
|
||||
raise HTTPException(status_code=400, detail='No selected file')
|
||||
|
||||
# Генерируем уникальное имя, чтобы файлы не перезаписывались
|
||||
file_id = str(uuid.uuid4())
|
||||
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)')
|
||||
|
||||
file_id = uuid.uuid4().hex
|
||||
filename = f"{file_id}.enc"
|
||||
file_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
|
||||
# Сохраняем
|
||||
with open(file_path, "wb") as f:
|
||||
content = await file.read()
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
print(f"Файл сохранен: {file_path}")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"file_id": file_id
|
||||
}
|
||||
'status': 'ok',
|
||||
'file_id': file_id,
|
||||
}
|
||||
|
||||
|
||||
@mediaRouter.post('/v2/upload')
|
||||
async def upload_file_v2(
|
||||
request: Request,
|
||||
file: UploadFile = File(None),
|
||||
purpose: str = Form('media'),
|
||||
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')
|
||||
|
||||
uploaded_file = await _get_upload_file(request, file)
|
||||
if uploaded_file is None or not uploaded_file.filename:
|
||||
raise HTTPException(status_code=400, detail='No selected 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)')
|
||||
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
cache_size = _get_cloud_cache_size_bytes(db)
|
||||
is_avatar = purpose == 'avatar'
|
||||
if cache_size >= config.CLOUD_CACHE_MAX_BYTES and not is_avatar:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail='Cloud media cache is full; new uploads are temporarily paused until pending files are forwarded.',
|
||||
)
|
||||
|
||||
file_id = uuid.uuid4().hex
|
||||
local_filename = f"{file_id}.enc"
|
||||
storage_path = os.path.join(
|
||||
config.CLOUD_MEDIA_CACHE_FOLDER, local_filename)
|
||||
with open(storage_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
item = models.CloudMediaItem(
|
||||
file_id=file_id,
|
||||
owner_id=current_user.id,
|
||||
original_filename=uploaded_file.filename,
|
||||
content_type=uploaded_file.content_type or 'application/octet-stream',
|
||||
local_filename=local_filename,
|
||||
size_bytes=len(content),
|
||||
status='avatar' if is_avatar else 'pending',
|
||||
is_avatar=1 if is_avatar else 0,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {'status': 'ok', 'file_id': file_id}
|
||||
|
||||
|
||||
@mediaRouter.post('/receive')
|
||||
async def receive_media(
|
||||
request: Request,
|
||||
file: UploadFile = File(None),
|
||||
owner_id: int | None = Form(None),
|
||||
cloud_file_id: str | None = Form(None),
|
||||
original_filename: str | None = Form(None),
|
||||
):
|
||||
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')
|
||||
|
||||
uploaded_file = await _get_upload_file(request, file)
|
||||
if uploaded_file is None or not uploaded_file.filename:
|
||||
raise HTTPException(status_code=400, detail='No selected 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)')
|
||||
|
||||
file_id = cloud_file_id or uuid.uuid4().hex
|
||||
storage_filename = f"{file_id}.enc"
|
||||
file_path = os.path.join(config.HOME_MEDIA_FOLDER, storage_filename)
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
home_record = models.HomeMediaFile(
|
||||
file_id=file_id,
|
||||
owner_id=owner_id,
|
||||
original_filename=original_filename or uploaded_file.filename,
|
||||
content_type=uploaded_file.content_type or 'application/octet-stream',
|
||||
storage_filename=storage_filename,
|
||||
size_bytes=len(content),
|
||||
)
|
||||
db.add(home_record)
|
||||
db.commit()
|
||||
_cleanup_home_quota(db, owner_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
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:
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -31,6 +31,38 @@ async def get_chat_history(
|
|||
(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()
|
||||
print(
|
||||
f"DEBUG get_chat_history: user={current_user.id}, contact={contact_id}, count={len(messages)}, ids={[m.id for m in messages]}",
|
||||
)
|
||||
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),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Удалить все сообщения пользователя"""
|
||||
# Удаляем все сообщения, где пользователь либо отправитель, либо получатель
|
||||
db.query(models.Message).filter(
|
||||
(models.Message.sender_id == current_user.id) | (models.Message.receiver_id == current_user.id)
|
||||
).delete()
|
||||
db.commit()
|
||||
|
||||
return {"status": "ok", "detail": "Все сообщения удалены"}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
|
||||
from fastapi import Depends, APIRouter, HTTPException, Depends
|
||||
import os
|
||||
|
||||
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
|
||||
from app.api import schemas
|
||||
from app.core.config import config
|
||||
from sqlalchemy import or_, and_, exists
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.websocket import connection_manager
|
||||
|
||||
# бд
|
||||
|
||||
|
||||
def get_db():
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
|
|
@ -17,6 +22,29 @@ def get_db():
|
|||
db.close()
|
||||
|
||||
|
||||
def _delete_old_avatar_file(file_id: str, db: Session):
|
||||
upload_path = os.path.join('uploads', f"{file_id}.enc")
|
||||
if os.path.exists(upload_path):
|
||||
try:
|
||||
os.remove(upload_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
cloud_item = db.query(models.CloudMediaItem).filter(
|
||||
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)
|
||||
if os.path.exists(cloud_path):
|
||||
try:
|
||||
os.remove(cloud_path)
|
||||
except OSError:
|
||||
pass
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
|
||||
|
||||
usersRouter = APIRouter(
|
||||
prefix="/users",
|
||||
tags=[],
|
||||
|
|
@ -37,6 +65,8 @@ async def read_users_me(current_user: models.User = Depends(get_current_user)):
|
|||
"about": current_user.about,
|
||||
"public_key": current_user.public_key,
|
||||
"encrypted_private_key": current_user.encrypted_private_key,
|
||||
"avatar_file_id": current_user.avatar_file_id,
|
||||
"totp_enabled": bool(current_user.totp_secret != None),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -69,6 +99,7 @@ async def update_users_me(
|
|||
status_code=400, detail="phone/email already in use")
|
||||
|
||||
db.refresh(user_to_update)
|
||||
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
|
||||
return {
|
||||
"status": "ok",
|
||||
"user": {
|
||||
|
|
@ -100,6 +131,7 @@ async def update_encrypted_private_key(
|
|||
status_code=500, detail="Не удалось сохранить ключ шифрования")
|
||||
|
||||
db.refresh(user_to_update)
|
||||
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
|
@ -144,9 +176,10 @@ 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:
|
||||
db.commit()
|
||||
except Exception:
|
||||
|
|
@ -155,6 +188,7 @@ async def update_privacy_settings(
|
|||
status_code=500, detail="Не удалось сохранить настройки конфиденциальности")
|
||||
|
||||
db.refresh(user_to_update)
|
||||
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
|
@ -168,18 +202,28 @@ 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()
|
||||
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]
|
||||
users_for_return = []
|
||||
if current_user.id >= 100:
|
||||
for user in users:
|
||||
if not 1 < int(user.id) < 100:
|
||||
users_for_return.append(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(
|
||||
request: Request,
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
|
|
@ -190,7 +234,6 @@ async def read_users_chats(
|
|||
Клиент должен расшифровать превью локально.
|
||||
"""
|
||||
|
||||
|
||||
users = (
|
||||
db.query(models.User)
|
||||
.filter(models.User.id != current_user.id)
|
||||
|
|
@ -241,9 +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 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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -251,9 +299,55 @@ 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,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user)
|
||||
):
|
||||
|
|
@ -271,22 +365,57 @@ def get_user_by_id(
|
|||
"public_key": user.public_key,
|
||||
}
|
||||
|
||||
# Проверяем настройки конфиденциальности
|
||||
if user.show_username:
|
||||
profile_data["username"] = user.username
|
||||
profile_data["first_name"] = user.first_name
|
||||
profile_data["last_name"] = user.last_name
|
||||
profile_data["username"] = user.username
|
||||
|
||||
if user.show_avatar:
|
||||
# Для аватара пока просто передаем имя, клиент сам сгенерирует аватар
|
||||
profile_data["first_name"] = user.first_name
|
||||
profile_data["last_name"] = user.last_name
|
||||
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
|
||||
|
||||
if user.show_about:
|
||||
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:
|
||||
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 or current_user.id == 1:
|
||||
profile_data["last_online"] = user.last_online.isoformat(
|
||||
) if user.last_online else None
|
||||
|
||||
return profile_data
|
||||
|
||||
|
||||
@usersRouter.put("/me/avatar")
|
||||
async def update_user_avatar(
|
||||
data: dict,
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_to_update = db.merge(current_user)
|
||||
avatar_file_id = data.get("avatar_file_id")
|
||||
if avatar_file_id:
|
||||
old_avatar_file_id = user_to_update.avatar_file_id
|
||||
if old_avatar_file_id and old_avatar_file_id != avatar_file_id:
|
||||
_delete_old_avatar_file(old_avatar_file_id, db)
|
||||
|
||||
user_to_update.avatar_file_id = avatar_file_id
|
||||
db.commit()
|
||||
print(
|
||||
f"Пользователь {user_to_update.id} обновил аватар: {avatar_file_id}")
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="avatar_file_id is required")
|
||||
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
|
||||
return {"message": "Avatar updated"}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ class SetPublicKey(BaseModel):
|
|||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
class SetupAccount(BaseModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
|
|
@ -45,6 +50,10 @@ class UpdatePrivacySettings(BaseModel):
|
|||
show_avatar: Optional[bool] = None
|
||||
show_about: Optional[bool] = None
|
||||
show_username: Optional[bool] = None
|
||||
show_last_online: Optional[bool] = None
|
||||
|
||||
class TOTPVerifyRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
id: int
|
||||
|
|
@ -54,7 +63,21 @@ class UserProfile(BaseModel):
|
|||
about: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
public_key: Optional[str] = None
|
||||
online: bool = False
|
||||
last_online: Optional[str] = None
|
||||
show_avatar: bool = False
|
||||
totp_enabled: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UserContactResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
username: str
|
||||
public_key: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
# Database
|
||||
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./chepuhagram.db")
|
||||
|
||||
# Security
|
||||
JWT_KEY: str = os.getenv("JWT_KEY", "")
|
||||
if not JWT_KEY:
|
||||
raise RuntimeError("JWT_KEY environment variable not set")
|
||||
|
||||
# Firebase
|
||||
FIREBASE_CREDENTIALS_PATH: str = os.getenv("FIREBASE_CREDENTIALS_PATH", "chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json")
|
||||
|
||||
# Server
|
||||
HOST: str = os.getenv("HOST", "0.0.0.0")
|
||||
PORT: int = int(os.getenv("PORT", "8000"))
|
||||
SERVER_ROLE: str = os.getenv("SERVER_ROLE", "cloud").lower()
|
||||
HOME_SERVER_URL: str = os.getenv("HOME_SERVER_URL", "http://home-server.local:8000")
|
||||
MEDIA_FORWARDING_SECRET: str = os.getenv("MEDIA_FORWARDING_SECRET", "changeme")
|
||||
CLOUD_MEDIA_CACHE_FOLDER: str = os.getenv("CLOUD_MEDIA_CACHE_FOLDER", "cloud_media_cache")
|
||||
HOME_MEDIA_FOLDER: str = os.getenv("HOME_MEDIA_FOLDER", "home_media_store")
|
||||
CLOUD_CACHE_MAX_BYTES: int = int(os.getenv("CLOUD_CACHE_MAX_BYTES", str(5 * 1024 * 1024 * 1024)))
|
||||
HOME_USER_QUOTA_BYTES: int = int(os.getenv("HOME_USER_QUOTA_BYTES", str(10 * 1024 * 1024 * 1024)))
|
||||
MEDIA_UPLOAD_MAX_BYTES: int = int(os.getenv("MEDIA_UPLOAD_MAX_BYTES", str(100 * 1024 * 1024)))
|
||||
MEDIA_FORWARD_INTERVAL_SECONDS: int = int(os.getenv("MEDIA_FORWARD_INTERVAL_SECONDS", "12"))
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
|
||||
|
||||
config = Config()
|
||||
|
|
@ -10,7 +10,10 @@ from jose import JWTError, jwt
|
|||
import os
|
||||
import bcrypt
|
||||
load_dotenv()
|
||||
SECRET_KEY = os.getenv("JWT_KEY").strip()
|
||||
SECRET_KEY = os.getenv("JWT_KEY")
|
||||
if not SECRET_KEY:
|
||||
raise RuntimeError("JWT_KEY environment variable not set")
|
||||
SECRET_KEY = SECRET_KEY.strip()
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ from sqlalchemy.orm import sessionmaker
|
|||
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy import text
|
||||
from app.core.config import config
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./chepuhagram.db"
|
||||
SQLALCHEMY_DATABASE_URL = config.DATABASE_URL
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
|
@ -21,10 +21,12 @@ class User(Base):
|
|||
phone = Column(String(20), unique=True, nullable=True)
|
||||
email = Column(String(255), unique=True, nullable=True)
|
||||
totp_secret = Column(String(32), nullable=True)
|
||||
totp_temp_secret = Column(String(32), nullable=True) # Temporary secret until verified
|
||||
hashed_password = Column(String)
|
||||
public_key = Column(String, nullable=True)
|
||||
encrypted_private_key = Column(String, nullable=True)
|
||||
fcm_token = Column(String, nullable=True)
|
||||
avatar_file_id = Column(String, nullable=True)
|
||||
|
||||
# Privacy settings
|
||||
show_email = Column(Integer, nullable=False, server_default="1") # 1 = true, 0 = false
|
||||
|
|
@ -32,19 +34,53 @@ class User(Base):
|
|||
show_avatar = Column(Integer, nullable=False, server_default="1")
|
||||
show_about = Column(Integer, nullable=False, server_default="1")
|
||||
show_username = Column(Integer, nullable=False, server_default="1")
|
||||
show_last_online = Column(Integer, nullable=False, server_default="1")
|
||||
last_online = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sender_id = Column(Integer, ForeignKey("users.id"))
|
||||
receiver_id = Column(Integer, ForeignKey("users.id"))
|
||||
content = Column(Text)
|
||||
content = Column(Text)
|
||||
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
||||
delivered_at = Column(DateTime(timezone=True), nullable=True)
|
||||
read_at = Column(DateTime(timezone=True), nullable=True)
|
||||
reply_to_id = Column(Integer, ForeignKey("messages.id"), nullable=True)
|
||||
reply_to_text = Column(Text, nullable=True)
|
||||
edited_at = Column(DateTime(timezone=True), nullable=True)
|
||||
message_type = Column(String, nullable=False, server_default="text")
|
||||
file_id = Column(String, nullable=True)
|
||||
encrypted_key = Column(String, nullable=True)
|
||||
|
||||
class CloudMediaItem(Base):
|
||||
__tablename__ = "cloud_media_items"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
file_id = Column(String, unique=True, nullable=False, index=True)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
original_filename = Column(String, nullable=True)
|
||||
content_type = Column(String, nullable=True)
|
||||
local_filename = Column(String, nullable=False)
|
||||
size_bytes = Column(Integer, nullable=False)
|
||||
status = Column(String, nullable=False, server_default="pending")
|
||||
is_avatar = Column(Integer, nullable=False, server_default="0")
|
||||
attempts = Column(Integer, nullable=False, server_default="0")
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
sent_at = Column(DateTime(timezone=True), nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
class HomeMediaFile(Base):
|
||||
__tablename__ = "home_media_files"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
file_id = Column(String, unique=True, nullable=False, index=True)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
original_filename = Column(String, nullable=True)
|
||||
content_type = Column(String, nullable=True)
|
||||
storage_filename = Column(String, nullable=False)
|
||||
size_bytes = Column(Integer, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
|
@ -66,6 +102,12 @@ def _ensure_sqlite_message_columns():
|
|||
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT"))
|
||||
if "edited_at" not in existing:
|
||||
conn.execute(text("ALTER TABLE messages ADD COLUMN edited_at DATETIME"))
|
||||
if "message_type" not in existing:
|
||||
conn.execute(text("ALTER TABLE messages ADD COLUMN message_type VARCHAR(32) DEFAULT 'text' NOT NULL"))
|
||||
if "file_id" not in existing:
|
||||
conn.execute(text("ALTER TABLE messages ADD COLUMN file_id VARCHAR(255)"))
|
||||
if "encrypted_key" not in existing:
|
||||
conn.execute(text("ALTER TABLE messages ADD COLUMN encrypted_key VARCHAR(1024)"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
|
@ -93,6 +135,15 @@ def _ensure_sqlite_user_columns():
|
|||
conn.execute(text("ALTER TABLE users ADD COLUMN show_about INTEGER DEFAULT 1"))
|
||||
if "show_username" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN show_username INTEGER DEFAULT 1"))
|
||||
if "show_last_online" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN show_last_online INTEGER DEFAULT 1"))
|
||||
if "last_online" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN last_online DATETIME"))
|
||||
conn.execute(text("UPDATE users SET last_online = datetime('now')"))
|
||||
if "avatar_file_id" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN avatar_file_id VARCHAR(255)"))
|
||||
if "totp_temp_secret" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN totp_temp_secret VARCHAR(32)"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
from fastapi import HTTPException, status, APIRouter, WebSocket, WebSocketDisconnect, Query, Depends
|
||||
from app.core.security import test_token
|
||||
from typing import Dict
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db import models
|
||||
from firebase_admin import messaging, credentials, exceptions
|
||||
import firebase_admin
|
||||
from app.core.config import config
|
||||
|
||||
cred = credentials.Certificate("chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json")
|
||||
cred = credentials.Certificate(config.FIREBASE_CREDENTIALS_PATH)
|
||||
firebase_admin.initialize_app(cred)
|
||||
|
||||
# бд
|
||||
|
|
@ -40,20 +41,40 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
print("ПОДКЛЮЧЕНИЕ")
|
||||
await manager.connect(user_id, websocket)
|
||||
print("ПОДКЛЮЧЕНО")
|
||||
|
||||
db.query(models.User).filter(models.User.id == user_id).update({"last_online": datetime.now(timezone.utc)},
|
||||
synchronize_session="fetch")
|
||||
db.commit()
|
||||
await manager.broadcast({
|
||||
"type": "user_online",
|
||||
"user_id": user_id,
|
||||
})
|
||||
try:
|
||||
while True:
|
||||
print("ОЖИДАНИЕ СООБЩЕНИЙ")
|
||||
data = await websocket.receive_text()
|
||||
message_data = json.loads(data)
|
||||
print(f"DEBUG: Получены данные: {message_data}")
|
||||
|
||||
db.query(models.User).filter(models.User.id == user_id).update({"last_online": datetime.now(timezone.utc)},
|
||||
synchronize_session="fetch")
|
||||
db.commit()
|
||||
|
||||
if message_data.get("type") == "private_message":
|
||||
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
|
||||
user = db.query(models.User).filter(
|
||||
models.User.id == user_id).first()
|
||||
receiver_id = message_data.get("receiver_id")
|
||||
temp_id = message_data.get("temp_id")
|
||||
content = message_data.get("content")
|
||||
content50 = message_data.get("content50")
|
||||
message_type = message_data.get("message_type") or "text"
|
||||
file_id = message_data.get("file_id")
|
||||
encrypted_key = message_data.get("encrypted_key")
|
||||
|
||||
print(
|
||||
f"DEBUG private_message payload: temp_id={temp_id}, receiver_id={receiver_id}, message_type={message_type}, file_id={file_id}, encrypted_key_present={encrypted_key is not None}",
|
||||
)
|
||||
|
||||
if receiver_id is None or content is None:
|
||||
await websocket.send_json({
|
||||
|
|
@ -75,6 +96,9 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
sender_id=user_id,
|
||||
receiver_id=receiver_id,
|
||||
content=content,
|
||||
message_type=message_type,
|
||||
file_id=file_id,
|
||||
encrypted_key=encrypted_key,
|
||||
reply_to_id=message_data.get("reply_to_id"),
|
||||
reply_to_text=message_data.get("reply_to_text")
|
||||
)
|
||||
|
|
@ -82,15 +106,19 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
db.commit()
|
||||
db.refresh(new_msg)
|
||||
|
||||
# ACK отправителю: сервер принял и сохранил сообщение (нужно для статусов клиента).
|
||||
print(
|
||||
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 отправителю: сервер принял и сохранил сообщение.
|
||||
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()
|
||||
|
|
@ -101,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 = {
|
||||
|
|
@ -110,18 +143,26 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
"sender_id": user_id,
|
||||
"receiver_id": receiver_id,
|
||||
"content": message_data.get("content"),
|
||||
"timestamp": (new_msg.timestamp or datetime.now()).isoformat(),
|
||||
"message_type": message_type,
|
||||
"file_id": file_id,
|
||||
"encrypted_key": message_data.get("encrypted_key"),
|
||||
"timestamp": (new_msg.timestamp or datetime.utcnow()).isoformat(),
|
||||
"reply_to_id": new_msg.reply_to_id,
|
||||
"reply_to_text": new_msg.reply_to_text,
|
||||
}
|
||||
|
||||
print(
|
||||
f"DEBUG outgoing_message: id={outgoing_message['id']}, receiver_id={outgoing_message['receiver_id']}, file_id={outgoing_message['file_id']}, encrypted_key_present={outgoing_message['encrypted_key'] is not None}",
|
||||
)
|
||||
# Пересылаем получателю, если он в сети
|
||||
sent_to_receiver = await manager.send_personal_message(outgoing_message, str(receiver_id))
|
||||
|
||||
print(f"DEBUG send_personal_message returned: {sent_to_receiver}")
|
||||
|
||||
# Если сообщение реально ушло по сокету получателю — отмечаем 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()
|
||||
|
|
@ -150,12 +191,13 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
"detail": "message_id must be int",
|
||||
})
|
||||
continue
|
||||
msg = db.query(models.Message).filter(models.Message.id == message_id).first()
|
||||
msg = db.query(models.Message).filter(
|
||||
models.Message.id == message_id).first()
|
||||
if msg is None or msg.sender_id != user_id:
|
||||
continue
|
||||
try:
|
||||
msg.content = content
|
||||
msg.edited_at = datetime.now()
|
||||
msg.edited_at = datetime.utcnow()
|
||||
db.add(msg)
|
||||
db.commit()
|
||||
except Exception:
|
||||
|
|
@ -164,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,
|
||||
}
|
||||
|
|
@ -186,7 +229,8 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
"detail": "message_id must be int",
|
||||
})
|
||||
continue
|
||||
msg = db.query(models.Message).filter(models.Message.id == message_id).first()
|
||||
msg = db.query(models.Message).filter(
|
||||
models.Message.id == message_id).first()
|
||||
if msg is None or msg.sender_id != user_id:
|
||||
continue
|
||||
receiver_id = msg.receiver_id
|
||||
|
|
@ -210,7 +254,8 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
msg = db.query(models.Message).filter(models.Message.id == message_id).first()
|
||||
msg = db.query(models.Message).filter(
|
||||
models.Message.id == message_id).first()
|
||||
if msg is None:
|
||||
continue
|
||||
|
||||
|
|
@ -220,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()
|
||||
|
|
@ -231,16 +276,50 @@ 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")
|
||||
if receiver_id is None:
|
||||
continue
|
||||
try:
|
||||
receiver_id = int(receiver_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
await manager.send_personal_message({
|
||||
"type": "typing",
|
||||
"sender_id": user_id,
|
||||
}, str(receiver_id))
|
||||
elif message_data.get("type") == "stop_typing":
|
||||
receiver_id = message_data.get("receiver_id")
|
||||
if receiver_id is None:
|
||||
continue
|
||||
try:
|
||||
receiver_id = int(receiver_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
await manager.send_personal_message({
|
||||
"type": "stop_typing",
|
||||
"sender_id": user_id,
|
||||
}, str(receiver_id))
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.disconnect(user_id)
|
||||
db.query(models.User).filter(models.User.id == user_id).update(
|
||||
{"last_online": datetime.now(timezone.utc)}, synchronize_session="fetch")
|
||||
db.commit()
|
||||
print("ОТКЛЮЧЕНИЕ")
|
||||
|
||||
await manager.broadcast({
|
||||
"type": "user_offline",
|
||||
"user_id": user_id,
|
||||
})
|
||||
|
||||
|
||||
def send_fcm_notification(token, user_id, username, public_key, encrypted_text, timestamp):
|
||||
print(f"DEBUG: Отправляем FCM уведомление пользователю {user_id} с токеном {token}")
|
||||
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(
|
||||
data={
|
||||
"type": "enc_message",
|
||||
|
|
@ -249,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',
|
||||
|
|
|
|||
47
srv/main.py
|
|
@ -4,6 +4,9 @@ from app.api.endpoints import users, auth, messages, media
|
|||
from app.websocket.connection_manager import wsRouter
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import os
|
||||
import asyncio
|
||||
from app.db import models
|
||||
from app.core.config import config
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
|
@ -15,7 +18,7 @@ app.include_router(wsRouter)
|
|||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=config.ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
|
@ -25,7 +28,7 @@ app.add_middleware(
|
|||
@app.get("/check-update")
|
||||
async def check_update():
|
||||
return {
|
||||
"latest_version": "2.0.0",
|
||||
"latest_version": "2.0.1",
|
||||
"apk_url": "https://api.chepuhagram.ru/get-update",
|
||||
"force_update": False
|
||||
}
|
||||
|
|
@ -47,11 +50,41 @@ async def head_image():
|
|||
if not os.path.exists(file_path):
|
||||
return {"error": "Файл не найден"}
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename="chepuhagram-release.apk",
|
||||
media_type="application/vnd.android.package-archive"
|
||||
)
|
||||
return FileResponse(path=file_path, filename="chepuhagram-release.apk",
|
||||
media_type="application/vnd.android.package-archive",)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
asyncio.create_task(cleanup_uploads())
|
||||
if config.SERVER_ROLE == 'cloud':
|
||||
asyncio.create_task(media.forward_pending_media_loop())
|
||||
elif config.SERVER_ROLE == 'home':
|
||||
asyncio.create_task(media.home_storage_maintenance_loop())
|
||||
|
||||
|
||||
async def cleanup_uploads():
|
||||
while True:
|
||||
try:
|
||||
db = models.SessionLocal()
|
||||
# Получить все используемые file_id из avatar_file_id
|
||||
file_ids = db.query(models.User.avatar_file_id).filter(models.User.avatar_file_id.isnot(None)).all()
|
||||
used_files = set(f[0] for f in file_ids)
|
||||
db.close()
|
||||
|
||||
# Проверить файлы в uploads
|
||||
uploads_dir = 'uploads'
|
||||
if os.path.exists(uploads_dir):
|
||||
for filename in os.listdir(uploads_dir):
|
||||
if filename.endswith('.enc'):
|
||||
file_id = filename[:-4] # убрать .enc
|
||||
if file_id not in used_files:
|
||||
file_path = os.path.join(uploads_dir, filename)
|
||||
os.remove(file_path)
|
||||
print(f"Удален неиспользуемый файл: {file_path}")
|
||||
except Exception as e:
|
||||
print(f"Ошибка в cleanup: {e}")
|
||||
await asyncio.sleep(300) # каждые 5 минут
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
|
|
|||
|
|
@ -3,4 +3,6 @@ uvicorn[standard]
|
|||
sqlalchemy
|
||||
passlib[bcrypt]
|
||||
python-jose[cryptography]
|
||||
python-multipart
|
||||
python-multipart
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
|
|
@ -6,21 +6,33 @@
|
|||
|
||||
#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(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
GalPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GalPluginCApi"));
|
||||
LocalAuthPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +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
|
||||
)
|
||||
|
||||
|
|
|
|||