Обновление интерфейса
This commit is contained in:
parent
966b1a6b84
commit
11340bdca1
|
|
@ -16,6 +16,8 @@ migrate_working_dir/
|
|||
venv/
|
||||
.venv/
|
||||
chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json
|
||||
chepuhagram-497610-47c286108afd.json
|
||||
client_secret_338589490139-9ocvlhs270l5hqj3sdrru14ampiacv0s.apps.googleusercontent.com.json
|
||||
.firebaserc
|
||||
firebase-tools-instant-win.exe
|
||||
google-services.json
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"cmake.sourceDirectory": "D:/FlutterProjects/chepuhagram/linux",
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"flutter": true
|
||||
}
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ flutter {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
implementation(platform("com.google.firebase:firebase-bom:34.12.0"))
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
}
|
||||
|
|
@ -1,220 +1,243 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:chepuhagram/data/models/message_model.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_sqflite/drift_sqflite.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class LocalDbService {
|
||||
static final LocalDbService _instance = LocalDbService._internal();
|
||||
static Database? _database;
|
||||
part 'local_db_service.g.dart';
|
||||
|
||||
factory LocalDbService() => _instance;
|
||||
LocalDbService._internal();
|
||||
class Messages extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get senderId => integer().named('sender_id')();
|
||||
IntColumn get receiverId => integer().named('receiver_id')();
|
||||
TextColumn get content => text().named('content')();
|
||||
TextColumn get timestamp => text().named('timestamp')();
|
||||
TextColumn get deliveredAt => text().named('delivered_at').nullable()();
|
||||
TextColumn get readAt => text().named('read_at').nullable()();
|
||||
IntColumn get replyToId => integer().named('reply_to_id').nullable()();
|
||||
TextColumn get replyToText => text().named('reply_to_text').nullable()();
|
||||
TextColumn get editedAt => text().named('edited_at').nullable()();
|
||||
TextColumn get messageType =>
|
||||
text().named('message_type').withDefault(const Constant('text'))();
|
||||
TextColumn get fileId => text().named('file_id').nullable()();
|
||||
TextColumn get encryptedKey => text().named('encrypted_key').nullable()();
|
||||
TextColumn get fileName => text().named('file_name').nullable()();
|
||||
IntColumn get fileSize => integer().named('file_size').nullable()();
|
||||
}
|
||||
|
||||
static const int _dbVersion = 8;
|
||||
class FileNameMappings extends Table {
|
||||
TextColumn get fileId => text().named('file_id')();
|
||||
TextColumn get originalFileName => text().named('original_file_name')();
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDb();
|
||||
return _database!;
|
||||
@override
|
||||
Set<Column> get primaryKey => {fileId};
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Messages, FileNameMappings])
|
||||
class LocalDbService extends _$LocalDbService {
|
||||
factory LocalDbService() => instance;
|
||||
|
||||
LocalDbService._internal() : super(_openConnection()) {
|
||||
print('LocalDbService constructor called');
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
''');
|
||||
}
|
||||
static final LocalDbService instance = LocalDbService._internal();
|
||||
|
||||
Future<Database> _initDb() async {
|
||||
String path = join(await getDatabasesPath(), 'chat_app.db');
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: _dbVersion,
|
||||
onCreate: (db, version) async {
|
||||
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');
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
await db.execute(
|
||||
'ALTER TABLE messages ADD COLUMN reply_to_id INTEGER',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE messages ADD COLUMN reply_to_text TEXT',
|
||||
);
|
||||
}
|
||||
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.
|
||||
},
|
||||
);
|
||||
}
|
||||
@override
|
||||
int get schemaVersion => 9;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (Migrator m) async {
|
||||
await m.createAll();
|
||||
},
|
||||
onUpgrade: (Migrator m, int from, int to) async {
|
||||
if (from < 8) {
|
||||
await m.deleteTable('messages');
|
||||
await m.createAll();
|
||||
return;
|
||||
}
|
||||
if (from < 9) {
|
||||
await m.createTable(fileNameMappings);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> clearDatabase() async {
|
||||
final db = await database;
|
||||
await db.delete('messages');
|
||||
await delete(messages).go();
|
||||
}
|
||||
|
||||
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);
|
||||
Future<void> saveMessages(List<dynamic> messageList) async {
|
||||
if (messageList.isEmpty) return;
|
||||
|
||||
final List<int> incomingIds = messageList
|
||||
.map<int?>(
|
||||
(msg) => (msg is MessageModel)
|
||||
? msg.id
|
||||
: (msg['id'] == null ? null : int.tryParse(msg['id'].toString())),
|
||||
)
|
||||
.whereType<int>()
|
||||
.toList();
|
||||
|
||||
// Преобразуем входящие данные в компаньоны заранее
|
||||
final companions = messageList.map<MessagesCompanion>((msg) {
|
||||
final int? id;
|
||||
final int senderId;
|
||||
final int receiverId;
|
||||
final String content;
|
||||
final String timestamp;
|
||||
final int? replyToId;
|
||||
final String? replyToText;
|
||||
final String? editedAt;
|
||||
final String messageType;
|
||||
final String? fileId;
|
||||
final String? encryptedKey;
|
||||
final String? fileName;
|
||||
final int? fileSize;
|
||||
|
||||
if (msg is MessageModel) {
|
||||
id = msg.id;
|
||||
senderId = msg.senderId;
|
||||
receiverId = msg.receiverId;
|
||||
content = msg.text;
|
||||
timestamp = msg.createdAt.toIso8601String();
|
||||
replyToId = msg.replyToId;
|
||||
replyToText = msg.replyToText;
|
||||
editedAt = msg.editedAt?.toIso8601String();
|
||||
messageType = msg.messageType.name;
|
||||
fileId = msg.fileId;
|
||||
encryptedKey = msg.encryptedFileKey;
|
||||
fileName = msg.fileName;
|
||||
fileSize = msg.fileSize;
|
||||
} else {
|
||||
id = msg['id'] == null ? null : int.tryParse(msg['id'].toString());
|
||||
senderId = int.parse(msg['sender_id'].toString());
|
||||
receiverId = int.parse(msg['receiver_id'].toString());
|
||||
content = msg['content']?.toString() ?? '';
|
||||
timestamp =
|
||||
msg['timestamp']?.toString() ?? DateTime.now().toIso8601String();
|
||||
replyToId = msg['reply_to_id'] == null
|
||||
? null
|
||||
: int.tryParse(msg['reply_to_id'].toString());
|
||||
replyToText = msg['reply_to_text']?.toString();
|
||||
editedAt = msg['edited_at']?.toString();
|
||||
messageType = msg['message_type']?.toString() ?? 'text';
|
||||
fileId = msg['file_id']?.toString();
|
||||
encryptedKey = msg['encrypted_key']?.toString();
|
||||
fileName = msg['file_name']?.toString();
|
||||
fileSize = msg['file_size'] == null
|
||||
? null
|
||||
: int.tryParse(msg['file_size'].toString());
|
||||
}
|
||||
|
||||
return MessagesCompanion(
|
||||
id: id == null ? const Value.absent() : Value(id),
|
||||
senderId: Value(senderId),
|
||||
receiverId: Value(receiverId),
|
||||
content: Value(content),
|
||||
timestamp: Value(timestamp),
|
||||
deliveredAt: const Value(null),
|
||||
readAt: const Value(null),
|
||||
replyToId: Value(replyToId),
|
||||
replyToText: Value(replyToText),
|
||||
editedAt: Value(editedAt),
|
||||
messageType: Value(messageType),
|
||||
fileId: Value(fileId),
|
||||
encryptedKey: Value(encryptedKey),
|
||||
fileName: Value(fileName),
|
||||
fileSize: Value(fileSize),
|
||||
);
|
||||
}).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,
|
||||
'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
|
||||
batch.insert('messages', {
|
||||
'id': msg['id'],
|
||||
'sender_id': msg['sender_id'],
|
||||
'receiver_id':
|
||||
msg['receiver_id'], // Убедись, что ключ совпадает с API
|
||||
'content': msg['content'],
|
||||
'timestamp': msg['timestamp'],
|
||||
'delivered_at': msg['delivered_at'],
|
||||
'read_at': msg['read_at'],
|
||||
'reply_to_id': msg['reply_to_id'],
|
||||
'reply_to_text': msg['reply_to_text'],
|
||||
'edited_at': msg['edited_at'],
|
||||
'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);
|
||||
// Выполняем все операции в рамках ОДНОЙ транзакции БД
|
||||
await transaction(() async {
|
||||
if (incomingIds.isNotEmpty) {
|
||||
// ВНИМАНИЕ: Ограничьте удаление только текущим чатом,
|
||||
// иначе эта строка очистит сообщения из всех остальных диалогов!
|
||||
final first = companions.first;
|
||||
await (delete(messages)..where(
|
||||
(tbl) =>
|
||||
((tbl.senderId.equals(first.senderId.value) &
|
||||
tbl.receiverId.equals(first.receiverId.value)) |
|
||||
(tbl.senderId.equals(first.receiverId.value) &
|
||||
tbl.receiverId.equals(first.senderId.value))) &
|
||||
tbl.id.isNotIn(incomingIds),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
|
||||
// Быстрая пакетная вставка/обновление
|
||||
await batch((b) {
|
||||
b.insertAll(
|
||||
messages,
|
||||
companions,
|
||||
mode: InsertMode
|
||||
.insertOrReplace, // Безопасный аналог insertOnConflictUpdate для batch
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Получение сообщений конкретного чата
|
||||
Future<List<Map<String, dynamic>>> getChatHistory(
|
||||
int contactId,
|
||||
int myId,
|
||||
) async {
|
||||
final db = await database;
|
||||
return await db.query(
|
||||
'messages',
|
||||
where:
|
||||
'(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)',
|
||||
whereArgs: [contactId, myId, myId, contactId],
|
||||
orderBy: 'timestamp ASC',
|
||||
);
|
||||
final query = select(messages)
|
||||
..where(
|
||||
(tbl) =>
|
||||
(tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) |
|
||||
(tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId)),
|
||||
)
|
||||
..orderBy([(tbl) => OrderingTerm(expression: tbl.timestamp)]);
|
||||
|
||||
final rows = await query.get();
|
||||
return rows.map((row) => row.toJson()).toList();
|
||||
}
|
||||
|
||||
Future<int> deleteChatHistory(int contactId, int myId) async {
|
||||
final db = await database;
|
||||
return await db.delete(
|
||||
'messages',
|
||||
where:
|
||||
'(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)',
|
||||
whereArgs: [contactId, myId, myId, contactId],
|
||||
);
|
||||
return await (delete(messages)..where(
|
||||
(tbl) =>
|
||||
(tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) |
|
||||
(tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId)),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getLastMessage(int contactId, int myId) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'messages',
|
||||
columns: ['sender_id', 'receiver_id', 'content', 'timestamp'],
|
||||
where:
|
||||
'(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)',
|
||||
whereArgs: [contactId, myId, myId, contactId],
|
||||
orderBy: 'timestamp DESC',
|
||||
limit: 1,
|
||||
);
|
||||
final query =
|
||||
(select(messages)
|
||||
..where(
|
||||
(tbl) =>
|
||||
(tbl.senderId.equals(contactId) &
|
||||
tbl.receiverId.equals(myId)) |
|
||||
(tbl.senderId.equals(myId) &
|
||||
tbl.receiverId.equals(contactId)),
|
||||
)
|
||||
..orderBy([
|
||||
(tbl) => OrderingTerm(
|
||||
expression: tbl.timestamp,
|
||||
mode: OrderingMode.desc,
|
||||
),
|
||||
])
|
||||
..limit(1))
|
||||
.get();
|
||||
|
||||
final rows = await query;
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first;
|
||||
return rows.first.toJson();
|
||||
}
|
||||
|
||||
Future<void> updateDeliveredAt(int messageId, DateTime deliveredAt) async {
|
||||
final db = await database;
|
||||
await db.update(
|
||||
'messages',
|
||||
{'delivered_at': deliveredAt.toIso8601String()},
|
||||
where: 'id = ?',
|
||||
whereArgs: [messageId],
|
||||
await (update(messages)..where((tbl) => tbl.id.equals(messageId))).write(
|
||||
MessagesCompanion(deliveredAt: Value(deliveredAt.toIso8601String())),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateReadAt(int messageId, DateTime readAt) async {
|
||||
final db = await database;
|
||||
await db.update(
|
||||
'messages',
|
||||
{'read_at': readAt.toIso8601String()},
|
||||
where: 'id = ?',
|
||||
whereArgs: [messageId],
|
||||
await (update(messages)..where((tbl) => tbl.id.equals(messageId))).write(
|
||||
MessagesCompanion(readAt: Value(readAt.toIso8601String())),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -223,17 +246,42 @@ class LocalDbService {
|
|||
String content,
|
||||
DateTime? editedAt,
|
||||
) async {
|
||||
final db = await database;
|
||||
await db.update(
|
||||
'messages',
|
||||
{'content': content, 'edited_at': editedAt?.toIso8601String()},
|
||||
where: 'id = ?',
|
||||
whereArgs: [messageId],
|
||||
await (update(messages)..where((tbl) => tbl.id.equals(messageId))).write(
|
||||
MessagesCompanion(
|
||||
content: Value(content),
|
||||
editedAt: Value(editedAt?.toIso8601String()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveOriginalFileNameForFileId(
|
||||
String fileId,
|
||||
String fileName,
|
||||
) async {
|
||||
await into(fileNameMappings).insertOnConflictUpdate(
|
||||
FileNameMappingsCompanion(
|
||||
fileId: Value(fileId),
|
||||
originalFileName: Value(fileName),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> getOriginalFileNameForFileId(String fileId) async {
|
||||
final query = select(fileNameMappings)
|
||||
..where((tbl) => tbl.fileId.equals(fileId));
|
||||
final result = await query.getSingleOrNull();
|
||||
return result?.originalFileName;
|
||||
}
|
||||
|
||||
Future<void> deleteMessage(int messageId) async {
|
||||
final db = await database;
|
||||
await db.delete('messages', where: 'id = ?', whereArgs: [messageId]);
|
||||
await (delete(messages)..where((tbl) => tbl.id.equals(messageId))).go();
|
||||
}
|
||||
}
|
||||
|
||||
QueryExecutor _openConnection() {
|
||||
// This now uses the SqfliteQueryExecutor, which will respect the global
|
||||
// databaseFactory set in `main.dart`. This fixes the 'unable to open file'
|
||||
// error on Windows. The database file will be 'chat_app.db' inside the
|
||||
// path configured in `main.dart`.
|
||||
return SqfliteQueryExecutor.inDatabaseFolder(path: 'chat_app.db');
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,6 +6,7 @@ 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';
|
||||
import 'package:chepuhagram/domain/services/webrtc_service.dart';
|
||||
|
||||
class SocketService with WidgetsBindingObserver {
|
||||
static final SocketService _instance = SocketService._internal();
|
||||
|
|
@ -35,6 +36,15 @@ class SocketService with WidgetsBindingObserver {
|
|||
}
|
||||
}
|
||||
|
||||
void _initMessageListener() {
|
||||
messages.listen((data) {
|
||||
if (data['type'] == 'call_accepted') {
|
||||
WebRTCService().handleOffer(data['call_id'], data['sdp']);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
Future<void> startConnect(ApiService apiService) async {
|
||||
if (_connectTimer != null && _connectTimer!.isActive)
|
||||
return; // Уже запущено
|
||||
|
|
@ -61,13 +71,11 @@ class SocketService with WidgetsBindingObserver {
|
|||
// В FastAPI эндпоинт ожидает токен в URL-параметре
|
||||
final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token");
|
||||
|
||||
//_channel = WebSocketChannel.connect(uri);
|
||||
|
||||
_channel = IOWebSocketChannel.connect(
|
||||
uri,
|
||||
connectTimeout: Duration(seconds: 10),
|
||||
);
|
||||
|
||||
if (_channel == null) return;
|
||||
await _channel!.ready;
|
||||
_channel!.stream.listen(
|
||||
(data) {
|
||||
|
|
|
|||
|
|
@ -3,33 +3,10 @@ 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 {
|
||||
late final CachedHttpClient _client;
|
||||
bool _isCacheInitialized = false;
|
||||
final ApiService _apiService = ApiService();
|
||||
|
||||
ContactRepository() {
|
||||
_initCachedClient();
|
||||
}
|
||||
|
||||
// Единая инициализация кэша для всех запросов репозитория
|
||||
void _initCachedClient() {
|
||||
final cache = _apiService.cache;
|
||||
_client = CachedHttpClient(
|
||||
cache: cache,
|
||||
defaultCachePolicy: CachePolicy.networkFirst,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _ensureCacheReady() async {
|
||||
if (!_isCacheInitialized) {
|
||||
await _apiService.cache.initialize();
|
||||
_isCacheInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Contact>> fetchChatContacts({bool forceRefresh = false}) async {
|
||||
final token = await _apiService.getAccessToken();
|
||||
|
||||
|
|
@ -45,10 +22,10 @@ class ContactRepository {
|
|||
requestHeaders['Cache-Control'] = 'no-cache';
|
||||
}
|
||||
|
||||
await _ensureCacheReady();
|
||||
//await _ensureCacheReady();
|
||||
|
||||
try {
|
||||
final response = await _client.get(
|
||||
final response = await http.get(
|
||||
Uri.parse('${AppConstants.baseUrl}/users/chats'),
|
||||
headers: requestHeaders,
|
||||
);
|
||||
|
|
@ -70,27 +47,8 @@ class ContactRepository {
|
|||
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('Нет доступа к сети. Проверте подключение к интернету.');
|
||||
}
|
||||
print('⚠️ Ошибка сети при загрузке контактов: $e.');
|
||||
throw Exception('Нет доступа к сети. Проверте подключение к интернету.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,8 +70,7 @@ class ContactRepository {
|
|||
if (forceRefresh) {
|
||||
requestHeaders['Cache-Control'] = 'no-cache';
|
||||
}
|
||||
await _ensureCacheReady();
|
||||
final response = await _client.get(
|
||||
final response = await http.get(
|
||||
Uri.parse('${AppConstants.baseUrl}/users/all'),
|
||||
headers: requestHeaders,
|
||||
);
|
||||
|
|
@ -148,8 +105,8 @@ class ContactRepository {
|
|||
if (forceRefresh) {
|
||||
requestHeaders['Cache-Control'] = 'no-cache';
|
||||
}
|
||||
await _ensureCacheReady();
|
||||
final response = await _client.get(
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('${AppConstants.baseUrl}/users/$userId'),
|
||||
headers: requestHeaders,
|
||||
);
|
||||
|
|
@ -181,8 +138,7 @@ class ContactRepository {
|
|||
requestHeaders['Cache-Control'] = 'no-cache';
|
||||
}
|
||||
|
||||
await _ensureCacheReady();
|
||||
final response = await _client.get(
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'${AppConstants.baseUrl}/messages/last?contact_id=$contactId&limit=$limit',
|
||||
),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import 'package:dio/dio.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:chepuhagram/core/constants.dart';
|
||||
import 'package:flutter_http_cache/flutter_http_cache.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'dart:io';
|
||||
|
|
@ -16,27 +15,12 @@ class ApiService extends ChangeNotifier {
|
|||
final _client = http.Client();
|
||||
final _storage = const FlutterSecureStorage();
|
||||
bool _isRefreshing = false;
|
||||
bool _isCacheInitialized = false;
|
||||
|
||||
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 {
|
||||
// Подставляй свой эндпоинт, например: /users/by-username/
|
||||
final response = await Dio().get('/users/by-username/$username');
|
||||
final response = await Dio().get('${AppConstants.baseUrl}//users/by-username/$username');
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
// Парсим полученные данные в модель контакта.
|
||||
|
|
@ -375,12 +359,8 @@ class ApiService extends ChangeNotifier {
|
|||
|
||||
Future<Map<String, dynamic>> getMe() async {
|
||||
final token = await getAccessToken();
|
||||
await cache.initialize();
|
||||
final client = CachedHttpClient(
|
||||
cache: cache,
|
||||
defaultCachePolicy: CachePolicy.networkFirst,
|
||||
);
|
||||
final response = await client.get(
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('${AppConstants.baseUrl}/users/me'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -434,12 +414,6 @@ class ApiService extends ChangeNotifier {
|
|||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final token = await getAccessToken();
|
||||
await _ensureCacheReady();
|
||||
|
||||
final client = CachedHttpClient(
|
||||
cache: cache,
|
||||
defaultCachePolicy: CachePolicy.networkFirst,
|
||||
);
|
||||
|
||||
final Map<String, String> requestHeaders = {
|
||||
'Authorization': 'Bearer $token',
|
||||
|
|
@ -449,7 +423,7 @@ class ApiService extends ChangeNotifier {
|
|||
requestHeaders['Cache-Control'] = 'no-cache';
|
||||
}
|
||||
|
||||
final response = await client.get(
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}',
|
||||
),
|
||||
|
|
@ -467,17 +441,12 @@ class ApiService extends ChangeNotifier {
|
|||
}) async {
|
||||
try {
|
||||
final token = await getAccessToken();
|
||||
await _ensureCacheReady();
|
||||
|
||||
final client = CachedHttpClient(
|
||||
cache: cache,
|
||||
defaultCachePolicy: CachePolicy.networkFirst,
|
||||
);
|
||||
final client = http.Client();
|
||||
|
||||
final uri = Uri.parse('${AppConstants.baseUrl}/media/$fileId');
|
||||
|
||||
if (onProgress == null) {
|
||||
final response = await client.get(
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
);
|
||||
|
|
@ -551,12 +520,7 @@ class ApiService extends ChangeNotifier {
|
|||
|
||||
Future<Map<String, dynamic>> getUserById(int userId) async {
|
||||
final token = await getAccessToken();
|
||||
await _ensureCacheReady();
|
||||
final client = CachedHttpClient(
|
||||
cache: cache,
|
||||
defaultCachePolicy: CachePolicy.networkFirst,
|
||||
);
|
||||
final response = await client.get(
|
||||
final response = await http.get(
|
||||
Uri.parse('${AppConstants.baseUrl}/users/$userId'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -601,13 +565,7 @@ class ApiService extends ChangeNotifier {
|
|||
|
||||
Future<Map<String, dynamic>> getPrivacySettings() async {
|
||||
final token = await getAccessToken();
|
||||
|
||||
await _ensureCacheReady();
|
||||
final client = CachedHttpClient(
|
||||
cache: cache,
|
||||
defaultCachePolicy: CachePolicy.networkFirst,
|
||||
);
|
||||
final response = await client.get(
|
||||
final response = await http.get(
|
||||
Uri.parse('${AppConstants.baseUrl}/users/me/privacy'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||||
|
||||
class WebRTCService {
|
||||
RTCPeerConnection? _peerConnection;
|
||||
MediaStream? _localStream;
|
||||
|
||||
// Конфигурация STUN-серверов (Google STUN)
|
||||
final Map<String, dynamic> _config = {
|
||||
"iceServers": [
|
||||
{"urls": "stun:stun.l.google.com:19302"},
|
||||
{"urls": "stun:stun1.l.google.com:19302"},
|
||||
]
|
||||
};
|
||||
|
||||
/// Инициализация PeerConnection
|
||||
Future<void> initPeerConnection(String callId, Function(MediaStream) onRemoteStream) async {
|
||||
_peerConnection = await createPeerConnection(_config);
|
||||
|
||||
// Слушаем удаленный поток
|
||||
_peerConnection!.onAddStream = (stream) {
|
||||
onRemoteStream(stream);
|
||||
};
|
||||
|
||||
// Отправляем ICE-кандидаты на сервер через SocketService
|
||||
_peerConnection!.onIceCandidate = (candidate) {
|
||||
SocketService().sendMessage({
|
||||
"type": "ice_candidate",
|
||||
"call_id": callId,
|
||||
"candidate": candidate.toMap(),
|
||||
});
|
||||
};
|
||||
|
||||
// Получаем локальный поток (микрофон + камера)
|
||||
_localStream = await navigator.mediaDevices.getUserMedia({
|
||||
'audio': true,
|
||||
'video': true,
|
||||
});
|
||||
|
||||
// Добавляем треки в соединение
|
||||
_localStream!.getTracks().forEach((track) {
|
||||
_peerConnection!.addTrack(track, _localStream!);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> handleOffer(String callId, String remoteSdp) async {
|
||||
// 1. Инициализируем соединение, если оно еще не создано
|
||||
if (_peerConnection == null) {
|
||||
await initPeerConnection(callId, (stream) {
|
||||
// Здесь можно добавить callback для отрисовки видео, если нужно
|
||||
print("Remote stream received");
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Создаем ответ (это вызывает ваш метод createAnswer)
|
||||
await createAnswer(callId, remoteSdp);
|
||||
}
|
||||
|
||||
/// Создание Offer (вызывает инициатор звонка)
|
||||
Future<void> createOffer(String callId) async {
|
||||
RTCSessionDescription offer = await _peerConnection!.createOffer();
|
||||
await _peerConnection!.setLocalDescription(offer);
|
||||
|
||||
SocketService().sendMessage({
|
||||
"type": "offer",
|
||||
"call_id": callId,
|
||||
"sdp": offer.sdp,
|
||||
});
|
||||
}
|
||||
|
||||
/// Создание Answer (вызывает получатель звонка)
|
||||
Future<void> createAnswer(String callId, String remoteSdp) async {
|
||||
await _peerConnection!.setRemoteDescription(
|
||||
RTCSessionDescription(remoteSdp, 'offer'),
|
||||
);
|
||||
|
||||
RTCSessionDescription answer = await _peerConnection!.createAnswer();
|
||||
await _peerConnection!.setLocalDescription(answer);
|
||||
|
||||
SocketService().sendMessage({
|
||||
"type": "answer",
|
||||
"call_id": callId,
|
||||
"sdp": answer.sdp,
|
||||
});
|
||||
}
|
||||
|
||||
/// Обработка ICE кандидатов от удаленного собеседника
|
||||
Future<void> addRemoteIceCandidate(Map<String, dynamic> candidateMap) async {
|
||||
await _peerConnection!.addCandidate(
|
||||
RTCIceCandidate(
|
||||
candidateMap['candidate'],
|
||||
candidateMap['sdpMid'],
|
||||
candidateMap['sdpMLineIndex'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Очистка ресурсов
|
||||
void dispose() {
|
||||
_localStream?.getTracks().forEach((track) => track.stop());
|
||||
_localStream?.dispose();
|
||||
_peerConnection?.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +75,8 @@ class AuthProvider extends ChangeNotifier {
|
|||
bool get hasPublicKeyOnServer => _hasPublicKeyOnServer;
|
||||
|
||||
final _storage = const FlutterSecureStorage();
|
||||
FlutterSecureStorage get storage => _storage;
|
||||
|
||||
final _client = http.Client();
|
||||
final ApiService _apiService = ApiService();
|
||||
final SocketService _socketService = SocketService();
|
||||
|
|
|
|||
287
lib/main.dart
287
lib/main.dart
|
|
@ -3,16 +3,24 @@ import 'logic/auth_provider.dart';
|
|||
import 'logic/contact_provider.dart';
|
||||
import 'core/theme_manager.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'dart:io';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
||||
import 'package:chepuhagram/presentation/screens/chat_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'presentation/screens/splash_screen.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
import 'presentation/screens/call_screen.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||
|
||||
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
|
@ -122,81 +130,115 @@ void _navigateToChat(int senderId) {
|
|||
}
|
||||
}
|
||||
|
||||
bool firebaseInitialized = false;
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
|
||||
sqfliteFfiInit();
|
||||
|
||||
// ИСПОЛЬЗУЕМ СУППОРТ-ДИРЕКТОРИЮ (App Data / Roaming) вместо Документов
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
|
||||
// Создаем подпапку внутри AppData, там Windows никогда не выдаст ошибку 2
|
||||
final sqfliteDbPath = p.join(appSupportDir.path, 'sqflite_databases');
|
||||
await Directory(sqfliteDbPath).create(recursive: true);
|
||||
|
||||
// Привязываем фабрику sqflite к новому безопасному пути
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
await databaseFactory.setDatabasesPath(sqfliteDbPath);
|
||||
|
||||
print('Безопасный путь для SQFlite на Windows: $sqfliteDbPath');
|
||||
}
|
||||
try {
|
||||
print('Initializing LocalDbService in main...');
|
||||
final db = LocalDbService();
|
||||
print('База данных сохранена по пути: ${db.connection.connectionData}');
|
||||
print('LocalDbService initialized successfully.');
|
||||
} catch (e, st) {
|
||||
print('LocalDbService init failed in main: $e');
|
||||
print(st);
|
||||
}
|
||||
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
await Firebase.initializeApp();
|
||||
firebaseInitialized = true;
|
||||
|
||||
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']}');
|
||||
print('Initial message from main(): $initialMessage');
|
||||
} else {
|
||||
print('Skipping Firebase initialization on desktop.');
|
||||
}
|
||||
|
||||
final payloadString = jsonEncode(initialMessage!.data);
|
||||
final lastHandled = prefs.getString(
|
||||
_lastHandledNotificationLaunchPayloadKey,
|
||||
);
|
||||
if (lastHandled != payloadString) {
|
||||
// Сохраняем данные уведомления
|
||||
await prefs.setString(_notificationLaunchKey, payloadString);
|
||||
await prefs.setString(
|
||||
_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);
|
||||
}
|
||||
// Сохраняем информацию в 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']}');
|
||||
|
||||
// Initialize local notifications
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
final InitializationSettings initializationSettings =
|
||||
InitializationSettings(android: initializationSettingsAndroid);
|
||||
await flutterLocalNotificationsPlugin.initialize(
|
||||
initializationSettings,
|
||||
onDidReceiveNotificationResponse: _onSelectNotification,
|
||||
final payloadString = jsonEncode(initialMessage!.data);
|
||||
final lastHandled = prefs.getString(
|
||||
_lastHandledNotificationLaunchPayloadKey,
|
||||
);
|
||||
if (lastHandled != payloadString) {
|
||||
// Сохраняем данные уведомления
|
||||
await prefs.setString(_notificationLaunchKey, payloadString);
|
||||
await prefs.setString(
|
||||
_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);
|
||||
}
|
||||
|
||||
// Если приложение было запущено из локального уведомления, сохраним 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(
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const WindowsInitializationSettings initializationSettingsWindows =
|
||||
WindowsInitializationSettings(
|
||||
appName: 'Chepuhagram',
|
||||
appUserModelId: 'ru.ArturKarasevich.Chepuhagram',
|
||||
guid: '6c0af055-e0b5-4f10-9aed-c12dc078f949',
|
||||
);
|
||||
final InitializationSettings initializationSettings = InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
windows: initializationSettingsWindows,
|
||||
);
|
||||
await flutterLocalNotificationsPlugin.initialize(
|
||||
settings: 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,
|
||||
);
|
||||
if (lastHandled != payload) {
|
||||
final data = jsonDecode(payload);
|
||||
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
|
||||
await prefs.setString(
|
||||
_lastHandledNotificationLaunchPayloadKey,
|
||||
payload,
|
||||
);
|
||||
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');
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -214,9 +256,17 @@ void main() async {
|
|||
>()
|
||||
?.createNotificationChannel(channel);
|
||||
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||
} catch (e) {
|
||||
print('Уведосления не были инициальзированы: $e');
|
||||
if (firebaseInitialized) {
|
||||
FirebaseMessaging.onBackgroundMessage(
|
||||
_firebaseMessagingBackgroundHandler,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
initCallkitListener();
|
||||
} else {
|
||||
print('Skipping CallKit listener on desktop platform.');
|
||||
}
|
||||
|
||||
runApp(
|
||||
|
|
@ -237,17 +287,102 @@ void main() async {
|
|||
);
|
||||
}
|
||||
|
||||
void initCallkitListener() {
|
||||
if (!(Platform.isAndroid || Platform.isIOS)) {
|
||||
print('Skipping CallKit event listener on non-mobile platform.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
FlutterCallkitIncoming.onEvent.listen((event) {
|
||||
if (event == null) return;
|
||||
|
||||
switch (event.event) {
|
||||
case Event.actionCallIncoming:
|
||||
// Звонок получен, но CallKit уже показал экран.
|
||||
// Здесь можно логировать или обновить статус в БД.
|
||||
print("Incoming call: ${event.body['id']}");
|
||||
break;
|
||||
|
||||
case Event.actionCallStart:
|
||||
// Исходящий звонок начат
|
||||
break;
|
||||
|
||||
case Event.actionCallAccept:
|
||||
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ПРИНЯТЬ"
|
||||
// 1. Уведомляем сервер (чтобы другая сторона узнала о начале звонка)
|
||||
SocketService().sendMessage({
|
||||
"type": "call_accept",
|
||||
"call_id": event.body['id'],
|
||||
});
|
||||
|
||||
// 2. Переходим на экран звонка
|
||||
navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CallScreen(
|
||||
callId: event.body['id'],
|
||||
isIncoming: true,
|
||||
callerName: event.body['nameCaller'] ?? 'Unknown',
|
||||
onAccept: () {},
|
||||
onHangup: () => _handleHangupGlobal(event.body['id']),
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case Event.actionCallDecline:
|
||||
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ОТКЛОНИТЬ"
|
||||
SocketService().sendMessage({
|
||||
"type": "decline",
|
||||
"call_id": event.body['id'],
|
||||
});
|
||||
break;
|
||||
|
||||
case Event.actionCallEnded:
|
||||
case Event.actionCallTimeout:
|
||||
// Звонок завершен или пропущен
|
||||
print("Call ended or timeout");
|
||||
break;
|
||||
|
||||
default:
|
||||
print("Event unhandled: ${event.event}");
|
||||
break;
|
||||
}
|
||||
});
|
||||
} catch (e, st) {
|
||||
print('CallKit listener initialization failed: $e');
|
||||
print(st);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleHangupGlobal(String callId) {
|
||||
SocketService().sendMessage({"type": "hangup", "call_id": callId});
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
print("Фоновый пуш получен: ${message.data}");
|
||||
if (message.data['type'] == 'enc_message') {
|
||||
try {
|
||||
// Initialize notifications for background
|
||||
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const InitializationSettings initializationSettings =
|
||||
InitializationSettings(android: initializationSettingsAndroid);
|
||||
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
||||
const WindowsInitializationSettings initializationSettingsWindows =
|
||||
WindowsInitializationSettings(
|
||||
appName: 'Chepuhagram',
|
||||
appUserModelId: 'ru.ArturKarasevich.Chepuhagram',
|
||||
guid: '6c0af055-e0b5-4f10-9aed-c12dc078f949',
|
||||
);
|
||||
final InitializationSettings initializationSettings =
|
||||
InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
windows: initializationSettingsWindows,
|
||||
);
|
||||
await flutterLocalNotificationsPlugin.initialize(
|
||||
settings: initializationSettings,
|
||||
onDidReceiveNotificationResponse: _onSelectNotification,
|
||||
);
|
||||
|
||||
// Create notification channel
|
||||
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||
|
|
@ -301,10 +436,10 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
|||
final String groupKey = 'ru.chepuhagram.app.$senderId';
|
||||
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
senderId!,
|
||||
'',
|
||||
'',
|
||||
NotificationDetails(
|
||||
id: senderId!,
|
||||
title: '',
|
||||
body: '',
|
||||
notificationDetails: NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'Messages',
|
||||
'Новые сообщения',
|
||||
|
|
@ -317,10 +452,10 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
|||
),
|
||||
);
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
message.hashCode,
|
||||
message.data['username'] ?? 'Unknown',
|
||||
notificationText,
|
||||
NotificationDetails(
|
||||
id: message.hashCode,
|
||||
title: message.data['username'] ?? 'Unknown',
|
||||
body: notificationText,
|
||||
notificationDetails: NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'chat_id',
|
||||
'Messages',
|
||||
|
|
|
|||
|
|
@ -62,13 +62,13 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
|
|||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Сохранено')),
|
||||
const SnackBar(content: Text('Сохранено'), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', '')), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
|
|
@ -77,91 +77,131 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Аккаунт'),
|
||||
title: const Text('Редактировать аккаунт', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text(
|
||||
'Сохранить',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Center(
|
||||
child: _isSaving
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5, color: colorScheme.primary),
|
||||
)
|
||||
: TextButton.icon(
|
||||
onPressed: _save,
|
||||
icon: const Icon(Icons.done_rounded, size: 18),
|
||||
label: const Text('Готово', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
TextFormField(
|
||||
_buildInputField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Имя пользователя',
|
||||
hintText: 'Латиница, цифры, подчеркивания',
|
||||
),
|
||||
label: 'Имя пользователя',
|
||||
hint: 'Латиница, цифры, подчеркивания',
|
||||
icon: Icons.alternate_email_rounded,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Введите имя пользователя';
|
||||
if (!RegExp(r'^[a-zA-Z0-9_]{3,20}$').hasMatch(v.trim())) {
|
||||
return 'Имя пользователя должно содержать от 3 до 20 символов (латиница, цифры, подчеркивания)';
|
||||
return 'От 3 до 20 символов (A-Z, 0-9, _)';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
_buildInputField(
|
||||
controller: _firstNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Имя',
|
||||
),
|
||||
label: 'Имя',
|
||||
hint: 'Введите ваше имя',
|
||||
icon: Icons.person_outline_rounded,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Введите имя';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
_buildInputField(
|
||||
controller: _lastNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Фамилия',
|
||||
),
|
||||
label: 'Фамилия',
|
||||
hint: 'Введите вашу фамилию',
|
||||
icon: Icons.people_outline_rounded,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
_buildInputField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Телефон',
|
||||
),
|
||||
label: 'Телефон',
|
||||
hint: 'Номер телефона',
|
||||
icon: Icons.phone_android_rounded,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
_buildInputField(
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Почта',
|
||||
),
|
||||
label: 'Почта',
|
||||
hint: 'Электронный адрес',
|
||||
icon: Icons.mail_outline_rounded,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
_buildInputField(
|
||||
controller: _aboutController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'О себе',
|
||||
),
|
||||
minLines: 1,
|
||||
maxLines: 10,
|
||||
label: 'О себе',
|
||||
hint: 'Расскажите немного о себе',
|
||||
icon: Icons.short_text_rounded,
|
||||
maxLines: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildInputField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String hint,
|
||||
required IconData icon,
|
||||
int maxLines = 1,
|
||||
TextInputType? keyboardType,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
validator: validator,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
decoration: InputDecoration(
|
||||
icon: Icon(icon, color: colorScheme.primary, size: 22),
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: colorScheme.outline, fontSize: 14),
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(color: colorScheme.outline.withOpacity(0.5)),
|
||||
border: InputBorder.none,
|
||||
errorStyle: TextStyle(color: colorScheme.error, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,378 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:chepuhagram/core/constants.dart';
|
||||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||
|
||||
class AdminPanelScreen extends StatefulWidget {
|
||||
const AdminPanelScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AdminPanelScreen> createState() => _AdminPanelScreenState();
|
||||
}
|
||||
|
||||
class _AdminPanelScreenState extends State<AdminPanelScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final ApiService _apiService = ApiService();
|
||||
|
||||
List<dynamic> _users = [];
|
||||
bool _isLoadingUsers = true;
|
||||
|
||||
// Контроллеры формы создания пользователя
|
||||
final _idController = TextEditingController();
|
||||
final _createFormKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _lastNameController = TextEditingController();
|
||||
bool _isCreating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_loadUsers();
|
||||
}
|
||||
|
||||
Future<void> _loadUsers() async {
|
||||
setState(() => _isLoadingUsers = true);
|
||||
try {
|
||||
final token = await _apiService.getAccessToken();
|
||||
final response = await Dio().get(
|
||||
'${AppConstants.baseUrl}/admin/users',
|
||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||
);
|
||||
setState(() {
|
||||
_users = response.data;
|
||||
_isLoadingUsers = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoadingUsers = false);
|
||||
_showSnackBar('Ошибка загрузки пользователей: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleBlock(int userId, bool currentBlockStatus) async {
|
||||
final action = currentBlockStatus ? 'unblock' : 'block';
|
||||
try {
|
||||
final token = await _apiService.getAccessToken();
|
||||
await Dio().post(
|
||||
'${AppConstants.baseUrl}/admin/users/$userId/$action',
|
||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||
);
|
||||
_showSnackBar(
|
||||
currentBlockStatus
|
||||
? 'Пользователь разблокирован'
|
||||
: 'Пользователь заблокирован',
|
||||
);
|
||||
_loadUsers();
|
||||
} catch (e) {
|
||||
_showSnackBar('Ошибка изменения статуса: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createUser() async {
|
||||
if (!_createFormKey.currentState!.validate()) return;
|
||||
setState(() => _isCreating = true);
|
||||
try {
|
||||
final token = await _apiService.getAccessToken();
|
||||
final Map<String, dynamic> requestData = {
|
||||
'username': _usernameController.text.trim(),
|
||||
'password': _passwordController.text.trim(),
|
||||
'first_name': _firstNameController.text.trim(),
|
||||
'last_name': _lastNameController.text.trim(),
|
||||
};
|
||||
final idText = _idController.text.trim();
|
||||
if (idText.isNotEmpty) {
|
||||
requestData['id'] = int.tryParse(idText);
|
||||
}
|
||||
await Dio().post(
|
||||
'${AppConstants.baseUrl}/admin/users',
|
||||
data: requestData,
|
||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||
);
|
||||
_showSnackBar('Пользователь успешно создан!');
|
||||
_idController.clear();
|
||||
_usernameController.clear();
|
||||
_passwordController.clear();
|
||||
_firstNameController.clear();
|
||||
_lastNameController.clear();
|
||||
_loadUsers();
|
||||
_tabController.animateTo(0);
|
||||
} catch (e) {
|
||||
_showSnackBar('Ошибка создания: $e');
|
||||
} finally {
|
||||
setState(() => _isCreating = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnackBar(String text) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(text), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Панель администратора',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.people_alt_rounded), text: 'Пользователи'),
|
||||
Tab(
|
||||
icon: Icon(Icons.person_add_alt_1_rounded),
|
||||
text: 'Создать аккаунт',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [_buildUsersTab(colorScheme), _buildCreateTab(colorScheme)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUsersTab(ColorScheme colorScheme) {
|
||||
if (_isLoadingUsers)
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadUsers,
|
||||
child: ListView.builder(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _users.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = _users[index];
|
||||
final bool isBlocked = user['is_blocked'] == 1;
|
||||
final int userId = user['id'];
|
||||
|
||||
if (userId == 1)
|
||||
return const SizedBox.shrink(); // Скрываем супер-админа из списка менеджмента
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||
elevation: 0,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
title: Text(
|
||||
'${user['first_name']} ${user['last_name'] ?? ''}'.trim(),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'@${user['username']}\nID: $userId',
|
||||
style: TextStyle(color: colorScheme.outline, fontSize: 13),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_note_rounded),
|
||||
onPressed: () => _showEditUserDialog(user),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isBlocked
|
||||
? Icons.lock_open_rounded
|
||||
: Icons.lock_person_rounded,
|
||||
),
|
||||
color: isBlocked ? Colors.green : colorScheme.error,
|
||||
onPressed: () => _toggleBlock(userId, isBlocked),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreateTab(ColorScheme colorScheme) {
|
||||
return Form(
|
||||
key: _createFormKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
_buildAdminInputField(
|
||||
_idController,
|
||||
'ID пользователя (оставьте пустым для автогенерации)',
|
||||
Icons.fingerprint_rounded,
|
||||
(v) {
|
||||
if (v != null && v.isNotEmpty && int.tryParse(v) == null) {
|
||||
return 'ID должен быть числом';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
_buildAdminInputField(
|
||||
_usernameController,
|
||||
'Имя пользователя (username)',
|
||||
Icons.alternate_email_rounded,
|
||||
(v) => v!.isEmpty ? 'Заполните юзернейм' : null,
|
||||
),
|
||||
_buildAdminInputField(
|
||||
_passwordController,
|
||||
'Временный пароль аккаунта',
|
||||
Icons.password_rounded,
|
||||
(v) => v!.length < 6 ? 'Минимум 6 символов' : null,
|
||||
obscure: true,
|
||||
),
|
||||
_buildAdminInputField(
|
||||
_firstNameController,
|
||||
'Имя',
|
||||
Icons.person_rounded,
|
||||
(v) => v!.isEmpty ? 'Введите имя' : null,
|
||||
),
|
||||
_buildAdminInputField(
|
||||
_lastNameController,
|
||||
'Фамилия',
|
||||
Icons.people_rounded,
|
||||
null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isCreating ? null : _createUser,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: _isCreating
|
||||
? const CircularProgressIndicator()
|
||||
: const Text(
|
||||
'Зарегистрировать пользователя',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdminInputField(
|
||||
TextEditingController controller,
|
||||
String label,
|
||||
IconData icon,
|
||||
String? Function(String?)? validator, {
|
||||
bool obscure = false,
|
||||
TextInputType keyboardType = TextInputType.text,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
obscureText: obscure,
|
||||
validator: validator,
|
||||
keyboardType: keyboardType,
|
||||
decoration: InputDecoration(
|
||||
icon: Icon(icon, color: colorScheme.primary),
|
||||
labelText: label,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditUserDialog(Map<String, dynamic> user) {
|
||||
final fNameController = TextEditingController(text: user['first_name']);
|
||||
final lNameController = TextEditingController(text: user['last_name']);
|
||||
final aboutController = TextEditingController(text: user['about']);
|
||||
final phoneController = TextEditingController(text: user['phone']);
|
||||
final emailController = TextEditingController(text: user['email']);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
title: Text('Редактирование ID: ${user['id']}'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: fNameController,
|
||||
decoration: const InputDecoration(labelText: 'Имя'),
|
||||
),
|
||||
TextField(
|
||||
controller: lNameController,
|
||||
decoration: const InputDecoration(labelText: 'Фамилия'),
|
||||
),
|
||||
TextField(
|
||||
controller: aboutController,
|
||||
decoration: const InputDecoration(labelText: 'О себе'),
|
||||
),
|
||||
TextField(
|
||||
controller: phoneController,
|
||||
decoration: const InputDecoration(labelText: 'Телефон'),
|
||||
),
|
||||
TextField(
|
||||
controller: emailController,
|
||||
decoration: const InputDecoration(labelText: 'Почта'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final token = await _apiService.getAccessToken();
|
||||
await Dio().put(
|
||||
'${AppConstants.baseUrl}/admin/users/${user['id']}/profile',
|
||||
data: {
|
||||
'first_name': fNameController.text.trim(),
|
||||
'last_name': lNameController.text.trim(),
|
||||
'about': aboutController.text.trim(),
|
||||
'phone': phoneController.text.trim(),
|
||||
'email': emailController.text.trim(),
|
||||
},
|
||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||
);
|
||||
Navigator.pop(ctx);
|
||||
_showSnackBar('Профиль успешно обновлен!');
|
||||
_loadUsers();
|
||||
} catch (e) {
|
||||
_showSnackBar('Ошибка обновления: $e');
|
||||
}
|
||||
},
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,104 +24,136 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeProv = context.watch<ThemeProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Оформление")),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
title: const Text("Оформление", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: ListView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Ночной режим
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.dark_mode),
|
||||
title: const Text("Ночной режим"),
|
||||
value: themeProv.themeMode == ThemeMode.dark,
|
||||
onChanged: (val) => themeProv.toggleTheme(val),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
secondary: Icon(Icons.dark_mode_rounded, color: colorScheme.primary),
|
||||
title: const Text("Ночной режим", style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
value: themeProv.themeMode == ThemeMode.dark,
|
||||
onChanged: (val) => themeProv.toggleTheme(val),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Выбор цвета акцента
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
// Цветовые акценты темы
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.palette_outlined, color: colorScheme.primary, size: 22),
|
||||
const SizedBox(width: 12),
|
||||
const Text("Цвет интерфейса", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
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),
|
||||
_colorCircle(const Color(0xFF24A1DE), themeProv),
|
||||
_colorCircle(const Color(0xFF3E8E7E), themeProv),
|
||||
_colorCircle(const Color(0xFF8E3E7E), themeProv),
|
||||
_colorCircle(const Color(0xFFFF9800), themeProv),
|
||||
_colorCircle(const Color(0xFFF44336), themeProv),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Обои чата
|
||||
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,
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||
leading: Icon(Icons.wallpaper_rounded, color: colorScheme.primary),
|
||||
title: const Text('Обои чата', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: const Text('Установить фоновое изображение'),
|
||||
trailing: Icon(Icons.chevron_right_rounded, color: colorScheme.outline),
|
||||
onTap: _pickWallpaper,
|
||||
),
|
||||
if (themeProv.wallpaperPath != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomRight,
|
||||
children: [
|
||||
Container(
|
||||
height: 160,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: FileImage(File(themeProv.wallpaperPath!)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: IconButton.filled(
|
||||
color: colorScheme.error,
|
||||
icon: const Icon(Icons.delete_outline_rounded, color: Colors.white),
|
||||
onPressed: () => themeProv.updateWallpaper(null),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () => themeProv.updateWallpaper(null),
|
||||
child: const Text('Удалить обои'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _colorCircle(BuildContext context, Color color, ThemeProvider prov) {
|
||||
Widget _colorCircle(Color color, ThemeProvider prov) {
|
||||
bool isSelected = prov.accentColor == color;
|
||||
return GestureDetector(
|
||||
onTap: () => prov.updateAccentColor(color),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected ? color : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
border: Border.all(color: isSelected ? color : Colors.transparent, width: 2),
|
||||
),
|
||||
child: CircleAvatar(backgroundColor: color, radius: 15),
|
||||
child: CircleAvatar(backgroundColor: color, radius: 16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
class CallScreen extends StatefulWidget {
|
||||
final String callId;
|
||||
final bool isIncoming;
|
||||
final String callerName;
|
||||
final VoidCallback onAccept;
|
||||
final VoidCallback onHangup;
|
||||
|
||||
const CallScreen({
|
||||
super.key,
|
||||
required this.callId,
|
||||
required this.isIncoming,
|
||||
required this.callerName,
|
||||
required this.onAccept,
|
||||
required this.onHangup,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CallScreen> createState() => _CallScreenState();
|
||||
}
|
||||
|
||||
class _CallScreenState extends State<CallScreen> {
|
||||
// Рендереры для видеопотока
|
||||
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
|
||||
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();
|
||||
|
||||
bool _isAccepted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_localRenderer.initialize();
|
||||
_remoteRenderer.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_localRenderer.dispose();
|
||||
_remoteRenderer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Основной контент (Видео или Аватар)
|
||||
Positioned.fill(
|
||||
child: _isAccepted
|
||||
? RTCVideoView(_remoteRenderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover)
|
||||
: Center(child: Text(widget.callerName, style: const TextStyle(color: Colors.white, fontSize: 24))),
|
||||
),
|
||||
|
||||
// Панель управления
|
||||
Positioned(
|
||||
bottom: 50,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
FloatingActionButton(
|
||||
backgroundColor: Colors.red,
|
||||
onPressed: () {
|
||||
widget.onHangup();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Icon(Icons.call_end, color: Colors.white),
|
||||
),
|
||||
if (widget.isIncoming && !_isAccepted)
|
||||
FloatingActionButton(
|
||||
backgroundColor: Colors.green,
|
||||
onPressed: () {
|
||||
setState(() => _isAccepted = true);
|
||||
widget.onAccept();
|
||||
},
|
||||
child: const Icon(Icons.call, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,6 @@
|
|||
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';
|
||||
|
|
@ -11,16 +10,15 @@ import '/domain/services/api_service.dart';
|
|||
class ForwardContactPickerScreen extends StatefulWidget {
|
||||
final MessageModel message;
|
||||
|
||||
const ForwardContactPickerScreen({
|
||||
super.key,
|
||||
required this.message,
|
||||
});
|
||||
const ForwardContactPickerScreen({super.key, required this.message});
|
||||
|
||||
@override
|
||||
State<ForwardContactPickerScreen> createState() => _ForwardContactPickerScreenState();
|
||||
State<ForwardContactPickerScreen> createState() =>
|
||||
_ForwardContactPickerScreenState();
|
||||
}
|
||||
|
||||
class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen> {
|
||||
class _ForwardContactPickerScreenState
|
||||
extends State<ForwardContactPickerScreen> {
|
||||
Contact? _selectedContact;
|
||||
bool _isInitLoading = true;
|
||||
SharedPreferences? _prefs;
|
||||
|
|
@ -151,11 +149,12 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
|||
final bool isDecrypted = contact.isLastMsgDecrypted ?? false;
|
||||
final String subtitleText = isDecrypted
|
||||
? (contact.lastMessage == null
|
||||
? "Нет сообщений"
|
||||
: "${contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : ''} ${contact.lastMessage}".trim())
|
||||
? "Нет сообщений"
|
||||
: "${contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : ''} ${contact.lastMessage}"
|
||||
.trim())
|
||||
: (contact.lastMessage != null
|
||||
? "Ожидание дешифровки..."
|
||||
: "Нет сообщений");
|
||||
? "Ожидание дешифровки..."
|
||||
: "Нет сообщений");
|
||||
|
||||
// Логика формирования URL аватарки
|
||||
final avatarUrl = contact.effectiveAvatarUrl;
|
||||
|
|
@ -172,9 +171,14 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
|||
});
|
||||
},
|
||||
child: Container(
|
||||
color: isSelected ? primaryColor.withOpacity(0.08) : Colors.transparent,
|
||||
color: isSelected
|
||||
? primaryColor.withOpacity(0.08)
|
||||
: Colors.transparent,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
|
||||
// 1. АВАТАРКА
|
||||
leading: Stack(
|
||||
|
|
@ -184,20 +188,50 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
|||
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),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
avatarUrl, // Первым аргументом идет строка, без "imageUrl:"
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
headers: token != null
|
||||
? {'Authorization': 'Bearer $token'}
|
||||
: null, // Заменено на headers
|
||||
// Аналог placeholder
|
||||
loadingBuilder:
|
||||
(context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return const SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
// Аналог errorWidget
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return CircleAvatar(
|
||||
radius: 24, // 24 радиус = 48 ширина/высота
|
||||
backgroundColor: primaryColor.withOpacity(
|
||||
0.1,
|
||||
),
|
||||
child: Text(
|
||||
_getDisplayName(contact).isNotEmpty
|
||||
? _getDisplayName(
|
||||
contact,
|
||||
)[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -207,8 +241,13 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
|||
radius: 24,
|
||||
backgroundColor: primaryColor.withOpacity(0.1),
|
||||
child: Text(
|
||||
_getDisplayName(contact).isNotEmpty ? _getDisplayName(contact)[0].toUpperCase() : '?',
|
||||
style: TextStyle(color: primaryColor, fontWeight: FontWeight.bold),
|
||||
_getDisplayName(contact).isNotEmpty
|
||||
? _getDisplayName(contact)[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
|
@ -222,7 +261,12 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
|||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Theme.of(context).scaffoldBackgroundColor, width: 2),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).scaffoldBackgroundColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -234,7 +278,10 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
|||
_getDisplayName(contact),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
|
||||
// 3. ПОСЛЕДНЕЕ СООБЩЕНИЕ
|
||||
|
|
@ -248,9 +295,13 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
|||
// 4. ПРАВАЯ ЧАСТЬ (Анимация переключения Время <-> Галочка)
|
||||
trailing: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return ScaleTransition(scale: animation, child: child);
|
||||
},
|
||||
transitionBuilder:
|
||||
(Widget child, Animation<double> animation) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: isSelected
|
||||
? Container(
|
||||
key: const ValueKey('checkmark'),
|
||||
|
|
@ -260,7 +311,11 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
|||
color: primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.check_rounded, color: Colors.white, size: 16),
|
||||
child: const Icon(
|
||||
Icons.check_rounded,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
key: const ValueKey('time_and_badge'),
|
||||
|
|
@ -270,19 +325,27 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
|||
children: [
|
||||
Text(
|
||||
_formatTime(contact.lastMessageTime),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
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()),
|
||||
color: primaryColor.withAlpha(
|
||||
(0.5 * 255).round(),
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'${contact.unreadCount}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,338 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '/logic/auth_provider.dart';
|
||||
import 'account_settings_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class MyProfileScreen extends StatefulWidget {
|
||||
const MyProfileScreen({super.key, required this.isFromList});
|
||||
final bool isFromList;
|
||||
|
||||
@override
|
||||
State<MyProfileScreen> createState() => _MyProfileScreenState();
|
||||
}
|
||||
|
||||
class _MyProfileScreenState extends State<MyProfileScreen> {
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
bool _isAvatarExpanded = false;
|
||||
String? privKey;
|
||||
|
||||
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 && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ошибка загрузки аватарки')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPrivKey();
|
||||
}
|
||||
|
||||
Future<void> _loadPrivKey() async {
|
||||
final storage = const FlutterSecureStorage();
|
||||
privKey = await storage.read(key: 'private_key');
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authProv = context.watch<AuthProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
final String fullName =
|
||||
'${authProv.firstName ?? ''} ${authProv.lastName ?? ''}'.trim();
|
||||
final String username = authProv.username ?? '';
|
||||
|
||||
ImageProvider? avatarImage;
|
||||
if (authProv.avatarUrl != null) {
|
||||
avatarImage = NetworkImage(authProv.avatarUrl!);
|
||||
} else if (authProv.avatarPath != null) {
|
||||
avatarImage = FileImage(File(authProv.avatarPath!));
|
||||
}
|
||||
|
||||
final initials =
|
||||
(authProv.displayName.isNotEmpty
|
||||
? authProv.displayName
|
||||
: (username.isNotEmpty ? username : 'U'))
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((p) => p.isNotEmpty)
|
||||
.take(2)
|
||||
.map((p) => p[0].toUpperCase())
|
||||
.join();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: (Platform.isWindows || !widget.isFromList)
|
||||
? AppBar(
|
||||
title: const Text(
|
||||
'Профиль',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
)
|
||||
: null,
|
||||
body: ListView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
// Анимированный интерактивный аватар
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _isAvatarExpanded = !_isAvatarExpanded),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.fastOutSlowIn,
|
||||
width: _isAvatarExpanded ? screenWidth : 130.0,
|
||||
height: _isAvatarExpanded ? screenWidth : 130.0,
|
||||
margin: _isAvatarExpanded
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
shape: _isAvatarExpanded ? BoxShape.rectangle : BoxShape.circle,
|
||||
color: colorScheme.primaryContainer.withOpacity(0.4),
|
||||
boxShadow: _isAvatarExpanded
|
||||
? []
|
||||
: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
image: avatarImage != null
|
||||
? DecorationImage(image: avatarImage, fit: BoxFit.cover)
|
||||
: null,
|
||||
),
|
||||
child: avatarImage == null
|
||||
? Center(
|
||||
child: Text(
|
||||
initials.isEmpty ? 'U' : initials,
|
||||
style: TextStyle(
|
||||
fontSize: _isAvatarExpanded ? 80 : 38,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
// Имя пользователя
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
fullName.isNotEmpty ? fullName : 'Имя не указано',
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (username.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 24),
|
||||
child: Text(
|
||||
'@$username',
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
// Блок кнопок (Выбрать фото, Изменить, Настройки)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildActionButton(
|
||||
icon: Icons.photo_camera_rounded,
|
||||
label: 'Фото',
|
||||
onTap: _pickAvatar,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildActionButton(
|
||||
icon: Icons.edit_note_rounded,
|
||||
label: 'Изменить',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const AccountSettingsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildActionButton(
|
||||
icon: Icons.settings_suggest_rounded,
|
||||
label: 'Настройки',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SettingsScreen(isFromList: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Блок с личной информацией
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
context,
|
||||
Icons.fingerprint_rounded,
|
||||
authProv.currentUserId.toString(),
|
||||
'ID пользователя',
|
||||
true,
|
||||
),
|
||||
_buildInfoRow(
|
||||
context,
|
||||
Icons.info_outline_rounded,
|
||||
authProv.about,
|
||||
'О себе',
|
||||
true,
|
||||
),
|
||||
_buildInfoRow(
|
||||
context,
|
||||
Icons.phone_android_rounded,
|
||||
authProv.phone,
|
||||
'Номер телефона',
|
||||
true,
|
||||
),
|
||||
_buildInfoRow(
|
||||
context,
|
||||
Icons.mail_outline_rounded,
|
||||
authProv.email,
|
||||
'Электронная почта',
|
||||
true,
|
||||
),
|
||||
_buildInfoRow(
|
||||
context,
|
||||
Icons.key_rounded,
|
||||
privKey ?? 'Отсутствует',
|
||||
'Публичный E2EE ключ',
|
||||
false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.4),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, color: colorScheme.primary, size: 22),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String? value,
|
||||
String label,
|
||||
bool showDivider,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 4,
|
||||
),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: colorScheme.primary, size: 20),
|
||||
),
|
||||
title: Text(
|
||||
value?.isNotEmpty == true ? value! : 'Не указано',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 12, color: colorScheme.outline),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 70,
|
||||
color: colorScheme.outlineVariant.withOpacity(0.2),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,38 +7,72 @@ class PrivacySettingsMenuScreen extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Конфиденциальность')),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Конфиденциальность', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: ListView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.security_outlined),
|
||||
title: const Text('Безопасность'),
|
||||
subtitle: const Text('Сменить пароль, пароль шифрования, TOTP'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SecuritySettingsScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.privacy_tip_outlined),
|
||||
title: const Text('Конфиденциальность'),
|
||||
subtitle: const Text('Кто может видеть почту, телефон, аватар и информацию о вас'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const PrivacySettingsScreen()),
|
||||
);
|
||||
},
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildMenuTile(
|
||||
context: context,
|
||||
icon: Icons.security_rounded,
|
||||
title: 'Безопасность',
|
||||
subtitle: 'Смена паролей, ключи шифрования, TOTP защита',
|
||||
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SecuritySettingsScreen())),
|
||||
),
|
||||
Divider(height: 1, indent: 68, color: colorScheme.outlineVariant.withOpacity(0.2)),
|
||||
_buildMenuTile(
|
||||
context: context,
|
||||
icon: Icons.privacy_tip_rounded,
|
||||
title: 'Конфиденциальность',
|
||||
subtitle: 'Видимость почты, телефона, аватара и онлайна',
|
||||
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const PrivacySettingsScreen())),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuTile({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: colorScheme.primary, size: 22),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.outline, fontSize: 12)),
|
||||
trailing: Icon(Icons.chevron_right_rounded, color: colorScheme.outline),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -52,14 +52,12 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
_showAbout = data['show_about'] ?? 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(_showLastOnlineKey, _showLastOnline);
|
||||
} catch (e) {
|
||||
// Если не удалось загрузить с сервера, используем локальные настройки
|
||||
print('Ошибка загрузки настроек с сервера: $e');
|
||||
}
|
||||
}
|
||||
|
|
@ -71,7 +69,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
|
||||
Future<void> _saveToServer() async {
|
||||
if (_isSaving) return;
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
|
||||
try {
|
||||
|
|
@ -85,7 +82,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
);
|
||||
|
||||
if (success) {
|
||||
// Сохраняем локально только после успешного сохранения на сервере
|
||||
await _savePreference(_showEmailKey, _showEmail);
|
||||
await _savePreference(_showPhoneKey, _showPhone);
|
||||
await _savePreference(_showAvatarKey, _showAvatar);
|
||||
|
|
@ -94,97 +90,108 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Настройки сохранены')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Не удалось сохранить настройки')),
|
||||
const SnackBar(content: Text('Настройки видимости сохранены'), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Ошибка: ${e.toString().replaceAll('Exception: ', '')}')),
|
||||
SnackBar(content: Text('Ошибка: ${e.toString().replaceAll('Exception: ', '')}'), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSaving = false);
|
||||
}
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Конфиденциальность'),
|
||||
title: const Text('Видимость данных', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : _saveToServer,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text(
|
||||
'Сохранить',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Center(
|
||||
child: _isSaving
|
||||
? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary))
|
||||
: TextButton.icon(
|
||||
onPressed: _saveToServer,
|
||||
icon: const Icon(Icons.save_rounded, size: 18),
|
||||
label: const Text('Сохранить'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text('Настройки видимости', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
title: const Text('Показывать почту другим'),
|
||||
value: _showEmail,
|
||||
onChanged: (value) {
|
||||
setState(() => _showEmail = value);
|
||||
},
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8.0, bottom: 12),
|
||||
child: Text('Кто видит мою информацию:', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, letterSpacing: 0.5)),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Показывать телефон другим'),
|
||||
value: _showPhone,
|
||||
onChanged: (value) {
|
||||
setState(() => _showPhone = value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Показывать аватар другим'),
|
||||
value: _showAvatar,
|
||||
onChanged: (value) {
|
||||
setState(() => _showAvatar = value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Показывать информацию «О себе»'),
|
||||
value: _showAbout,
|
||||
onChanged: (value) {
|
||||
setState(() => _showAbout = value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Показывать последний онлайн'),
|
||||
value: _showLastOnline,
|
||||
onChanged: (value) {
|
||||
setState(() => _showLastOnline = value);
|
||||
},
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSwitchTile('Показывать почту другим', _showEmail, (v) => setState(() => _showEmail = v)),
|
||||
_buildDivider(),
|
||||
_buildSwitchTile('Показывать телефон другим', _showPhone, (v) => setState(() => _showPhone = v)),
|
||||
_buildDivider(),
|
||||
_buildSwitchTile('Показывать аватар другим', _showAvatar, (v) => setState(() => _showAvatar = v)),
|
||||
_buildDivider(),
|
||||
_buildSwitchTile('Показывать информацию «О себе»', _showAbout, (v) => setState(() => _showAbout = v)),
|
||||
_buildDivider(),
|
||||
_buildSwitchTile('Показывать последний онлайн', _showLastOnline, (v) => setState(() => _showLastOnline = v)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Эти настройки влияют на то, какую информацию о вас видят другие пользователи приложения.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withOpacity(0.04),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.lock_person_rounded, color: colorScheme.primary, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Эти настройки напрямую влияют на то, какие персональные данные будут доступны другим участникам в глобальном поиске и карточках чатов.',
|
||||
style: TextStyle(color: colorScheme.outline, fontSize: 13, height: 1.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSwitchTile(String title, bool value, ValueChanged<bool> onChanged) {
|
||||
return SwitchListTile(
|
||||
title: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() => Divider(height: 1, indent: 20, endIndent: 20, color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.2));
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ class SecuritySettingsScreen extends StatefulWidget {
|
|||
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||
final _passwordFormKey = GlobalKey<FormState>();
|
||||
final _encryptionFormKey = GlobalKey<FormState>();
|
||||
//final _totpFormKey = GlobalKey<FormState>();
|
||||
|
||||
final _currentPasswordController = TextEditingController();
|
||||
final _newPasswordController = TextEditingController();
|
||||
|
|
@ -64,9 +63,7 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isBiometricAvailable = false;
|
||||
});
|
||||
setState(() => _isBiometricAvailable = false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,15 +71,11 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
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);
|
||||
}
|
||||
|
|
@ -91,16 +84,14 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
Future<bool> _authenticateBiometric() async {
|
||||
try {
|
||||
return await _localAuth.authenticate(
|
||||
localizedReason: 'Подтвердите личность для смены пароля шифрования',
|
||||
localizedReason: 'Подтвердите личность для изменения крипто-пароля',
|
||||
options: const AuthenticationOptions(
|
||||
biometricOnly: false,
|
||||
stickyAuth: false,
|
||||
useErrorDialogs: true,
|
||||
sensitiveTransaction: true,
|
||||
stickyAuth: false,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint('Biometric authentication error: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -116,31 +107,33 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
_newPasswordController.text.trim(),
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
throw Exception('Не удалось изменить пароль');
|
||||
}
|
||||
if (!success) throw Exception('Не удалось изменить пароль');
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Пароль успешно изменён')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Основной пароль успешно обновлен'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
_currentPasswordController.clear();
|
||||
_newPasswordController.clear();
|
||||
_confirmPasswordController.clear();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
SnackBar(
|
||||
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSavingPassword = false);
|
||||
if (mounted) setState(() => _isSavingPassword = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveEncryptionPassword() async {
|
||||
await _checkBiometricSupport();
|
||||
|
||||
if (!_encryptionFormKey.currentState!.validate()) return;
|
||||
setState(() => _isSavingEncryption = true);
|
||||
|
||||
|
|
@ -151,29 +144,22 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
String privateKeyBase64;
|
||||
|
||||
if (currentPassword.isEmpty) {
|
||||
if (!_isBiometricAvailable) {
|
||||
throw Exception('Биометрия не настроена. Введите текущий пароль.');
|
||||
}
|
||||
|
||||
if (!_isBiometricAvailable)
|
||||
throw Exception('Биометрия недоступна. Введите пароль.');
|
||||
final authenticated = await _authenticateBiometric();
|
||||
if (!authenticated) {
|
||||
throw Exception('Биометрическая аутентификация не пройдена.');
|
||||
}
|
||||
if (!authenticated) throw Exception('Аутентификация отменена.');
|
||||
|
||||
final localPrivateKey = await cryptoService.getPrivateKey();
|
||||
if (localPrivateKey == null || localPrivateKey.isEmpty) {
|
||||
throw Exception('Локальный приватный ключ не найден.');
|
||||
}
|
||||
if (localPrivateKey == null || localPrivateKey.isEmpty)
|
||||
throw Exception('Локальный ключ отсутствует.');
|
||||
privateKeyBase64 = localPrivateKey;
|
||||
} else {
|
||||
final api = ApiService();
|
||||
final userData = await api.getMe();
|
||||
final encryptedPrivateKey = userData['encrypted_private_key']
|
||||
?.toString();
|
||||
|
||||
if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty) {
|
||||
throw Exception('Зашифрованный ключ не найден на сервере.');
|
||||
}
|
||||
if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty)
|
||||
throw Exception('Ключ не найден на сервере.');
|
||||
|
||||
privateKeyBase64 = await cryptoService.decryptPrivateKey(
|
||||
encryptedPrivateKey,
|
||||
|
|
@ -184,17 +170,17 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
|
||||
final updatedEncryptedPrivateKey = await cryptoService
|
||||
.encryptPrivateKeyWithPassword(privateKeyBase64, newPassword);
|
||||
|
||||
final success = await ApiService().updateEncryptedPrivateKey(
|
||||
updatedEncryptedPrivateKey,
|
||||
);
|
||||
if (!success) {
|
||||
throw Exception('Не удалось обновить пароль шифрования на сервере.');
|
||||
}
|
||||
if (!success) throw Exception('Сервер отклонил обновление крипто-ключа.');
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Пароль шифрования успешно обновлён')),
|
||||
const SnackBar(
|
||||
content: Text('Крипто-пароль успешно изменен'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
_currentEncryptPasswordController.clear();
|
||||
_newEncryptPasswordController.clear();
|
||||
|
|
@ -202,20 +188,20 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
SnackBar(
|
||||
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSavingEncryption = false);
|
||||
if (mounted) setState(() => _isSavingEncryption = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setupTotp() async {
|
||||
if (_isTotpEnabled) {
|
||||
// Показываем диалог с опциями
|
||||
_showTotpOptionsDialog();
|
||||
} else {
|
||||
// Enable TOTP
|
||||
setState(() => _isSavingTotp = true);
|
||||
try {
|
||||
final api = ApiService();
|
||||
|
|
@ -224,11 +210,13 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
_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: ', ''))),
|
||||
SnackBar(
|
||||
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isSavingTotp = false);
|
||||
|
|
@ -240,57 +228,35 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('TOTP'),
|
||||
content: const Text('TOTP включён. Выберите действие:'),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: const Text('Защита TOTP активна'),
|
||||
content: const Text(
|
||||
'Выберите необходимое действие для управления двухфакторной аутентификацией.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_reissueTotp();
|
||||
},
|
||||
child: const Text('Перевыпустить ключ'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.pop(context);
|
||||
_disableTotp();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'Отключить 2FA',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
child: const Text('Отключить TOTP'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _reissueTotp() async {
|
||||
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(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 {
|
||||
|
|
@ -303,122 +269,130 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
_totpQrCode = null;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('TOTP отключён')),
|
||||
const SnackBar(
|
||||
content: Text('Двухфакторная защита отключена'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
SnackBar(
|
||||
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isSavingTotp = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showTotpSetupDialog({bool isReissue = false}) {
|
||||
void _showTotpSetupDialog() {
|
||||
final codeController = TextEditingController();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(isReissue ? 'Перевыпуск ключа TOTP' : 'Настройка TOTP'),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
title: const Text('Активация TOTP 2FA'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(isReissue
|
||||
? 'Отсканируйте новый QR-код в приложении аутентификатора:'
|
||||
: 'Отсканируйте QR-код в приложении аутентификатора:'),
|
||||
const Text(
|
||||
'Сканируйте код приложением аутентификатора (Google Authenticator / Aegis):',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
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);
|
||||
},
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.memory(
|
||||
base64Decode(_totpQrCode!.split(',').last),
|
||||
width: 180,
|
||||
height: 180,
|
||||
),
|
||||
),
|
||||
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(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.4),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_totpSecret ?? '',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
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: 'Скопировать ключ',
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy_rounded, size: 16),
|
||||
onPressed: () {
|
||||
if (_totpSecret != null) {
|
||||
Clipboard.setData(ClipboardData(text: _totpSecret!));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Ключ скопирован в буфер'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: codeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Введите код из приложения',
|
||||
helperText: 'Обычно это 6 цифр',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '6-значный одноразовый код',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
setState(() {
|
||||
_totpSecret = null;
|
||||
_totpQrCode = null;
|
||||
});
|
||||
},
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final code = codeController.text.trim();
|
||||
if (code.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Введите код')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (codeController.text.trim().isEmpty) return;
|
||||
try {
|
||||
final api = ApiService();
|
||||
final success = await api.verifyTotp(code);
|
||||
final success = await ApiService().verifyTotp(
|
||||
codeController.text.trim(),
|
||||
);
|
||||
if (success) {
|
||||
Navigator.of(context).pop();
|
||||
setState(() {
|
||||
_isTotpEnabled = true;
|
||||
_totpSecret = null;
|
||||
_totpQrCode = null;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
setState(() => _isTotpEnabled = true);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(isReissue ? 'Ключ перевыпущен' : 'TOTP включён')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Неверный код')),
|
||||
const SnackBar(
|
||||
content: Text('Двухфакторный ключ успешно привязан'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
SnackBar(
|
||||
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
@ -429,153 +403,253 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
String? _currentEncryptionPasswordValidator(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
if (!_isBiometricAvailable) {
|
||||
return 'Введите текущий пароль';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Безопасность')),
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: AppBar(
|
||||
title: const Text('Безопасность'),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: ListView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text(
|
||||
'Смена пароля аккаунта',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
// Модуль 1: Основной пароль
|
||||
_buildCardSection(
|
||||
title: 'Смена пароля аккаунта',
|
||||
child: Form(
|
||||
key: _passwordFormKey,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildFormInput(
|
||||
_currentPasswordController,
|
||||
'Текущий пароль',
|
||||
true,
|
||||
),
|
||||
_buildFormInput(_newPasswordController, 'Новый пароль', true),
|
||||
_buildFormInput(
|
||||
_confirmPasswordController,
|
||||
'Повторите новый пароль',
|
||||
true,
|
||||
validator: (v) {
|
||||
if (v != _newPasswordController.text)
|
||||
return 'Пароли не совпадают';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isSavingPassword ? null : _savePassword,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isSavingPassword
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Обновить основной пароль'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Form(
|
||||
key: _passwordFormKey,
|
||||
child: Column(
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Модуль 2: Сквозное шифрование
|
||||
_buildCardSection(
|
||||
title: 'Пароль сквозного шифрования (E2EE)',
|
||||
child: Form(
|
||||
key: _encryptionFormKey,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildFormInput(
|
||||
_currentEncryptPasswordController,
|
||||
_isBiometricAvailable
|
||||
? 'Оставьте пустым и подтвердите биометрией'
|
||||
: 'Текущий крипто-пароль',
|
||||
true,
|
||||
hint: _isBiometricAvailable
|
||||
? 'Подтвердите биометрией'
|
||||
: null,
|
||||
validator: (v) {
|
||||
// Если биометрия доступна на устройстве, поле МОЖЕТ быть пустым
|
||||
if (_isBiometricAvailable) return null;
|
||||
|
||||
// Если биометрии нет, то поле становится строго обязательным
|
||||
if (v == null || v.isEmpty)
|
||||
return 'Введите текущий пароль';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
_buildFormInput(
|
||||
_newEncryptPasswordController,
|
||||
'Новый крипто-пароль',
|
||||
true,
|
||||
),
|
||||
_buildFormInput(
|
||||
_confirmEncryptPasswordController,
|
||||
'Повторите новый крипто-пароль',
|
||||
true,
|
||||
validator: (v) {
|
||||
if (v != _newEncryptPasswordController.text)
|
||||
return 'Пароли не совпадают';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isSavingEncryption
|
||||
? null
|
||||
: _saveEncryptionPassword,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isSavingEncryption
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Обновить ключ шифрования'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Модуль 3: Двухфакторная аутентификация
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _currentPasswordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Текущий пароль',
|
||||
Icon(
|
||||
Icons.lock_clock_rounded,
|
||||
color: colorScheme.primary,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Двухфакторная защита (TOTP)',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_isTotpEnabled
|
||||
? 'Статус: Активна'
|
||||
: 'Статус: Отключена',
|
||||
style: TextStyle(
|
||||
color: _isTotpEnabled
|
||||
? Colors.green
|
||||
: colorScheme.outline,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Введите текущий пароль';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _newPasswordController,
|
||||
decoration: const InputDecoration(labelText: 'Новый пароль'),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Введите новый пароль';
|
||||
if (value.length < 6) return 'Пароль слишком короткий';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Повторите пароль',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
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('Сохранить пароль'),
|
||||
onPressed: _isSavingTotp ? null : _setupTotp,
|
||||
style: ElevatedButton.styleFrom(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(_isTotpEnabled ? 'Опции' : 'Включить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Пароль шифрования сообщений',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Form(
|
||||
key: _encryptionFormKey,
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _currentEncryptPasswordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Текущий пароль шифрования',
|
||||
helperText: _isBiometricAvailable
|
||||
? 'Оставьте поле пустым и подтвердите биометрией'
|
||||
: 'Требуется текущий пароль',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: _currentEncryptionPasswordValidator,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _newEncryptPasswordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Новый пароль шифрования',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.length < 6)
|
||||
return 'Пароль слишком короткий';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _confirmEncryptPasswordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Повторите новый пароль',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
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('Сохранить пароль шифрования'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'TOTP',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(_isTotpEnabled ? 'TOTP включён' : 'TOTP отключён'),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _isSavingTotp ? null : _setupTotp,
|
||||
child: _isSavingTotp
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: Text(_isTotpEnabled ? 'Отключить TOTP' : 'Включить TOTP'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardSection({required String title, required Widget child}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFormInput(
|
||||
TextEditingController controller,
|
||||
String label,
|
||||
bool obscure, {
|
||||
String? hint,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
obscureText: obscure,
|
||||
validator:
|
||||
validator ??
|
||||
(v) => (v == null || v.isEmpty) ? 'Обязательное поле' : null,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import '/logic/auth_provider.dart';
|
|||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'dart:io';
|
||||
import 'admin_panel_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
final bool isFromList;
|
||||
const SettingsScreen({super.key, this.isFromList = true});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
|
|
@ -19,6 +21,7 @@ class SettingsScreen extends StatefulWidget {
|
|||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
String? versionCode;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
bool _isAvatarExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -38,8 +41,10 @@ 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) {
|
||||
final success = await context.read<AuthProvider>().updateAvatar(
|
||||
image.path,
|
||||
);
|
||||
if (!success && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ошибка загрузки аватарки')),
|
||||
);
|
||||
|
|
@ -50,168 +55,337 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authProv = context.watch<AuthProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
final accountUsername = authProv.username?.isNotEmpty == true
|
||||
? '@${authProv.username!}'
|
||||
: 'Не указано';
|
||||
String platformName = Platform.isAndroid
|
||||
? 'Android'
|
||||
: Platform.isIOS
|
||||
? 'iOS'
|
||||
: Platform.isWindows
|
||||
? 'Windows'
|
||||
: Platform.isLinux
|
||||
? 'Linux'
|
||||
: Platform.isMacOS
|
||||
? 'macOS'
|
||||
: 'Unknown';
|
||||
|
||||
final username = authProv.username;
|
||||
final displayName = authProv.displayName;
|
||||
final initials = (displayName.isNotEmpty ? displayName : (username ?? 'U'))
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((p) => p.isNotEmpty)
|
||||
.take(2)
|
||||
.map((p) => p[0].toUpperCase())
|
||||
.join();
|
||||
final String fullName =
|
||||
'${authProv.firstName ?? ''} ${authProv.lastName ?? ''}'.trim();
|
||||
final String username = authProv.username ?? '';
|
||||
|
||||
ImageProvider? avatarImage;
|
||||
if (authProv.avatarUrl != null) {
|
||||
avatarImage = NetworkImage(authProv.avatarUrl!);
|
||||
} else if (authProv.avatarPath != null) {
|
||||
avatarImage = FileImage(File(authProv.avatarPath!));
|
||||
}
|
||||
|
||||
final initials =
|
||||
(authProv.displayName.isNotEmpty
|
||||
? authProv.displayName
|
||||
: (username.isNotEmpty ? username : 'U'))
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((p) => p.isNotEmpty)
|
||||
.take(2)
|
||||
.map((p) => p[0].toUpperCase())
|
||||
.join();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Настройки")),
|
||||
body: Column(
|
||||
backgroundColor: colorScheme.background,
|
||||
appBar: (Platform.isWindows || !widget.isFromList)
|
||||
? AppBar(
|
||||
title: const Text(
|
||||
'Настройки',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
)
|
||||
: null,
|
||||
body: ListView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
// Секция Профиля
|
||||
UserAccountsDrawerHeader(
|
||||
accountName: Text(
|
||||
authProv.displayName,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
),
|
||||
accountEmail: Text(
|
||||
accountUsername,
|
||||
style: TextStyle(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,
|
||||
// Анимированный интерактивный аватар как в MyProfileScreen
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _isAvatarExpanded = !_isAvatarExpanded),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.fastOutSlowIn,
|
||||
width: _isAvatarExpanded ? screenWidth : 130.0,
|
||||
height: _isAvatarExpanded ? screenWidth : 130.0,
|
||||
margin: _isAvatarExpanded
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
shape: _isAvatarExpanded ? BoxShape.rectangle : BoxShape.circle,
|
||||
color: colorScheme.primaryContainer.withOpacity(0.4),
|
||||
boxShadow: _isAvatarExpanded
|
||||
? []
|
||||
: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.camera_alt,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
],
|
||||
image: avatarImage != null
|
||||
? DecorationImage(image: avatarImage, fit: BoxFit.cover)
|
||||
: null,
|
||||
),
|
||||
child: avatarImage == null
|
||||
? Center(
|
||||
child: Text(
|
||||
initials.isEmpty ? 'U' : initials,
|
||||
style: TextStyle(
|
||||
fontSize: _isAvatarExpanded ? 80 : 38,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
// Имя пользователя
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
fullName.isNotEmpty
|
||||
? fullName
|
||||
: (authProv.displayName.isNotEmpty
|
||||
? authProv.displayName
|
||||
: 'Имя не указано'),
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (username.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 24),
|
||||
child: Text(
|
||||
'@$username',
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Секция навигации меню (Сгруппированный контейнер)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildMenuTile(
|
||||
context,
|
||||
Icons.person_outline_rounded,
|
||||
'Аккаунт',
|
||||
'Имя, телефон, почта, о себе',
|
||||
() => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const AccountSettingsScreen(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildDivider(context),
|
||||
_buildMenuTile(
|
||||
context,
|
||||
Icons.shield_outlined,
|
||||
'Конфиденциальность',
|
||||
'Безопасность и видимость данных',
|
||||
() => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const PrivacySettingsMenuScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildDivider(context),
|
||||
_buildMenuTile(
|
||||
context,
|
||||
Icons.palette_outlined,
|
||||
'Оформление',
|
||||
'Тема, цвета, обои чата',
|
||||
() => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const AppearanceSettingsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (authProv.currentUserId == 1) ...[
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: colorScheme.primary.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 4,
|
||||
),
|
||||
leading: Icon(
|
||||
Icons.admin_panel_settings_rounded,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
"Админ-панель",
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: const Text("Управление пользователями системы"),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const AdminPanelScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
decoration: const BoxDecoration(color: Colors.transparent),
|
||||
),
|
||||
],
|
||||
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_outline),
|
||||
title: const Text('Аккаунт'),
|
||||
subtitle: const Text('Имя, телефон, почта, информация о себе'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const AccountSettingsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.shield_outlined),
|
||||
title: const Text('Конфиденциальность'),
|
||||
subtitle: const Text('Безопасность и видимость данных профиля'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const PrivacySettingsMenuScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
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 SizedBox(height: 16),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Выход
|
||||
ListTile(
|
||||
leading: const Icon(Icons.exit_to_app, color: Colors.red),
|
||||
title: const Text(
|
||||
"Выйти из аккаунта",
|
||||
style: TextStyle(color: Colors.red),
|
||||
// Кнопка выхода из аккаунта
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: colorScheme.errorContainer.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 4,
|
||||
),
|
||||
leading: Icon(Icons.logout_rounded, color: colorScheme.error),
|
||||
title: Text(
|
||||
"Выйти из аккаунта",
|
||||
style: TextStyle(
|
||||
color: colorScheme.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
onTap: () async {
|
||||
await authProv.logout();
|
||||
if (context.mounted) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
(r) => false,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
await authProv.logout();
|
||||
if (context.mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
Center(
|
||||
child: Text(
|
||||
"Chepuhagram for Android v$versionCode",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
"Chepuhagram for $platformName v${versionCode ?? '1.0.0'}",
|
||||
style: TextStyle(color: colorScheme.outline, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const Center(
|
||||
const SizedBox(height: 4),
|
||||
Center(
|
||||
child: Text(
|
||||
"Made by ArturKarasevich",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: colorScheme.outline.withOpacity(0.6),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuTile(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String title,
|
||||
String subtitle,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: colorScheme.primary, size: 22),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: colorScheme.outline, fontSize: 13),
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right_rounded, color: colorScheme.outline),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider(BuildContext context) => Divider(
|
||||
height: 1,
|
||||
indent: 68,
|
||||
color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.15),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,18 +5,20 @@ 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';
|
||||
import 'dart:io';
|
||||
|
||||
class UserProfileScreen extends StatefulWidget {
|
||||
final int userId;
|
||||
final String username;
|
||||
final String name;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const UserProfileScreen({
|
||||
super.key,
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.name,
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -38,9 +40,7 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
super.initState();
|
||||
_loadUserData();
|
||||
startOnlineUpdates();
|
||||
|
||||
DateTime now = DateTime.now();
|
||||
|
||||
offset = now.timeZoneOffset;
|
||||
|
||||
final socketService = Provider.of<SocketService>(context, listen: false);
|
||||
|
|
@ -54,19 +54,12 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
}
|
||||
|
||||
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;
|
||||
firstName = prefs.getString('firstname_${widget.userId}');
|
||||
lastName = prefs.getString('lastname_${widget.userId}');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_userData = data;
|
||||
|
|
@ -76,18 +69,12 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (e.toString().contains('SocketFailed')) {
|
||||
_error =
|
||||
'Ошибка соединения с сервером. Проверьте интернет соединение.';
|
||||
} else {
|
||||
_error = e.toString().replaceAll('Exception: ', '');
|
||||
}
|
||||
_error = e.toString().contains('SocketFailed')
|
||||
? 'Соединение разорвано'
|
||||
: e.toString().replaceAll('Exception: ', '');
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
Future.delayed(Duration(seconds: 2), () {
|
||||
_loadUserData();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,289 +85,29 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
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('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _buildUserInfo(),
|
||||
);
|
||||
}
|
||||
String _formatLastSeen(String? lastSeenStr) {
|
||||
if (lastSeenStr == null) return 'Был(а) недавно';
|
||||
final lastSeen = DateTime.tryParse(lastSeenStr);
|
||||
if (lastSeen == null) return 'Был(а) недавно';
|
||||
|
||||
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: [
|
||||
// Avatar placeholder
|
||||
Center(
|
||||
child: CircleAvatar(
|
||||
radius: 50,
|
||||
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
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
|
||||
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]),
|
||||
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),
|
||||
|
||||
// User ID
|
||||
_buildInfoTile('ID пользователя', _userData!['id'].toString()),
|
||||
|
||||
// Public Key (if available)
|
||||
if (_userData!['public_key'] != null)
|
||||
_buildInfoTile(
|
||||
'Публичный ключ',
|
||||
_userData!['public_key'],
|
||||
maxLines: 3,
|
||||
),
|
||||
|
||||
// About
|
||||
if (_userData!['about'] != null && _userData!['about'].isNotEmpty)
|
||||
_buildInfoTile('О себе', _userData!['about'], maxLines: 5),
|
||||
|
||||
// Phone
|
||||
if (_userData!['phone'] != null && _userData!['phone'].isNotEmpty)
|
||||
_buildInfoTile('Телефон', _userData!['phone']),
|
||||
|
||||
// Email
|
||||
if (_userData!['email'] != null && _userData!['email'].isNotEmpty)
|
||||
_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) &&
|
||||
(_userData!['email'] == null || _userData!['email'].isEmpty))
|
||||
const Text(
|
||||
'Пользователь скрыл дополнительную информацию',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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 localLastSeen = offset != null ? lastSeen.add(offset!) : lastSeen;
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(lastOnline);
|
||||
final difference = now.difference(localLastSeen);
|
||||
|
||||
if (difference.inSeconds < 60) {
|
||||
return 'только что';
|
||||
if (difference.inMinutes < 1) {
|
||||
return 'Был(а) только что';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes} минут${_pluralize(difference.inMinutes, "у", "ы", "")} назад';
|
||||
return 'Был(а) ${difference.inMinutes} ${_pluralize(difference.inMinutes, "минуту", "минуты", "минут")} назад';
|
||||
} else if (difference.inHours < 24) {
|
||||
return '${difference.inHours} час${_pluralize(difference.inHours, "", "а", "ов")} назад';
|
||||
return 'Был(а) ${difference.inHours} ${_pluralize(difference.inHours, "час", "часа", "часов")} назад';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
|
||||
return 'Был(а) ${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
|
||||
} else if (difference.inDays < 30) {
|
||||
final weeks = (difference.inDays / 7).floor();
|
||||
return '$weeks ${_pluralize(weeks, "неделю", "недели", "недель")} назад';
|
||||
return 'Был(а) $weeks ${_pluralize(weeks, "неделю", "недели", "недель")} назад';
|
||||
} else {
|
||||
return 'давно';
|
||||
return 'Был(а) давно';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -396,30 +123,388 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Widget _buildInfoTile(String label, String value, {int maxLines = 1}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
maxLines: maxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.background,
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Основное содержимое экрана
|
||||
_buildMainContent(colorScheme),
|
||||
|
||||
if (Platform.isWindows) ...[
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 16,
|
||||
child: ClipOval(
|
||||
child: Material(
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
onPressed: () {
|
||||
if (widget.onClose != null) {
|
||||
widget.onClose!();
|
||||
} else if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else if (Platform.isAndroid) ...[
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 16,
|
||||
child: ClipOval(
|
||||
child: Material(
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
onPressed: () {
|
||||
if (widget.onClose != null) {
|
||||
widget.onClose!();
|
||||
} else if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainContent(ColorScheme colorScheme) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.error,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildUserInfo();
|
||||
}
|
||||
|
||||
Widget _buildUserInfo() {
|
||||
if (_userData == null) return const SizedBox.shrink();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final String displayFN = firstName ?? _userData?['first_name'] ?? '';
|
||||
final String displayLN = lastName ?? _userData?['last_name'] ?? '';
|
||||
final String combinedName = '$displayFN $displayLN'.trim();
|
||||
final String username = _userData?['username'] ?? '';
|
||||
final rawAvatarUrl = _userData?['avatar_url']?.toString();
|
||||
final avatarUrl = rawAvatarUrl != null && rawAvatarUrl.startsWith('/')
|
||||
? '${AppConstants.baseUrl}$rawAvatarUrl'
|
||||
: rawAvatarUrl;
|
||||
|
||||
return ListView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.only(top: 44, bottom: 24),
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 110,
|
||||
height: 110,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.primaryContainer.withOpacity(0.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
image: (avatarUrl != null && _userData?['show_avatar'] == true)
|
||||
? DecorationImage(
|
||||
image: NetworkImage(avatarUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: (avatarUrl == null || _userData?['show_avatar'] != true)
|
||||
? Center(
|
||||
child: Text(
|
||||
combinedName.isNotEmpty
|
||||
? combinedName[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Center(
|
||||
child: InkWell(
|
||||
onTap: () => _editUserName(displayFN, displayLN),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
combinedName.isNotEmpty ? combinedName : 'Без имени',
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.edit_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (username.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'@$username',
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
_buildOnlineStatus(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
Icons.fingerprint_rounded,
|
||||
_userData!['id'].toString(),
|
||||
'ID пользователя',
|
||||
true,
|
||||
),
|
||||
if (_userData!['about'] != null &&
|
||||
_userData!['about'].toString().isNotEmpty)
|
||||
_buildInfoRow(
|
||||
Icons.info_outline_rounded,
|
||||
_userData!['about'],
|
||||
'О себе',
|
||||
true,
|
||||
),
|
||||
if (_userData!['phone'] != null &&
|
||||
_userData!['phone'].toString().isNotEmpty)
|
||||
_buildInfoRow(
|
||||
Icons.phone_android_rounded,
|
||||
_userData!['phone'],
|
||||
'Номер телефона',
|
||||
true,
|
||||
),
|
||||
if (_userData!['email'] != null &&
|
||||
_userData!['email'].toString().isNotEmpty)
|
||||
_buildInfoRow(
|
||||
Icons.mail_outline_rounded,
|
||||
_userData!['email'],
|
||||
'Электронная почта',
|
||||
true,
|
||||
),
|
||||
_buildInfoRow(
|
||||
Icons.key_rounded,
|
||||
_userData!['public_key'] ?? 'Отсутствует',
|
||||
'Публичный E2EE ключ',
|
||||
false,
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOnlineStatus() {
|
||||
if (_userData?['online'] == true) {
|
||||
return const Text(
|
||||
'В сети',
|
||||
style: TextStyle(
|
||||
color: Colors.green,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
// Получаем строку последнего онлайна из данных сервера
|
||||
final String? lastSeenStr = _userData?['last_online']?.toString();
|
||||
return Text(
|
||||
_formatLastSeen(lastSeenStr),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
fontSize: 13,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(
|
||||
IconData icon,
|
||||
String value,
|
||||
String label,
|
||||
bool showDivider, {
|
||||
int maxLines = 1,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 2,
|
||||
),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withOpacity(0.06),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: colorScheme.primary, size: 18),
|
||||
),
|
||||
title: Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
maxLines: maxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 12, color: colorScheme.outline),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 68,
|
||||
color: colorScheme.outlineVariant.withOpacity(0.15),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: const Text('Задать локальное имя'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: firstnameController,
|
||||
decoration: const InputDecoration(labelText: 'Имя'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: lastnameController,
|
||||
decoration: const InputDecoration(labelText: 'Фамилия'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Сбросить'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (result == true) {
|
||||
await prefs.setString(
|
||||
'firstname_${widget.userId}',
|
||||
firstnameController.text.trim(),
|
||||
);
|
||||
await prefs.setString(
|
||||
'lastname_${widget.userId}',
|
||||
lastnameController.text.trim(),
|
||||
);
|
||||
} else if (result == false) {
|
||||
await prefs.remove('firstname_${widget.userId}');
|
||||
await prefs.remove('lastname_${widget.userId}');
|
||||
}
|
||||
_loadUserData();
|
||||
}
|
||||
|
||||
void _handleIncomingMessage(Map<String, dynamic> data) {
|
||||
if (data['type'] == 'user_online' && data['user_id'] == widget.userId) {
|
||||
if (mounted) setState(() => _userData?['online'] = true);
|
||||
}
|
||||
if (data['type'] == 'user_offline' && data['user_id'] == widget.userId) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_userData?['online'] = false;
|
||||
_userData?['last_online'] = DateTime.now().toIso8601String();
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data['type'] == 'user_updated' && data['user_id'] == widget.userId) {
|
||||
_loadUserData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
import 'package:chepuhagram/domain/services/aPI_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '/data/models/contact_model.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:chepuhagram/data/models/message_model.dart';
|
||||
|
||||
class ContactTile extends StatefulWidget {
|
||||
final Contact contact;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
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 {
|
||||
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 (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 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: 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(
|
||||
displayName, //
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), //
|
||||
),
|
||||
subtitle: Text(
|
||||
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: TextStyle(color: Colors.grey), //
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(widget.contact.lastMessageTime), //
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12), //
|
||||
),
|
||||
SizedBox(height: 4), //
|
||||
if (widget.contact.unreadCount > 0) //
|
||||
Container(
|
||||
padding: EdgeInsets.all(6), //
|
||||
decoration: BoxDecoration(
|
||||
color: primary.withAlpha((0.5 * 255).round()), //
|
||||
shape: BoxShape.circle, //
|
||||
),
|
||||
child: Text(
|
||||
'${widget.contact.unreadCount}', //
|
||||
style: TextStyle(color: Colors.white, fontSize: 10), //
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime? time) {
|
||||
if (time == null) return "";
|
||||
return "${time.hour}:${time.minute.toString().padLeft(2, '0')}";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,7 +9,9 @@
|
|||
#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 <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||
#include <record_linux/record_linux_plugin.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
|
|
@ -22,9 +24,15 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||
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) flutter_webrtc_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
|
||||
flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_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) sqlite3_flutter_libs_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
|
||||
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_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);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||
audioplayers_linux
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
flutter_webrtc
|
||||
record_linux
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import firebase_messaging
|
|||
import flutter_image_compress_macos
|
||||
import flutter_local_notifications
|
||||
import flutter_secure_storage_darwin
|
||||
import flutter_webrtc
|
||||
import gal
|
||||
import local_auth_darwin
|
||||
import package_info_plus
|
||||
|
|
@ -23,6 +24,7 @@ import photo_manager
|
|||
import record_macos
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import sqlite3_flutter_libs
|
||||
import url_launcher_macos
|
||||
import video_compress
|
||||
import video_player_avfoundation
|
||||
|
|
@ -38,6 +40,7 @@ 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"))
|
||||
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
||||
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
|
|
@ -46,6 +49,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin"))
|
||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||
|
|
|
|||
344
pubspec.lock
344
pubspec.lock
|
|
@ -1,6 +1,14 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "96.0.0"
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -9,6 +17,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.35"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.0"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -97,30 +113,54 @@ 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:
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_platform_interface
|
||||
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
|
||||
name: build
|
||||
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
cached_network_image_web:
|
||||
dependency: transitive
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: cached_network_image_web
|
||||
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
||||
name: build_runner
|
||||
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
version: "2.15.0"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.6"
|
||||
camera:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -161,6 +201,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+3"
|
||||
camera_windows:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: camera_windows
|
||||
sha256: "35d85c9eb707f6f43d08b82e9aba7b5f0347428f6334fd5d68ae9bd1bc9262af"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.6+4"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -169,6 +217,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -249,6 +305,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.7"
|
||||
dart_webrtc:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_webrtc
|
||||
sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.8.1"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -273,6 +345,38 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.31.0"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.31.0"
|
||||
drift_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift_flutter
|
||||
sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.8"
|
||||
drift_sqflite:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift_sqflite
|
||||
sha256: dd1afbd72555b7a72ebf053926078d8c302059af4f1eb22040fc27a056429acb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
extended_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -462,14 +566,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
flutter_http_cache:
|
||||
flutter_callkit_incoming:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_http_cache
|
||||
sha256: "2227f5694d730622d6dad580b0e4fdfec6b5884868148101d13c61a09661fa78"
|
||||
name: flutter_callkit_incoming
|
||||
sha256: "3589deb8b71e43f2d520a9c8a5240243f611062a8b246cdca4b1fda01fbbf9b8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.3"
|
||||
version: "3.0.0"
|
||||
flutter_image_compress:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -546,26 +650,34 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
||||
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.2.4"
|
||||
version: "21.0.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
|
||||
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
version: "8.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
|
||||
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
version: "11.0.0"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -632,6 +744,14 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_webrtc:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_webrtc
|
||||
sha256: c7b0a67ca2c878575fc5c146d801cd874f58f5f1ef5fa6e8eb0c93d413beb948
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
gal:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -640,6 +760,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -664,6 +800,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -752,6 +896,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -872,6 +1024,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.11"
|
||||
logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logger
|
||||
sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -912,14 +1080,6 @@ 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:
|
||||
|
|
@ -1104,6 +1264,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.9.1"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1120,6 +1288,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubspec_parse:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pubspec_parse
|
||||
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
recase:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: recase
|
||||
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
record:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -1248,11 +1440,35 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.3"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1262,7 +1478,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqflite:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
|
||||
|
|
@ -1281,10 +1497,18 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "5e8377564d95166761a968ed96104e0569b6b6cc611faac92a36ab8a169112c3"
|
||||
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6+1"
|
||||
version: "2.5.8"
|
||||
sqflite_common_ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite_common_ffi
|
||||
sha256: "8d7b8749a516cbf6e9057f9b480b716ad14fc4f3d3873ca6938919cc626d9025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.7+1"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1301,6 +1525,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.42"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.43.1"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1361,10 +1609,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
version: "0.11.0"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1525,6 +1773,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.1.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1549,6 +1805,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webrtc_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webrtc_interface
|
||||
sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.1"
|
||||
wechat_assets_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -1599,4 +1863,4 @@ packages:
|
|||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.38.0"
|
||||
flutter: ">=3.38.1"
|
||||
|
|
|
|||
21
pubspec.yaml
21
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.2+1
|
||||
version: 2.0.3+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
|
@ -41,11 +41,10 @@ dependencies:
|
|||
jwt_decoder: ^2.0.1
|
||||
web_socket_channel: ^3.0.3
|
||||
cryptography: ^2.5.0
|
||||
sqflite: ^2.3.0
|
||||
path: ^1.9.0
|
||||
firebase_core: ^2.24.2
|
||||
firebase_messaging: ^14.7.10
|
||||
flutter_local_notifications: ^17.2.2
|
||||
flutter_local_notifications: ^21.0.0
|
||||
firebase_analytics: ^10.10.7
|
||||
shared_preferences: ^2.5.5
|
||||
flutter_linkify: ^6.0.0
|
||||
|
|
@ -56,13 +55,16 @@ dependencies:
|
|||
package_info_plus: ^9.0.1
|
||||
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
|
||||
drift: ^2.17.0
|
||||
drift_flutter: ^0.2.8
|
||||
sqlite3_flutter_libs: ^0.5.24
|
||||
sqflite_common_ffi: ^2.3.3
|
||||
drift_sqflite: ^2.0.0
|
||||
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
|
||||
|
|
@ -74,10 +76,15 @@ dependencies:
|
|||
record: ^6.2.0
|
||||
audioplayers: ^6.6.0
|
||||
ffmpeg_kit_flutter_new_min_gpl: ^2.1.1
|
||||
flutter_callkit_incoming: ^3.0.0
|
||||
flutter_webrtc: ^1.4.1
|
||||
camera_windows: ^0.2.6+4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
build_runner: ^2.4.0
|
||||
drift_dev: ^2.17.0
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
|
|
@ -90,6 +97,10 @@ dev_dependencies:
|
|||
|
||||
flutter_launcher_icons:
|
||||
android: "launcher_icon"
|
||||
windows:
|
||||
generate: true
|
||||
image_path: "assets/images/icon.png"
|
||||
icon_size: 48
|
||||
ios: true
|
||||
image_path: "assets/images/icon.png"
|
||||
remove_alpha_channel_ios: true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.db import models
|
||||
from app.api import schemas
|
||||
# Используем вашу функцию хэширования из security.py
|
||||
from app.core.security import get_current_user, get_password_hash
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
adminRouter = APIRouter(prefix="/admin", tags=["admin"], include_in_schema=False)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Зависимость: пропускает только аккаунт с ID 1
|
||||
|
||||
|
||||
def require_admin(current_user: models.User = Depends(get_current_user)):
|
||||
if current_user.id != 1:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Доступ запрещен. Требуются права супер-администратора.")
|
||||
return current_user
|
||||
|
||||
|
||||
@adminRouter.get("/users", response_model=List[schemas.AdminUserListItem])
|
||||
async def get_all_users(db: Session = Depends(get_db), admin: models.User = Depends(require_admin)):
|
||||
return db.query(models.User).all()
|
||||
|
||||
|
||||
@adminRouter.post("/users")
|
||||
async def admin_create_user(user_data: schemas.AdminCreateUser, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)):
|
||||
existing = db.query(models.User).filter(
|
||||
models.User.username == user_data.username).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Пользователь с таким именем уже существует")
|
||||
|
||||
if user_data.id is not None:
|
||||
existing_id = db.query(models.User).filter(
|
||||
models.User.id == user_data.id).first()
|
||||
if existing_id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Пользователь с таким ID уже существует")
|
||||
|
||||
hashed_pw = get_password_hash(user_data.password)
|
||||
|
||||
new_user = models.User(
|
||||
username=user_data.username,
|
||||
hashed_password=hashed_pw,
|
||||
first_name=user_data.first_name,
|
||||
last_name=user_data.last_name,
|
||||
is_blocked=0
|
||||
)
|
||||
if user_data.id is not None:
|
||||
new_user.id = user_data.id
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return {"status": "ok", "user_id": new_user.id}
|
||||
|
||||
|
||||
@adminRouter.post("/users/{user_id}/block")
|
||||
async def block_user(user_id: int, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)):
|
||||
if user_id == 1:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Нельзя заблокировать главного администратора")
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
user.is_blocked = 1
|
||||
db.commit()
|
||||
return {"status": "ok", "message": f"Пользователь {user_id} заблокирован"}
|
||||
|
||||
|
||||
@adminRouter.post("/users/{user_id}/unblock")
|
||||
async def unblock_user(user_id: int, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)):
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
user.is_blocked = 0
|
||||
db.commit()
|
||||
return {"status": "ok", "message": f"Пользователь {user_id} разблокирован"}
|
||||
|
||||
|
||||
@adminRouter.put("/users/{user_id}/profile")
|
||||
async def admin_update_user_profile(user_id: int, profile_data: schemas.UpdateMe, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)):
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
if profile_data.username is not None and profile_data.username != '':
|
||||
dup = db.query(models.User).filter(models.User.username ==
|
||||
profile_data.username, models.User.id != user_id).first()
|
||||
if dup:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Имя пользователя уже занято")
|
||||
user.username = profile_data.username
|
||||
|
||||
if profile_data.first_name is not None and profile_data.last_name != '':
|
||||
user.first_name = profile_data.first_name
|
||||
if profile_data.last_name is not None and profile_data.last_name != '':
|
||||
user.last_name = profile_data.last_name
|
||||
if profile_data.phone is not None and profile_data.phone != '':
|
||||
user.phone = profile_data.phone
|
||||
if profile_data.email is not None and profile_data.email != '':
|
||||
user.email = profile_data.email
|
||||
if profile_data.about is not None and profile_data.about != '':
|
||||
user.about = profile_data.about
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as e:
|
||||
db.rollback() # Обязательно откатываем транзакцию при ошибке
|
||||
raise HTTPException(status_code=400, detail="Ошибка: такой телефон или email уже используется другим аккаунтом")
|
||||
return {"status": "ok", "message": "Профиль успешно изменен администратором"}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from fastapi import FastAPI, Depends, HTTPException, status, APIRouter
|
||||
from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, Form
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core import security
|
||||
|
|
@ -11,6 +11,7 @@ import qrcode
|
|||
import base64
|
||||
from io import BytesIO
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import Optional
|
||||
# бд
|
||||
|
||||
|
||||
|
|
@ -67,7 +68,8 @@ async def register(password: str):
|
|||
|
||||
@authRouter.post("/login")
|
||||
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)}")
|
||||
print(
|
||||
f"Login attempt: username={data.username}, totp_code provided={bool(data.totp_code)}")
|
||||
|
||||
user = db.query(models.User).filter(
|
||||
models.User.username == data.username).first()
|
||||
|
|
@ -96,15 +98,51 @@ async def login(data: schemas.LoginRequest, db: Session = Depends(get_db)):
|
|||
}
|
||||
|
||||
|
||||
@authRouter.post("/login-oauth")
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)):
|
||||
totp_code = form_data.client_secret if form_data.client_secret else None
|
||||
print(
|
||||
f"Login attempt: username={form_data}, totp_code provided={bool(totp_code)}")
|
||||
|
||||
|
||||
user = db.query(models.User).filter(
|
||||
models.User.username == form_data.username).first()
|
||||
|
||||
if not user or not security.verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверный логин или пароль",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if user.totp_secret:
|
||||
if not totp_code:
|
||||
raise HTTPException(status_code=400, detail="TOTP код требуется")
|
||||
totp = pyotp.TOTP(user.totp_secret)
|
||||
if not totp.verify(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 {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
"user_id": user.id
|
||||
}
|
||||
|
||||
|
||||
@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()
|
||||
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 уже включен")
|
||||
# if user.totp_secret:
|
||||
# raise HTTPException(status_code=400, detail="TOTP уже включен")
|
||||
|
||||
secret = pyotp.random_base32()
|
||||
user.totp_temp_secret = secret
|
||||
|
|
@ -127,7 +165,8 @@ async def enable_totp(current_user: models.User = Depends(get_current_user), db:
|
|||
@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()
|
||||
user = db.query(models.User).filter(
|
||||
models.User.id == current_user.id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
||||
|
||||
|
|
@ -138,7 +177,8 @@ async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User
|
|||
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}")
|
||||
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
|
||||
|
|
@ -151,12 +191,14 @@ async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User
|
|||
raise
|
||||
except Exception as e:
|
||||
print(f"TOTP verify error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка верификации: {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()
|
||||
user = db.query(models.User).filter(
|
||||
models.User.id == current_user.id).first()
|
||||
if user:
|
||||
user.totp_secret = None
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -1,584 +1,412 @@
|
|||
import shutil
|
||||
from fastapi import Depends, FastAPI, HTTPException, status, APIRouter, File, UploadFile, Request, Form
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
import io
|
||||
import os
|
||||
import uuid
|
||||
import urllib.parse
|
||||
from fastapi import Depends, HTTPException, status, APIRouter, File, UploadFile, Form
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql import func
|
||||
from google.oauth2.credentials import Credentials
|
||||
import asyncio
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.http import MediaIoBaseUpload, MediaIoBaseDownload
|
||||
|
||||
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 _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:
|
||||
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=['media'],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Инициализация клиента Google Drive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ensure_directory(UPLOAD_FOLDER)
|
||||
_ensure_directory(config.CLOUD_MEDIA_CACHE_FOLDER)
|
||||
_ensure_directory(config.HOME_MEDIA_FOLDER)
|
||||
|
||||
def _get_drive_service():
|
||||
try:
|
||||
credentials = Credentials(
|
||||
token=None,
|
||||
refresh_token=config.GOOGLE_REFRESH_TOKEN,
|
||||
token_uri='https://oauth2.googleapis.com/token',
|
||||
client_id=config.GOOGLE_CLIENT_ID,
|
||||
client_secret=config.GOOGLE_CLIENT_SECRET,
|
||||
scopes=['https://www.googleapis.com/auth/drive']
|
||||
)
|
||||
|
||||
return build('drive', 'v3', credentials=credentials)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to initialize Google Drive service: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Контроль квоты пользователя (с удалением старых файлов)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _cleanup_google_drive_quota(db: Session, owner_id: int | None, new_file_size: int):
|
||||
"""
|
||||
Проверяет квоту пользователя (из .env). Если лимит превышен,
|
||||
автоматически удаляет самые старые файлы из Google Drive и таблицы media_items,
|
||||
пока не освободится достаточно места для нового файла.
|
||||
"""
|
||||
if owner_id is None:
|
||||
return
|
||||
|
||||
# Получаем лимит квоты из конфигурации приложения (.env)
|
||||
user_quota = getattr(config, "HOME_USER_QUOTA_BYTES",
|
||||
10737418240) # 10 ГБ по умолчанию
|
||||
|
||||
user = db.query(models.User).filter(models.User.id == owner_id).first()
|
||||
active_avatar_id = user.avatar_file_id if user else None
|
||||
|
||||
sum_query = db.query(func.sum(models.MediaItem.size_bytes)).filter(
|
||||
models.MediaItem.owner_id == owner_id
|
||||
)
|
||||
if active_avatar_id:
|
||||
sum_query = sum_query.filter(models.MediaItem.file_id != active_avatar_id)
|
||||
|
||||
total_used = sum_query.scalar() or 0
|
||||
total_used = int(total_used)
|
||||
|
||||
# Если вместе с новым файлом мы укладываемся в квоту — чистка не требуется
|
||||
if total_used + new_file_size <= user_quota:
|
||||
return
|
||||
|
||||
# 2. Если места не хватает, выбираем файлы пользователя от старых к новым
|
||||
files = db.query(models.MediaItem).filter(
|
||||
models.MediaItem.owner_id == owner_id
|
||||
).order_by(models.MediaItem.created_at.asc()).all()
|
||||
|
||||
service = _get_drive_service()
|
||||
|
||||
for file_record in files:
|
||||
# Удаляем старые файлы до тех пор, пока новый файл не поместится в квоту
|
||||
if total_used + new_file_size <= user_quota:
|
||||
break
|
||||
|
||||
# Физическое удаление файла из Google Drive
|
||||
try:
|
||||
drive_id = file_record.storage_file_id
|
||||
service.files().delete(fileId=drive_id).execute()
|
||||
except Exception:
|
||||
# Если файл уже удален из Google Drive вручную, игнорируем ошибку
|
||||
pass
|
||||
|
||||
# Корректируем счетчик занятого места и удаляем запись из БД
|
||||
total_used -= file_record.size_bytes
|
||||
db.delete(file_record)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Эндпоинты: Загрузка (Upload)
|
||||
# ---------------------------------------------------------------------------
|
||||
class SeekableFastAPIStream(io.RawIOBase):
|
||||
"""
|
||||
Обертка над синхронным внутренним файлом FastAPI.
|
||||
Удовлетворяет требованиям Google SDK по наличию методов seek/tell.
|
||||
"""
|
||||
|
||||
def __init__(self, raw_file):
|
||||
self.raw_file = raw_file
|
||||
self._position = 0
|
||||
|
||||
def readinto(self, b):
|
||||
# file.file.read в FastAPI работает СИНХРОННО
|
||||
chunk = self.raw_file.read(len(b))
|
||||
if not chunk:
|
||||
return 0
|
||||
|
||||
n = len(chunk)
|
||||
b[:n] = chunk
|
||||
self._position += n
|
||||
return n
|
||||
|
||||
def seek(self, offset, whence=io.SEEK_SET):
|
||||
if whence == io.SEEK_SET and offset == 0:
|
||||
try:
|
||||
self.raw_file.seek(0)
|
||||
except Exception:
|
||||
pass
|
||||
self._position = 0
|
||||
return 0
|
||||
elif whence == io.SEEK_CUR:
|
||||
return self._position
|
||||
elif whence == io.SEEK_END:
|
||||
return self._position
|
||||
return self._position
|
||||
|
||||
def tell(self):
|
||||
return self._position
|
||||
|
||||
def seekable(self):
|
||||
return True
|
||||
|
||||
def readable(self):
|
||||
return True
|
||||
|
||||
|
||||
@mediaRouter.post('/upload')
|
||||
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')
|
||||
|
||||
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:
|
||||
f.write(content)
|
||||
|
||||
return {
|
||||
'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'),
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
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:
|
||||
"""
|
||||
Загружает файл на Google Drive в режиме стриминга без блокировки RAM.
|
||||
"""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail='No selected file')
|
||||
|
||||
content = await uploaded_file.read()
|
||||
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
|
||||
max_upload_size = getattr(config, "MEDIA_UPLOAD_MAX_BYTES", 52428800)
|
||||
file_size = file.size
|
||||
|
||||
if file_size and file_size > max_upload_size:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
|
||||
status_code=400,
|
||||
detail=f'File too large (max {max_upload_size} 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.',
|
||||
)
|
||||
if file_size:
|
||||
_cleanup_google_drive_quota(db, current_user.id, file_size)
|
||||
|
||||
service = _get_drive_service()
|
||||
|
||||
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)
|
||||
file_metadata = {
|
||||
'name': f"{file_id}.enc",
|
||||
'parents': [config.GOOGLE_DRIVE_FOLDER_ID]
|
||||
}
|
||||
|
||||
item = models.CloudMediaItem(
|
||||
# Передаем file.file (синхронный поток FastAPI)
|
||||
stream = SeekableFastAPIStream(file.file)
|
||||
|
||||
# 1. Задаем размер чанка (строго кратен 256 КБ).
|
||||
# Если файлы тяжелые, можно увеличить до 5 * 1024 * 1024 (5 МБ) для стабильности SSL
|
||||
chunk_size = 5 * 1024 * 1024 # 1 МБ
|
||||
|
||||
# 2. Инициализируем медиа-загрузчик
|
||||
# Если file_size известен (из заголовков), обязательно передаем его Google.
|
||||
# Если не передан (None), Google будет ждать закрывающий чанк, что часто вызывает сброс SSL.
|
||||
media = MediaIoBaseUpload(
|
||||
stream,
|
||||
mimetype=file.content_type or 'application/octet-stream',
|
||||
chunksize=chunk_size,
|
||||
resumable=True
|
||||
)
|
||||
if file_size:
|
||||
media._size = file_size
|
||||
|
||||
# 3. Переписываем логику выполнения загрузки.
|
||||
# Вместо одного слепого вызова .execute() мы инициализируем запрос
|
||||
# и пошагово отправляем чанки. Это предотвращает таймауты SSL-сессии.
|
||||
def _execute_resumable_upload():
|
||||
request = service.files().create(
|
||||
body=file_metadata,
|
||||
media_body=media,
|
||||
fields='id,size',
|
||||
supportsAllDrives=True
|
||||
)
|
||||
|
||||
response = None
|
||||
retries = 0
|
||||
max_retries = 3
|
||||
|
||||
while response is None:
|
||||
try:
|
||||
status, response = request.next_chunk()
|
||||
if status:
|
||||
print(f"Uploaded {int(status.progress() * 100)}%...")
|
||||
retries = 0 # Сбрасываем попытки при успешном чанке
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
print(
|
||||
f"Ошибка при загрузке чанка: {e}. Попытка {retries} из {max_retries}")
|
||||
if retries >= max_retries:
|
||||
raise e # Если интернет совсем пропал — падаем окончательно
|
||||
import time
|
||||
time.sleep(1) # Ждем секунду перед повторной попыткой
|
||||
|
||||
return response
|
||||
|
||||
# Запускаем пошаговый стриминг в пуле потоков
|
||||
drive_file = await asyncio.to_thread(_execute_resumable_upload)
|
||||
drive_id = drive_file.get('id')
|
||||
|
||||
final_size = int(drive_file.get('size', 0)
|
||||
) if file_size is None else file_size
|
||||
|
||||
media_item = models.MediaItem(
|
||||
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,
|
||||
original_filename=file.filename,
|
||||
content_type=file.content_type or 'application/octet-stream',
|
||||
storage_file_id=drive_id,
|
||||
size_bytes=final_size,
|
||||
)
|
||||
db.add(item)
|
||||
db.add(media_item)
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Upload operation failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Upload operation failed: {str(e)}"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
await file.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}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Эндпоинты: Получение метаданных и скачивание (Size / Download)
|
||||
# ---------------------------------------------------------------------------
|
||||
@mediaRouter.get('/size/{file_id}')
|
||||
async def get_file_size(file_id: str):
|
||||
"""
|
||||
Возвращает информацию о размере и типе файла из таблицы media_items.
|
||||
"""
|
||||
db = models.SessionLocal()
|
||||
db_file = None
|
||||
try:
|
||||
db_file = db.query(models.HomeMediaFile).filter(
|
||||
models.HomeMediaFile.file_id == file_id).first()
|
||||
db_file = db.query(models.MediaItem).filter(
|
||||
models.MediaItem.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
|
||||
if not db_file:
|
||||
raise HTTPException(status_code=404, detail='File not found')
|
||||
|
||||
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')
|
||||
encoded_filename = urllib.parse.quote(db_file.original_filename)
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"size": db_file.size_bytes,
|
||||
"file_name": encoded_filename,
|
||||
"content_type": db_file.content_type
|
||||
}
|
||||
|
||||
|
||||
@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()
|
||||
db_file = db.query(models.MediaItem).filter(
|
||||
models.MediaItem.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)
|
||||
|
||||
if not db_file:
|
||||
raise HTTPException(status_code=404, detail='File not found')
|
||||
|
||||
drive_id = db_file.storage_file_id
|
||||
|
||||
try:
|
||||
service = _get_drive_service()
|
||||
request = service.files().get_media(fileId=drive_id)
|
||||
|
||||
async def _async_stream_drive_file():
|
||||
fh = io.BytesIO()
|
||||
downloader = MediaIoBaseDownload(
|
||||
fh, request, chunksize=1024 * 1024)
|
||||
done = False
|
||||
last_position = 0
|
||||
|
||||
while not done:
|
||||
# Оборачиваем синхронный сетевой запрос Google SDK в asyncio.to_thread
|
||||
status, done = await asyncio.to_thread(downloader.next_chunk)
|
||||
|
||||
fh.seek(last_position)
|
||||
chunk = fh.read()
|
||||
if chunk:
|
||||
yield chunk
|
||||
last_position = fh.tell()
|
||||
|
||||
encoded_filename = urllib.parse.quote(db_file.original_filename)
|
||||
headers = {
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
}
|
||||
|
||||
return FileResponse(
|
||||
local_path,
|
||||
media_type=content_type,
|
||||
# StreamingResponse отлично работает с async генераторами
|
||||
return StreamingResponse(
|
||||
_async_stream_drive_file(),
|
||||
media_type=db_file.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),
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Error fetching file from Google Drive: {str(e)}"
|
||||
)
|
||||
db.add(new_record)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {"status": "ok", "new_file_id": new_file_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Эндпоинты: Копирование файлов (Copy)
|
||||
# ---------------------------------------------------------------------------
|
||||
@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'
|
||||
)
|
||||
|
||||
"""
|
||||
Копирует файл внутри Google Drive новому пользователю с валидацией его личной квоты.
|
||||
"""
|
||||
db = models.SessionLocal()
|
||||
old_record = None
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
if response.status == 200:
|
||||
import json
|
||||
return json.loads(response.read().decode('utf-8'))
|
||||
old_record = db.query(models.MediaItem).filter(
|
||||
models.MediaItem.file_id == file_id
|
||||
).first()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not old_record:
|
||||
raise HTTPException(status_code=404, detail='Source file not found')
|
||||
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
# Проверяем квоту для получателя копии (и удаляем его старые файлы при необходимости)
|
||||
_cleanup_google_drive_quota(db, current_user.id, old_record.size_bytes)
|
||||
|
||||
new_file_id = uuid.uuid4().hex
|
||||
service = _get_drive_service()
|
||||
|
||||
def _execute_copy():
|
||||
return service.files().copy(
|
||||
fileId=old_record.storage_file_id,
|
||||
body={'name': f"{new_file_id}.enc"},
|
||||
fields='id'
|
||||
).execute()
|
||||
|
||||
drive_file = await asyncio.to_thread(_execute_copy)
|
||||
new_drive_id = drive_file.get('id')
|
||||
|
||||
# Сохранение информации о скопированном файле
|
||||
new_record = models.MediaItem(
|
||||
file_id=new_file_id,
|
||||
owner_id=current_user.id,
|
||||
original_filename=old_record.original_filename,
|
||||
content_type=old_record.content_type,
|
||||
storage_file_id=new_drive_id,
|
||||
size_bytes=old_record.size_bytes,
|
||||
)
|
||||
db.add(new_record)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f'Failed to copy on home server: {e}')
|
||||
status_code=500, detail=f"Copy operation failed: {str(e)}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
raise HTTPException(status_code=500, detail='Copying failed')
|
||||
return {"status": "ok", "new_file_id": new_file_id}
|
||||
|
|
|
|||
|
|
@ -22,28 +22,50 @@ def get_db():
|
|||
db.close()
|
||||
|
||||
|
||||
def _get_drive_service_for_users():
|
||||
credentials = Credentials(
|
||||
token=None,
|
||||
refresh_token=config.GOOGLE_REFRESH_TOKEN,
|
||||
token_uri='https://oauth2.googleapis.com/token',
|
||||
client_id=config.GOOGLE_CLIENT_ID,
|
||||
client_secret=config.GOOGLE_CLIENT_SECRET,
|
||||
scopes=['https://www.googleapis.com/auth/drive']
|
||||
)
|
||||
return build('drive', 'v3', credentials=credentials)
|
||||
|
||||
# Полностью обновленная функция удаления старых аватарок
|
||||
|
||||
|
||||
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,
|
||||
# Ищем старую аватарку в базе данных
|
||||
cloud_items = db.query(models.MediaItem).filter(
|
||||
models.MediaItem.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()
|
||||
|
||||
if not cloud_items:
|
||||
return
|
||||
|
||||
try:
|
||||
service = _get_drive_service_for_users()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Не удалось инициализировать Google Drive для удаления аватарки: {e}")
|
||||
return
|
||||
|
||||
for item in cloud_items:
|
||||
# 1. Удаляем физический файл из Google Drive
|
||||
try:
|
||||
service.files().delete(fileId=item.storage_file_id).execute()
|
||||
print(
|
||||
f"Старая аватарка {item.storage_file_id} успешно удалена из Google Drive")
|
||||
except Exception as e:
|
||||
print(f"Ошибка физического удаления аватарки из Google Drive: {e}")
|
||||
|
||||
# 2. Удаляем запись из базы данных (это возвращает место в квоту пользователя)
|
||||
db.delete(item)
|
||||
|
||||
db.commit()
|
||||
print(f"Старая аватарка {file_id} успешно удалена из базы данных")
|
||||
|
||||
usersRouter = APIRouter(
|
||||
prefix="/users",
|
||||
|
|
@ -207,7 +229,6 @@ async def get_privacy_settings(current_user: models.User = Depends(get_current_u
|
|||
}
|
||||
|
||||
|
||||
|
||||
@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()
|
||||
|
|
@ -299,10 +320,10 @@ 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()
|
||||
user = db.query(models.User).filter(
|
||||
models.User.username == username).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
|
|
|||
|
|
@ -81,3 +81,23 @@ class UserContactResponse(BaseModel):
|
|||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class AdminUserListItem(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
first_name: str
|
||||
last_name: Optional[str] = None
|
||||
is_blocked: int
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
about: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class AdminCreateUser(BaseModel):
|
||||
id: Optional[int] = None
|
||||
username: str
|
||||
password: str
|
||||
first_name: str
|
||||
last_name: Optional[str] = None
|
||||
|
|
@ -15,18 +15,24 @@ class Config:
|
|||
# Firebase
|
||||
FIREBASE_CREDENTIALS_PATH: str = os.getenv("FIREBASE_CREDENTIALS_PATH", "chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json")
|
||||
|
||||
# Google Cloud / Google Drive Integration
|
||||
# Путь к скачанному JSON-ключу вашего сервисного аккаунта Google
|
||||
GOOGLE_APPLICATION_CREDENTIALS: str = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", "google-credentials.json")
|
||||
GOOGLE_DRIVE_FOLDER_ID: str = os.getenv("GOOGLE_DRIVE_FOLDER_ID", None)
|
||||
GOOGLE_REFRESH_TOKEN: str = os.getenv("GOOGLE_REFRESH_TOKEN", None)
|
||||
GOOGLE_CLIENT_ID: str = os.getenv("GOOGLE_CLIENT_ID", None)
|
||||
GOOGLE_CLIENT_SECRET: str = os.getenv("GOOGLE_CLIENT_SECRET", None)
|
||||
|
||||
|
||||
# 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)))
|
||||
PORT: int = int(os.getenv("PORT", "8587"))
|
||||
|
||||
# Media Storage & Quotas (Google Drive)
|
||||
# 10 ГБ лимита на пользователя (при превышении удаляются старые медиафайлы)
|
||||
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"))
|
||||
# Максимальный размер одного загружаемого файла (500 МБ)
|
||||
MEDIA_UPLOAD_MAX_BYTES: int = int(os.getenv("MEDIA_UPLOAD_MAX_BYTES", str(500 * 1024 * 1024)))
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
|
||||
|
|
|
|||
|
|
@ -18,9 +18,16 @@ ALGORITHM = "HS256"
|
|||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login-oauth", description=(
|
||||
"### Инструкция по авторизации\n\n"
|
||||
"1. Введите ваш **username** и **password**.\n"
|
||||
"2. В поле **client_secret** введите текущий **6-значный код TOTP**, если он подключен\n"
|
||||
"3. Поле *client_id* оставьте пустым."
|
||||
))
|
||||
|
||||
# бд
|
||||
|
||||
|
||||
def get_db():
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
|
|
@ -28,21 +35,25 @@ def get_db():
|
|||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
try:
|
||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password)
|
||||
except TypeError:
|
||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
||||
|
||||
|
||||
def get_password_hash(password):
|
||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
|
||||
|
||||
|
||||
def create_access_token(data: dict):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(data: dict):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
|
|
@ -50,6 +61,8 @@ def create_refresh_token(data: dict):
|
|||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
# проверка токена
|
||||
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -67,8 +80,11 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
|
|||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if getattr(user, "is_blocked", 0) == 1:
|
||||
raise HTTPException(status_code=403, detail="Ваш аккаунт заблокирован")
|
||||
return user
|
||||
|
||||
|
||||
async def test_token(token: str):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,22 @@
|
|||
from sqlalchemy import Column, Integer, String, Sequence, create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime
|
||||
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime, BigInteger
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy import text
|
||||
from app.core.config import config
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = config.DATABASE_URL
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={
|
||||
"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id = Column(Integer, Sequence('user_id_seq', start=100), primary_key=True, index=True)
|
||||
id = Column(Integer, Sequence('user_id_seq', start=100),
|
||||
primary_key=True, index=True)
|
||||
first_name = Column(String(50), nullable=False, server_default="User")
|
||||
last_name = Column(String(50), nullable=True)
|
||||
username = Column(String, unique=True, index=True)
|
||||
|
|
@ -21,7 +24,8 @@ 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
|
||||
# Temporary secret until verified
|
||||
totp_temp_secret = Column(String(32), nullable=True)
|
||||
hashed_password = Column(String)
|
||||
public_key = Column(String, nullable=True)
|
||||
encrypted_private_key = Column(String, nullable=True)
|
||||
|
|
@ -29,13 +33,17 @@ class User(Base):
|
|||
avatar_file_id = Column(String, nullable=True)
|
||||
|
||||
# Privacy settings
|
||||
show_email = Column(Integer, nullable=False, server_default="1") # 1 = true, 0 = false
|
||||
show_email = Column(Integer, nullable=False,
|
||||
server_default="1") # 1 = true, 0 = false
|
||||
show_phone = Column(Integer, nullable=False, server_default="1")
|
||||
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())
|
||||
last_online = Column(DateTime(timezone=True),
|
||||
server_default=func.now(), onupdate=func.now())
|
||||
is_blocked = Column(Integer, default=0, server_default="0")
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
|
@ -53,34 +61,30 @@ class Message(Base):
|
|||
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"
|
||||
class MediaItem(Base):
|
||||
__tablename__ = "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)
|
||||
storage_filename = Column(String, nullable=False)
|
||||
size_bytes = Column(Integer, nullable=False)
|
||||
# Уникальный внутренний UUID файла (используется в эндпоинтах API)
|
||||
file_id = Column(String(32), unique=True, nullable=False, index=True)
|
||||
# ID владельца из таблицы пользователей
|
||||
owner_id = Column(Integer, ForeignKey(
|
||||
"users.id"), nullable=True, index=True)
|
||||
# Оригинальное имя файла (например, "photo.jpg")
|
||||
original_filename = Column(String(255), nullable=True)
|
||||
# MIME-тип файла (например, "image/jpeg")
|
||||
content_type = Column(String(100), nullable=True)
|
||||
# ID файла внутри Google Drive (заменяет старый storage_filename)
|
||||
storage_file_id = Column(String(255), nullable=False)
|
||||
# Размер файла в байтах (BigInteger обязателен для поддержки квот > 2 ГБ)
|
||||
size_bytes = Column(BigInteger, 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())
|
||||
updated_at = Column(DateTime(timezone=True),
|
||||
server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
|
@ -93,21 +97,29 @@ def _ensure_sqlite_message_columns():
|
|||
existing = {row[1] for row in cols} # row[1] = name
|
||||
|
||||
if "delivered_at" not in existing:
|
||||
conn.execute(text("ALTER TABLE messages ADD COLUMN delivered_at DATETIME"))
|
||||
conn.execute(
|
||||
text("ALTER TABLE messages ADD COLUMN delivered_at DATETIME"))
|
||||
if "read_at" not in existing:
|
||||
conn.execute(text("ALTER TABLE messages ADD COLUMN read_at DATETIME"))
|
||||
conn.execute(
|
||||
text("ALTER TABLE messages ADD COLUMN read_at DATETIME"))
|
||||
if "reply_to_id" not in existing:
|
||||
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_id INTEGER REFERENCES messages(id)"))
|
||||
conn.execute(text(
|
||||
"ALTER TABLE messages ADD COLUMN reply_to_id INTEGER REFERENCES messages(id)"))
|
||||
if "reply_to_text" not in existing:
|
||||
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT"))
|
||||
conn.execute(
|
||||
text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT"))
|
||||
if "edited_at" not in existing:
|
||||
conn.execute(text("ALTER TABLE messages ADD COLUMN edited_at DATETIME"))
|
||||
conn.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"))
|
||||
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)"))
|
||||
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.execute(
|
||||
text("ALTER TABLE messages ADD COLUMN encrypted_key VARCHAR(1024)"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
|
@ -122,28 +134,43 @@ def _ensure_sqlite_user_columns():
|
|||
if "about" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN about TEXT"))
|
||||
if "phone" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN phone VARCHAR(20)"))
|
||||
conn.execute(
|
||||
text("ALTER TABLE users ADD COLUMN phone VARCHAR(20)"))
|
||||
if "email" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR(255)"))
|
||||
conn.execute(
|
||||
text("ALTER TABLE users ADD COLUMN email VARCHAR(255)"))
|
||||
if "show_email" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN show_email INTEGER DEFAULT 1"))
|
||||
conn.execute(
|
||||
text("ALTER TABLE users ADD COLUMN show_email INTEGER DEFAULT 1"))
|
||||
if "show_phone" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN show_phone INTEGER DEFAULT 1"))
|
||||
conn.execute(
|
||||
text("ALTER TABLE users ADD COLUMN show_phone INTEGER DEFAULT 1"))
|
||||
if "show_avatar" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN show_avatar INTEGER DEFAULT 1"))
|
||||
conn.execute(
|
||||
text("ALTER TABLE users ADD COLUMN show_avatar INTEGER DEFAULT 1"))
|
||||
if "show_about" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN show_about INTEGER DEFAULT 1"))
|
||||
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"))
|
||||
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"))
|
||||
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')"))
|
||||
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)"))
|
||||
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.execute(
|
||||
text("ALTER TABLE users ADD COLUMN totp_temp_secret VARCHAR(32)"))
|
||||
if "is_blocked" not in existing:
|
||||
conn.execute(
|
||||
text("ALTER TABLE users ADD COLUMN is_blocked INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from app.db import models
|
|||
from firebase_admin import messaging, credentials, exceptions
|
||||
import firebase_admin
|
||||
from app.core.config import config
|
||||
import uuid
|
||||
|
||||
cred = credentials.Certificate(config.FIREBASE_CREDENTIALS_PATH)
|
||||
firebase_admin.initialize_app(cred)
|
||||
|
|
@ -53,11 +54,12 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
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")
|
||||
synchronize_session="fetch")
|
||||
db.commit()
|
||||
|
||||
if message_data.get("type") == "private_message":
|
||||
|
|
@ -157,7 +159,8 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
# Пересылаем получателю, если он в сети
|
||||
sent_to_receiver = await manager.send_personal_message(outgoing_message, str(receiver_id))
|
||||
|
||||
print(f"DEBUG send_personal_message returned: {sent_to_receiver}")
|
||||
print(
|
||||
f"DEBUG send_personal_message returned: {sent_to_receiver}")
|
||||
|
||||
# Если сообщение реально ушло по сокету получателю — отмечаем delivered_at.
|
||||
if sent_to_receiver:
|
||||
|
|
@ -302,6 +305,32 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
"type": "stop_typing",
|
||||
"sender_id": user_id,
|
||||
}, str(receiver_id))
|
||||
|
||||
# 1. Инициация звонка (Клиент А -> Сервер -> Клиент Б)
|
||||
if message_data.get("type") == "call_init":
|
||||
receiver_id = message_data.get("receiver_id")
|
||||
# Генерируем UUID на сервере
|
||||
call_id = str(uuid.uuid4())
|
||||
|
||||
# Сообщаем Клиенту Б, что ему звонят
|
||||
call_data = {
|
||||
"type": "call_init",
|
||||
"call_id": call_id,
|
||||
"caller_username": message_data.get("caller_username"),
|
||||
"caller_id": str(user_id)
|
||||
}
|
||||
|
||||
# Отправляем Клиенту Б
|
||||
sent = await manager.send_personal_message(call_data, str(receiver_id))
|
||||
|
||||
# Возвращаем Клиенту А подтверждение с созданным ID
|
||||
if sent:
|
||||
await manager.send_personal_message({"type": "call_created", "call_id": call_id}, str(user_id))
|
||||
# 2. Обработка сигналов (offer, answer, ice_candidate)
|
||||
elif message_data.get("type") in ["offer", "answer", "ice_candidate", "hangup", "decline"]:
|
||||
receiver_id = message_data.get("receiver_id")
|
||||
# Просто прокидываем сообщение дальше
|
||||
await manager.send_personal_message(message_data, str(receiver_id))
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
|
|
@ -0,0 +1,17 @@
|
|||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
|
||||
SCOPES = ['https://www.googleapis.com/auth/drive']
|
||||
|
||||
flow = InstalledAppFlow.from_client_secrets_file(
|
||||
'client_secret_338589490139-9ocvlhs270l5hqj3sdrru14ampiacv0s.apps.googleusercontent.com.json',
|
||||
SCOPES
|
||||
)
|
||||
|
||||
creds = flow.run_local_server(port=8000)
|
||||
|
||||
print("REFRESH TOKEN:")
|
||||
print(creds.refresh_token)
|
||||
print("CLIENT ID:")
|
||||
print(creds.client_id)
|
||||
print("CLIENT SECRET:")
|
||||
print(creds.client_secret)
|
||||
38
srv/main.py
38
srv/main.py
|
|
@ -1,6 +1,6 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from app.api.endpoints import users, auth, messages, media
|
||||
from app.api.endpoints import users, auth, messages, media, admin
|
||||
from app.websocket.connection_manager import wsRouter
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import os
|
||||
|
|
@ -15,6 +15,7 @@ app.include_router(users.usersRouter)
|
|||
app.include_router(messages.messagesRouter)
|
||||
app.include_router(media.mediaRouter)
|
||||
app.include_router(wsRouter)
|
||||
app.include_router(admin.adminRouter)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
|
@ -28,7 +29,7 @@ app.add_middleware(
|
|||
@app.get("/check-update")
|
||||
async def check_update():
|
||||
return {
|
||||
"latest_version": "2.0.1",
|
||||
"latest_version": "2.0.2",
|
||||
"apk_url": "https://api.chepuhagram.ru/get-update",
|
||||
"force_update": False
|
||||
}
|
||||
|
|
@ -53,39 +54,6 @@ async def head_image():
|
|||
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
|
||||
uvicorn.run(app, host="0.0.0.0", port=8587)
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 MiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
|
|
@ -7,24 +7,31 @@
|
|||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||
#include <camera_windows/camera_windows.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 <flutter_webrtc/flutter_web_r_t_c_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 <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||
CameraWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("CameraWindows"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
FlutterWebRTCPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
|
||||
GalPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GalPluginCApi"));
|
||||
LocalAuthPluginRegisterWithRegistrar(
|
||||
|
|
@ -33,6 +40,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,21 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_windows
|
||||
camera_windows
|
||||
file_selector_windows
|
||||
firebase_core
|
||||
flutter_secure_storage_windows
|
||||
flutter_webrtc
|
||||
gal
|
||||
local_auth_windows
|
||||
permission_handler_windows
|
||||
record_windows
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
flutter_local_notifications_windows
|
||||
jni
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -89,13 +89,13 @@ BEGIN
|
|||
BEGIN
|
||||
BLOCK "040904e4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "com.example" "\0"
|
||||
VALUE "FileDescription", "chepuhagram" "\0"
|
||||
VALUE "CompanyName", "ArturKarasevich" "\0"
|
||||
VALUE "FileDescription", "Chepuhagram" "\0"
|
||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||
VALUE "InternalName", "chepuhagram" "\0"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "chepuhagram.exe" "\0"
|
||||
VALUE "ProductName", "chepuhagram" "\0"
|
||||
VALUE "InternalName", "Chepuhagram" "\0"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2026 ArturKarasevich. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "Chepuhagram.exe" "\0"
|
||||
VALUE "ProductName", "Chepuhagram" "\0"
|
||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||
END
|
||||
END
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
|||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
if (!window.Create(L"chepuhagram", origin, size)) {
|
||||
if (!window.Create(L"Chepuhagram", origin, size)) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
window.SetQuitOnClose(true);
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 3.0 KiB |
Loading…
Reference in New Issue