diff --git a/.gitignore b/.gitignore index ac9dacf..f9c007f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..96aae64 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "cmake.sourceDirectory": "D:/FlutterProjects/chepuhagram/linux", + "chat.tools.terminal.autoApprove": { + "flutter": true + } +} \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4e0e044..60af13f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") } \ No newline at end of file diff --git a/lib/data/datasources/local_db_service.dart b/lib/data/datasources/local_db_service.dart index 3d69db7..c6612ec 100644 --- a/lib/data/datasources/local_db_service.dart +++ b/lib/data/datasources/local_db_service.dart @@ -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 get database async { - if (_database != null) return _database!; - _database = await _initDb(); - return _database!; + @override + Set get primaryKey => {fileId}; +} + +@DriftDatabase(tables: [Messages, FileNameMappings]) +class LocalDbService extends _$LocalDbService { + factory LocalDbService() => instance; + + LocalDbService._internal() : super(_openConnection()) { + print('LocalDbService constructor called'); } - Future _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 _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 clearDatabase() async { - final db = await database; - await db.delete('messages'); + await delete(messages).go(); } - Future saveMessages(List messages) async { - final db = await database; - final List incomingIds = messages.map((msg) { - return (msg is MessageModel) ? msg.id! : (msg['id'] as int); + Future saveMessages(List messageList) async { + if (messageList.isEmpty) return; + + final List incomingIds = messageList + .map( + (msg) => (msg is MessageModel) + ? msg.id + : (msg['id'] == null ? null : int.tryParse(msg['id'].toString())), + ) + .whereType() + .toList(); + + // Преобразуем входящие данные в компаньоны заранее + final companions = messageList.map((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>> 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 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?> 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 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 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 saveOriginalFileNameForFileId( + String fileId, + String fileName, + ) async { + await into(fileNameMappings).insertOnConflictUpdate( + FileNameMappingsCompanion( + fileId: Value(fileId), + originalFileName: Value(fileName), + ), + ); + } + + Future getOriginalFileNameForFileId(String fileId) async { + final query = select(fileNameMappings) + ..where((tbl) => tbl.fileId.equals(fileId)); + final result = await query.getSingleOrNull(); + return result?.originalFileName; + } + Future 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'); +} diff --git a/lib/data/datasources/local_db_service.g.dart b/lib/data/datasources/local_db_service.g.dart new file mode 100644 index 0000000..37aee5f --- /dev/null +++ b/lib/data/datasources/local_db_service.g.dart @@ -0,0 +1,1657 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'local_db_service.dart'; + +// ignore_for_file: type=lint +class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $MessagesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _senderIdMeta = const VerificationMeta( + 'senderId', + ); + @override + late final GeneratedColumn senderId = GeneratedColumn( + 'sender_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _receiverIdMeta = const VerificationMeta( + 'receiverId', + ); + @override + late final GeneratedColumn receiverId = GeneratedColumn( + 'receiver_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _contentMeta = const VerificationMeta( + 'content', + ); + @override + late final GeneratedColumn content = GeneratedColumn( + 'content', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _timestampMeta = const VerificationMeta( + 'timestamp', + ); + @override + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _deliveredAtMeta = const VerificationMeta( + 'deliveredAt', + ); + @override + late final GeneratedColumn deliveredAt = GeneratedColumn( + 'delivered_at', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _readAtMeta = const VerificationMeta('readAt'); + @override + late final GeneratedColumn readAt = GeneratedColumn( + 'read_at', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _replyToIdMeta = const VerificationMeta( + 'replyToId', + ); + @override + late final GeneratedColumn replyToId = GeneratedColumn( + 'reply_to_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _replyToTextMeta = const VerificationMeta( + 'replyToText', + ); + @override + late final GeneratedColumn replyToText = GeneratedColumn( + 'reply_to_text', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _editedAtMeta = const VerificationMeta( + 'editedAt', + ); + @override + late final GeneratedColumn editedAt = GeneratedColumn( + 'edited_at', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _messageTypeMeta = const VerificationMeta( + 'messageType', + ); + @override + late final GeneratedColumn messageType = GeneratedColumn( + 'message_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('text'), + ); + static const VerificationMeta _fileIdMeta = const VerificationMeta('fileId'); + @override + late final GeneratedColumn fileId = GeneratedColumn( + 'file_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _encryptedKeyMeta = const VerificationMeta( + 'encryptedKey', + ); + @override + late final GeneratedColumn encryptedKey = GeneratedColumn( + 'encrypted_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _fileNameMeta = const VerificationMeta( + 'fileName', + ); + @override + late final GeneratedColumn fileName = GeneratedColumn( + 'file_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _fileSizeMeta = const VerificationMeta( + 'fileSize', + ); + @override + late final GeneratedColumn fileSize = GeneratedColumn( + 'file_size', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + senderId, + receiverId, + content, + timestamp, + deliveredAt, + readAt, + replyToId, + replyToText, + editedAt, + messageType, + fileId, + encryptedKey, + fileName, + fileSize, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'messages'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('sender_id')) { + context.handle( + _senderIdMeta, + senderId.isAcceptableOrUnknown(data['sender_id']!, _senderIdMeta), + ); + } else if (isInserting) { + context.missing(_senderIdMeta); + } + if (data.containsKey('receiver_id')) { + context.handle( + _receiverIdMeta, + receiverId.isAcceptableOrUnknown(data['receiver_id']!, _receiverIdMeta), + ); + } else if (isInserting) { + context.missing(_receiverIdMeta); + } + if (data.containsKey('content')) { + context.handle( + _contentMeta, + content.isAcceptableOrUnknown(data['content']!, _contentMeta), + ); + } else if (isInserting) { + context.missing(_contentMeta); + } + if (data.containsKey('timestamp')) { + context.handle( + _timestampMeta, + timestamp.isAcceptableOrUnknown(data['timestamp']!, _timestampMeta), + ); + } else if (isInserting) { + context.missing(_timestampMeta); + } + if (data.containsKey('delivered_at')) { + context.handle( + _deliveredAtMeta, + deliveredAt.isAcceptableOrUnknown( + data['delivered_at']!, + _deliveredAtMeta, + ), + ); + } + if (data.containsKey('read_at')) { + context.handle( + _readAtMeta, + readAt.isAcceptableOrUnknown(data['read_at']!, _readAtMeta), + ); + } + if (data.containsKey('reply_to_id')) { + context.handle( + _replyToIdMeta, + replyToId.isAcceptableOrUnknown(data['reply_to_id']!, _replyToIdMeta), + ); + } + if (data.containsKey('reply_to_text')) { + context.handle( + _replyToTextMeta, + replyToText.isAcceptableOrUnknown( + data['reply_to_text']!, + _replyToTextMeta, + ), + ); + } + if (data.containsKey('edited_at')) { + context.handle( + _editedAtMeta, + editedAt.isAcceptableOrUnknown(data['edited_at']!, _editedAtMeta), + ); + } + if (data.containsKey('message_type')) { + context.handle( + _messageTypeMeta, + messageType.isAcceptableOrUnknown( + data['message_type']!, + _messageTypeMeta, + ), + ); + } + if (data.containsKey('file_id')) { + context.handle( + _fileIdMeta, + fileId.isAcceptableOrUnknown(data['file_id']!, _fileIdMeta), + ); + } + if (data.containsKey('encrypted_key')) { + context.handle( + _encryptedKeyMeta, + encryptedKey.isAcceptableOrUnknown( + data['encrypted_key']!, + _encryptedKeyMeta, + ), + ); + } + if (data.containsKey('file_name')) { + context.handle( + _fileNameMeta, + fileName.isAcceptableOrUnknown(data['file_name']!, _fileNameMeta), + ); + } + if (data.containsKey('file_size')) { + context.handle( + _fileSizeMeta, + fileSize.isAcceptableOrUnknown(data['file_size']!, _fileSizeMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + Message map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Message( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + senderId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sender_id'], + )!, + receiverId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}receiver_id'], + )!, + content: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content'], + )!, + timestamp: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}timestamp'], + )!, + deliveredAt: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}delivered_at'], + ), + readAt: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}read_at'], + ), + replyToId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}reply_to_id'], + ), + replyToText: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}reply_to_text'], + ), + editedAt: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}edited_at'], + ), + messageType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}message_type'], + )!, + fileId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}file_id'], + ), + encryptedKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}encrypted_key'], + ), + fileName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}file_name'], + ), + fileSize: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}file_size'], + ), + ); + } + + @override + $MessagesTable createAlias(String alias) { + return $MessagesTable(attachedDatabase, alias); + } +} + +class Message extends DataClass implements Insertable { + final int id; + final int senderId; + final int receiverId; + final String content; + final String timestamp; + final String? deliveredAt; + final String? readAt; + final int? replyToId; + final String? replyToText; + final String? editedAt; + final String messageType; + final String? fileId; + final String? encryptedKey; + final String? fileName; + final int? fileSize; + const Message({ + required this.id, + required this.senderId, + required this.receiverId, + required this.content, + required this.timestamp, + this.deliveredAt, + this.readAt, + this.replyToId, + this.replyToText, + this.editedAt, + required this.messageType, + this.fileId, + this.encryptedKey, + this.fileName, + this.fileSize, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['sender_id'] = Variable(senderId); + map['receiver_id'] = Variable(receiverId); + map['content'] = Variable(content); + map['timestamp'] = Variable(timestamp); + if (!nullToAbsent || deliveredAt != null) { + map['delivered_at'] = Variable(deliveredAt); + } + if (!nullToAbsent || readAt != null) { + map['read_at'] = Variable(readAt); + } + if (!nullToAbsent || replyToId != null) { + map['reply_to_id'] = Variable(replyToId); + } + if (!nullToAbsent || replyToText != null) { + map['reply_to_text'] = Variable(replyToText); + } + if (!nullToAbsent || editedAt != null) { + map['edited_at'] = Variable(editedAt); + } + map['message_type'] = Variable(messageType); + if (!nullToAbsent || fileId != null) { + map['file_id'] = Variable(fileId); + } + if (!nullToAbsent || encryptedKey != null) { + map['encrypted_key'] = Variable(encryptedKey); + } + if (!nullToAbsent || fileName != null) { + map['file_name'] = Variable(fileName); + } + if (!nullToAbsent || fileSize != null) { + map['file_size'] = Variable(fileSize); + } + return map; + } + + MessagesCompanion toCompanion(bool nullToAbsent) { + return MessagesCompanion( + id: Value(id), + senderId: Value(senderId), + receiverId: Value(receiverId), + content: Value(content), + timestamp: Value(timestamp), + deliveredAt: deliveredAt == null && nullToAbsent + ? const Value.absent() + : Value(deliveredAt), + readAt: readAt == null && nullToAbsent + ? const Value.absent() + : Value(readAt), + replyToId: replyToId == null && nullToAbsent + ? const Value.absent() + : Value(replyToId), + replyToText: replyToText == null && nullToAbsent + ? const Value.absent() + : Value(replyToText), + editedAt: editedAt == null && nullToAbsent + ? const Value.absent() + : Value(editedAt), + messageType: Value(messageType), + fileId: fileId == null && nullToAbsent + ? const Value.absent() + : Value(fileId), + encryptedKey: encryptedKey == null && nullToAbsent + ? const Value.absent() + : Value(encryptedKey), + fileName: fileName == null && nullToAbsent + ? const Value.absent() + : Value(fileName), + fileSize: fileSize == null && nullToAbsent + ? const Value.absent() + : Value(fileSize), + ); + } + + factory Message.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Message( + id: serializer.fromJson(json['id']), + senderId: serializer.fromJson(json['senderId']), + receiverId: serializer.fromJson(json['receiverId']), + content: serializer.fromJson(json['content']), + timestamp: serializer.fromJson(json['timestamp']), + deliveredAt: serializer.fromJson(json['deliveredAt']), + readAt: serializer.fromJson(json['readAt']), + replyToId: serializer.fromJson(json['replyToId']), + replyToText: serializer.fromJson(json['replyToText']), + editedAt: serializer.fromJson(json['editedAt']), + messageType: serializer.fromJson(json['messageType']), + fileId: serializer.fromJson(json['fileId']), + encryptedKey: serializer.fromJson(json['encryptedKey']), + fileName: serializer.fromJson(json['fileName']), + fileSize: serializer.fromJson(json['fileSize']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'senderId': serializer.toJson(senderId), + 'receiverId': serializer.toJson(receiverId), + 'content': serializer.toJson(content), + 'timestamp': serializer.toJson(timestamp), + 'deliveredAt': serializer.toJson(deliveredAt), + 'readAt': serializer.toJson(readAt), + 'replyToId': serializer.toJson(replyToId), + 'replyToText': serializer.toJson(replyToText), + 'editedAt': serializer.toJson(editedAt), + 'messageType': serializer.toJson(messageType), + 'fileId': serializer.toJson(fileId), + 'encryptedKey': serializer.toJson(encryptedKey), + 'fileName': serializer.toJson(fileName), + 'fileSize': serializer.toJson(fileSize), + }; + } + + Message copyWith({ + int? id, + int? senderId, + int? receiverId, + String? content, + String? timestamp, + Value deliveredAt = const Value.absent(), + Value readAt = const Value.absent(), + Value replyToId = const Value.absent(), + Value replyToText = const Value.absent(), + Value editedAt = const Value.absent(), + String? messageType, + Value fileId = const Value.absent(), + Value encryptedKey = const Value.absent(), + Value fileName = const Value.absent(), + Value fileSize = const Value.absent(), + }) => Message( + id: id ?? this.id, + senderId: senderId ?? this.senderId, + receiverId: receiverId ?? this.receiverId, + content: content ?? this.content, + timestamp: timestamp ?? this.timestamp, + deliveredAt: deliveredAt.present ? deliveredAt.value : this.deliveredAt, + readAt: readAt.present ? readAt.value : this.readAt, + replyToId: replyToId.present ? replyToId.value : this.replyToId, + replyToText: replyToText.present ? replyToText.value : this.replyToText, + editedAt: editedAt.present ? editedAt.value : this.editedAt, + messageType: messageType ?? this.messageType, + fileId: fileId.present ? fileId.value : this.fileId, + encryptedKey: encryptedKey.present ? encryptedKey.value : this.encryptedKey, + fileName: fileName.present ? fileName.value : this.fileName, + fileSize: fileSize.present ? fileSize.value : this.fileSize, + ); + Message copyWithCompanion(MessagesCompanion data) { + return Message( + id: data.id.present ? data.id.value : this.id, + senderId: data.senderId.present ? data.senderId.value : this.senderId, + receiverId: data.receiverId.present + ? data.receiverId.value + : this.receiverId, + content: data.content.present ? data.content.value : this.content, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + deliveredAt: data.deliveredAt.present + ? data.deliveredAt.value + : this.deliveredAt, + readAt: data.readAt.present ? data.readAt.value : this.readAt, + replyToId: data.replyToId.present ? data.replyToId.value : this.replyToId, + replyToText: data.replyToText.present + ? data.replyToText.value + : this.replyToText, + editedAt: data.editedAt.present ? data.editedAt.value : this.editedAt, + messageType: data.messageType.present + ? data.messageType.value + : this.messageType, + fileId: data.fileId.present ? data.fileId.value : this.fileId, + encryptedKey: data.encryptedKey.present + ? data.encryptedKey.value + : this.encryptedKey, + fileName: data.fileName.present ? data.fileName.value : this.fileName, + fileSize: data.fileSize.present ? data.fileSize.value : this.fileSize, + ); + } + + @override + String toString() { + return (StringBuffer('Message(') + ..write('id: $id, ') + ..write('senderId: $senderId, ') + ..write('receiverId: $receiverId, ') + ..write('content: $content, ') + ..write('timestamp: $timestamp, ') + ..write('deliveredAt: $deliveredAt, ') + ..write('readAt: $readAt, ') + ..write('replyToId: $replyToId, ') + ..write('replyToText: $replyToText, ') + ..write('editedAt: $editedAt, ') + ..write('messageType: $messageType, ') + ..write('fileId: $fileId, ') + ..write('encryptedKey: $encryptedKey, ') + ..write('fileName: $fileName, ') + ..write('fileSize: $fileSize') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + senderId, + receiverId, + content, + timestamp, + deliveredAt, + readAt, + replyToId, + replyToText, + editedAt, + messageType, + fileId, + encryptedKey, + fileName, + fileSize, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Message && + other.id == this.id && + other.senderId == this.senderId && + other.receiverId == this.receiverId && + other.content == this.content && + other.timestamp == this.timestamp && + other.deliveredAt == this.deliveredAt && + other.readAt == this.readAt && + other.replyToId == this.replyToId && + other.replyToText == this.replyToText && + other.editedAt == this.editedAt && + other.messageType == this.messageType && + other.fileId == this.fileId && + other.encryptedKey == this.encryptedKey && + other.fileName == this.fileName && + other.fileSize == this.fileSize); +} + +class MessagesCompanion extends UpdateCompanion { + final Value id; + final Value senderId; + final Value receiverId; + final Value content; + final Value timestamp; + final Value deliveredAt; + final Value readAt; + final Value replyToId; + final Value replyToText; + final Value editedAt; + final Value messageType; + final Value fileId; + final Value encryptedKey; + final Value fileName; + final Value fileSize; + const MessagesCompanion({ + this.id = const Value.absent(), + this.senderId = const Value.absent(), + this.receiverId = const Value.absent(), + this.content = const Value.absent(), + this.timestamp = const Value.absent(), + this.deliveredAt = const Value.absent(), + this.readAt = const Value.absent(), + this.replyToId = const Value.absent(), + this.replyToText = const Value.absent(), + this.editedAt = const Value.absent(), + this.messageType = const Value.absent(), + this.fileId = const Value.absent(), + this.encryptedKey = const Value.absent(), + this.fileName = const Value.absent(), + this.fileSize = const Value.absent(), + }); + MessagesCompanion.insert({ + this.id = const Value.absent(), + required int senderId, + required int receiverId, + required String content, + required String timestamp, + this.deliveredAt = const Value.absent(), + this.readAt = const Value.absent(), + this.replyToId = const Value.absent(), + this.replyToText = const Value.absent(), + this.editedAt = const Value.absent(), + this.messageType = const Value.absent(), + this.fileId = const Value.absent(), + this.encryptedKey = const Value.absent(), + this.fileName = const Value.absent(), + this.fileSize = const Value.absent(), + }) : senderId = Value(senderId), + receiverId = Value(receiverId), + content = Value(content), + timestamp = Value(timestamp); + static Insertable custom({ + Expression? id, + Expression? senderId, + Expression? receiverId, + Expression? content, + Expression? timestamp, + Expression? deliveredAt, + Expression? readAt, + Expression? replyToId, + Expression? replyToText, + Expression? editedAt, + Expression? messageType, + Expression? fileId, + Expression? encryptedKey, + Expression? fileName, + Expression? fileSize, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (senderId != null) 'sender_id': senderId, + if (receiverId != null) 'receiver_id': receiverId, + if (content != null) 'content': content, + if (timestamp != null) 'timestamp': timestamp, + if (deliveredAt != null) 'delivered_at': deliveredAt, + if (readAt != null) 'read_at': readAt, + if (replyToId != null) 'reply_to_id': replyToId, + if (replyToText != null) 'reply_to_text': replyToText, + if (editedAt != null) 'edited_at': editedAt, + if (messageType != null) 'message_type': messageType, + if (fileId != null) 'file_id': fileId, + if (encryptedKey != null) 'encrypted_key': encryptedKey, + if (fileName != null) 'file_name': fileName, + if (fileSize != null) 'file_size': fileSize, + }); + } + + MessagesCompanion copyWith({ + Value? id, + Value? senderId, + Value? receiverId, + Value? content, + Value? timestamp, + Value? deliveredAt, + Value? readAt, + Value? replyToId, + Value? replyToText, + Value? editedAt, + Value? messageType, + Value? fileId, + Value? encryptedKey, + Value? fileName, + Value? fileSize, + }) { + return MessagesCompanion( + id: id ?? this.id, + senderId: senderId ?? this.senderId, + receiverId: receiverId ?? this.receiverId, + content: content ?? this.content, + timestamp: timestamp ?? this.timestamp, + deliveredAt: deliveredAt ?? this.deliveredAt, + readAt: readAt ?? this.readAt, + replyToId: replyToId ?? this.replyToId, + replyToText: replyToText ?? this.replyToText, + editedAt: editedAt ?? this.editedAt, + messageType: messageType ?? this.messageType, + fileId: fileId ?? this.fileId, + encryptedKey: encryptedKey ?? this.encryptedKey, + fileName: fileName ?? this.fileName, + fileSize: fileSize ?? this.fileSize, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (senderId.present) { + map['sender_id'] = Variable(senderId.value); + } + if (receiverId.present) { + map['receiver_id'] = Variable(receiverId.value); + } + if (content.present) { + map['content'] = Variable(content.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (deliveredAt.present) { + map['delivered_at'] = Variable(deliveredAt.value); + } + if (readAt.present) { + map['read_at'] = Variable(readAt.value); + } + if (replyToId.present) { + map['reply_to_id'] = Variable(replyToId.value); + } + if (replyToText.present) { + map['reply_to_text'] = Variable(replyToText.value); + } + if (editedAt.present) { + map['edited_at'] = Variable(editedAt.value); + } + if (messageType.present) { + map['message_type'] = Variable(messageType.value); + } + if (fileId.present) { + map['file_id'] = Variable(fileId.value); + } + if (encryptedKey.present) { + map['encrypted_key'] = Variable(encryptedKey.value); + } + if (fileName.present) { + map['file_name'] = Variable(fileName.value); + } + if (fileSize.present) { + map['file_size'] = Variable(fileSize.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MessagesCompanion(') + ..write('id: $id, ') + ..write('senderId: $senderId, ') + ..write('receiverId: $receiverId, ') + ..write('content: $content, ') + ..write('timestamp: $timestamp, ') + ..write('deliveredAt: $deliveredAt, ') + ..write('readAt: $readAt, ') + ..write('replyToId: $replyToId, ') + ..write('replyToText: $replyToText, ') + ..write('editedAt: $editedAt, ') + ..write('messageType: $messageType, ') + ..write('fileId: $fileId, ') + ..write('encryptedKey: $encryptedKey, ') + ..write('fileName: $fileName, ') + ..write('fileSize: $fileSize') + ..write(')')) + .toString(); + } +} + +class $FileNameMappingsTable extends FileNameMappings + with TableInfo<$FileNameMappingsTable, FileNameMapping> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $FileNameMappingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _fileIdMeta = const VerificationMeta('fileId'); + @override + late final GeneratedColumn fileId = GeneratedColumn( + 'file_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _originalFileNameMeta = const VerificationMeta( + 'originalFileName', + ); + @override + late final GeneratedColumn originalFileName = GeneratedColumn( + 'original_file_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [fileId, originalFileName]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'file_name_mappings'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('file_id')) { + context.handle( + _fileIdMeta, + fileId.isAcceptableOrUnknown(data['file_id']!, _fileIdMeta), + ); + } else if (isInserting) { + context.missing(_fileIdMeta); + } + if (data.containsKey('original_file_name')) { + context.handle( + _originalFileNameMeta, + originalFileName.isAcceptableOrUnknown( + data['original_file_name']!, + _originalFileNameMeta, + ), + ); + } else if (isInserting) { + context.missing(_originalFileNameMeta); + } + return context; + } + + @override + Set get $primaryKey => {fileId}; + @override + FileNameMapping map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return FileNameMapping( + fileId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}file_id'], + )!, + originalFileName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}original_file_name'], + )!, + ); + } + + @override + $FileNameMappingsTable createAlias(String alias) { + return $FileNameMappingsTable(attachedDatabase, alias); + } +} + +class FileNameMapping extends DataClass implements Insertable { + final String fileId; + final String originalFileName; + const FileNameMapping({required this.fileId, required this.originalFileName}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['file_id'] = Variable(fileId); + map['original_file_name'] = Variable(originalFileName); + return map; + } + + FileNameMappingsCompanion toCompanion(bool nullToAbsent) { + return FileNameMappingsCompanion( + fileId: Value(fileId), + originalFileName: Value(originalFileName), + ); + } + + factory FileNameMapping.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return FileNameMapping( + fileId: serializer.fromJson(json['fileId']), + originalFileName: serializer.fromJson(json['originalFileName']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'fileId': serializer.toJson(fileId), + 'originalFileName': serializer.toJson(originalFileName), + }; + } + + FileNameMapping copyWith({String? fileId, String? originalFileName}) => + FileNameMapping( + fileId: fileId ?? this.fileId, + originalFileName: originalFileName ?? this.originalFileName, + ); + FileNameMapping copyWithCompanion(FileNameMappingsCompanion data) { + return FileNameMapping( + fileId: data.fileId.present ? data.fileId.value : this.fileId, + originalFileName: data.originalFileName.present + ? data.originalFileName.value + : this.originalFileName, + ); + } + + @override + String toString() { + return (StringBuffer('FileNameMapping(') + ..write('fileId: $fileId, ') + ..write('originalFileName: $originalFileName') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(fileId, originalFileName); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is FileNameMapping && + other.fileId == this.fileId && + other.originalFileName == this.originalFileName); +} + +class FileNameMappingsCompanion extends UpdateCompanion { + final Value fileId; + final Value originalFileName; + final Value rowid; + const FileNameMappingsCompanion({ + this.fileId = const Value.absent(), + this.originalFileName = const Value.absent(), + this.rowid = const Value.absent(), + }); + FileNameMappingsCompanion.insert({ + required String fileId, + required String originalFileName, + this.rowid = const Value.absent(), + }) : fileId = Value(fileId), + originalFileName = Value(originalFileName); + static Insertable custom({ + Expression? fileId, + Expression? originalFileName, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (fileId != null) 'file_id': fileId, + if (originalFileName != null) 'original_file_name': originalFileName, + if (rowid != null) 'rowid': rowid, + }); + } + + FileNameMappingsCompanion copyWith({ + Value? fileId, + Value? originalFileName, + Value? rowid, + }) { + return FileNameMappingsCompanion( + fileId: fileId ?? this.fileId, + originalFileName: originalFileName ?? this.originalFileName, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (fileId.present) { + map['file_id'] = Variable(fileId.value); + } + if (originalFileName.present) { + map['original_file_name'] = Variable(originalFileName.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('FileNameMappingsCompanion(') + ..write('fileId: $fileId, ') + ..write('originalFileName: $originalFileName, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$LocalDbService extends GeneratedDatabase { + _$LocalDbService(QueryExecutor e) : super(e); + $LocalDbServiceManager get managers => $LocalDbServiceManager(this); + late final $MessagesTable messages = $MessagesTable(this); + late final $FileNameMappingsTable fileNameMappings = $FileNameMappingsTable( + this, + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + messages, + fileNameMappings, + ]; +} + +typedef $$MessagesTableCreateCompanionBuilder = + MessagesCompanion Function({ + Value id, + required int senderId, + required int receiverId, + required String content, + required String timestamp, + Value deliveredAt, + Value readAt, + Value replyToId, + Value replyToText, + Value editedAt, + Value messageType, + Value fileId, + Value encryptedKey, + Value fileName, + Value fileSize, + }); +typedef $$MessagesTableUpdateCompanionBuilder = + MessagesCompanion Function({ + Value id, + Value senderId, + Value receiverId, + Value content, + Value timestamp, + Value deliveredAt, + Value readAt, + Value replyToId, + Value replyToText, + Value editedAt, + Value messageType, + Value fileId, + Value encryptedKey, + Value fileName, + Value fileSize, + }); + +class $$MessagesTableFilterComposer + extends Composer<_$LocalDbService, $MessagesTable> { + $$MessagesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get senderId => $composableBuilder( + column: $table.senderId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get receiverId => $composableBuilder( + column: $table.receiverId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get timestamp => $composableBuilder( + column: $table.timestamp, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deliveredAt => $composableBuilder( + column: $table.deliveredAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get readAt => $composableBuilder( + column: $table.readAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get replyToId => $composableBuilder( + column: $table.replyToId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get replyToText => $composableBuilder( + column: $table.replyToText, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get editedAt => $composableBuilder( + column: $table.editedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get messageType => $composableBuilder( + column: $table.messageType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fileId => $composableBuilder( + column: $table.fileId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get encryptedKey => $composableBuilder( + column: $table.encryptedKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fileName => $composableBuilder( + column: $table.fileName, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get fileSize => $composableBuilder( + column: $table.fileSize, + builder: (column) => ColumnFilters(column), + ); +} + +class $$MessagesTableOrderingComposer + extends Composer<_$LocalDbService, $MessagesTable> { + $$MessagesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get senderId => $composableBuilder( + column: $table.senderId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get receiverId => $composableBuilder( + column: $table.receiverId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get content => $composableBuilder( + column: $table.content, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get timestamp => $composableBuilder( + column: $table.timestamp, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get deliveredAt => $composableBuilder( + column: $table.deliveredAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get readAt => $composableBuilder( + column: $table.readAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get replyToId => $composableBuilder( + column: $table.replyToId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get replyToText => $composableBuilder( + column: $table.replyToText, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get editedAt => $composableBuilder( + column: $table.editedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get messageType => $composableBuilder( + column: $table.messageType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fileId => $composableBuilder( + column: $table.fileId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get encryptedKey => $composableBuilder( + column: $table.encryptedKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fileName => $composableBuilder( + column: $table.fileName, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get fileSize => $composableBuilder( + column: $table.fileSize, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$MessagesTableAnnotationComposer + extends Composer<_$LocalDbService, $MessagesTable> { + $$MessagesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get senderId => + $composableBuilder(column: $table.senderId, builder: (column) => column); + + GeneratedColumn get receiverId => $composableBuilder( + column: $table.receiverId, + builder: (column) => column, + ); + + GeneratedColumn get content => + $composableBuilder(column: $table.content, builder: (column) => column); + + GeneratedColumn get timestamp => + $composableBuilder(column: $table.timestamp, builder: (column) => column); + + GeneratedColumn get deliveredAt => $composableBuilder( + column: $table.deliveredAt, + builder: (column) => column, + ); + + GeneratedColumn get readAt => + $composableBuilder(column: $table.readAt, builder: (column) => column); + + GeneratedColumn get replyToId => + $composableBuilder(column: $table.replyToId, builder: (column) => column); + + GeneratedColumn get replyToText => $composableBuilder( + column: $table.replyToText, + builder: (column) => column, + ); + + GeneratedColumn get editedAt => + $composableBuilder(column: $table.editedAt, builder: (column) => column); + + GeneratedColumn get messageType => $composableBuilder( + column: $table.messageType, + builder: (column) => column, + ); + + GeneratedColumn get fileId => + $composableBuilder(column: $table.fileId, builder: (column) => column); + + GeneratedColumn get encryptedKey => $composableBuilder( + column: $table.encryptedKey, + builder: (column) => column, + ); + + GeneratedColumn get fileName => + $composableBuilder(column: $table.fileName, builder: (column) => column); + + GeneratedColumn get fileSize => + $composableBuilder(column: $table.fileSize, builder: (column) => column); +} + +class $$MessagesTableTableManager + extends + RootTableManager< + _$LocalDbService, + $MessagesTable, + Message, + $$MessagesTableFilterComposer, + $$MessagesTableOrderingComposer, + $$MessagesTableAnnotationComposer, + $$MessagesTableCreateCompanionBuilder, + $$MessagesTableUpdateCompanionBuilder, + (Message, BaseReferences<_$LocalDbService, $MessagesTable, Message>), + Message, + PrefetchHooks Function() + > { + $$MessagesTableTableManager(_$LocalDbService db, $MessagesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$MessagesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$MessagesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$MessagesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value senderId = const Value.absent(), + Value receiverId = const Value.absent(), + Value content = const Value.absent(), + Value timestamp = const Value.absent(), + Value deliveredAt = const Value.absent(), + Value readAt = const Value.absent(), + Value replyToId = const Value.absent(), + Value replyToText = const Value.absent(), + Value editedAt = const Value.absent(), + Value messageType = const Value.absent(), + Value fileId = const Value.absent(), + Value encryptedKey = const Value.absent(), + Value fileName = const Value.absent(), + Value fileSize = const Value.absent(), + }) => MessagesCompanion( + id: id, + senderId: senderId, + receiverId: receiverId, + content: content, + timestamp: timestamp, + deliveredAt: deliveredAt, + readAt: readAt, + replyToId: replyToId, + replyToText: replyToText, + editedAt: editedAt, + messageType: messageType, + fileId: fileId, + encryptedKey: encryptedKey, + fileName: fileName, + fileSize: fileSize, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required int senderId, + required int receiverId, + required String content, + required String timestamp, + Value deliveredAt = const Value.absent(), + Value readAt = const Value.absent(), + Value replyToId = const Value.absent(), + Value replyToText = const Value.absent(), + Value editedAt = const Value.absent(), + Value messageType = const Value.absent(), + Value fileId = const Value.absent(), + Value encryptedKey = const Value.absent(), + Value fileName = const Value.absent(), + Value fileSize = const Value.absent(), + }) => MessagesCompanion.insert( + id: id, + senderId: senderId, + receiverId: receiverId, + content: content, + timestamp: timestamp, + deliveredAt: deliveredAt, + readAt: readAt, + replyToId: replyToId, + replyToText: replyToText, + editedAt: editedAt, + messageType: messageType, + fileId: fileId, + encryptedKey: encryptedKey, + fileName: fileName, + fileSize: fileSize, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$MessagesTableProcessedTableManager = + ProcessedTableManager< + _$LocalDbService, + $MessagesTable, + Message, + $$MessagesTableFilterComposer, + $$MessagesTableOrderingComposer, + $$MessagesTableAnnotationComposer, + $$MessagesTableCreateCompanionBuilder, + $$MessagesTableUpdateCompanionBuilder, + (Message, BaseReferences<_$LocalDbService, $MessagesTable, Message>), + Message, + PrefetchHooks Function() + >; +typedef $$FileNameMappingsTableCreateCompanionBuilder = + FileNameMappingsCompanion Function({ + required String fileId, + required String originalFileName, + Value rowid, + }); +typedef $$FileNameMappingsTableUpdateCompanionBuilder = + FileNameMappingsCompanion Function({ + Value fileId, + Value originalFileName, + Value rowid, + }); + +class $$FileNameMappingsTableFilterComposer + extends Composer<_$LocalDbService, $FileNameMappingsTable> { + $$FileNameMappingsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get fileId => $composableBuilder( + column: $table.fileId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get originalFileName => $composableBuilder( + column: $table.originalFileName, + builder: (column) => ColumnFilters(column), + ); +} + +class $$FileNameMappingsTableOrderingComposer + extends Composer<_$LocalDbService, $FileNameMappingsTable> { + $$FileNameMappingsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get fileId => $composableBuilder( + column: $table.fileId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get originalFileName => $composableBuilder( + column: $table.originalFileName, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$FileNameMappingsTableAnnotationComposer + extends Composer<_$LocalDbService, $FileNameMappingsTable> { + $$FileNameMappingsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get fileId => + $composableBuilder(column: $table.fileId, builder: (column) => column); + + GeneratedColumn get originalFileName => $composableBuilder( + column: $table.originalFileName, + builder: (column) => column, + ); +} + +class $$FileNameMappingsTableTableManager + extends + RootTableManager< + _$LocalDbService, + $FileNameMappingsTable, + FileNameMapping, + $$FileNameMappingsTableFilterComposer, + $$FileNameMappingsTableOrderingComposer, + $$FileNameMappingsTableAnnotationComposer, + $$FileNameMappingsTableCreateCompanionBuilder, + $$FileNameMappingsTableUpdateCompanionBuilder, + ( + FileNameMapping, + BaseReferences< + _$LocalDbService, + $FileNameMappingsTable, + FileNameMapping + >, + ), + FileNameMapping, + PrefetchHooks Function() + > { + $$FileNameMappingsTableTableManager( + _$LocalDbService db, + $FileNameMappingsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$FileNameMappingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$FileNameMappingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$FileNameMappingsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value fileId = const Value.absent(), + Value originalFileName = const Value.absent(), + Value rowid = const Value.absent(), + }) => FileNameMappingsCompanion( + fileId: fileId, + originalFileName: originalFileName, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String fileId, + required String originalFileName, + Value rowid = const Value.absent(), + }) => FileNameMappingsCompanion.insert( + fileId: fileId, + originalFileName: originalFileName, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$FileNameMappingsTableProcessedTableManager = + ProcessedTableManager< + _$LocalDbService, + $FileNameMappingsTable, + FileNameMapping, + $$FileNameMappingsTableFilterComposer, + $$FileNameMappingsTableOrderingComposer, + $$FileNameMappingsTableAnnotationComposer, + $$FileNameMappingsTableCreateCompanionBuilder, + $$FileNameMappingsTableUpdateCompanionBuilder, + ( + FileNameMapping, + BaseReferences< + _$LocalDbService, + $FileNameMappingsTable, + FileNameMapping + >, + ), + FileNameMapping, + PrefetchHooks Function() + >; + +class $LocalDbServiceManager { + final _$LocalDbService _db; + $LocalDbServiceManager(this._db); + $$MessagesTableTableManager get messages => + $$MessagesTableTableManager(_db, _db.messages); + $$FileNameMappingsTableTableManager get fileNameMappings => + $$FileNameMappingsTableTableManager(_db, _db.fileNameMappings); +} diff --git a/lib/data/datasources/ws_client.dart b/lib/data/datasources/ws_client.dart index f1fd482..e3b9264 100644 --- a/lib/data/datasources/ws_client.dart +++ b/lib/data/datasources/ws_client.dart @@ -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 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) { diff --git a/lib/data/repositories/contact_repository.dart b/lib/data/repositories/contact_repository.dart index 9d04834..234c2b5 100644 --- a/lib/data/repositories/contact_repository.dart +++ b/lib/data/repositories/contact_repository.dart @@ -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 _ensureCacheReady() async { - if (!_isCacheInitialized) { - await _apiService.cache.initialize(); - _isCacheInitialized = true; - } - } - Future> 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 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', ), diff --git a/lib/domain/services/api_service.dart b/lib/domain/services/api_service.dart index 7a03e1d..650e9e2 100644 --- a/lib/domain/services/api_service.dart +++ b/lib/domain/services/api_service.dart @@ -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 _ensureCacheReady() async { - if (!_isCacheInitialized) { - await cache.initialize(); - _isCacheInitialized = true; - } - } /// Получает данные пользователя (включая его публичный ключ E2EE) по username Future 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> 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 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> 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> 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', diff --git a/lib/domain/services/webrtc_service.dart b/lib/domain/services/webrtc_service.dart new file mode 100644 index 0000000..c2b64cf --- /dev/null +++ b/lib/domain/services/webrtc_service.dart @@ -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 _config = { + "iceServers": [ + {"urls": "stun:stun.l.google.com:19302"}, + {"urls": "stun:stun1.l.google.com:19302"}, + ] + }; + + /// Инициализация PeerConnection + Future 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 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 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 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 addRemoteIceCandidate(Map candidateMap) async { + await _peerConnection!.addCandidate( + RTCIceCandidate( + candidateMap['candidate'], + candidateMap['sdpMid'], + candidateMap['sdpMLineIndex'], + ), + ); + } + + /// Очистка ресурсов + void dispose() { + _localStream?.getTracks().forEach((track) => track.stop()); + _localStream?.dispose(); + _peerConnection?.close(); + } +} \ No newline at end of file diff --git a/lib/logic/auth_provider.dart b/lib/logic/auth_provider.dart index 30705ff..b373865 100644 --- a/lib/logic/auth_provider.dart +++ b/lib/logic/auth_provider.dart @@ -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(); diff --git a/lib/main.dart b/lib/main.dart index 7a253ce..86395b0 100644 --- a/lib/main.dart +++ b/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 _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 _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 _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', diff --git a/lib/presentation/screens/account_settings_screen.dart b/lib/presentation/screens/account_settings_screen.dart index e35d2d3..d689651 100644 --- a/lib/presentation/screens/account_settings_screen.dart +++ b/lib/presentation/screens/account_settings_screen.dart @@ -62,13 +62,13 @@ class _AccountSettingsScreenState extends State { 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 { @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), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/admin_panel_screen.dart b/lib/presentation/screens/admin_panel_screen.dart new file mode 100644 index 0000000..42fef29 --- /dev/null +++ b/lib/presentation/screens/admin_panel_screen.dart @@ -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 createState() => _AdminPanelScreenState(); +} + +class _AdminPanelScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + final ApiService _apiService = ApiService(); + + List _users = []; + bool _isLoadingUsers = true; + + // Контроллеры формы создания пользователя + final _idController = TextEditingController(); + final _createFormKey = GlobalKey(); + 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 _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 _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 _createUser() async { + if (!_createFormKey.currentState!.validate()) return; + setState(() => _isCreating = true); + try { + final token = await _apiService.getAccessToken(); + final Map 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 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('Сохранить'), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/screens/appearance_settings_screen.dart b/lib/presentation/screens/appearance_settings_screen.dart index f27622f..d167c9e 100644 --- a/lib/presentation/screens/appearance_settings_screen.dart +++ b/lib/presentation/screens/appearance_settings_screen.dart @@ -24,104 +24,136 @@ class _AppearanceSettingsScreenState extends State { @override Widget build(BuildContext context) { final themeProv = context.watch(); + 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), ), ); } diff --git a/lib/presentation/screens/call_screen.dart b/lib/presentation/screens/call_screen.dart new file mode 100644 index 0000000..9c5c4b2 --- /dev/null +++ b/lib/presentation/screens/call_screen.dart @@ -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 createState() => _CallScreenState(); +} + +class _CallScreenState extends State { + // Рендереры для видеопотока + 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), + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/chat_screen.dart b/lib/presentation/screens/chat_screen.dart index 24aa08e..6236ad2 100644 --- a/lib/presentation/screens/chat_screen.dart +++ b/lib/presentation/screens/chat_screen.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:cryptography/cryptography.dart'; @@ -23,7 +22,6 @@ import 'contacts_screen.dart'; import 'package:flutter/services.dart'; import 'user_profile_screen.dart'; import '/core/theme_manager.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:file_picker/file_picker.dart'; import 'package:wechat_assets_picker/wechat_assets_picker.dart'; @@ -37,11 +35,23 @@ import 'package:camera/camera.dart'; import 'package:ffmpeg_kit_flutter_new_min_gpl/ffmpeg_kit.dart'; import 'package:ffmpeg_kit_flutter_new_min_gpl/return_code.dart'; import '../screens/forward_contact_picker_screen.dart'; +import 'call_screen.dart'; +import 'package:flutter/services.dart'; +import 'package:drift/drift.dart' as drift; class ChatScreen extends StatefulWidget { final Contact contact; + final void Function(Contact contact)? onOpenProfile; + final VoidCallback? onBack; + final bool showBackButton; - const ChatScreen({super.key, required this.contact}); + const ChatScreen({ + super.key, + required this.contact, + this.onOpenProfile, + this.onBack, + this.showBackButton = true, + }); @override State createState() => _ChatScreenState(); @@ -64,7 +74,7 @@ class _ChatScreenState extends State with RouteAware { final LocalDbService _localDbService = LocalDbService(); final ScrollController _scrollController = ScrollController(); final Map _messageKeys = {}; - Map _messageMap = {}; + final Map _messageMap = {}; bool _showScrollToEnd = false; MessageModel? _replyTo; bool _isOnline = false; @@ -81,7 +91,6 @@ class _ChatScreenState extends State with RouteAware { double _inputBarHeight = 0; SecretKey? _chatSharedSecret; - final Map> _mediaLoadFutures = {}; final Map> _messageProgressNotifiers = {}; // Состояния для аудио/видео записи @@ -98,7 +107,7 @@ class _ChatScreenState extends State with RouteAware { -80.0; // Порог свайпа вверх для лока final AudioRecorder _audioRecorder = AudioRecorder(); - Stopwatch _stopwatch = Stopwatch(); + final Stopwatch _stopwatch = Stopwatch(); Timer? _stopwatchTimer; String _stopwatchDisplay = "0:00"; @@ -109,11 +118,13 @@ class _ChatScreenState extends State with RouteAware { @override void initState() { super.initState(); + messages.clear(); + _isKeyLoading = true; _currentContact = widget.contact; _socketService = Provider.of(context, listen: false); currentActiveChatContactId = _currentContact.id; // Устанавливаем активный чат - flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!); + _cancelChatNotifications(); final contactProvider = context.read(); myId = contactProvider.getCurrentUserId() ?? 0; // Если ключа нет, загружаем его при входе @@ -134,6 +145,24 @@ class _ChatScreenState extends State with RouteAware { _initCameras(); } + @override + void didUpdateWidget(covariant ChatScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.contact.id != oldWidget.contact.id) { + setState(() { + _currentContact = widget.contact; + }); + currentActiveChatContactId = _currentContact.id; + _cancelChatNotifications(); + _loadLocalName(); + if (_currentContact.publicKey == null) { + _loadContactKey(); + } + _loadHistory(); + _loadOnlineStatus(); + } + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -144,7 +173,17 @@ class _ChatScreenState extends State with RouteAware { void didPopNext() { print("Пользователь вернулся на этот экран!"); _loadLocalName(); - flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!); + _cancelChatNotifications(); + } + + Future _cancelChatNotifications() async { + try { + await flutterLocalNotificationsPlugin.cancel( + id: currentActiveChatContactId!, + ); + } catch (e) { + print('Delete notifications for this chat wasnt succesful: $e'); + } } Future _loadLocalName() async { @@ -305,6 +344,7 @@ class _ChatScreenState extends State with RouteAware { if (_cameraController != null && _cameraController!.value.isRecordingVideo) { XFile videoFile = await _cameraController!.stopVideoRecording(); + await _cameraController?.dispose(); filePath = videoFile.path; } } @@ -380,7 +420,13 @@ class _ChatScreenState extends State with RouteAware { Future _loadOnlineStatus() async { if (currentActiveChatContactId == null) return; - flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!); + try { + await flutterLocalNotificationsPlugin.cancel( + id: currentActiveChatContactId!, + ); + } catch (e) { + print('Delete notifications for this chat wasnt succesful: $e'); + } try { print( "🔍 Загружаем онлайн статус для контакта ${_currentContact.name} (ID: ${_currentContact.id})", @@ -452,7 +498,6 @@ class _ChatScreenState extends State with RouteAware { case MessageType.file: return '[Файл]'; case MessageType.text: - default: return ''; } } @@ -504,80 +549,155 @@ class _ChatScreenState extends State with RouteAware { return Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - if (Navigator.canPop(context)) { - Navigator.pop(context); - } else { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const ContactsScreen()), - ); + backgroundColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + leadingWidth: widget.showBackButton ? 64 : 16, + leading: widget.showBackButton + ? Center( + child: Padding( + padding: const EdgeInsets.only(left: 12.0), + child: ClipOval( + child: Material( + child: IconButton( + icon: const Icon(Icons.arrow_back_rounded, size: 20), + color: Theme.of(context).colorScheme.onSurface, + onPressed: () { + if (widget.onBack != null) { + widget.onBack!(); + } else if (Navigator.canPop(context)) { + Navigator.pop(context); + } + }, + ), + ), + ), + ), + ) + : const SizedBox.shrink(), + title: Consumer( + builder: (context, contactProvider, child) { + // Реактивно отслеживаем изменения пользователя в провайдере (например, аватарку) + final freshContact = contactProvider.contacts.firstWhere( + (c) => c.id == widget.contact.id, + orElse: () => widget.contact, + ); + + // ФИКС ОНЛАЙНА: Определяем статус на основе встроенного таймера экрана чата + final bool currentOnline = freshContact.isOnline || _isOnline; + final String subtitleText = currentOnline + ? 'в сети' + : (_lastOnline != null + ? 'был(а) ${_formatLastOnline(_lastOnline!)}' + : 'был(а) недавно'); + + String fName = _currentContact.name; + if (fName.toLowerCase() == 'unknown' || + fName.toLowerCase() == 'uncnown' || + fName == 'null') { + fName = 'Без имени'; } - }, - ), - title: GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => UserProfileScreen( - userId: _currentContact.id, - username: _currentContact.username, - name: _currentContact.name, + + String lName = _currentContact.surname; + if (lName.toLowerCase() == 'unknown' || + lName.toLowerCase() == 'uncnown' || + lName == 'null') { + lName = ''; + } + + final String cleanFullName = '$fName $lName'.trim(); + return InkWell( + onTap: () { + if (widget.onOpenProfile != null) { + widget.onOpenProfile!(freshContact); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => UserProfileScreen( + userId: freshContact.id, + username: freshContact.username, + name: cleanFullName, + ), + ), + ).then( + (_) => _loadLocalName(), + ); // Обновляем имя при возвращении с экрана профиля + } + }, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.4), + image: freshContact.avatarUrl != null + ? DecorationImage( + image: NetworkImage(freshContact.avatarUrl!), + fit: BoxFit.cover, + ) + : null, + ), + child: freshContact.avatarUrl == null + ? Center( + child: Text( + _currentContact.name.isNotEmpty + ? _currentContact.name[0].toUpperCase() + : '?', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // ФИКС ИМЕНИ: Читаем из стабильной переменной _currentContact + Text( + cleanFullName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + letterSpacing: -0.2, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + // ФИКС ВРЕМЕНИ ОНЛАЙНА: Выводим точное рассчитанное время + Text( + subtitleText, + style: TextStyle( + fontSize: 12, + fontWeight: currentOnline + ? FontWeight.bold + : FontWeight.normal, + color: currentOnline + ? Colors.green.shade400 + : Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ), + ], ), ), ); }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${_currentContact.name} ${_currentContact.surname != 'Unknown' ? _currentContact.surname : ''}', - ), - if (_isKeyLoading == true) - const Text( - 'загрузка...', - style: TextStyle( - fontSize: 12, - color: Color.fromARGB(255, 219, 219, 219), - ), - ) - else if (_isTyping) - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Text( - 'печатает', - style: TextStyle(fontSize: 12, color: Colors.greenAccent), - ), - const SizedBox(width: 4), - TypingIndicator(), - ], - ) - else if (_isOnline) - const Text( - 'онлайн', - style: TextStyle(fontSize: 12, color: Colors.greenAccent), - ) - else if (_lastOnline != null) - Text( - 'был(а) в сети ${_formatLastOnline(_lastOnline!)}', - style: const TextStyle( - fontSize: 12, - color: Color.fromARGB(255, 219, 219, 219), - ), - ) - else - const Text( - 'был(а) недавно', - style: TextStyle( - fontSize: 12, - color: Color.fromARGB(255, 219, 219, 219), - ), - ), - ], - ), ), ), body: Container( @@ -629,7 +749,7 @@ class _ChatScreenState extends State with RouteAware { // Формируем основное содержимое элемента сообщения Widget itemChild = Column( crossAxisAlignment: CrossAxisAlignment.start, - key: ValueKey(keyId.hashCode), + key: itemKey, mainAxisSize: MainAxisSize.min, children: [ if (showDateDivider) @@ -898,6 +1018,14 @@ class _ChatScreenState extends State with RouteAware { ); } + void _initiateCall(BuildContext context) { + // Отправляем сигнал на сервер + SocketService().sendMessage({ + "type": "call_init", + "receiver_id": widget.contact.id, + }); + } + bool _isNewDay(int currentIndex) { final int realIndex = messages.length - 1 - currentIndex; @@ -1099,35 +1227,6 @@ class _ChatScreenState extends State with RouteAware { ); } - Future _deleteLocalFile(MessageModel msg) async { - if (msg.localFile != null && msg.localFile!.existsSync()) { - try { - await msg.localFile!.delete(); - debugPrint("Локальный файл успешно удален с диска: ${msg.fileId}"); - } catch (e) { - debugPrint("Ошибка при физическом удалении файла с диска: $e"); - // Даже если файл не удалился физически, мы всё равно очистим стейт, - // чтобы приложение не пыталось его прочитать и не падало. - } - - final sharedPrefs = await SharedPreferences.getInstance(); - final String sizeKey = 'valid_dec_size_${msg.fileId}'; - await sharedPrefs.remove(sizeKey); - - if (mounted) { - setState(() { - final idx = messages.indexWhere((m) => m.id == msg.id); - print( - "Очистка локального файла для сообщения ${msg.id}. Индекс в списке: $idx", - ); - if (idx != -1) { - messages[idx] = messages[idx].copyWith(localFile: null); - } - }); - } - } - } - Future _editMessage(MessageModel msg) async { final controller = TextEditingController(text: msg.text); final result = await showDialog( @@ -1300,7 +1399,7 @@ class _ChatScreenState extends State with RouteAware { if (originalMsg.localFile != null) { final directory = await getApplicationDocumentsDirectory(); // Сохраняем строго под префиксом file_, который ожидает MessageBubble - final decFile = '${directory.path}/dec_$copiedFileId'; + final decFile = p.join(directory.path, 'dec_$copiedFileId'); newLocalFile = await originalMsg.localFile!.copy(decFile); print( "Локальный файл для пересылки создан по пути: ${newLocalFile.path}", @@ -1308,9 +1407,9 @@ class _ChatScreenState extends State with RouteAware { } else if (originalMsg.fileId != null) { final directory = await getApplicationDocumentsDirectory(); // Сохраняем строго под префиксом file_, который ожидает MessageBubble - final decFile = '${directory.path}/dec_$copiedFileId'; + final decFile = p.join(directory.path, 'dec_$copiedFileId'); final File oldFile = File( - '${directory.path}/dec_${originalMsg.fileId!}', + p.join(directory.path, 'dec_${originalMsg.fileId!}'), ); newLocalFile = await oldFile.copy(decFile); print( @@ -1469,6 +1568,7 @@ class _ChatScreenState extends State with RouteAware { final bool hasTextOrFile = _controller.text.trim().isNotEmpty || _pendingFile != null; final bool showSendButton = hasTextOrFile || _isRecordLocked; + final colorScheme = Theme.of(context).colorScheme; return Stack( clipBehavior: Clip.none, @@ -1476,16 +1576,21 @@ class _ChatScreenState extends State with RouteAware { Container( constraints: const BoxConstraints(maxHeight: 250), decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceVariant.withOpacity(0.75), - borderRadius: BorderRadius.circular(18), + color: colorScheme.surfaceVariant.withOpacity(0.35), + borderRadius: BorderRadius.circular(24), border: Border.all( - color: Theme.of(context).dividerColor.withOpacity(0.25), + color: colorScheme.outlineVariant.withOpacity(0.15), width: 1, ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 24, + offset: const Offset(0, -4), + ), + ], ), - padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 4.0), + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1497,12 +1602,16 @@ class _ChatScreenState extends State with RouteAware { ), margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), + color: colorScheme.surface.withOpacity(0.7), + borderRadius: BorderRadius.circular(14), ), child: Row( children: [ - const Icon(Icons.reply, size: 18), + Icon( + Icons.reply_rounded, + size: 18, + color: colorScheme.primary, + ), const SizedBox(width: 8), Expanded( child: Text( @@ -1511,10 +1620,13 @@ class _ChatScreenState extends State with RouteAware { : _getMediaPreview(_replyTo!.messageType), maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 13), ), ), IconButton( - icon: const Icon(Icons.close, size: 18), + icon: const Icon(Icons.close_rounded, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), onPressed: () => setState(() => _replyTo = null), ), ], @@ -1522,20 +1634,22 @@ class _ChatScreenState extends State with RouteAware { ), if (_pendingFile != null) Container( - margin: const EdgeInsets.only(bottom: 6), - padding: const EdgeInsets.all(6), + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.withOpacity(0.3)), + color: colorScheme.surface.withOpacity(0.8), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.outlineVariant.withOpacity(0.2), + ), ), child: Row( children: [ SizedBox( - width: 44, - height: 44, + width: 40, + height: 40, child: ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(10), child: _buildPreviewIcon(), ), ), @@ -1551,17 +1665,23 @@ class _ChatScreenState extends State with RouteAware { overflow: TextOverflow.ellipsis, style: const TextStyle( fontWeight: FontWeight.bold, + fontSize: 14, ), ), + const SizedBox(height: 2), Text( _pendingMessageType.name.toUpperCase(), - style: const TextStyle(fontSize: 12), + style: TextStyle( + fontSize: 11, + color: colorScheme.outline, + fontWeight: FontWeight.bold, + ), ), ], ), ), IconButton( - icon: const Icon(Icons.close, size: 22), + icon: const Icon(Icons.cancel_rounded, size: 20), onPressed: () => setState(() { _pendingFile = null; _pendingFileName = null; @@ -1577,30 +1697,38 @@ class _ChatScreenState extends State with RouteAware { children: [ if (!_isRecording) GestureDetector( - onTapDown: (details) { - _showPopup(context, details.globalPosition); - }, + onTapDown: (details) => + _showPopup(context, details.globalPosition), child: Container( - width: 32, - height: 32, + width: 38, + height: 38, + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.08), + shape: BoxShape.circle, + ), alignment: Alignment.center, - child: const Icon(Icons.photo, size: 22), + child: Icon( + Icons.attach_file_rounded, + size: 20, + color: colorScheme.primary, + ), ), ) else const Padding( - padding: EdgeInsets.symmetric(horizontal: 6, vertical: 6), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: Icon( - Icons.fiber_manual_record, + Icons.fiber_manual_record_rounded, color: Colors.red, - size: 20, + size: 18, ), ), + const SizedBox(width: 8), Expanded( child: _isRecording ? Container( padding: const EdgeInsets.symmetric( - vertical: 6, + vertical: 8, horizontal: 4, ), child: Row( @@ -1613,52 +1741,67 @@ class _ChatScreenState extends State with RouteAware { fontWeight: FontWeight.bold, ), ), - const SizedBox(width: 8), - _isRecordLocked - ? Text( - "Удержание записи", - style: TextStyle( - color: Colors.grey.shade500, - fontSize: 11, - ), - ) - : Text( - _recordDragX < _swipeCancelThreshold / 2 - ? "Отпусти для отмены" - : (_recordDragY < - _swipeLockThreshold / 2 - ? "Отпусти для удержания" - : "Проведите вверх для удержания"), - style: TextStyle( - color: Colors.grey.shade500, - fontSize: 11, - ), - ), - const SizedBox(width: 4), + const SizedBox(width: 12), + Expanded( + child: Text( + _isRecordLocked + ? "Запись..." + : (_recordDragX < + _swipeCancelThreshold / 2 + ? "Отпустите для удаления" + : "Смахните влево для отмены"), + style: TextStyle( + color: colorScheme.outline, + fontSize: 13, + ), + overflow: TextOverflow.ellipsis, + ), + ), ], ), ) - : TextField( - controller: _controller, - minLines: 1, - maxLines: 5, - readOnly: _isRecordLocked, - textInputAction: TextInputAction.newline, - textCapitalization: TextCapitalization.sentences, - textAlignVertical: TextAlignVertical.center, - style: const TextStyle(fontSize: 15), - decoration: InputDecoration( - hintText: _isRecordLocked - ? "Запись зафиксирована..." - : "Напиши сообщение...", - isDense: true, - isCollapsed: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 6, + : Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: CallbackShortcuts( + bindings: { + // 1. Просто Enter — отправка сообщения + const SingleActivator( + LogicalKeyboardKey.enter, + ): () { + if (showSendButton) { + _sendMessage(); + } + }, + const SingleActivator( + LogicalKeyboardKey.enter, + control: true, + ): () { + _insertNewLine(); + }, + const SingleActivator( + LogicalKeyboardKey.enter, + meta: true, + ): () { + _insertNewLine(); + }, + }, + child: TextField( + controller: _controller, + minLines: 1, + maxLines: 5, + readOnly: _isRecordLocked, + style: const TextStyle(fontSize: 15), + decoration: InputDecoration( + hintText: "Сообщение...", + hintStyle: TextStyle( + color: colorScheme.outline.withOpacity(0.6), + ), + isDense: true, + border: InputBorder.none, + ), + onChanged: (text) => setState(() {}), ), ), - onChanged: (text) => setState(() {}), ), ), _buildContextButton(showSendButton), @@ -1671,21 +1814,37 @@ class _ChatScreenState extends State with RouteAware { ); } + void _insertNewLine() { + final text = _controller.text; + final selection = _controller.selection; + + // Проверяем, что курсор корректно установлен + if (!selection.isValid) return; + + // Вставляем перенос строки в позицию курсора (или заменяем выделенный текст) + final newText = text.replaceRange(selection.start, selection.end, '\n'); + final newOffset = selection.start + 1; + + _controller.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newOffset), + ); + } + Widget _buildContextButton(bool showSendButton) { + final colorScheme = Theme.of(context).colorScheme; if (showSendButton) { return GestureDetector( - onTap: () { - if (_isRecordLocked) { - _stopAndSendRecording(); - } else { - _sendMessage(); - } - }, + onTap: () => _isRecordLocked ? _stopAndSendRecording() : _sendMessage(), child: Container( - width: 36, - height: 36, + width: 38, + height: 38, + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), alignment: Alignment.center, - child: const Icon(Icons.send, size: 22), + child: const Icon(Icons.send_rounded, size: 18, color: Colors.white), ), ); } @@ -1695,12 +1854,10 @@ class _ChatScreenState extends State with RouteAware { onLongPressStart: (_) => _startRecording(), onLongPressMoveUpdate: (details) { if (!_isRecording || _isRecordLocked) return; - setState(() { _recordDragX = details.localOffsetFromOrigin.dx; _recordDragY = details.localOffsetFromOrigin.dy; }); - if (_recordDragX < _swipeCancelThreshold) { _cancelRecording(); } else if (_recordDragY < _swipeLockThreshold) { @@ -1708,25 +1865,23 @@ class _ChatScreenState extends State with RouteAware { } }, onLongPressEnd: (_) { - if (_isRecording && !_isRecordLocked) { - _stopAndSendRecording(); - } + if (_isRecording && !_isRecordLocked) _stopAndSendRecording(); }, child: AnimatedContainer( - duration: const Duration(milliseconds: 100), - width: 36, - height: 36, + duration: const Duration(milliseconds: 150), + width: 38, + height: 38, alignment: Alignment.center, decoration: BoxDecoration( color: _isRecording - ? Colors.red.withOpacity(0.15) - : Colors.transparent, + ? Colors.red.withOpacity(0.12) + : colorScheme.primary.withOpacity(0.08), shape: BoxShape.circle, ), child: Icon( - _isVoiceMode ? Icons.mic : Icons.videocam, - size: _isRecording ? 24 : 22, - color: _isRecording ? Colors.red : Theme.of(context).iconTheme.color, + _isVoiceMode ? Icons.mic_rounded : Icons.videocam_rounded, + size: 20, + color: _isRecording ? Colors.red : colorScheme.primary, ), ), ); @@ -1742,16 +1897,17 @@ class _ChatScreenState extends State with RouteAware { position.dy, ), items: [ - PopupMenuItem( - value: 'camera', - child: Row( - children: const [ - Icon(Icons.camera_alt), - SizedBox(width: 8), - Text("Камера"), - ], + if (!Platform.isWindows) + PopupMenuItem( + value: 'camera', + child: Row( + children: const [ + Icon(Icons.camera_alt), + SizedBox(width: 8), + Text("Камера"), + ], + ), ), - ), PopupMenuItem( value: 'gallery', child: Row( @@ -1790,6 +1946,16 @@ class _ChatScreenState extends State with RouteAware { } Future _pickCamera() async { + if (Platform.isWindows) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Съемка фото/видео недоступна на Windows.'), + ), + ); + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) async { final result = await Navigator.push<(XFile, String)>( context, @@ -1813,6 +1979,86 @@ class _ChatScreenState extends State with RouteAware { } Future _pickGallery() async { + if (Platform.isWindows) { + final FilePickerResult? result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'bmp', + 'webp', + 'heic', + 'heif', + 'mp4', + 'mov', + 'avi', + 'mkv', + 'webm', + 'flv', + 'wmv', + 'm4v', + ], + ); + + if (result == null || result.files.isEmpty) return; + + try { + final picked = result.files.single; + if (picked.path == null) return; + final file = File(picked.path!); + if (!mounted) return; + + final lowerPath = picked.path!.toLowerCase(); + final isVideo = + lowerPath.endsWith('.mp4') || + lowerPath.endsWith('.mov') || + lowerPath.endsWith('.avi') || + lowerPath.endsWith('.mkv') || + lowerPath.endsWith('.webm') || + lowerPath.endsWith('.flv') || + lowerPath.endsWith('.wmv') || + lowerPath.endsWith('.m4v'); + final isImage = + lowerPath.endsWith('.jpg') || + lowerPath.endsWith('.jpeg') || + lowerPath.endsWith('.png') || + lowerPath.endsWith('.gif') || + lowerPath.endsWith('.bmp') || + lowerPath.endsWith('.webp') || + lowerPath.endsWith('.heic') || + lowerPath.endsWith('.heif'); + + Uint8List? bytes; + if (isImage) { + bytes = await file.readAsBytes(); + } + + setState(() { + if (isImage && bytes != null) { + _previewBytes = bytes; + } + _pendingFile = file; + _pendingFileName = picked.name; + _pendingMessageType = isVideo + ? MessageType.video + : isImage + ? MessageType.image + : MessageType.file; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка при выборе медиа: $e'), + duration: const Duration(seconds: 3), + ), + ); + } + return; + } + final photosGranted = await Permission.photos.request(); final videosGranted = await Permission.videos.request(); if (!photosGranted.isGranted || !videosGranted.isGranted) { @@ -2143,27 +2389,7 @@ class _ChatScreenState extends State with RouteAware { sharedSecret, ); - // Генерируем превью текст в зависимости от типа медиа - String previewText; - if (rawText.isNotEmpty) { - previewText = rawText; - } else if (hasMedia) { - previewText = switch (messageType) { -<<<<<<< HEAD - MessageType.videoNote => "[Кружок]", - MessageType.voiceNote => "[Голосовое]", -======= - MessageType.voiceNote => "[Кружок}", - MessageType.videoNote => "[Голосовое]", ->>>>>>> 4b306f3ceef54b88bb73fa4130a0e501641e6ca8 - MessageType.image => "[Фото]", - MessageType.video => "[Видео]", - MessageType.file => "[Файл]", - MessageType.text => "", - }; - } else { - previewText = ""; - } + String previewText = rawText.isNotEmpty ? rawText : "[Фото]"; if (previewText.length > 50) previewText = previewText.substring(0, 50); encryptedContent50 = await _cryptoService.encryptMessage( previewText, @@ -2197,7 +2423,7 @@ class _ChatScreenState extends State with RouteAware { ); final directory = await getApplicationDocumentsDirectory(); if (file != null) { - await file.copy('${directory.path}/dec_$fileId'); + await file.copy(p.join(directory.path, 'dec_$fileId')); print( "DEBUG: Сохраняю файл: ${file.path}, существует: ${await file.exists()}, размер: ${await file.length()}", ); @@ -2322,6 +2548,30 @@ class _ChatScreenState extends State with RouteAware { DateTime now = DateTime.now(); Duration offset = now.timeZoneOffset; + + if (data['type'] == 'call_created') { + final String serverCallId = data['call_id']; + final String targetName = data['receiver_name'] ?? "Пользователь"; + // Переходим на экран звонка, используя ID от сервера + navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (_) => CallScreen( + callId: serverCallId, + isIncoming: false, // Мы инициатор + callerName: targetName, + onAccept: () async {}, + onHangup: () { + // Отправляем сигнал отмены на сервер + SocketService().sendMessage({ + "type": "hangup", + "call_id": serverCallId, + }); + }, + ), + ), + ); + } + if (data['type'] == 'message_sent') { final tempId = int.tryParse(data['temp_id']?.toString() ?? ''); final serverId = int.tryParse(data['server_id']?.toString() ?? ''); @@ -2541,7 +2791,6 @@ class _ChatScreenState extends State with RouteAware { // 4. Добавляем в список и обновляем экран String? encryptedFileKey = data['encrypted_key']?.toString(); - Uint8List? decryptedImageBytes; // Lazy load images later if (!mounted) return; @@ -2637,11 +2886,12 @@ class _ChatScreenState extends State with RouteAware { Future _loadHistory() async { DateTime now = DateTime.now(); - Duration offset = now.timeZoneOffset; - initialMessage = null; // Сбрасываем данные уведомления при загрузке ключа + initialMessage = null; + final prefs = await SharedPreferences.getInstance(); await prefs.remove(_notificationLaunchKey); + try { print('[DEBUG] Начало загрузки истории'); final myPrivKey = await _cryptoService.getPrivateKey(); @@ -2650,147 +2900,49 @@ class _ChatScreenState extends State with RouteAware { widget.contact.publicKey!, ); print('[DEBUG] Ключи получены'); + _chatSharedSecret = sharedSecret; + + // 1. БЫСТРАЯ ЗАГРУЗКА ИЗ ЛОКАЛЬНОЙ БД (Оно читает ШИФРТЕКСТ и дешифрует для UI) final cached = await _localDbService.getChatHistory( widget.contact.id, myId, ); print('[DEBUG] Локальная история загружена: ${cached.length} сообщений'); - _chatSharedSecret = sharedSecret; - - // Сюда будем складывать успешно расшифрованные локальные сообщения - // Используем Map, где ключ — id сообщения, для мгновенного поиска Map localMessagesMap = {}; - try { - for (var msg in cached) { - final msgId = int.tryParse(msg['id']?.toString() ?? ''); - if (msgId == null) continue; - - try { - final decrypted = await _cryptoService.decryptMessage( - msg['content'], - sharedSecret, - ); - - final deliveredAt = msg['delivered_at'] == null - ? null - : DateTime.tryParse( - msg['delivered_at'].toString(), - )?.add(offset); - final readAt = msg['read_at'] == null - ? null - : DateTime.tryParse(msg['read_at'].toString())?.add(offset); - - MessageStatus status = (msg['sender_id'] == myId) - ? MessageStatus.sent - : MessageStatus.delivered; - if (msg['sender_id'] == myId) { - if (readAt != null) { - status = MessageStatus.read; - } else if (deliveredAt != null) { - status = MessageStatus.delivered; - } - } - - localMessagesMap[msgId] = MessageModel( - id: msgId, - text: decrypted, - isMe: msg['sender_id'] == myId, - senderId: msg['sender_id'], - receiverId: msg['receiver_id'], - createdAt: DateTime.parse(msg['timestamp']).add(offset), - status: status, - replyToId: msg['reply_to_id'] == null - ? null - : int.tryParse(msg['reply_to_id'].toString()), - replyToText: await _decryptReplyText( - msg['reply_to_text']?.toString(), - sharedSecret, - ), - editedAt: msg['edited_at'] != null - ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset) - : null, - messageType: _parseMessageTypeString(msg['message_type']), - fileId: msg['file_id']?.toString(), - encryptedFileKey: msg['encrypted_key']?.toString(), - fileName: msg['file_name']?.toString(), - fileSize: msg['file_size'] == null - ? null - : int.tryParse(msg['file_size'].toString()), - // ВАЖНО: Если в твоем MessageModel при чтении из БД как-то парсится localFile, - // обязательно пропиши его инициализацию здесь! - ); - } catch (e) { - // Обработка ошибки дешифровки локального сообщения... - final deliveredAt = msg['delivered_at'] == null - ? null - : DateTime.tryParse( - msg['delivered_at'].toString(), - )?.add(offset); - final readAt = msg['read_at'] == null - ? null - : DateTime.tryParse(msg['read_at'].toString())?.add(offset); - - MessageStatus status = (msg['sender_id'] == myId) - ? MessageStatus.sent - : MessageStatus.delivered; - if (msg['sender_id'] == myId) { - if (readAt != null) { - status = MessageStatus.read; - } else if (deliveredAt != null) { - status = MessageStatus.delivered; - } - } - localMessagesMap[msgId] = MessageModel( - id: msgId, - text: msg['content'], - isMe: msg['sender_id'] == myId, - senderId: msg['sender_id'], - receiverId: msg['receiver_id'], - createdAt: DateTime.parse(msg['timestamp']).add(offset), - status: status, - replyToId: msg['reply_to_id'] == null - ? null - : int.tryParse(msg['reply_to_id'].toString()), - replyToText: await _decryptReplyText( - msg['reply_to_text']?.toString(), - sharedSecret, - ), - editedAt: msg['edited_at'] != null - ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset) - : null, - messageType: _parseMessageTypeString(msg['message_type']), - fileId: msg['file_id']?.toString(), - encryptedFileKey: msg['encrypted_key']?.toString(), - fileName: msg['file_name']?.toString(), - fileSize: msg['file_size'] == null - ? null - : int.tryParse(msg['file_size'].toString()), - ); - //print('Ошибка дешифровки сообщения: $e'); - } + for (var msg in cached) { + final parsed = await _parseAndDecryptMessage( + msg, + sharedSecret, + offset, + {}, + ); + if (parsed != null && parsed.id != null) { + localMessagesMap[parsed.id!] = parsed; } - - if (localMessagesMap.isNotEmpty) { - if (!mounted) return; - setState(() { - messages = localMessagesMap.values.toList(); - _isKeyLoading = false; - }); - } - } catch (e) { - print(e); } + if (localMessagesMap.isNotEmpty) { + if (!mounted) return; + setState(() { + messages = localMessagesMap.values.toList(); + messages.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + _isKeyLoading = false; + }); + } + + // 2. ФОНОВАЯ ЗАГРУЗКА АКТУАЛЬНОЙ ИСТОРИИ ИЗ API final history = await apiService.getChatHistory(widget.contact.id); print('[DEBUG] Загружена история из API: ${history.length}'); final alreadyReadIncomingMessageIds = {}; - List loadedMessages = []; + List loadedMessages = + []; // Список для отображения в UI (дешифрованный) + List encryptedMessagesForStorage = + []; // Список для кэширования в БД (ЗАШИФРОВАННЫЙ) for (var msg in history) { - print(msg); final msgId = int.tryParse(msg['id']?.toString() ?? ''); if (msgId != null && msg['sender_id'] != myId && @@ -2798,91 +2950,79 @@ class _ChatScreenState extends State with RouteAware { alreadyReadIncomingMessageIds.add(msgId); } - final decrypted = await _cryptoService.decryptMessage( - msg['content'], + // Извлекаем оригинальный сырой шифртекст из ответа сервера + final String rawCiphertext = msg['content'].toString(); + final String? rawEncryptedReplyText = msg['reply_to_text']?.toString(); + + // Парсим и расшифровываем сообщение для UI (с защитой от повторной дешифровки) + final parsed = await _parseAndDecryptMessage( + msg, sharedSecret, + offset, + localMessagesMap, ); + if (parsed != null) { + loadedMessages.add(parsed); - final deliveredAt = msg['delivered_at'] == null - ? null - : DateTime.tryParse(msg['delivered_at'].toString())?.add(offset); - final readAt = msg['read_at'] == null - ? null - : DateTime.tryParse(msg['read_at'].toString())?.add(offset); - - MessageStatus status = (msg['sender_id'] == myId) - ? MessageStatus.sent - : MessageStatus.delivered; - if (msg['sender_id'] == myId) { - if (readAt != null) { - status = MessageStatus.read; - } else if (deliveredAt != null) { - status = MessageStatus.delivered; - } - } - - // КРИТИЧЕСКИЙ ФИКС: Ищем, есть ли это сообщение уже в локальном кэше - File? existingLocalFile; - if (msgId != null && localMessagesMap.containsKey(msgId)) { - existingLocalFile = localMessagesMap[msgId]?.localFile; - } - - loadedMessages.insert( - 0, - MessageModel( - id: msgId, - text: decrypted, - isMe: msg['sender_id'] == myId, - senderId: msg['sender_id'], - receiverId: msg['receiver_id'], - createdAt: DateTime.parse(msg['timestamp']).add(offset), - status: status, - replyToId: msg['reply_to_id'] == null - ? null - : int.tryParse(msg['reply_to_id'].toString()), - replyToText: await _decryptReplyText( - msg['reply_to_text']?.toString(), - sharedSecret, + // ФИКС КВОТЫ И БЕЗОПАСНОСТИ: Создаем клон модели специально для БД, + // подменяя открытый текст на исходный шифртекст сервера + encryptedMessagesForStorage.add( + MessageModel( + id: parsed.id, + text: rawCiphertext, // ТЕПЕРЬ ТУТ ХРАНИТСЯ ШИФРТЕКСТ + isMe: parsed.isMe, + senderId: parsed.senderId, + receiverId: parsed.receiverId, + createdAt: parsed.createdAt, + status: parsed.status, + replyToId: parsed.replyToId, + replyToText: + rawEncryptedReplyText, // ТЕПЕРЬ ТУТ ТОЖЕ ШИФРТЕКСТ ОТВЕТА + editedAt: parsed.editedAt, + messageType: parsed.messageType, + fileId: parsed.fileId, + encryptedFileKey: parsed.encryptedFileKey, + fileName: parsed.fileName, + fileSize: parsed.fileSize, + localFile: parsed.localFile, ), - editedAt: msg['edited_at'] != null - ? DateTime.tryParse(msg['edited_at'].toString())?.add(offset) - : null, - messageType: _parseMessageTypeString(msg['message_type']), - fileId: msg['file_id']?.toString(), - encryptedFileKey: msg['encrypted_key']?.toString(), - fileName: msg['file_name']?.toString(), - fileSize: msg['file_size'] == null - ? null - : int.tryParse(msg['file_size'].toString()), - // СОХРАНЯЕМ ФАЙЛ: Если он уже был скачан, мы не даем ему стать null! - localFile: existingLocalFile, - ), - ); + ); + } } + loadedMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + // 3. СОХРАНЕНИЕ В ЛОКАЛЬНУЮ БД СТРОГО ЗАШИФРОВАННЫХ ДАННЫХ try { - print('[DEBUG] Начинаем очищение и сохранение истории в локальную БД'); - await _localDbService.saveMessages(loadedMessages); - print('[DEBUG] Сообщения сохранени в локальную бд'); + print( + '[DEBUG] Начинаем сохранение истории в локальную БД в зашифрованном виде', + ); + // Передаем список с шифртекстом. Метод saveMessages запишет в базу именно его + await _localDbService.saveMessages(encryptedMessagesForStorage); + print('[DEBUG] Сообщения успешно защищены и сохранены в локальную бд'); } catch (e) { print("[ERROR] Ошибка сохранения истории в локальную базу: $e"); } + // 4. ФИНАЛЬНОЕ ОБНОВЛЕНИЕ ИНТЕРФЕЙСА (Интерфейс видит чистый текст, база — шифртекст) if (!mounted) return; setState(() { messages = loadedMessages; + loadedMessages.sort((a, b) => a.createdAt.compareTo(b.createdAt)); _isKeyLoading = false; }); - // Отправка read_receipt... + // Отправка отчетов о прочтении... for (final m in loadedMessages) { - if (m.isMe) continue; - final id = m.id; - if (id == null) continue; - if (alreadyReadIncomingMessageIds.contains(id)) continue; - if (_sentReadReceipts.contains(id)) continue; - Provider.of(context, listen: false).sendReadReceipt(id); - _sentReadReceipts.add(id); + if (m.isMe || m.id == null) continue; + if (alreadyReadIncomingMessageIds.contains(m.id)) continue; + if (_sentReadReceipts.contains(m.id)) continue; + + Provider.of( + context, + listen: false, + ).sendReadReceipt(m.id!); + _sentReadReceipts.add(m.id!); } } catch (e) { print("Ошибка загрузки истории: $e"); @@ -2891,6 +3031,132 @@ class _ChatScreenState extends State with RouteAware { } } + Future _parseAndDecryptMessage( + Map msg, + SecretKey sharedSecret, + Duration offset, + Map localCache, + ) async { + final msgId = int.tryParse(msg['id']?.toString() ?? ''); + if (msgId == null) return null; + + try { + // 1. УМНОЕ ИЗВЛЕЧЕНИЕ ID (понимает и 'sender_id' от API, и 'senderId' от БД Drift) + final int senderId = + int.tryParse( + msg['sender_id']?.toString() ?? msg['senderId']?.toString() ?? '', + ) ?? + 0; + final int receiverId = + int.tryParse( + msg['receiver_id']?.toString() ?? + msg['receiverId']?.toString() ?? + '', + ) ?? + 0; + + final cachedMsg = localCache[msgId]; + final String decryptedText; + final String? decryptedReplyText; + + // Аналогично проверяем даты + final rawEditedAt = msg['edited_at'] ?? msg['editedAt']; + final currentEditedAt = rawEditedAt != null + ? DateTime.tryParse(rawEditedAt.toString())?.add(offset) + : null; + + if (cachedMsg != null && cachedMsg.editedAt == currentEditedAt) { + decryptedText = cachedMsg.text; + decryptedReplyText = cachedMsg.replyToText; + } else { + // Контент тоже может прийти как 'content' (БД) или 'text' + final rawContent = + msg['content']?.toString() ?? msg['text']?.toString() ?? ''; + decryptedText = await _cryptoService.decryptMessage( + rawContent, + sharedSecret, + ); + + final rawReplyText = + msg['reply_to_text']?.toString() ?? msg['replyToText']?.toString(); + decryptedReplyText = await _decryptReplyText( + rawReplyText, + sharedSecret, + ); + } + + final rawDeliveredAt = msg['delivered_at'] ?? msg['deliveredAt']; + final deliveredAt = rawDeliveredAt != null + ? DateTime.tryParse(rawDeliveredAt.toString())?.add(offset) + : null; + + final rawReadAt = msg['read_at'] ?? msg['readAt']; + final readAt = rawReadAt != null + ? DateTime.tryParse(rawReadAt.toString())?.add(offset) + : null; + + MessageStatus status = (senderId == myId) + ? MessageStatus.sent + : MessageStatus.delivered; + + if (senderId == myId) { + if (readAt != null) { + status = MessageStatus.read; + } else if (deliveredAt != null) { + status = MessageStatus.delivered; + } + } + + final rawFileId = msg['file_id']?.toString() ?? msg['fileId']?.toString(); + final rawFileName = + msg['file_name']?.toString() ?? msg['fileName']?.toString(); + + File? existingLocalFile = await _findExistingCachedDecryptedFile( + rawFileId, + rawFileName, + ); + if (cachedMsg != null) { + existingLocalFile = cachedMsg.localFile ?? existingLocalFile; + } + + // Безопасное время + final rawTimestamp = msg['timestamp'] ?? msg['createdAt']; + final timestampStr = + rawTimestamp?.toString() ?? DateTime.now().toIso8601String(); + + return MessageModel( + id: msgId, + text: decryptedText, + isMe: senderId == myId, + senderId: senderId, + receiverId: receiverId, + createdAt: DateTime.parse(timestampStr).add(offset), + status: status, + replyToId: int.tryParse( + msg['reply_to_id']?.toString() ?? msg['replyToId']?.toString() ?? '', + ), + replyToText: decryptedReplyText, + editedAt: currentEditedAt, + messageType: _parseMessageTypeString( + (msg['message_type'] ?? msg['messageType'])?.toString() ?? 'text', + ), + fileId: rawFileId, + encryptedFileKey: + msg['encrypted_key']?.toString() ?? + msg['encryptedKey']?.toString() ?? + msg['encryptedFileKey']?.toString(), + fileName: rawFileName, + fileSize: int.tryParse( + msg['file_size']?.toString() ?? msg['fileSize']?.toString() ?? '', + ), + localFile: existingLocalFile, + ); + } catch (e) { + print('Ошибка парсинга/дешифровки сообщения $msgId: $e'); + return null; + } + } + final Map>> _activeDownloads = {}; Future _stopFileLoading(MessageModel message) async { @@ -2945,11 +3211,13 @@ class _ChatScreenState extends State with RouteAware { "Размер файла на сервере (${remoteSize} байт) отличается от локального (${message.fileSize} байт). Локальный файл признан недействительным.", ); if (message.localFile != null) { - message.localFile!.delete().catchError((e) { + try { + await message.localFile!.delete(); + } catch (e) { debugPrint( "Ошибка удаления недействительного локального файла: $e", ); - }); + } } } } @@ -2981,8 +3249,7 @@ class _ChatScreenState extends State with RouteAware { if (_activeDownloads.containsKey(msg.fileId)) return; if (msg.fileId == null || _chatSharedSecret == null) return; - final directory = await getApplicationDocumentsDirectory(); - final decFile = File('${directory.path}/dec_${msg.fileId}'); + final decFile = await _getCachedDecryptedFile(msg.fileId!, msg.fileName); final sharedPrefs = await SharedPreferences.getInstance(); final String sizeKey = 'valid_dec_size_${msg.fileId}'; @@ -2997,9 +3264,11 @@ class _ChatScreenState extends State with RouteAware { debugPrint( "Размер локального файла ($localLength байт) не совпадает с сохраненным эталоном ($expectedDecryptedSize байт). Файл поврежден. Удаляем.", ); - await decFile.delete().catchError( - (e) => debugPrint("Ошибка удаления: $e"), - ); + try { + await decFile.delete(); + } catch (e) { + debugPrint("Ошибка удаления: $e"); + } await sharedPrefs.remove(sizeKey); } else { debugPrint( @@ -3016,9 +3285,11 @@ class _ChatScreenState extends State with RouteAware { return; } } else { - await decFile.delete().catchError( - (e) => debugPrint("Удаление пустого файла: $e"), - ); + try { + await decFile.delete(); + } catch (e) { + debugPrint("Удаление пустого файла: $e"); + } } } // --- КОНЕЦ БЛОКА ВАЛИДАЦИИ --- @@ -3070,6 +3341,12 @@ class _ChatScreenState extends State with RouteAware { final response = await apiService.downloadFileAsStream(msg.fileId!); final networkStream = response.$1; final fileName = response.$2; + if (msg.fileId != null && fileName != null && fileName.isNotEmpty) { + await _localDbService.saveOriginalFileNameForFileId( + msg.fileId!, + fileName, + ); + } Stream> decryptedStream = _cryptoService.decryptFileStream( networkStream, @@ -3147,14 +3424,107 @@ class _ChatScreenState extends State with RouteAware { } catch (e) { debugPrint("Загрузка прервана или завершилась с ошибкой: $e"); if (await decFile.exists()) { - await decFile.delete().catchError( - (e) => debugPrint("Ошибка очистки файла: $e"), - ); + try { + await decFile.delete(); + } catch (e) { + debugPrint("Ошибка очистки файла: $e"); + } } await sharedPrefs.remove(sizeKey); // Чистим ключ при ошибке } } + Future _getDownloadsDirectory() async { + try { + final downloads = await getDownloadsDirectory(); + if (downloads != null) { + final dir = Directory(p.join(downloads.path, 'Chepuhagram')); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + } catch (e) { + debugPrint( + 'Downloads directory unavailable, falling back to app documents: $e', + ); + } + return await getApplicationDocumentsDirectory(); + } + + Future _getOriginalFileName(String fileId, String? fallback) async { + if (fallback != null && fallback.isNotEmpty) { + return fallback; + } + + return await _localDbService.getOriginalFileNameForFileId(fileId); + } + + Future _resolveUniqueFilePath(String fileName) async { + final safeName = p.basename(fileName); + final directory = await _getDownloadsDirectory(); + var candidatePath = p.join(directory.path, safeName); + if (!await File(candidatePath).exists()) { + return File(candidatePath); + } + + final nameWithoutExtension = p.basenameWithoutExtension(safeName); + final extension = p.extension(safeName); + var counter = 1; + while (true) { + final candidateName = '$nameWithoutExtension ($counter)$extension'; + candidatePath = p.join(directory.path, candidateName); + if (!await File(candidatePath).exists()) { + return File(candidatePath); + } + counter++; + } + } + + Future _getCachedDecryptedFile(String fileId, String? fileName) async { + final effectiveName = + await _getOriginalFileName(fileId, fileName) ?? 'file_$fileId'; + final safeName = p.basename(effectiveName); + final prefs = await SharedPreferences.getInstance(); + final cachedPathKey = 'cached_dec_file_path_$fileId'; + + final existingPath = prefs.getString(cachedPathKey); + if (existingPath != null) { + final existingFile = File(existingPath); + if (await existingFile.exists()) { + return existingFile; + } + } + + final decFile = await _resolveUniqueFilePath(safeName); + await prefs.setString(cachedPathKey, decFile.path); + return decFile; + } + + Future _findExistingCachedDecryptedFile( + String? fileId, + String? fileName, + ) async { + if (fileId == null) return null; + final prefs = await SharedPreferences.getInstance(); + final cachedPathKey = 'cached_dec_file_path_$fileId'; + final existingPath = prefs.getString(cachedPathKey); + + if (existingPath != null) { + final existingFile = File(existingPath); + if (await existingFile.exists()) { + return existingFile; + } + } + return null; + } + + Future _getDownloadCopyFile(String fileId, String? fileName) async { + final effectiveName = + await _getOriginalFileName(fileId, fileName) ?? 'file_$fileId'; + return _resolveUniqueFilePath(effectiveName); + } + Future _updateScrollButtonVisibility() async { if (!mounted) return; final shouldShow = @@ -3201,15 +3571,15 @@ class _ChatScreenState extends State with RouteAware { print('Открытие медиа'); if (msg.fileId == null) return; - // Получаем доступ к папке документов приложения - final directory = await getApplicationDocumentsDirectory(); + // Показываем индикатор загрузки (диалог или крутилку) + _showLoadingDialog(); - final decPath = '${directory.path}/dec_${msg.fileId}'; - - final decFile = File(decPath); + final decFile = await _getCachedDecryptedFile(msg.fileId!, msg.fileName); + final decPath = decFile.path; // 1. ПРОВЕРКА КЭША: если расшифрованный файл уже есть, открываем мгновенно if (await decFile.exists() && await decFile.length() > 0) { + _closeLoadingDialog(); _navigateToViewer(decPath, msg); return; } @@ -3222,14 +3592,12 @@ class _ChatScreenState extends State with RouteAware { return; } - // Показываем индикатор загрузки (диалог или крутилку) - _showLoadingDialog(); - try { - _ensureFileDecrypted(msg); + await _ensureFileDecrypted(msg); final decFile = File(decPath); if (await decFile.exists() && await decFile.length() > 0) { + _closeLoadingDialog(); _navigateToViewer(decPath, msg); return; } @@ -3239,10 +3607,7 @@ class _ChatScreenState extends State with RouteAware { // Если что-то пошло не так (ошибка сети, ошибка дешифрации) if (mounted) { - // Закрываем _showLoadingDialog(), если он все еще открыт - WidgetsBinding.instance.addPostFrameCallback((_) { - Navigator.of(context, rootNavigator: true).pop(); - }); + _closeLoadingDialog(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -3252,6 +3617,12 @@ class _ChatScreenState extends State with RouteAware { } } + void _closeLoadingDialog() { + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + } + void _showLoadingDialog() { showDialog( context: context, @@ -3416,6 +3787,62 @@ class _ChatScreenState extends State with RouteAware { ); } } + + // 1. Метод конвертации: из БД в UI-модель + MessageModel _fromDbMessage(Message dbMsg, int currentUserId) { + return MessageModel( + id: dbMsg.id, + tempId: + null, // Из базы мы поднимаем уже подтвержденные сервером сообщения + senderId: dbMsg.senderId, + receiverId: dbMsg.receiverId, + text: dbMsg.content, // ФИКС: маппинг content -> text + createdAt: DateTime.parse( + dbMsg.timestamp, + ), // ФИКС: маппинг timestamp -> createdAt + isMe: dbMsg.senderId == currentUserId, + // ФИКС: Высчитываем статус на основе дат из БД + status: dbMsg.readAt != null + ? MessageStatus.read + : (dbMsg.deliveredAt != null + ? MessageStatus.delivered + : MessageStatus.sent), + replyToId: dbMsg.replyToId, + replyToText: dbMsg.replyToText, + editedAt: dbMsg.editedAt != null ? DateTime.parse(dbMsg.editedAt!) : null, + messageType: MessageModel.parseMessageType(dbMsg.messageType), + fileId: dbMsg.fileId, + encryptedFileKey: dbMsg.encryptedKey, + fileName: dbMsg.fileName, + ); + } + + // 2. Метод конвертации: из UI-модели в сущность для БД + MessagesCompanion _toDbCompanion(MessageModel msg) { + return MessagesCompanion( + id: msg.id != null ? drift.Value(msg.id!) : const drift.Value.absent(), + senderId: drift.Value(msg.senderId), + receiverId: drift.Value(msg.receiverId), + content: drift.Value(msg.text), // ФИКС: text -> content + timestamp: drift.Value(msg.createdAt.toIso8601String()), + deliveredAt: + msg.status == MessageStatus.delivered || + msg.status == MessageStatus.read + ? drift.Value(DateTime.now().toIso8601String()) + : const drift.Value.absent(), + readAt: msg.status == MessageStatus.read + ? drift.Value(DateTime.now().toIso8601String()) + : const drift.Value.absent(), + + replyToId: drift.Value(msg.replyToId), + replyToText: drift.Value(msg.replyToText), + editedAt: drift.Value(msg.editedAt?.toIso8601String()), + messageType: drift.Value(msg.messageType.name), + fileId: drift.Value(msg.fileId), + encryptedKey: drift.Value(msg.encryptedFileKey), + fileName: drift.Value(msg.fileName), + ); + } } class TypingIndicator extends StatefulWidget { diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 567fd21..69e51b3 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -3,13 +3,13 @@ import 'package:chepuhagram/core/constants.dart'; import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../widgets/contact_tile.dart'; import '../screens/settings_screen.dart'; import '../screens/new_chat_screen.dart'; import '../screens/chat_screen.dart'; +import 'my_profile_screen.dart'; import '/logic/contact_provider.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import '/logic/auth_provider.dart'; +import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:chepuhagram/domain/services/crypto_service.dart'; @@ -23,6 +23,8 @@ import 'package:dio/dio.dart'; import 'package:path_provider/path_provider.dart'; import 'package:open_filex/open_filex.dart'; import '/data/datasources/ws_client.dart'; +import '/data/models/contact_model.dart'; +import 'user_profile_screen.dart'; class ContactsScreen extends StatefulWidget { final int? targetChatId; @@ -47,6 +49,23 @@ class _ContactsScreenState extends State with RouteAware { bool _contactsLoaded = false; Timer? _contactLoadTimer; + Contact? _selectedContact; + Contact? _profileContact; + double _contactsPaneWidth = 290; + double _profilePaneWidth = 360; + + final double _collapsedContactsWidth = 80; + final double _minExpandedContactsWidth = 290; + final double _maxExpandedContactsWidth = 500; + double _dragStartWidth = 0; + + // Адаптивное состояние навигации + int _currentIndex = 0; + bool _isLeftRailExpanded = false; + + // Хранилище стабильно загруженных локальных имён + Map _localFullNames = {}; + @override void initState() { super.initState(); @@ -58,7 +77,6 @@ class _ContactsScreenState extends State with RouteAware { final authProvider = context.read(); final contactProvider = context.read(); - // Установить текущего пользователя и загрузить контакты с сообщениями print( 'Setting current user ID in ContactProvider: ${authProvider.currentUserId}', ); @@ -67,6 +85,26 @@ class _ContactsScreenState extends State with RouteAware { }); } + // Метод стабильной потокобезопасной подгрузки локальных имён из кэша + Future _loadLocalNames() async { + final prefs = await SharedPreferences.getInstance(); + final contactProvider = context.read(); + final Map tempNames = {}; + + for (var contact in contactProvider.contacts) { + final String? fName = prefs.getString('firstname_${contact.id}'); + final String? lName = prefs.getString('lastname_${contact.id}'); + if (fName != null || lName != null) { + tempNames[contact.id] = '${fName ?? ''} ${lName ?? ''}'.trim(); + } + } + if (mounted) { + setState(() { + _localFullNames = tempNames; + }); + } + } + Future _startContactsLoadTimer() async { if (_contactLoadTimer != null && _contactLoadTimer!.isActive) return; _contactLoadTimer = Timer(const Duration(seconds: 2), () { @@ -75,10 +113,10 @@ class _ContactsScreenState extends State with RouteAware { } Future _initContacts() async { - if (_contactsLoaded) return; // Предотвращаем повторную загрузку + if (_contactsLoaded) return; final contactProvider = context.read(); - // Ждем завершения загрузки контактов await contactProvider.loadContacts(); + await _loadLocalNames(); // Гарантированный вызов после загрузки контактов print('Contacts loaded, checking targetChatId: ${widget.targetChatId}'); @@ -86,7 +124,6 @@ class _ContactsScreenState extends State with RouteAware { _checkAppUpdate(); }); - // Дальнейшая логика выполнится только после того, как loadContacts завершится if (widget.targetChatId != null) { _navigateToTargetChat(); } else { @@ -104,9 +141,9 @@ class _ContactsScreenState extends State with RouteAware { } @override - void didPopNext() { + void didPopNext() async { print("Пользователь вернулся на этот экран!"); - _refreshData(); + await _refreshData(); } @override @@ -116,11 +153,11 @@ class _ContactsScreenState extends State with RouteAware { super.dispose(); } - void _refreshData() { + Future _refreshData() async { print("Обновляем данные контактов и сообщений..."); final contactProvider = context.read(); - - contactProvider.loadContacts(); + await contactProvider.loadContacts(); + await _loadLocalNames(); // Синхронизируем локальные имена при возврате } Future _checkSavedNotificationTarget() async { @@ -137,10 +174,8 @@ class _ContactsScreenState extends State with RouteAware { final senderId = int.tryParse(data['sender_id']?.toString() ?? ''); final type = data['type']?.toString(); - // Поддерживаем старый payload (только sender_id) и новый (type+sender_id) if (senderId != null && (type == null || type == 'enc_message')) { print('Recovered targetChatId from saved data: $senderId'); - await prefs.remove(_notificationLaunchKey); _navigateToTargetChatWithId(senderId); return; @@ -156,11 +191,10 @@ class _ContactsScreenState extends State with RouteAware { void _navigateToTargetChat() { if (widget.targetChatId == null) return; - _navigateToTargetChatWithId(widget.targetChatId!); } - void _navigateToTargetChatWithId(int targetChatId) async { + void _navigateToTargetChatWithId(int targetChatId) { print('_navigateToTargetChat called with targetChatId: $targetChatId'); final contactProvider = context.read(); try { @@ -168,601 +202,514 @@ class _ContactsScreenState extends State with RouteAware { (c) => c.id == targetChatId, ); print('Auto-navigating to chat with contact: ${contact.username}'); - currentActiveChatContactId = targetChatId; // Устанавливаем активный чат - final result = await Navigator.push( - context, - MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)), - ); - if (result != null) { - _refreshData(); // Обновляем данные при возвращении с чата, если нужно - } + _selectContact(contact); } catch (e) { print('Target contact with id $targetChatId not found: $e'); } } - Future _checkAppUpdate() async { - print('Проверка обновлений'); - PackageInfo packageInfo = await PackageInfo.fromPlatform(); - try { - // 1. Запрос к вашему FastAPI - final response = await http.get( - Uri.parse('${AppConstants.baseUrl}/check-update'), - ); - if (response.statusCode == 200) { - final data = jsonDecode(response.body); - final String latestVersion = data['latest_version']; - print('444444'); - print(latestVersion); - print(packageInfo.version); - // Сравнение версий (предположим, у вас есть способ получить текущую версию) - if (latestVersion != packageInfo.version) { - setState(() { - _showUpdateBanner = true; - _latestApkUrl = data['apk_url']; - }); - if (_latestApkUrl != null) { - final size = await _fetchApkSize(_latestApkUrl!); - if (mounted) { - setState(() { - _apkFileSizeBytes = size; - }); - } - } - } - } - } catch (e) { - print("Ошибка проверки обновлений: $e"); - } + bool _isMobileLayout(BuildContext context) { + return MediaQuery.of(context).size.width < 700; } - Future _setupPushNotifications() async { - // Request permissions - await FirebaseMessaging.instance.requestPermission(); + bool _isTabletLayout(BuildContext context) { + final width = MediaQuery.of(context).size.width; + return width >= 700 && width < 1000; + } - String? token = await FirebaseMessaging.instance.getToken(); - if (token != null) { - ApiService apiService = ApiService(); - print(token); - await apiService.updateFcmToken(token); - } + bool _isDesktopLayout(BuildContext context) { + return MediaQuery.of(context).size.width >= 1000; + } - // Listen for token refresh - FirebaseMessaging.instance.onTokenRefresh.listen((newToken) { - ApiService apiService = ApiService(); - apiService.updateFcmToken(newToken); - print('FCM Token refreshed: $newToken'); - }); - - // Listen for foreground messages - FirebaseMessaging.onMessage.listen(_handleIncomingMessage); - - // Handle notification tap when app was terminated/backgrounded - FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { - print('Notification tapped, app opened: ${message.data}'); - if (message.data['type'] == 'enc_message') { - final senderId = int.tryParse( - message.data['sender_id']?.toString() ?? '', - ); - if (senderId != null) { - _navigateToChatFromNotification(senderId); - } else { - print( - 'Notification tap contains invalid sender_id: ${message.data['sender_id']}', - ); - } + void _selectContact(Contact contact) { + setState(() { + _selectedContact = contact; + if (_profileContact != null && _isDesktopLayout(context)) { + _profileContact = contact; } + currentActiveChatContactId = contact.id; }); } - void _navigateToChatFromNotification(int senderId) async { - final contactProvider = context.read(); - print('Navigate to chat from notification with senderId: $senderId'); - - // Если контакты еще не загружены, ждем их загрузки - if (contactProvider.contacts.isEmpty) { - print('Contacts not loaded yet, waiting...'); - // Ждем немного и пробуем снова - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted) { - _navigateToChatFromNotification(senderId); - } + void _openProfile(Contact contact) { + if (_isDesktopLayout(context)) { + setState(() { + _profileContact = contact; }); return; } - try { - final contact = contactProvider.contacts.firstWhere( - (c) => c.id == senderId, - ); - print('Navigating to chat from notification: ${contact.username}'); - currentActiveChatContactId = senderId; // Устанавливаем активный чат - final result = await Navigator.push( - context, - MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)), - ); - if (result != null) { - _refreshData(); // Обновляем данные при возвращении с чата, если нужно - } - } catch (e) { - // Contact not found, stay on contacts screen - print('Contact not found for notification: $senderId'); - } - } - - Future _handleIncomingMessage(dynamic data) async { - if (data is RemoteMessage) { - // FCM message - await _handleFCMMessage(data); - } else if (data is Map) { - // WebSocket message - print('WebSocket message received: $data'); - if (data['type'] == 'user_updated') { - final userId = int.tryParse(data['user_id']?.toString() ?? ''); - if (userId != null) { - final contactProvider = context.read(); - contactProvider.updateContact(userId); - } - } - if (data['type'] == 'user_online') { - final userId = int.tryParse(data['user_id']?.toString() ?? ''); - if (userId != null) { - final contactProvider = context.read(); - contactProvider.updateContactOnlineStatus(userId, true); - } - } - if (data['type'] == 'user_offline') { - final userId = int.tryParse(data['user_id']?.toString() ?? ''); - if (userId != null) { - final contactProvider = context.read(); - contactProvider.updateContactOnlineStatus(userId, false); - } - } - if (data['type'] == 'message_edited') { - final messageId = int.tryParse(data['message_id']?.toString() ?? ''); - final senderId = int.tryParse(data['sender_id']?.toString() ?? ''); - if (messageId != null && senderId != null) { - final contactProvider = context.read(); - final contact = contactProvider.contacts - .where((c) => c.id == senderId) - .firstOrNull; - - if (contact != null) { - final editedAt = DateTime.tryParse( - data['edited_at']?.toString() ?? '', - ); - - // Дефолтные значения на случай ошибки расшифровки - String lastMessageText = contact.lastMessage ?? ''; - bool isDecrypted = false; - - final myPrivKey = await CryptoService().getPrivateKey(); - if (myPrivKey != null && contact.publicKey != null) { - try { - final sharedSecret = await CryptoService().deriveSharedSecret( - myPrivKey, - contact.publicKey!, - ); - lastMessageText = await CryptoService().decryptMessage( - data['content']?.toString() ?? '', - sharedSecret, - ); - isDecrypted = true; - } catch (e) { - print('Error decrypting edited message for contacts list: $e'); - } - } - - // Единая точка обновления состояния - await contactProvider.updateContactLastMessage( - contact.id, - lastMessage: lastMessageText, - lastMessageTime: editedAt, - isLastMsgDecrypted: isDecrypted, - lastMessageId: messageId, - isEdited: true, - ); - } - } - } - - if (data['type'] == 'message_deleted') { - final messageId = int.tryParse(data['message_id']?.toString() ?? ''); - if (messageId != null) { - final contactProvider = context.read(); - final contactIndex = contactProvider.contacts.indexWhere( - (c) => c.lastMessageId == messageId, - ); - if (contactIndex != -1) { - final contactId = contactProvider.contacts[contactIndex].id; - await contactProvider.refreshContactLastMessage(contactId); - } - } - } - } - } - - Future _handleFCMMessage(RemoteMessage message) async { - try { - // Проверяем, не находимся ли мы уже в чате с отправителем - final senderId = int.tryParse( - message.data['sender_id']?.toString() ?? '', - ); - if (senderId != null && currentActiveChatContactId == senderId) { - print('Already in chat with sender $senderId, skipping notification'); - return; - } - - // Ensure notification channel exists - const AndroidNotificationChannel channel = AndroidNotificationChannel( - 'Messages', - 'Новые сообщения', - description: 'Chat messages notifications', - importance: Importance.high, - ); - - await flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >() - ?.createNotificationChannel(channel); - - final crypto = CryptoService(); - final myPrivKey = await crypto.getPrivateKey(); - if (myPrivKey == null) { - print('Private key not found, cannot decrypt message'); - return; - } - - final sharedSecret = await crypto.deriveSharedSecret( - myPrivKey, - message.data['public_key'], - ); - final decryptedText = await crypto.decryptMessage( - message.data['content'], - sharedSecret, - ); - - if (senderId == null) return; - final String groupKey = 'ru.chepuhagram.app.$senderId'; - - final prefs = await SharedPreferences.getInstance(); - final String? firstName = prefs.getString( - 'firstname_${message.data['sender_id']}', - ); - final String? lastName = prefs.getString( - 'lastname_${message.data['sender_id']}', - ); - final String localFullName = '${firstName ?? ''} ${lastName ?? ''}' - .trim(); - - final String title = localFullName.isNotEmpty - ? localFullName - : (message.data['username'] ?? 'Unknown'); - // Show local notification - - await flutterLocalNotificationsPlugin.show( - senderId, - '', - '', - NotificationDetails( - android: AndroidNotificationDetails( - 'Messages', - 'Новые сообщения', - groupKey: groupKey, - setAsGroupSummary: true, - importance: Importance.high, - priority: Priority.high, - groupAlertBehavior: GroupAlertBehavior.all, + if (_isTabletLayout(context)) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => SizedBox( + height: MediaQuery.of(context).size.height * 0.85, + child: UserProfileScreen( + userId: contact.id, + username: contact.username, + name: contact.name, ), ), ); - await flutterLocalNotificationsPlugin.show( - message.hashCode, - title, - decryptedText, - NotificationDetails( - android: AndroidNotificationDetails( - 'Messages', - 'Новые сообщения', - groupKey: groupKey, - importance: Importance.high, - priority: Priority.high, - showWhen: true, - ), - ), - payload: jsonEncode({ - 'type': 'enc_message', - 'sender_id': message.data['sender_id'], - 'timestamp': - message.data['timestamp'] ?? DateTime.now().toIso8601String(), - }), - ); - - if (message.data['type'] == 'enc_message') { - print('Received private message FCM, updating contact $senderId'); - final contactProvider = context.read(); - contactProvider.updateContact( - senderId, - lastMessage: decryptedText, - lastMessageTime: DateTime.tryParse( - message.data['timestamp'] ?? DateTime.now().toIso8601String(), - ), - isLastMsgDecrypted: true, - unreadCount: message.data['unread_count'] != null - ? int.tryParse(message.data['unread_count'].toString()) ?? 1 - : null, - ); - } - } catch (e) { - print('Error processing foreground FCM message: $e'); + return; } - } - @override - Widget build(BuildContext context) { - double bannerHeight = 0.0; - if (_showUpdateBanner) { - bannerHeight = _isDownloading ? 152.0 : 96.0; - } - final double fabBottomPadding = _showUpdateBanner - ? (bannerHeight + 20.0) - : 16.0; - return Scaffold( - appBar: AppBar( - title: Text( - "Chepuhagram", - style: TextStyle(fontWeight: FontWeight.bold), - ), - centerTitle: false, - elevation: 0, - actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})], - ), - body: Stack( - children: [ - Consumer( - builder: (context, contactProvider, child) { - if (contactProvider.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (contactProvider.error != null) { - return Center( - child: Text( - '${contactProvider.error?.replaceAll('Exception: ', '')}', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - textAlign: TextAlign.center, - ), - ); - } - return ListView.separated( - itemCount: contactProvider.contacts.length, - separatorBuilder: (context, index) => Divider( - height: 1, - indent: 80, - color: Theme.of(context).colorScheme.primaryContainer, - ), - itemBuilder: (context, index) { - final contact = contactProvider.contacts[index]; - return ContactTile( - contact: contact, - onTap: () async { - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ChatScreen(contact: contact), - ), - ); - if (result != null) { - _refreshData(); // Обновляем данные при возвращении с чата, если нужно - } - }, - ); - }, - ); - }, - ), - if (_showUpdateBanner) - Positioned( - left: 0, - right: 0, - bottom: 40, - child: _buildUpdateBanner(), - ), - ], - ), - floatingActionButton: AnimatedPadding( - duration: const Duration(milliseconds: 100), - curve: Curves.easeInOut, - padding: EdgeInsets.only(bottom: fabBottomPadding), - child: FloatingActionButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const NewChatScreen()), - ); - }, - child: const Icon(Icons.edit), - ), - ), - drawer: Drawer( - child: ListView( - padding: EdgeInsets.zero, - children: [ - // Шапка меню с данными юзера - Consumer( - builder: (context, authProvider, _) { - final username = authProvider.username; - final displayName = authProvider.displayName; - final initials = - (displayName.isNotEmpty ? displayName : (username ?? 'U')) - .trim() - .split(RegExp(r'\s+')) - .where((p) => p.isNotEmpty) - .take(2) - .map((p) => p[0].toUpperCase()) - .join(); - - return UserAccountsDrawerHeader( - accountName: Text( - displayName, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - accountEmail: Text( - username == null || username.isEmpty ? '' : '@$username', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - currentAccountPicture: CircleAvatar( - backgroundColor: - authProvider.avatarUrl == null && - authProvider.avatarPath == null - ? Theme.of(context).colorScheme.onSurface - : null, - backgroundImage: authProvider.avatarUrl != null - ? CachedNetworkImageProvider(authProvider.avatarUrl!) - : authProvider.avatarPath != null - ? FileImage(File(authProvider.avatarPath!)) - : null, - child: - (authProvider.avatarUrl == null && - authProvider.avatarPath == null) - ? Text( - initials.isEmpty ? 'U' : initials, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Theme.of( - context, - ).colorScheme.primaryContainer, - ), - ) - : null, - ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.inversePrimary, - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.settings), - title: const Text("Настройки"), - onTap: () { - // Закрываем Drawer и переходим на экран настроек - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute(builder: (_) => SettingsScreen()), - ); - }, - ), - ListTile( - leading: const Icon(Icons.info_outline), - title: const Text("О приложении"), - onTap: () { - /* ... */ - }, - ), - ], + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => UserProfileScreen( + userId: contact.id, + username: contact.username, + name: contact.name, ), ), ); } - Future _startDownload() async { - if (_latestApkUrl == null) return; + void _clearSelectedContact() { + setState(() { + _selectedContact = null; + if (!_isDesktopLayout(context)) { + _profileContact = null; + } + currentActiveChatContactId = null; + }); + } - // Показываем индикатор - setState(() => _isDownloading = true); + Widget _buildPlaceholder(String text) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Text( + text, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } - final dir = await getExternalStorageDirectory(); - final path = '${dir!.path}/update.apk'; - final file = File(path); + Widget _buildContactsPane() { + return Consumer( + builder: (context, contactProvider, child) { + if (contactProvider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (contactProvider.error != null) { + return Center( + child: Text( + '${contactProvider.error?.replaceAll('Exception: ', '')}', + style: TextStyle(color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ), + ); + } - // Удаляем старый файл, если он есть, чтобы гарантировать чистоту - if (await file.exists()) { - await file.delete(); + final isCollapsed = + !_isMobileLayout(context) && + (_contactsPaneWidth <= _collapsedContactsWidth); + + if (contactProvider.contacts.isEmpty) { + return _buildPlaceholder( + 'Список чатов пуст. Нажмите карандаш, чтобы начать.', + ); + } + + return ListView.separated( + physics: const BouncingScrollPhysics(), + itemCount: contactProvider.contacts.length, + separatorBuilder: (context, index) => Divider( + height: 1, + indent: isCollapsed ? 12 : 84, + endIndent: 12, + color: Theme.of( + context, + ).colorScheme.outlineVariant.withOpacity(0.15), + ), + itemBuilder: (context, index) { + final contact = contactProvider.contacts[index]; + final colorScheme = Theme.of(context).colorScheme; + final isSelected = _selectedContact?.id == contact.id; + + final localName = _localFullNames[contact.id]; + final displayName = (localName != null && localName.isNotEmpty) + ? localName + : contact.name; + + final contactInitials = displayName.isNotEmpty + ? displayName + .trim() + .split(RegExp(r'\s+')) + .take(2) + .map((e) => e[0].toUpperCase()) + .join() + : '?'; + + String timeText = ''; + if (contact.lastMessageTime != null) { + final localTime = contact.lastMessageTime!.toLocal(); + timeText = + '${localTime.hour.toString().padLeft(2, '0')}:${localTime.minute.toString().padLeft(2, '0')}'; + } + + final bool isLastMessageEmpty = + contact.lastMessage != null && + contact.lastMessage!.trim().isEmpty; + final String displayLastMessage = isLastMessageEmpty + ? 'Вложение' + : (contact.lastMessage ?? 'Нет сообщений'); + final Color lastMessageColor = + (contact.lastMessage != null && !isLastMessageEmpty) + ? colorScheme.onSurfaceVariant + : colorScheme.outline.withOpacity(1); + + return Padding( + // ФИКС ОВЕРФЛОУ: уменьшаем внешний отступ при сжатии до 4px (было 8) + padding: EdgeInsets.symmetric( + horizontal: isCollapsed ? 4 : 8, + vertical: 2, + ), + child: InkWell( + onTap: () => _selectContact(contact), + borderRadius: BorderRadius.circular(16), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + // ФИКС ОВЕРФЛОУ: уменьшаем внутренний отступ при сжатии до 6px (было 12) + padding: EdgeInsets.symmetric( + horizontal: isCollapsed ? 6 : 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primaryContainer.withOpacity(0.4) + : Colors.transparent, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + // Центрируем аватарку по оси, когда колонка зажата + mainAxisAlignment: isCollapsed + ? MainAxisAlignment.center + : MainAxisAlignment.start, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.primary.withOpacity(0.08), + ), + child: ClipOval( + child: Stack( + alignment: Alignment.center, + children: [ + Text( + contactInitials, + style: TextStyle( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + if (contact.avatarUrl != null) + Image.network( + contact.avatarUrl!, + fit: BoxFit.cover, + width: 52, + height: 52, + errorBuilder: + (context, error, stackTrace) => + const SizedBox.shrink(), + loadingBuilder: + (context, child, loadingProgress) { + if (loadingProgress == null) + return child; + return const SizedBox.shrink(); + }, + ), + ], + ), + ), + ), + if (contact.isOnline) + Positioned( + right: -1, + bottom: -1, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: Colors.green.shade500, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? Color.alphaBlend( + colorScheme.primaryContainer + .withOpacity(0.4), + colorScheme.background, + ) + : colorScheme.background, + width: 2.5, + ), + ), + ), + ), + ], + ), + + if (!isCollapsed) ...[ + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + displayName, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + letterSpacing: -0.3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (timeText.isNotEmpty) + Text( + timeText, + style: TextStyle( + color: colorScheme.outline, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 5), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + displayLastMessage, + style: TextStyle( + color: lastMessageColor, + fontSize: 14, + fontStyle: isLastMessageEmpty + ? FontStyle.italic + : FontStyle.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (contact.unreadCount != null && + contact.unreadCount! > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 3, + ), + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints( + minWidth: 20, + ), + child: Text( + '${contact.unreadCount}', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ], + ), + ), + ], + ], + ), + ), + ), + ); + }, + ); + }, + ); + } + + Widget _buildChatPane() { + if (_selectedContact == null) { + return _buildPlaceholder('Выберите чат слева, чтобы начать переписку.'); } + return ChatScreen( + key: ValueKey(_selectedContact!.id), + contact: _selectedContact!, + onOpenProfile: _openProfile, + onBack: _clearSelectedContact, + showBackButton: false, + ); + } - try { - setState(() { - _downloadProgress = 0.0; - _downloadedBytes = 0; - _downloadTotalBytes = 0; - }); - - // Скачиваем файл «в лоб» - await Dio().download( - _latestApkUrl!, - path, - cancelToken: _cancelToken, - onReceiveProgress: (rec, total) { - if (mounted) { - setState(() { - _downloadedBytes = rec; - _downloadTotalBytes = total > 0 ? total : 0; - _downloadProgress = total > 0 ? rec / total : 0.0; - }); - } - }, - ); - - // После успешного скачивания — установка - final result = await OpenFilex.open(path); - if (result.type != ResultType.done) { - print("Ошибка при установке: ${result.message}"); - } - } on DioException catch (e) { - if (e.type != DioExceptionType.cancel) { - print("Ошибка скачивания: $e"); - } - } catch (e) { - print("Ошибка: $e"); - } finally { - if (mounted) { + Widget _buildProfilePane() { + final contact = _profileContact; + if (contact == null) { + return _buildPlaceholder('Профиль выбранного пользователя будет здесь.'); + } + return UserProfileScreen( + key: ValueKey(contact.id), + userId: contact.id, + username: contact.username, + name: contact.name, + onClose: () { setState(() { - _isDownloading = false; - _downloadProgress = 0.0; - _downloadedBytes = 0; - _downloadTotalBytes = 0; + _profileContact = null; }); + }, + ); + } + + Widget _buildContactsListWithScaffold(bool isPhone) { + final colorScheme = Theme.of(context).colorScheme; + final isCollapsed = + !isPhone && (_contactsPaneWidth <= _collapsedContactsWidth); + + Widget bodyWidget; + String titleText = "Chepuhagram"; + bool showSearch = true; + + if (isPhone) { + switch (_currentIndex) { + case 1: + titleText = "Профиль"; + showSearch = false; + bodyWidget = const MyProfileScreen(isFromList: true); + break; + case 2: + titleText = "Настройки"; + showSearch = false; + bodyWidget = const SettingsScreen(isFromList: true); + break; + case 0: + default: + titleText = "Chepuhagram"; + showSearch = true; + bodyWidget = _buildContactsPane(); + break; } + } else { + bodyWidget = _buildContactsPane(); } + + return Scaffold( + backgroundColor: isPhone ? colorScheme.background : Colors.transparent, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + title: Text( + titleText, + style: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 24, + letterSpacing: -0.5, + ), + ), + centerTitle: false, + actions: [ + if (showSearch) + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: ClipOval( + child: Material( + child: IconButton( + icon: const Icon(Icons.search_rounded, size: 22), + onPressed: () {}, + ), + ), + ), + ), + ], + ), + body: Column( + children: [ + Expanded(child: bodyWidget), + if (_showUpdateBanner) + SafeArea(top: false, child: _buildUpdateBanner(isPhone)), + ], + ), + floatingActionButton: (isCollapsed || (isPhone && _currentIndex != 0)) + ? null + : AnimatedPadding( + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOut, + padding: EdgeInsets.only( + bottom: _showUpdateBanner + ? _isDownloading + ? 150.0 + : 100.0 + : 16.0, + ), + child: FloatingActionButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const NewChatScreen()), + ), + child: const Icon(Icons.edit_note_rounded), + ), + ), + bottomNavigationBar: isPhone + ? BottomNavigationBar( + currentIndex: _currentIndex, + elevation: 8, + onTap: (index) => setState(() => _currentIndex = index), + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.chat_bubble_outline_rounded), + activeIcon: Icon(Icons.chat_bubble_rounded), + label: "Чаты", + ), + BottomNavigationBarItem( + icon: Icon(Icons.person_outline_rounded), + activeIcon: Icon(Icons.person_rounded), + label: "Профиль", + ), + BottomNavigationBarItem( + icon: Icon(Icons.settings_outlined), + activeIcon: Icon(Icons.settings_rounded), + label: "Настройки", + ), + ], + ) + : null, + ); } - Future _fetchApkSize(String url) async { - try { - final response = await http.head(Uri.parse(url)); - final lengthHeader = response.headers['content-length']; - if (lengthHeader == null) return 0; - return int.tryParse(lengthHeader) ?? 0; - } catch (_) { - return 0; - } - } + Widget _buildUpdateBanner(bool isPhone) { + final isCollapsed = + !isPhone && (_contactsPaneWidth <= _collapsedContactsWidth); + if (isCollapsed) return const SizedBox.shrink(); - String _formatBytes(int bytes) { - if (bytes <= 0) return '0 B'; - const kb = 1024; - const mb = kb * 1024; - if (bytes < kb) return '$bytes B'; - if (bytes < mb) return '${(bytes / kb).toStringAsFixed(1)} KB'; - return '${(bytes / mb).toStringAsFixed(1)} MB'; - } - - Widget _buildUpdateBanner() { return Container( - margin: const EdgeInsets.fromLTRB( - 12, - 0, - 12, - 16, - ), // Отступы от краев и снизу + // Сделали аккуратные отступы сверху для баннера + margin: const EdgeInsets.fromLTRB(16, 4, 16, 12), child: Material( elevation: 6, borderRadius: BorderRadius.circular(12), @@ -781,12 +728,6 @@ class _ContactsScreenState extends State with RouteAware { children: [ Row( children: [ - const Icon( - Icons.system_update_alt, - color: Colors.white, - size: 28, - ), - const SizedBox(width: 12), Expanded( child: Text( _isDownloading @@ -799,29 +740,23 @@ class _ContactsScreenState extends State with RouteAware { fontWeight: FontWeight.bold, fontSize: 16, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), ), TextButton( onPressed: () async { if (_isDownloading) { - // Если уже качаем — отменяем _cancelToken?.cancel("Пользователь отменил загрузку"); setState(() { _isDownloading = false; - _cancelToken = null; // Обязательно обнуляем токен! _downloadProgress = 0.0; - _downloadedBytes = 0; - _downloadTotalBytes = 0; }); } else { - // Если не качаем — запускаем setState(() { _isDownloading = true; - _cancelToken = - CancelToken(); // Создаем новый токен перед началом + _cancelToken = CancelToken(); }); - - // ВАЖНО: вызываем саму функцию скачивания await _startDownload(); } }, @@ -863,4 +798,574 @@ class _ContactsScreenState extends State with RouteAware { ), ); } + + Widget _buildWindowsNavigationRail() { + final double railWidth = _isLeftRailExpanded ? 220 : 68; + final colorScheme = Theme.of(context).colorScheme; + + return AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.fastOutSlowIn, + width: railWidth, + color: colorScheme.surfaceVariant.withOpacity(0.25), + child: Column( + children: [ + const SizedBox(height: 12), + IconButton( + icon: Icon( + _isLeftRailExpanded + ? Icons.menu_open_rounded + : Icons.menu_rounded, + ), + onPressed: () => + setState(() => _isLeftRailExpanded = !_isLeftRailExpanded), + ), + const Divider(height: 24, indent: 12, endIndent: 12), + _buildRailItem( + Icons.chat_bubble_outline_rounded, + Icons.chat_bubble_rounded, + "Чаты", + 0, + ), + _buildRailItem( + Icons.settings_outlined, + Icons.settings_rounded, + "Настройки", + 2, + ), + _buildRailItem( + Icons.person_outline_rounded, + Icons.person_rounded, + "Профиль", + 1, + ), + ], + ), + ); + } + + Widget _buildRailItem( + IconData icon, + IconData activeIcon, + String label, + int index, + ) { + final isSelected = _currentIndex == index; + final colorScheme = Theme.of(context).colorScheme; + final color = isSelected ? colorScheme.primary : colorScheme.onSurface; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: InkWell( + onTap: () => setState(() => _currentIndex = index), + borderRadius: BorderRadius.circular(12), + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withOpacity(0.08) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: _isLeftRailExpanded + ? MainAxisAlignment.start + : MainAxisAlignment.center, + children: [ + Icon(isSelected ? activeIcon : icon, color: color, size: 22), + if (_isLeftRailExpanded) ...[ + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: color, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildResizableDivider({ + required Function(DragUpdateDetails) onPanUpdate, + Function(DragStartDetails)? onPanStart, + }) { + return GestureDetector( + onPanStart: onPanStart, + onPanUpdate: onPanUpdate, + child: MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + child: Container( + width: 8, + color: Colors.transparent, + alignment: Alignment.center, + child: VerticalDivider( + width: 1, + thickness: 1, + color: Theme.of(context).dividerColor.withOpacity(0.4), + ), + ), + ), + ); + } + + Widget _buildResponsiveBody(bool isPhone) { + final media = MediaQuery.of(context); + // Проверка физического форм-фактора (надежнее, чем Platform.isAndroid) + final bool isPhoneFormFactor = media.size.shortestSide < 600; + + // 1. ЛОГИКА ДЛЯ СМАРТФОНОВ (любая ОС, если экран маленький) + if (isPhoneFormFactor) { + if (_selectedContact != null) { + return ChatScreen( + contact: _selectedContact!, + onOpenProfile: _openProfile, + onBack: _clearSelectedContact, + showBackButton: true, + ); + } + return _buildContactsListWithScaffold(true); + } + + // 2. ЛОГИКА ДЛЯ ПЛАНШЕТОВ И КОМПЬЮТЕРОВ (Широкие экраны) + Widget centerPane; + + switch (_currentIndex) { + case 1: + centerPane = const Expanded(child: MyProfileScreen(isFromList: false)); + break; + case 2: + centerPane = const Expanded(child: SettingsScreen(isFromList: false)); + break; + case 0: + default: + centerPane = Expanded( + child: Row( + children: [ + SizedBox( + width: _contactsPaneWidth, + child: _buildContactsListWithScaffold(false), + ), + _buildResizableDivider( + onPanStart: (details) => _dragStartWidth = _contactsPaneWidth, + onPanUpdate: (details) { + setState(() { + final newWidth = details.globalPosition.dx; + if (_dragStartWidth > _collapsedContactsWidth) { + if (newWidth < (_minExpandedContactsWidth / 2)) { + _contactsPaneWidth = _collapsedContactsWidth; + } else { + _contactsPaneWidth = newWidth.clamp( + _minExpandedContactsWidth, + _maxExpandedContactsWidth, + ); + } + } else { + if (newWidth > (_minExpandedContactsWidth / 2) + 40) { + _contactsPaneWidth = newWidth.clamp( + _minExpandedContactsWidth, + _maxExpandedContactsWidth, + ); + } + } + }); + }, + ), + Expanded(child: _buildChatPane()), + if (_profileContact != null && _isDesktopLayout(context)) ...[ + _buildResizableDivider( + onPanUpdate: (details) { + setState(() { + final screenWidth = MediaQuery.of(context).size.width; + _profilePaneWidth = + (screenWidth - details.globalPosition.dx).clamp( + 280, + 500, + ); + }); + }, + ), + SizedBox(width: _profilePaneWidth, child: _buildProfilePane()), + ], + ], + ), + ); + break; + } + + return Scaffold( + body: Row(children: [_buildWindowsNavigationRail(), centerPane]), + ); + } + + @override + Widget build(BuildContext context) { + final bool isPhoneFormFactor = + MediaQuery.of(context).size.shortestSide < 600; + + return PopScope( + canPop: _selectedContact == null || !isPhoneFormFactor, + onPopInvokedWithResult: (didPop, result) { + if (didPop) return; + + if (_selectedContact != null && isPhoneFormFactor) { + _clearSelectedContact(); // Плавно закрываем чат и возвращаемся к списку + } + }, + child: _buildResponsiveBody(isPhoneFormFactor), + ); + } + + Future _checkAppUpdate() async { + print('Проверка обновлений'); + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + try { + final response = await http.get( + Uri.parse('${AppConstants.baseUrl}/check-update'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final String latestVersion = data['latest_version']; + if (latestVersion != packageInfo.version) { + setState(() { + _showUpdateBanner = true; + _latestApkUrl = data['apk_url']; + }); + if (_latestApkUrl != null) { + final size = await _fetchApkSize(_latestApkUrl!); + if (mounted) { + setState(() => _apkFileSizeBytes = size); + } + } + } + } + } catch (e) { + print("Ошибка проверки обновлений: $e"); + } + } + + Future _setupPushNotifications() async { + try { + if (Firebase.apps.isEmpty) { + print('Firebase is not initialized, skipping push notification setup.'); + return; + } + await FirebaseMessaging.instance.requestPermission(); + String? token = await FirebaseMessaging.instance.getToken(); + if (token != null) { + ApiService apiService = ApiService(); + await apiService.updateFcmToken(token); + } + FirebaseMessaging.instance.onTokenRefresh.listen((newToken) { + ApiService apiService = ApiService(); + apiService.updateFcmToken(newToken); + }); + FirebaseMessaging.onMessage.listen(_handleIncomingMessage); + FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { + if (message.data['type'] == 'enc_message') { + final senderId = int.tryParse( + message.data['sender_id']?.toString() ?? '', + ); + if (senderId != null) _navigateToChatFromNotification(senderId); + } + }); + } catch (e) { + print('Push notification setup failed: $e'); + } + } + + void _navigateToChatFromNotification(int senderId) { + final contactProvider = context.read(); + if (contactProvider.contacts.isEmpty) { + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) _navigateToChatFromNotification(senderId); + }); + return; + } + try { + final contact = contactProvider.contacts.firstWhere( + (c) => c.id == senderId, + ); + _selectContact(contact); + } catch (_) {} + } + + Future _handleIncomingMessage(dynamic data) async { + if (data is RemoteMessage) { + await _handleFCMMessage(data); + } else if (data is Map) { + print('WebSocket message received in ContactsScreen: $data'); + final contactProvider = context.read(); + + if (data['type'] == 'user_updated') { + final userId = int.tryParse(data['user_id']?.toString() ?? ''); + if (userId != null) { + await contactProvider.updateContact(userId); + await _loadLocalNames(); // Синхронно обновляем кэш имен на сокет + } + } + + if (data['type'] == 'user_online') { + final userId = int.tryParse(data['user_id']?.toString() ?? ''); + if (userId != null) { + contactProvider.updateContactOnlineStatus(userId, true); + if (mounted) { + setState(() { + if (_selectedContact != null && _selectedContact!.id == userId) { + _selectedContact = contactProvider.contacts.firstWhere( + (c) => c.id == userId, + orElse: () => _selectedContact!, + ); + } + }); + } + } + } + if (data['type'] == 'user_offline') { + final userId = int.tryParse(data['user_id']?.toString() ?? ''); + if (userId != null) { + contactProvider.updateContactOnlineStatus(userId, false); + if (mounted) { + setState(() { + if (_selectedContact != null && _selectedContact!.id == userId) { + _selectedContact = contactProvider.contacts.firstWhere( + (c) => c.id == userId, + orElse: () => _selectedContact!, + ); + } + }); + } + } + } + + if (data['type'] == 'message_edited') { + final messageId = int.tryParse(data['message_id']?.toString() ?? ''); + final senderId = int.tryParse(data['sender_id']?.toString() ?? ''); + if (messageId != null && senderId != null) { + final contact = contactProvider.contacts + .where((c) => c.id == senderId) + .firstOrNull; + if (contact != null) { + final editedAt = DateTime.tryParse( + data['edited_at']?.toString() ?? '', + ); + String lastMessageText = contact.lastMessage ?? ''; + bool isDecrypted = false; + + final myPrivKey = await CryptoService().getPrivateKey(); + if (myPrivKey != null && contact.publicKey != null) { + try { + final sharedSecret = await CryptoService().deriveSharedSecret( + myPrivKey, + contact.publicKey!, + ); + lastMessageText = await CryptoService().decryptMessage( + data['content']?.toString() ?? '', + sharedSecret, + ); + isDecrypted = true; + } catch (_) {} + } + await contactProvider.updateContactLastMessage( + contact.id, + lastMessage: lastMessageText, + lastMessageTime: editedAt, + isLastMsgDecrypted: isDecrypted, + lastMessageId: messageId, + isEdited: true, + ); + } + } + } + + if (data['type'] == 'message_deleted') { + final messageId = int.tryParse(data['message_id']?.toString() ?? ''); + if (messageId != null) { + final contactIndex = contactProvider.contacts.indexWhere( + (c) => c.lastMessageId == messageId, + ); + if (contactIndex != -1) { + await contactProvider.refreshContactLastMessage( + contactProvider.contacts[contactIndex].id, + ); + } + } + } + } + } + + Future _handleFCMMessage(RemoteMessage message) async { + try { + final senderId = int.tryParse( + message.data['sender_id']?.toString() ?? '', + ); + if (senderId != null && currentActiveChatContactId == senderId) return; + + const AndroidNotificationChannel channel = AndroidNotificationChannel( + 'Messages', + 'Новые сообщения', + description: 'Chat messages notifications', + importance: Importance.high, + ); + + await flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel(channel); + + final crypto = CryptoService(); + final myPrivKey = await crypto.getPrivateKey(); + if (myPrivKey == null) return; + + final sharedSecret = await crypto.deriveSharedSecret( + myPrivKey, + message.data['public_key'], + ); + final decryptedText = await crypto.decryptMessage( + message.data['content'], + sharedSecret, + ); + + if (senderId == null) return; + final String groupKey = 'ru.chepuhagram.app.$senderId'; + + final prefs = await SharedPreferences.getInstance(); + final String? firstName = prefs.getString( + 'firstname_${message.data['sender_id']}', + ); + final String? lastName = prefs.getString( + 'lastname_${message.data['sender_id']}', + ); + final String localFullName = '${firstName ?? ''} ${lastName ?? ''}' + .trim(); + final String title = localFullName.isNotEmpty + ? localFullName + : (message.data['username'] ?? 'Unknown'); + + await flutterLocalNotificationsPlugin.show( + id: senderId, + title: '', + body: '', + notificationDetails: NotificationDetails( + android: AndroidNotificationDetails( + 'Messages', + 'Новые сообщения', + groupKey: groupKey, + setAsGroupSummary: true, + importance: Importance.high, + priority: Priority.high, + groupAlertBehavior: GroupAlertBehavior.all, + ), + ), + ); + await flutterLocalNotificationsPlugin.show( + id: message.hashCode, + title: title, + body: decryptedText, + notificationDetails: NotificationDetails( + android: AndroidNotificationDetails( + 'Messages', + 'Новые сообщения', + groupKey: groupKey, + importance: Importance.high, + priority: Priority.high, + showWhen: true, + ), + ), + payload: jsonEncode({ + 'type': 'enc_message', + 'sender_id': message.data['sender_id'], + 'timestamp': + message.data['timestamp'] ?? DateTime.now().toIso8601String(), + }), + ); + + if (message.data['type'] == 'enc_message') { + context.read().updateContact( + senderId, + lastMessage: decryptedText, + lastMessageTime: DateTime.tryParse( + message.data['timestamp'] ?? DateTime.now().toIso8601String(), + ), + isLastMsgDecrypted: true, + unreadCount: message.data['unread_count'] != null + ? int.tryParse(message.data['unread_count'].toString()) + : null, + ); + } + } catch (e) { + print('Error processing foreground FCM: $e'); + } + } + + Future _startDownload() async { + if (_latestApkUrl == null) return; + setState(() => _isDownloading = true); + + Directory? dir = await getExternalStorageDirectory(); + final path = '${dir!.path}/update.apk'; + final file = File(path); + + if (await file.exists()) await file.delete(); + + try { + setState(() { + _downloadProgress = 0.0; + _downloadedBytes = 0; + _downloadTotalBytes = 0; + }); + await Dio().download( + _latestApkUrl!, + path, + cancelToken: _cancelToken, + onReceiveProgress: (rec, total) { + if (mounted) { + setState(() { + _downloadedBytes = rec; + _downloadTotalBytes = total > 0 ? total : 0; + _downloadProgress = total > 0 ? rec / total : 0.0; + }); + } + }, + ); + await OpenFilex.open(path); + } catch (_) { + } finally { + if (mounted) + setState(() { + _isDownloading = false; + _downloadProgress = 0.0; + }); + } + } + + Future _fetchApkSize(String url) async { + try { + final response = await http.head(Uri.parse(url)); + return int.tryParse(response.headers['content-length'] ?? '') ?? 0; + } catch (_) { + return 0; + } + } + + String _formatBytes(int bytes) { + if (bytes <= 0) return '0 B'; + const kb = 1024; + const mb = kb * 1024; + if (bytes < kb) return '$bytes B'; + if (bytes < mb) return '${(bytes / kb).toStringAsFixed(1)} KB'; + return '${(bytes / mb).toStringAsFixed(1)} MB'; + } } diff --git a/lib/presentation/screens/forward_contact_picker_screen.dart b/lib/presentation/screens/forward_contact_picker_screen.dart index 95cd9ed..93d368f 100644 --- a/lib/presentation/screens/forward_contact_picker_screen.dart +++ b/lib/presentation/screens/forward_contact_picker_screen.dart @@ -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 createState() => _ForwardContactPickerScreenState(); + State createState() => + _ForwardContactPickerScreenState(); } -class _ForwardContactPickerScreenState extends State { +class _ForwardContactPickerScreenState + extends State { Contact? _selectedContact; bool _isInitLoading = true; SharedPreferences? _prefs; @@ -36,11 +34,11 @@ class _ForwardContactPickerScreenState extends State try { final contactProvider = context.read(); await contactProvider.loadContacts(); - + final apiService = ApiService(); final accessToken = await apiService.getAccessToken(); final shared = await SharedPreferences.getInstance(); - + if (mounted) { setState(() { _prefs = shared; @@ -151,11 +149,12 @@ class _ForwardContactPickerScreenState extends State 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,10 +171,15 @@ class _ForwardContactPickerScreenState extends State }); }, 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( children: [ @@ -184,20 +188,50 @@ class _ForwardContactPickerScreenState extends State 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,11 +241,16 @@ class _ForwardContactPickerScreenState extends State 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, + ), ), ), - + if (contact.isOnline == true) Positioned( right: 0, @@ -222,7 +261,12 @@ class _ForwardContactPickerScreenState extends State 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 _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 // 4. ПРАВАЯ ЧАСТЬ (Анимация переключения Время <-> Галочка) trailing: AnimatedSwitcher( duration: const Duration(milliseconds: 200), - transitionBuilder: (Widget child, Animation animation) { - return ScaleTransition(scale: animation, child: child); - }, + transitionBuilder: + (Widget child, Animation animation) { + return ScaleTransition( + scale: animation, + child: child, + ); + }, child: isSelected ? Container( key: const ValueKey('checkmark'), @@ -260,7 +311,11 @@ class _ForwardContactPickerScreenState extends State 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 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, + ), ), ), ], @@ -297,4 +360,4 @@ class _ForwardContactPickerScreenState extends State }(), ); } -} \ No newline at end of file +} diff --git a/lib/presentation/screens/my_profile_screen.dart b/lib/presentation/screens/my_profile_screen.dart new file mode 100644 index 0000000..809bb67 --- /dev/null +++ b/lib/presentation/screens/my_profile_screen.dart @@ -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 createState() => _MyProfileScreenState(); +} + +class _MyProfileScreenState extends State { + final ImagePicker _picker = ImagePicker(); + bool _isAvatarExpanded = false; + String? privKey; + + Future _pickAvatar() async { + final XFile? image = await _picker.pickImage(source: ImageSource.gallery); + if (image != null) { + final success = await context.read().updateAvatar( + image.path, + ); + if (!success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Ошибка загрузки аватарки')), + ); + } + } + } + + @override + void initState() { + super.initState(); + _loadPrivKey(); + } + + Future _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(); + 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), + ), + ], + ); + } +} diff --git a/lib/presentation/screens/privacy_settings_menu_screen.dart b/lib/presentation/screens/privacy_settings_menu_screen.dart index b6b36cb..75038d1 100644 --- a/lib/presentation/screens/privacy_settings_menu_screen.dart +++ b/lib/presentation/screens/privacy_settings_menu_screen.dart @@ -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, + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/privacy_settings_screen.dart b/lib/presentation/screens/privacy_settings_screen.dart index a8bc354..1b3140f 100644 --- a/lib/presentation/screens/privacy_settings_screen.dart +++ b/lib/presentation/screens/privacy_settings_screen.dart @@ -52,14 +52,12 @@ class _PrivacySettingsScreenState extends State { _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 { Future _saveToServer() async { if (_isSaving) return; - setState(() => _isSaving = true); try { @@ -85,7 +82,6 @@ class _PrivacySettingsScreenState extends State { ); if (success) { - // Сохраняем локально только после успешного сохранения на сервере await _savePreference(_showEmailKey, _showEmail); await _savePreference(_showPhoneKey, _showPhone); await _savePreference(_showAvatarKey, _showAvatar); @@ -94,97 +90,108 @@ class _PrivacySettingsScreenState extends State { 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 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)); +} \ No newline at end of file diff --git a/lib/presentation/screens/security_settings_screen.dart b/lib/presentation/screens/security_settings_screen.dart index faa33d3..c4d9701 100644 --- a/lib/presentation/screens/security_settings_screen.dart +++ b/lib/presentation/screens/security_settings_screen.dart @@ -15,7 +15,6 @@ class SecuritySettingsScreen extends StatefulWidget { class _SecuritySettingsScreenState extends State { final _passwordFormKey = GlobalKey(); final _encryptionFormKey = GlobalKey(); - //final _totpFormKey = GlobalKey(); final _currentPasswordController = TextEditingController(); final _newPasswordController = TextEditingController(); @@ -64,9 +63,7 @@ class _SecuritySettingsScreenState extends State { }); } catch (_) { if (!mounted) return; - setState(() { - _isBiometricAvailable = false; - }); + setState(() => _isBiometricAvailable = false); } } @@ -74,15 +71,11 @@ class _SecuritySettingsScreenState extends State { 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 { Future _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 { _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 _saveEncryptionPassword() async { await _checkBiometricSupport(); - if (!_encryptionFormKey.currentState!.validate()) return; setState(() => _isSavingEncryption = true); @@ -151,29 +144,22 @@ class _SecuritySettingsScreenState extends State { 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 { 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 { } 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 _setupTotp() async { if (_isTotpEnabled) { - // Показываем диалог с опциями _showTotpOptionsDialog(); } else { - // Enable TOTP setState(() => _isSavingTotp = true); try { final api = ApiService(); @@ -224,11 +210,13 @@ class _SecuritySettingsScreenState extends State { _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 { 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 _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 _disableTotp() async { setState(() => _isSavingTotp = true); try { @@ -303,122 +269,130 @@ class _SecuritySettingsScreenState extends State { _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 { ); } - 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, + ), + ), + ), + ); + } } diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 3173f88..4612720 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -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 createState() => _SettingsScreenState(); @@ -19,6 +21,7 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { String? versionCode; final ImagePicker _picker = ImagePicker(); + bool _isAvatarExpanded = false; @override void initState() { @@ -38,8 +41,10 @@ class _SettingsScreenState extends State { Future _pickAvatar() async { final XFile? image = await _picker.pickImage(source: ImageSource.gallery); if (image != null) { - final success = await context.read().updateAvatar(image.path); - if (!success) { + final success = await context.read().updateAvatar( + image.path, + ); + if (!success && mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Ошибка загрузки аватарки')), ); @@ -50,168 +55,337 @@ class _SettingsScreenState extends State { @override Widget build(BuildContext context) { final authProv = context.watch(); + 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), + ); } diff --git a/lib/presentation/screens/user_profile_screen.dart b/lib/presentation/screens/user_profile_screen.dart index da63dd2..a6546d6 100644 --- a/lib/presentation/screens/user_profile_screen.dart +++ b/lib/presentation/screens/user_profile_screen.dart @@ -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 { super.initState(); _loadUserData(); startOnlineUpdates(); - DateTime now = DateTime.now(); - offset = now.timeZoneOffset; final socketService = Provider.of(context, listen: false); @@ -54,19 +54,12 @@ class _UserProfileScreenState extends State { } Future _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 { } 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 { 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 _editUserName(String firstname, String lastname) async { - final firstnameController = TextEditingController(text: firstname); - final lastnameController = TextEditingController(text: lastname); - final result = await showDialog( - 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 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 { } } - 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 _editUserName(String firstname, String lastname) async { + final firstnameController = TextEditingController(text: firstname); + final lastnameController = TextEditingController(text: lastname); + + final result = await showDialog( + 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 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(); + } + } } diff --git a/lib/presentation/widgets/contact_tile.dart b/lib/presentation/widgets/contact_tile.dart deleted file mode 100644 index d01b371..0000000 --- a/lib/presentation/widgets/contact_tile.dart +++ /dev/null @@ -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 createState() => _ContactTileState(); -} - -class _ContactTileState extends State { - SharedPreferences? _prefs; - String? token; - - @override - void initState() { - super.initState(); - _initPrefs(); - } - - Future _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')}"; - } -} diff --git a/lib/presentation/widgets/message_bubble.dart b/lib/presentation/widgets/message_bubble.dart index d06ca46..6ac53c4 100644 --- a/lib/presentation/widgets/message_bubble.dart +++ b/lib/presentation/widgets/message_bubble.dart @@ -11,6 +11,7 @@ import 'package:flutter/foundation.dart'; import 'package:open_filex/open_filex.dart'; import 'package:video_thumbnail/video_thumbnail.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; import 'dart:ui' as ui; import 'dart:math' as math; import 'package:video_player/video_player.dart'; @@ -74,6 +75,10 @@ class _MessageBubbleState extends State { _resolveFileSize(); _generateVideoThumbnail(); + if (widget.message.messageType == MessageType.image && widget.message.localFile != null) { + _loadImageDimensionsFromFile(widget.message.localFile!); + } + final isMedia = widget.message.messageType == MessageType.image || widget.message.messageType == MessageType.video || @@ -121,6 +126,32 @@ class _MessageBubbleState extends State { return frameInfo.image; } + void _loadImageDimensionsFromFile(File file) async { + if (!file.existsSync()) return; + try { + final cached = _mediaCache.getDimensions(_messageKeyId); + if (cached != null) { + if (mounted) { + setState(() { + minWidth = cached.width.toInt(); + minHeight = cached.height.toInt(); + }); + } + return; + } + final img = await getImageDimensions(file); + _mediaCache.saveDimensions(_messageKeyId, img.width, img.height); + if (mounted) { + setState(() { + minWidth = img.width; + minHeight = img.height; + }); + } + } catch (e) { + debugPrint("Ошибка чтения размеров картинки: $e"); + } + } + Future _generateVideoThumbnail() async { if (widget.message.messageType != MessageType.video) { return; @@ -181,25 +212,24 @@ class _MessageBubbleState extends State { super.didUpdateWidget(oldWidget); _syncDownloadProgressListener(); - print( - "MessageBubble: didUpdateWidget called for message id ${widget.message.id}", - ); + final bool fileJustAppeared = + widget.message.localFile != null && oldWidget.message.localFile == null; + final bool fileJustDisappeared = + widget.message.localFile == null && oldWidget.message.localFile != null; + final bool fileExistsNow = widget.message.localFile != null && widget.message.localFile!.existsSync(); + final bool sizeChanged = + widget.message.fileSize != oldWidget.message.fileSize; final bool _lastKnownFileExists = oldWidget.message.localFile != null && oldWidget.message.localFile!.existsSync(); - // Флаг удаления: файл был (мы это помним), а сейчас его нет final bool becameNull = !fileExistsNow && _lastKnownFileExists; - // Флаг появления: файла не было (мы это помнили), а сейчас он есть final bool becameReady = fileExistsNow && !_lastKnownFileExists; - print( - "ID ${widget.message.id}: fileExistsNow=$fileExistsNow, lastKnown=$_lastKnownFileExists, becameNull=$becameNull", - ); - // Сценарий А: Файл есть в модели И физически существует на диске + if (widget.message.localFile != null && fileExistsNow) { if (_requiresManualLoad || _isMediaLoading || becameReady) { setState(() { @@ -210,80 +240,103 @@ class _MessageBubbleState extends State { if (becameReady) { _resolveFileSize(); - _generateVideoThumbnail(); + if (widget.message.messageType == MessageType.image) { + _loadImageDimensionsFromFile(widget.message.localFile!); + } else { + _generateVideoThumbnail(); + } } return; } - // Сценарий Б: Файл был удален физически с диска (или ссылка стала null) if (becameNull) { - print("ОБНАРУЖЕНО УДАЛЕНИЕ ФАЙЛА ДЛЯ ${widget.message.id}"); - - setState(() { - _isMediaLoading = false; - _requiresManualLoad = true; - }); - - _resolveFileSize(); - return; - } - - if (fileExistsNow && !_lastKnownFileExists) { - setState(() { - _isMediaLoading = false; - }); - _resolveFileSize(); - } - - // Сценарий В: Файла нет (и не было), проверяем изменение остальных параметров (текст, статус и т.д.) - final bool fileChanged = - widget.message.localFile != oldWidget.message.localFile; - final bool sizeChanged = - widget.message.fileSize != oldWidget.message.fileSize; - final bool statusChanged = - widget.message.status != oldWidget.message.status; - final bool textChanged = widget.message.text != oldWidget.message.text; - final bool fileIdChanged = widget.message.fileId != oldWidget.message.fileId; - final bool fileNameChanged = - widget.message.fileName != oldWidget.message.fileName; - - if (statusChanged || textChanged || fileChanged || fileIdChanged || fileNameChanged) { - setState(() {}); - } - if (fileChanged || sizeChanged || statusChanged || fileIdChanged || fileNameChanged) { - _resolveFileSize(); - } - - // Ниже идет ваш стандартный код для автозагрузки медиа (isMedia && sizeChanged)... - final isMedia = - widget.message.messageType == MessageType.image || - widget.message.messageType == MessageType.video || - widget.message.messageType == MessageType.file || - widget.message.messageType == MessageType.videoNote || - widget.message.messageType == MessageType.voiceNote; - - if (isMedia && sizeChanged) { - final oldSize = oldWidget.message.fileSize ?? 0; - final newSize = widget.message.fileSize ?? 0; - final type = widget.message.messageType; - final bool isNote = - type == MessageType.voiceNote || type == MessageType.videoNote; - - if (oldSize == 0 && newSize > 0) { + if (fileJustAppeared) { setState(() { - _requiresManualLoad = isNote - ? false - : !widget.autoLoadMedia || newSize > _autoDownloadLimit; + _isMediaLoading = false; + _requiresManualLoad = true; + _requiresManualLoad = false; }); - if (widget.message.localFile == null) { - widget.onDownloadRequestedWithoutLoad?.call(widget.message); - } - if (!_requiresManualLoad && widget.message.localFile == null) { - _startDownload(); + _resolveFileSize(); + return; + } + + if (fileExistsNow && !_lastKnownFileExists) { + if (widget.message.messageType == MessageType.image) { + _loadImageDimensionsFromFile(widget.message.localFile!); + } else { _generateVideoThumbnail(); - } else if (widget.message.localFile == null && isNote) { - _startDownload(); + } + } else if (fileJustDisappeared) { + setState(() { + _isMediaLoading = false; + _requiresManualLoad = true; + }); + _resolveFileSize(); + } + + final bool fileChanged = + widget.message.localFile != oldWidget.message.localFile; + final bool statusChanged = + widget.message.status != oldWidget.message.status; + final bool textChanged = widget.message.text != oldWidget.message.text; + final bool fileIdChanged = + widget.message.fileId != oldWidget.message.fileId; + final bool fileNameChanged = + widget.message.fileName != oldWidget.message.fileName; + + if (statusChanged || + textChanged || + fileChanged || + fileIdChanged || + fileNameChanged) { + } else if (widget.message.localFile == null && sizeChanged) { + _resolveFileSize(); + _checkAutoDownload(); + } else { + setState(() {}); + } + if (fileChanged || + sizeChanged || + statusChanged || + fileIdChanged || + fileNameChanged) { + _resolveFileSize(); + } + + final isMedia = + widget.message.messageType == MessageType.image || + widget.message.messageType == MessageType.video || + widget.message.messageType == MessageType.file || + widget.message.messageType == MessageType.videoNote || + widget.message.messageType == MessageType.voiceNote; + + if (isMedia && sizeChanged && widget.message.localFile == null) { + final oldSize = oldWidget.message.fileSize ?? 0; + final newSize = widget.message.fileSize ?? 0; + final type = widget.message.messageType; + final bool isNote = + type == MessageType.voiceNote || type == MessageType.videoNote; + + if (oldSize == 0 && newSize > 0) { + setState(() { + _requiresManualLoad = isNote + ? false + : !widget.autoLoadMedia || newSize > _autoDownloadLimit; + }); + + if (widget.message.localFile == null) { + widget.onDownloadRequestedWithoutLoad?.call(widget.message); + if (!_requiresManualLoad || isNote) { + _startDownload(); + } + } + if (!_requiresManualLoad && widget.message.localFile == null) { + _startDownload(); + _generateVideoThumbnail(); + } else if (widget.message.localFile == null && isNote) { + _startDownload(); + } } } } @@ -326,12 +379,6 @@ class _MessageBubbleState extends State { } } - @override - void dispose() { - _downloadProgressNotifier?.removeListener(_onDownloadProgressUpdated); - super.dispose(); - } - Future _handleDownload() async { if (widget.message.localFile != null) return; if (_isMediaLoading) return; @@ -343,13 +390,7 @@ class _MessageBubbleState extends State { await widget.onDownloadRequested?.call(widget.message); if (widget.message.messageType == MessageType.image && widget.message.localFile != null) { - ui.Image img = await getImageDimensions(widget.message.localFile!); - if (mounted) { - setState(() { - minHeight = img.height; - minWidth = img.width; - }); - } + _loadImageDimensionsFromFile(widget.message.localFile!); } } catch (e) { debugPrint('Download error: $e'); @@ -420,11 +461,12 @@ class _MessageBubbleState extends State { void _openFile() async { if (widget.message.localFile != null) { debugPrint("Открываем файл: ${widget.message.localFile!.path}"); - final directory = await getApplicationDocumentsDirectory(); + final targetFile = await _resolveUniqueFilePath( + widget.message.fileName ?? 'file', + ); - final decPath = '${directory.path}/${widget.message.fileName ?? 'file'}'; widget.message.localFile! - .copy(decPath) + .copy(targetFile.path) .then((copiedFile) { OpenFilex.open(copiedFile.path) .then((result) { @@ -443,6 +485,45 @@ class _MessageBubbleState extends State { } } + Future _getDownloadsDirectory() async { + try { + final downloads = await getDownloadsDirectory(); + if (downloads != null) { + final dir = Directory(p.join(downloads.path, 'Chepuhagram')); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + } catch (e) { + debugPrint( + 'Downloads directory unavailable, falling back to app documents: $e', + ); + } + return await getApplicationDocumentsDirectory(); + } + + Future _resolveUniqueFilePath(String fileName) async { + final safeName = p.basename(fileName); + final directory = await _getDownloadsDirectory(); + var candidatePath = p.join(directory.path, safeName); + if (!await File(candidatePath).exists()) { + return File(candidatePath); + } + + final nameWithoutExtension = p.basenameWithoutExtension(safeName); + final extension = p.extension(safeName); + var counter = 1; + while (true) { + final candidateName = '$nameWithoutExtension ($counter)$extension'; + candidatePath = p.join(directory.path, candidateName); + if (!await File(candidatePath).exists()) { + return File(candidatePath); + } + counter++; + } + } + bool get _isDisplayableFileReady { return widget.message.localFile != null && widget.message.localFile!.existsSync(); @@ -454,7 +535,18 @@ class _MessageBubbleState extends State { final primaryTextColor = Colors.white; final secondaryTextColor = Colors.white70; final linkColor = const Color(0xFF81D4FA); - final bool canShowMedia = _isDisplayableFileReady; + + // ВЫЧИСЛЕНИЕ ДИНАМИЧЕСКИХ РАЗМЕРОВ ДЛЯ БОЛЬШИХ ЭКРАНОВ (PC / Web / Планшеты) + final double screenWidth = MediaQuery.of(context).size.width; + final bool isLargeScreen = screenWidth > 750; + + // Адаптивные шрифты и паддинги + final double bodyFontSize = isLargeScreen ? 15.5 : 14.0; + final double timeFontSize = isLargeScreen ? 11.0 : 10.0; + final double replyFontSize = isLargeScreen ? 13.0 : 12.0; + + final double paddingVertical = isLargeScreen ? 12.0 : 10.0; + final double paddingHorizontal = isLargeScreen ? 16.0 : 14.0; return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, @@ -471,19 +563,17 @@ class _MessageBubbleState extends State { ), child: Container( margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), + padding: EdgeInsets.symmetric(vertical: paddingVertical, horizontal: paddingHorizontal), constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.7, + // Максимальная ширина на больших мониторах ограничена 460px (как в Telegram Desktop) + maxWidth: math.min(screenWidth * 0.75, 460.0), ), decoration: BoxDecoration( - color: widget.message.messageType != MessageType.videoNote - ? isMe - ? Theme.of(context).colorScheme.brightness == - Brightness.dark - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.primary - : Colors.grey[800] - : Colors.transparent, + color: isMe + ? Theme.of(context).colorScheme.brightness == Brightness.dark + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.primary + : Colors.grey[800], borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), @@ -493,21 +583,14 @@ class _MessageBubbleState extends State { ), child: IntrinsicWidth( child: Column( - crossAxisAlignment: isMe - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, + crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (widget.message.replyToText != null) ...[ - _buildReplyWidget(isMe, secondaryTextColor), + _buildReplyWidget(isMe, secondaryTextColor, replyFontSize), ], Align( - alignment: isMe - ? Alignment.centerRight - : Alignment.centerLeft, - child: _buildMessageBody( - primaryTextColor, - secondaryTextColor, - ), + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: _buildMessageBody(primaryTextColor, secondaryTextColor, isLargeScreen), ), if (widget.message.messageType == MessageType.text || widget.message.text.isNotEmpty) ...[ @@ -517,24 +600,18 @@ class _MessageBubbleState extends State { child: Linkify( onOpen: (link) async { final Uri url = Uri.parse(link.url); - if (!await launchUrl( - url, - mode: LaunchMode.externalApplication, - )) { + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { throw Exception('Could not launch $url'); } }, text: widget.message.text, - style: TextStyle(color: primaryTextColor), - linkStyle: TextStyle( - color: linkColor, - fontWeight: FontWeight.bold, - ), + style: TextStyle(color: primaryTextColor, fontSize: bodyFontSize), + linkStyle: TextStyle(color: linkColor, fontWeight: FontWeight.bold), ), ), ], const SizedBox(height: 4), - _buildTimeAndStatusRow(isMe, secondaryTextColor), + _buildTimeAndStatusRow(isMe, secondaryTextColor, timeFontSize), ], ), ), @@ -544,24 +621,24 @@ class _MessageBubbleState extends State { ); } - Widget _buildMessageBody(Color primaryColor, Color secondaryColor) { + Widget _buildMessageBody(Color primaryColor, Color secondaryColor, bool isLargeScreen) { switch (widget.message.messageType) { case MessageType.image: - return _buildImagePreview(primaryColor, secondaryColor); + return _buildImagePreview(primaryColor, secondaryColor, isLargeScreen); case MessageType.video: - return _buildVideoPreview(primaryColor, secondaryColor); + return _buildVideoPreview(primaryColor, secondaryColor, isLargeScreen); case MessageType.file: - return _buildFileBubble(primaryColor, secondaryColor); + return _buildFileBubble(primaryColor, secondaryColor, isLargeScreen); case MessageType.videoNote: - return _buildVideoNotePreview(primaryColor, secondaryColor); + return _buildVideoNotePreview(primaryColor, secondaryColor, isLargeScreen); case MessageType.voiceNote: - return _buildVoiceNoteBubble(primaryColor, secondaryColor); + return _buildVoiceNoteBubble(primaryColor, secondaryColor, isLargeScreen); default: return const SizedBox.shrink(); } } - Widget _buildImagePreview(Color textCol, Color subTextCol) { + Widget _buildImagePreview(Color textCol, Color subTextCol, bool isLargeScreen) { _resolveFileSize(); final bool isDownloaded = widget.message.localFile != null; final bool isSending = widget.message.status == MessageStatus.sending; @@ -569,16 +646,20 @@ class _MessageBubbleState extends State { final isTooLarge = _calculatedFileSize > _autoDownloadLimit; final displaySize = formatBytes(_calculatedFileSize, 1); - final double screenMaxWidth = MediaQuery.of(context).size.width * 0.6; - final double screenMaxHeight = MediaQuery.of(context).size.height * 0.4; + double imgWidth = minWidth > 0 ? minWidth.toDouble() : 240.0; + double imgHeight = minHeight > 0 ? minHeight.toDouble() : 180.0; + double aspectRatio = imgWidth / imgHeight; + aspectRatio = aspectRatio.clamp(0.5, 2.0); - final double calculatedMinWidth = minWidth > 0 - ? math.min(minWidth.toDouble(), screenMaxWidth) - : MediaQuery.of(context).size.width * 0.4; + // На больших экранах пропорциональный бокс медиафайлов делается чуть крупнее для удобного просмотра + double finalWidth = isLargeScreen ? 320.0 : 260.0; + double finalHeight = finalWidth / aspectRatio; - final double calculatedMinHeight = minHeight > 0 - ? math.min(minHeight.toDouble(), screenMaxHeight) - : MediaQuery.of(context).size.height * 0.25; + double maxVerticalLimit = isLargeScreen ? 250.0 : 200.0; + if (finalHeight > maxVerticalLimit) { + finalHeight = maxVerticalLimit; + finalWidth = finalHeight * aspectRatio; + } return GestureDetector( onTap: (_isDownloading || isSending || isEncrypting || !isDownloaded) @@ -597,27 +678,22 @@ class _MessageBubbleState extends State { child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Container( + width: finalWidth, + height: finalHeight, color: Colors.black.withOpacity(0.05), - constraints: BoxConstraints( - minWidth: calculatedMinWidth, - maxWidth: screenMaxWidth, - minHeight: calculatedMinHeight, - maxHeight: screenMaxHeight, - ), child: Stack( - fit: StackFit.loose, - alignment: Alignment.centerRight, + fit: StackFit.expand, + alignment: Alignment.center, children: [ if (isDownloaded) - Image.file(widget.message.localFile!, fit: BoxFit.cover) + Image.file( + widget.message.localFile!, + fit: BoxFit.cover, + width: finalWidth, + height: finalHeight, + ) else - _buildMediaPlaceholder( - Icons.image, - "Фото", - isTooLarge, - textCol, - subTextCol, - ), + _buildMediaPlaceholder(Icons.image, "Фото", isTooLarge, textCol, subTextCol, isLargeScreen), if (!isDownloaded && !_isDownloading && !isSending && @@ -627,10 +703,7 @@ class _MessageBubbleState extends State { bottom: 8, left: 8, child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 3, - ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( color: Colors.black.withOpacity(0.55), borderRadius: BorderRadius.circular(10), @@ -638,19 +711,11 @@ class _MessageBubbleState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon( - Icons.arrow_downward_rounded, - color: Colors.white, - size: 12, - ), + const Icon(Icons.arrow_downward_rounded, color: Colors.white, size: 12), const SizedBox(width: 3), Text( displaySize, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500), ), ], ), @@ -658,10 +723,7 @@ class _MessageBubbleState extends State { ), if (_isMediaLoading || isSending || isEncrypting) Positioned.fill( - child: _buildProgressOverlay( - (isSending || isEncrypting), - isEncrypting, - ), + child: _buildProgressOverlay((isSending || isEncrypting), isEncrypting), ), ], ), @@ -670,7 +732,7 @@ class _MessageBubbleState extends State { ); } - Widget _buildVideoPreview(Color textCol, Color subTextCol) { + Widget _buildVideoPreview(Color textCol, Color subTextCol, bool isLargeScreen) { final bool isDownloaded = widget.message.localFile != null; final bool isSending = widget.message.status == MessageStatus.sending; final bool isEncrypting = widget.message.status == MessageStatus.encrypting; @@ -679,16 +741,18 @@ class _MessageBubbleState extends State { final cachedSize = _mediaCache.getDimensions(_messageKeyId); final cachedThumbPath = _mediaCache.getThumbnailPath(_messageKeyId); - double finalWidth = 240.0; - double finalHeight = 160.0; + double vidWidth = cachedSize != null && cachedSize.width > 0 ? cachedSize.width : 240.0; + double vidHeight = cachedSize != null && cachedSize.height > 0 ? cachedSize.height : 160.0; + double aspectRatio = vidWidth / vidHeight; + aspectRatio = aspectRatio.clamp(0.5, 2.0); - if (cachedSize != null && cachedSize.width > 0 && cachedSize.height > 0) { - final double aspectRatio = cachedSize.width / cachedSize.height; - finalWidth = 250.0; - finalHeight = finalWidth / aspectRatio; - final double maxAllowedHeight = MediaQuery.of(context).size.height * 0.4; - final double minAllowedHeight = MediaQuery.of(context).size.height * 0.15; - finalHeight = finalHeight.clamp(minAllowedHeight, maxAllowedHeight); + double finalWidth = isLargeScreen ? 320.0 : 260.0; + double finalHeight = finalWidth / aspectRatio; + + double maxVerticalLimit = isLargeScreen ? 250.0 : 200.0; + if (finalHeight > maxVerticalLimit) { + finalHeight = maxVerticalLimit; + finalWidth = finalHeight * aspectRatio; } return GestureDetector( @@ -717,32 +781,18 @@ class _MessageBubbleState extends State { children: [ if (isDownloaded && !isSending && !isEncrypting) ...[ if (cachedThumbPath != null) - Image.file(File(cachedThumbPath), fit: BoxFit.cover) + Image.file(File(cachedThumbPath), fit: BoxFit.cover, width: finalWidth, height: finalHeight) else - const SizedBox( - child: Center( - child: CircularProgressIndicator(color: Colors.white), - ), - ), - Icon( - Icons.play_circle_fill, - color: Colors.white.withOpacity(0.9), - size: 50, - ), + const Center(child: CircularProgressIndicator(color: Colors.white)), + Icon(Icons.play_circle_fill, color: Colors.white.withOpacity(0.9), size: isLargeScreen ? 52 : 44), ] else ...[ if (!_isDisplayableFileReady && (isSending || isEncrypting) && widget.message.localFile != null && cachedThumbPath != null) - Image.file(File(cachedThumbPath), fit: BoxFit.cover) + Image.file(File(cachedThumbPath), fit: BoxFit.cover, width: finalWidth, height: finalHeight) else - _buildMediaPlaceholder( - Icons.videocam, - "Video", - isTooLarge, - textCol, - subTextCol, - ), + _buildMediaPlaceholder(Icons.videocam, "Видео", isTooLarge, textCol, subTextCol, isLargeScreen), ], if (!isDownloaded && !_isDownloading && @@ -753,30 +803,19 @@ class _MessageBubbleState extends State { bottom: 8, left: 8, child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 3, - ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( color: Colors.black.withOpacity(0.55), borderRadius: BorderRadius.circular(10), - ), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon( - Icons.arrow_downward_rounded, - color: Colors.white, - size: 12, - ), + const Icon(Icons.arrow_downward_rounded, color: Colors.white, size: 12), const SizedBox(width: 3), Text( displaySize, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w500), ), ], ), @@ -784,10 +823,7 @@ class _MessageBubbleState extends State { ), if (_isDownloading || isSending || isEncrypting) Positioned.fill( - child: _buildProgressOverlay( - (isSending || isEncrypting), - isEncrypting, - ), + child: _buildProgressOverlay((isSending || isEncrypting), isEncrypting), ), ], ), @@ -803,12 +839,11 @@ class _MessageBubbleState extends State { bool isTooLarge, Color textCol, Color subTextCol, + bool isLargeScreen, ) { _resolveFileSize(); final displaySize = formatBytes(_calculatedFileSize, 1); - final sizeString = _calculatedFileSize > 0 - ? " ($displaySize)" - : " (Загрузка размера...)"; + final sizeString = _calculatedFileSize > 0 ? " ($displaySize)" : " (Загрузка...)"; if (_isMediaLoading) return const SizedBox.shrink(); return Container( @@ -817,31 +852,17 @@ class _MessageBubbleState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - isTooLarge ? Icons.download_for_offline : icon, - size: 42, - color: subTextCol, - ), + Icon(isTooLarge ? Icons.download_for_offline : icon, size: isLargeScreen ? 48 : 42, color: subTextCol), const SizedBox(height: 6), Text( - isTooLarge - ? "Файл слишком большой$sizeString" - : "$typeLabel$sizeString", + isTooLarge ? "Файл слишком большой$sizeString" : "$typeLabel$sizeString", textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - color: textCol, - fontWeight: FontWeight.w500, - ), + style: TextStyle(fontSize: isLargeScreen ? 13 : 12, color: textCol, fontWeight: FontWeight.w500), ), const SizedBox(height: 4), Text( "Нажмите для загрузки", - style: TextStyle( - fontSize: 10, - color: isTooLarge ? Colors.black54 : subTextCol, - fontWeight: FontWeight.bold, - ), + style: TextStyle(fontSize: isLargeScreen ? 11 : 10, color: isTooLarge ? Colors.black54 : subTextCol, fontWeight: FontWeight.bold), ), ], ), @@ -867,12 +888,7 @@ class _MessageBubbleState extends State { tween: Tween(begin: 0.0, end: currentProgress), duration: const Duration(milliseconds: 150), builder: (context, val, _) { - return _buildCircularIndicator( - val, - isEncrypting ? "Шифрование" : "Отправка", - false, - _calculatedFileSize, - ); + return _buildCircularIndicator(val, isEncrypting ? "Шифрование" : "Отправка", false, _calculatedFileSize); }, ); }, @@ -881,12 +897,7 @@ class _MessageBubbleState extends State { tween: Tween(begin: 0.0, end: 0.0), duration: const Duration(milliseconds: 150), builder: (context, val, _) { - return _buildCircularIndicator( - val, - isEncrypting ? "Шифрование" : "Отправка", - false, - _calculatedFileSize, - ); + return _buildCircularIndicator(val, isEncrypting ? "Шифрование" : "Отправка", false, _calculatedFileSize); }, ), ] else ...[ @@ -900,12 +911,7 @@ class _MessageBubbleState extends State { tween: Tween(begin: 0.0, end: value ?? 0.0), duration: const Duration(milliseconds: 150), builder: (context, val, _) { - return _buildCircularIndicator( - val, - "Загрузка", - isIndeterminate, - _calculatedFileSize, - ); + return _buildCircularIndicator(val, "Загрузка", isIndeterminate, _calculatedFileSize); }, ); }, @@ -952,19 +958,11 @@ class _MessageBubbleState extends State { if (!isIndeterminate) Text( "${(val * 100).toInt()}%", - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.bold, - ), + style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold), ), Text( label, - style: TextStyle( - color: Colors.white60, - fontSize: isIndeterminate ? 10 : 8, - fontWeight: FontWeight.bold, - ), + style: TextStyle(color: Colors.white60, fontSize: isIndeterminate ? 10 : 8, fontWeight: FontWeight.bold), ), ], ), @@ -979,18 +977,14 @@ class _MessageBubbleState extends State { ), child: Text( progressText, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.w600, - ), + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600), ), ), ], ); } - Widget _buildFileBubble(Color textCol, Color subTextCol) { + Widget _buildFileBubble(Color textCol, Color subTextCol, bool isLargeScreen) { _resolveFileSize(); final bool isDownloaded = widget.message.localFile != null; final bool isSending = widget.message.status == MessageStatus.sending; @@ -1003,6 +997,10 @@ class _MessageBubbleState extends State { ? 'Загрузка' : ''; final displaySize = formatBytes(_calculatedFileSize, 1); + + // Адаптивная ширина файловой плашки + final double bubbleWidth = isLargeScreen ? 340.0 : 260.0; + return GestureDetector( onTap: () { if (widget.message.localFile != null) { @@ -1018,24 +1016,17 @@ class _MessageBubbleState extends State { borderRadius: BorderRadius.circular(12), ), constraints: BoxConstraints( - minWidth: MediaQuery.of(context).size.width * 0.6, - maxWidth: MediaQuery.of(context).size.width * 0.6, + minWidth: bubbleWidth, + maxWidth: bubbleWidth, ), child: Row( children: [ Stack( alignment: Alignment.center, children: [ - Icon(Icons.insert_drive_file, size: 38, color: subTextCol), - if (!_isMediaLoading && - !isDownloaded && - !isSending && - !isEncrypting) - const Icon( - Icons.download_rounded, - size: 16, - color: Colors.white70, - ), + Icon(Icons.insert_drive_file, size: isLargeScreen ? 42 : 38, color: subTextCol), + if (!_isMediaLoading && !isDownloaded && !isSending && !isEncrypting) + Icon(Icons.download_rounded, size: isLargeScreen ? 18 : 16, color: Colors.white70), ], ), const SizedBox(width: 10), @@ -1047,11 +1038,7 @@ class _MessageBubbleState extends State { widget.message.fileName ?? 'Файл', maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 13, - color: textCol, - ), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: isLargeScreen ? 14.5 : 13.0, color: textCol), ), Row( children: [ @@ -1060,7 +1047,7 @@ class _MessageBubbleState extends State { displaySize, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 11, color: subTextCol), + style: TextStyle(fontSize: isLargeScreen ? 12 : 11, color: subTextCol), ), ), const SizedBox(width: 6), @@ -1071,7 +1058,7 @@ class _MessageBubbleState extends State { status, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 11, color: subTextCol), + style: TextStyle(fontSize: isLargeScreen ? 12 : 11, color: subTextCol), ), ), ), @@ -1079,20 +1066,13 @@ class _MessageBubbleState extends State { ), if (_isMediaLoading || isSending || isEncrypting) ValueListenableBuilder( - valueListenable: - widget.downloadProgress ?? - ValueNotifier(0.0), + valueListenable: widget.downloadProgress ?? ValueNotifier(0.0), builder: (context, value, _) { - // Если value null, принудительно показываем indeterminate (бесконечную полоску) - // или 0, чтобы пользователь видел, что что-то происходит final progress = value ?? 0.0; - return Padding( padding: const EdgeInsets.only(top: 6), child: LinearProgressIndicator( - value: progress > 0 - ? progress - : null, // null = бесконечная анимация, если прогресс еще не пришел + value: progress > 0 ? progress : null, minHeight: 3, backgroundColor: Colors.white24, color: Colors.white70, @@ -1103,17 +1083,14 @@ class _MessageBubbleState extends State { ], ), ), - if (!isDownloaded && - !_isMediaLoading && - !isSending && - !isEncrypting) + if (!isDownloaded && !_isMediaLoading && !isSending && !isEncrypting) IconButton( - icon: const Icon(Icons.download_rounded, color: Colors.white70), + icon: Icon(Icons.download_rounded, color: Colors.white70, size: isLargeScreen ? 24 : 20), onPressed: _handleDownload, ), if (!isDownloaded && _isMediaLoading && !isSending && !isEncrypting) IconButton( - icon: const Icon(Icons.cancel, color: Colors.white70), + icon: Icon(Icons.cancel, color: Colors.white70, size: isLargeScreen ? 24 : 20), onPressed: _handleStopDownload, ), ], @@ -1122,7 +1099,7 @@ class _MessageBubbleState extends State { ); } - Widget _buildReplyWidget(bool isMe, Color subTextCol) { + Widget _buildReplyWidget(bool isMe, Color subTextCol, double replyFontSize) { final isMedia = widget.message.messageType == MessageType.image || widget.message.messageType == MessageType.video || @@ -1152,11 +1129,7 @@ class _MessageBubbleState extends State { widget.message.replyToText!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle( - color: subTextCol, - fontSize: 12, - fontStyle: FontStyle.italic, - ), + style: TextStyle(color: subTextCol, fontSize: replyFontSize, fontStyle: FontStyle.italic), ), ), ], @@ -1165,10 +1138,7 @@ class _MessageBubbleState extends State { ); } - Widget _buildVideoNotePreview( - Color primaryTextColor, - Color secondaryTextColor, - ) { + Widget _buildVideoNotePreview(Color primaryTextColor, Color secondaryTextColor, bool isLargeScreen) { final file = widget.message.localFile; final path = file?.path ?? ""; final id = widget.message.id ?? widget.message.tempId ?? "no_id"; @@ -1176,15 +1146,14 @@ class _MessageBubbleState extends State { final bool isSending = widget.message.status == MessageStatus.sending; final bool isEncrypting = widget.message.status == MessageStatus.encrypting; - debugPrint( - '==> BUBBLE_VIDEO_RENDER: msgId=$id, status=${widget.message.status}, hasFile=${file != null}, path=$path', - ); + debugPrint('==> BUBBLE_VIDEO_RENDER: msgId=$id, status=${widget.message.status}, hasFile=${file != null}, path=$path'); return ValueListenableBuilder( valueListenable: InlineVideoNotePlayer.activeVideoPathNotifier, builder: (context, activePath, _) { final bool isActive = activePath == path; - final double size = isActive ? 260 : 160; + // Масштабируем кружки-видеозаметки на десктопе + final double size = isActive ? (isLargeScreen ? 300 : 260) : (isLargeScreen ? 190 : 160); return GestureDetector( onTap: (_isDownloading || isSending || isEncrypting || !isDownloaded) @@ -1201,7 +1170,6 @@ class _MessageBubbleState extends State { child: Stack( alignment: Alignment.center, children: [ - // Плавное изменение размеров кружка при проигрывании AnimatedContainer( duration: const Duration(milliseconds: 250), curve: Curves.fastOutSlowIn, @@ -1216,41 +1184,24 @@ class _MessageBubbleState extends State { ? InlineVideoNotePlayer(videoPath: path) : Container( color: Colors.black54, - child: Icon( - Icons.play_arrow_rounded, - size: 48, - color: primaryTextColor, - ), + child: Icon(Icons.play_arrow_rounded, size: isLargeScreen ? 54 : 48, color: primaryTextColor), ), ), ), - - // Защита: Кнопка скачать поверх кружка выводится ТОЛЬКО если файла нет физически на диске - if (!isDownloaded && - !_isDownloading && - !isSending && - !isEncrypting) + if (!isDownloaded && !_isDownloading && !isSending && !isEncrypting) ClipOval( child: Container( width: size, height: size, color: Colors.black.withOpacity(0.4), - child: const Icon( - Icons.arrow_downward_rounded, - color: Colors.white, - size: 36, - ), + child: Icon(Icons.arrow_downward_rounded, color: Colors.white, size: isLargeScreen ? 42 : 36), ), ), - if (_isDownloading || isSending || isEncrypting) SizedBox( width: size, height: size, - child: _buildProgressOverlay( - (isSending || isEncrypting), - isEncrypting, - ), + child: _buildProgressOverlay((isSending || isEncrypting), isEncrypting), ), ], ), @@ -1259,32 +1210,27 @@ class _MessageBubbleState extends State { ); } - Widget _buildVoiceNoteBubble(Color textCol, Color subTextCol) { + Widget _buildVoiceNoteBubble(Color textCol, Color subTextCol, bool isLargeScreen) { final String path = widget.message.localFile?.path ?? ''; final bool isDownloaded = path.isNotEmpty && File(path).existsSync(); - - // Определяем статусы отправки на основе твоей модели данных MessageStatus final bool isSendingNow = widget.message.status == MessageStatus.sending; + + final double noteWidth = isLargeScreen ? 280.0 : 240.0; - // Если файл еще НЕ скачан локально, показываем заглушку загрузки if (!isDownloaded) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), constraints: BoxConstraints( - minWidth: MediaQuery.of(context).size.width * 0.6, - maxWidth: MediaQuery.of(context).size.width * 0.6, + minWidth: noteWidth, + maxWidth: noteWidth, ), child: Row( children: [ - // Индикатор процесса или стрелочка скачивания SizedBox( width: 28, height: 28, child: _isMediaLoading - ? CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(textCol), - strokeWidth: 2.5, - ) + ? CircularProgressIndicator(valueColor: AlwaysStoppedAnimation(textCol), strokeWidth: 2.5) : Icon(Icons.download_rounded, color: textCol, size: 28), ), const SizedBox(width: 12), @@ -1297,16 +1243,12 @@ class _MessageBubbleState extends State { "Голосовое сообщение", maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14, - color: textCol, - fontWeight: FontWeight.w500, - ), + style: TextStyle(fontSize: isLargeScreen ? 15 : 14, color: textCol, fontWeight: FontWeight.w500), ), const SizedBox(height: 2), Text( _isMediaLoading ? "Загрузка..." : "Нажмите для скачивания", - style: TextStyle(fontSize: 11, color: subTextCol), + style: TextStyle(fontSize: isLargeScreen ? 12 : 11, color: subTextCol), ), ], ), @@ -1316,53 +1258,45 @@ class _MessageBubbleState extends State { ); } - // Если файл СКАЧАН, отдаем управление полноценному интерактивному плееру! return Container( padding: const EdgeInsets.all(4), constraints: BoxConstraints( - minWidth: MediaQuery.of(context).size.width * 0.65, - maxWidth: MediaQuery.of(context).size.width * 0.65, + minWidth: noteWidth + 10, + maxWidth: noteWidth + 10, ), child: Opacity( opacity: isSendingNow ? 0.5 : 1.0, child: AbsorbPointer( - // Блокируем клики только во время непосредственной отправки сообщения absorbing: isSendingNow, child: InlineVoiceNotePlayer( - key: ValueKey( - 'voice_note_${widget.message.fileId ?? widget.message.tempId}', - ), + key: ValueKey('voice_note_${widget.message.fileId ?? widget.message.tempId}'), audioPath: path, + isLargeScreen: isLargeScreen, ), ), ), ); } - Widget _buildTimeAndStatusRow(bool isMe, Color secondaryTextColor) { + Widget _buildTimeAndStatusRow(bool isMe, Color secondaryTextColor, double timeFontSize) { final timeStr = "${widget.message.createdAt.hour.toString().padLeft(2, '0')}:${widget.message.createdAt.minute.toString().padLeft(2, '0')}"; return Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - timeStr, - style: TextStyle(color: secondaryTextColor, fontSize: 10), - ), + Text(timeStr, style: TextStyle(color: secondaryTextColor, fontSize: timeFontSize)), if (widget.message.editedAt != null) Text( " (изменено)", - style: TextStyle(color: secondaryTextColor, fontSize: 10, fontStyle: FontStyle.italic), + style: TextStyle(color: secondaryTextColor, fontSize: timeFontSize, fontStyle: FontStyle.italic), ), if (isMe) ...[ const SizedBox(width: 4), Icon( - widget.message.status == MessageStatus.read - ? Icons.done_all - : Icons.done, + widget.message.status == MessageStatus.read ? Icons.done_all : Icons.done, color: secondaryTextColor, - size: 14, + size: timeFontSize + 4, ), ], ], @@ -1373,9 +1307,7 @@ class _MessageBubbleState extends State { if (bytes <= 0) return "0 B"; const suffixes = ["B", "KB", "MB", "GB", "TB"]; var i = (log(bytes) / log(1024)).floor(); - return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) + - " " + - suffixes[i]; + return ((bytes / pow(1024, i)).toStringAsFixed(decimals)) + " " + suffixes[i]; } } @@ -1400,9 +1332,6 @@ class MediaCacheManager { String? getThumbnailPath(String id) => _thumbnailPathCache[id]; } -// ========================================== -// ВСТРОЕННЫЙ ПЛЕЕР ДЛЯ ВИДЕО-КРУЖКОВ -// ========================================== class InlineVideoNotePlayer extends StatefulWidget { final String videoPath; const InlineVideoNotePlayer({super.key, required this.videoPath}); @@ -1425,36 +1354,22 @@ class _InlineVideoNotePlayerState extends State { @override void initState() { super.initState(); - debugPrint(' --> PLAYER_STATE: initState() для пути: ${widget.videoPath}'); WidgetsBinding.instance.addPostFrameCallback((_) { _initVideoWithDelay(); }); - InlineVideoNotePlayer.activeVideoPathNotifier.addListener( - _onActiveVideoChanged, - ); + InlineVideoNotePlayer.activeVideoPathNotifier.addListener(_onActiveVideoChanged); } void _initVideoWithDelay() async { if (widget.videoPath.isEmpty) return; await Future.delayed(const Duration(milliseconds: 200)); - // Извлекаем msgId из пути или передаем из MessageBubble. - // Если у тебя в InlineVideoNotePlayer есть доступ к msgId, лучше использовать его. - // Для примера вытащим цифры из хэша файла или сделаем случайный сдвиг: - final int stableSalt = - widget.videoPath.hashCode % 6; // Получим число от 0 до 5 - - // Каждое видео получит свой уникальный сдвиг: 0мс, 150мс, 300мс, 450мс и т.д. + final int stableSalt = widget.videoPath.hashCode % 6; final int delayMs = 150 * stableSalt; - debugPrint( - '--> PLAYER_STATE: Планируем запуск плеера с задержкой $delayMs мс для ${widget.videoPath.split('/').last}', - ); - _delayFuture = Future.delayed(Duration(milliseconds: delayMs)).then(( _, ) async { - // КРИТИЧЕСКИ ВАЖНО: проверяем, жив ли еще виджет на экране после паузы if (!mounted) return; _initVideo(); }); @@ -1462,13 +1377,7 @@ class _InlineVideoNotePlayerState extends State { @override void dispose() { - debugPrint( - ' --> PLAYER_STATE: dispose() вызыван для пути: ${widget.videoPath}', - ); - - InlineVideoNotePlayer.activeVideoPathNotifier.removeListener( - _onActiveVideoChanged, - ); + InlineVideoNotePlayer.activeVideoPathNotifier.removeListener(_onActiveVideoChanged); _controller?.removeListener(_videoListener); _controller?.dispose(); super.dispose(); @@ -1478,24 +1387,11 @@ class _InlineVideoNotePlayerState extends State { void didUpdateWidget(covariant InlineVideoNotePlayer oldWidget) { super.didUpdateWidget(oldWidget); - debugPrint( - ' --> PLAYER_STATE: didUpdateWidget. Старый путь="${oldWidget.videoPath}", Новый путь="${widget.videoPath}"', - ); - - // 1. Если новый путь пустой, НЕ уничтожаем старый рабочий контроллер! - // Скорее всего, это временный лаг обновления состояния в ListView. if (widget.videoPath.isEmpty && oldWidget.videoPath.isNotEmpty) { - debugPrint( - ' --> PLAYER_STATE: Новый путь пустой. Игнорируем сброс плеера.', - ); return; } - // 2. Если пути формально отличаются, но плеер уже инициализирован и успешно играет, - // а разница лишь в динамическом префиксе дешифрованного файла (одно и то же видео) if (oldWidget.videoPath != widget.videoPath) { - // Защита: Если старый файл существовал и новый существует, и они имеют одинаковый размер - // (или мы просто доверяем текущему плееру), не нужно дергать нативный контроллер. if (_controller != null && _controller!.value.isInitialized) { final oldFile = File(oldWidget.videoPath); final newFile = File(widget.videoPath); @@ -1503,72 +1399,44 @@ class _InlineVideoNotePlayerState extends State { if (oldFile.existsSync() && newFile.existsSync() && oldFile.lengthSync() == newFile.lengthSync()) { - debugPrint( - ' --> PLAYER_STATE: Пути разные, но файлы идентичны по размеру. Не пересоздаем.', - ); return; } } - debugPrint( - ' --> PLAYER_STATE: Путь изменился на валидный! Пересоздаем контроллер.', - ); if (_controller != null) { _controller!.removeListener(_videoListener); final oldController = _controller!; _controller = null; - oldController.dispose().catchError( - (e) => debugPrint('Error disposing vc: $e'), - ); + oldController.dispose().catchError((e) => debugPrint('Error disposing vc: $e')); } _initVideo(); } } void _initVideo() { - if (widget.videoPath.isEmpty) { - debugPrint(' --> PLAYER_INIT: Отмена инициализации. Путь пустой.'); - return; - } + if (widget.videoPath.isEmpty) return; final file = File(widget.videoPath); - if (!file.existsSync()) { - debugPrint( - ' --> PLAYER_INIT: Отмена инициализации. Файла физически НЕТ на диске по пути: ${widget.videoPath}', - ); - return; - } - - debugPrint( - ' --> PLAYER_INIT: Начинаем VideoPlayerController.file(). Исходный файл существует.', - ); + if (!file.existsSync()) return; if (_isInitializing) return; _isInitializing = true; _initError = null; _controller = VideoPlayerController.file(file) - ..initialize() - .then((_) { - debugPrint( - ' --> PLAYER_INIT: СУПЕР! Контроллер успешно инициализирован для ${widget.videoPath}', - ); - _isInitializing = false; - _initError = null; - if (mounted) setState(() {}); - }) - .catchError((e) { - _isInitializing = false; - _initError = e.toString(); - final oldController = _controller; - _controller = null; - oldController?.removeListener(_videoListener); - oldController?.dispose().catchError((_) {}); - if (mounted) setState(() {}); - debugPrint( - ' --> PLAYER_INIT_ERROR: Фатальный сбой VideoPlayer: $e', - ); - }); + ..initialize().then((_) { + _isInitializing = false; + _initError = null; + if (mounted) setState(() {}); + }).catchError((e) { + _isInitializing = false; + _initError = e.toString(); + final oldController = _controller; + _controller = null; + oldController?.removeListener(_videoListener); + oldController?.dispose().catchError((_) {}); + if (mounted) setState(() {}); + }); _controller?.addListener(_videoListener); } @@ -1581,15 +1449,11 @@ class _InlineVideoNotePlayerState extends State { if (isInitialized && _wasPlaying && !isPlaying) { _isExpanded = false; - - if (InlineVideoNotePlayer.activeVideoPathNotifier.value == - widget.videoPath) { + if (InlineVideoNotePlayer.activeVideoPathNotifier.value == widget.videoPath) { InlineVideoNotePlayer.activeVideoPathNotifier.value = null; } } - _wasPlaying = isPlaying; - setState(() {}); } @@ -1614,8 +1478,7 @@ class _InlineVideoNotePlayerState extends State { if (_controller!.value.isPlaying) { _controller!.pause(); _isExpanded = false; - if (InlineVideoNotePlayer.activeVideoPathNotifier.value == - widget.videoPath) { + if (InlineVideoNotePlayer.activeVideoPathNotifier.value == widget.videoPath) { InlineVideoNotePlayer.activeVideoPathNotifier.value = null; } } else { @@ -1629,9 +1492,9 @@ class _InlineVideoNotePlayerState extends State { @override Widget build(BuildContext context) { - final double size = _isExpanded ? 260.0 : 160.0; - final bool isInitialized = - _controller != null && _controller!.value.isInitialized; + final bool isLargeScreen = MediaQuery.of(context).size.width > 750; + final double size = _isExpanded ? (isLargeScreen ? 300.0 : 260.0) : (isLargeScreen ? 190.0 : 160.0); + final bool isInitialized = _controller != null && _controller!.value.isInitialized; final bool hasInitError = _initError != null; double progress = 0.0; @@ -1646,11 +1509,7 @@ class _InlineVideoNotePlayerState extends State { curve: Curves.easeOut, width: size, height: size, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors - .black12, // Даем легкую подложку вместо прозрачности, чтобы круг было видно - ), + decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.black12), child: ClipOval( child: Stack( alignment: Alignment.center, @@ -1674,43 +1533,19 @@ class _InlineVideoNotePlayerState extends State { ), ) else - // Красивый лоадер, пока файл скачивается или обрабатывается - const Center( - child: SizedBox( - width: 30, - height: 30, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white70), - ), - ), - ), + const Center(child: SizedBox(width: 30, height: 30, child: CircularProgressIndicator(strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white70)))), if (isInitialized && !hasInitError) Positioned.fill( child: IgnorePointer( child: CustomPaint( - painter: CircleProgressPainter( - progress: progress, - progressColor: Colors.white, - backgroundColor: Colors.white30, - strokeWidth: 4.0, - ), + painter: CircleProgressPainter(progress: progress, progressColor: Colors.white, backgroundColor: Colors.white30, strokeWidth: 4.0), ), ), ), - if (isInitialized && !_controller!.value.isPlaying && !hasInitError) IgnorePointer( - child: Container( - color: Colors.black26, - alignment: Alignment.center, - child: const Icon( - Icons.play_arrow, - size: 40, - color: Colors.white, - ), - ), + child: Container(color: Colors.black26, alignment: Alignment.center, child: const Icon(Icons.play_arrow, size: 40, color: Colors.white)), ), ], ), @@ -1721,7 +1556,6 @@ class _InlineVideoNotePlayerState extends State { class _InlineVideoInitErrorFallback extends StatelessWidget { final String videoPath; - const _InlineVideoInitErrorFallback({required this.videoPath}); @override @@ -1733,7 +1567,7 @@ class _InlineVideoInitErrorFallback extends StatelessWidget { try { await OpenFilex.open(videoPath); } catch (e) { - debugPrint(' --> PLAYER_FALLBACK_ERROR: $e'); + debugPrint('Fallback error: $e'); } }, child: const Center( @@ -1742,11 +1576,7 @@ class _InlineVideoInitErrorFallback extends StatelessWidget { children: [ Icon(Icons.play_disabled, color: Colors.white70, size: 40), SizedBox(height: 8), - Text( - 'Видео не воспроизводится\n Нажмите, чтобы открыть внешним плеером', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white70, fontSize: 12), - ), + Text('Видео не воспроизводится\n Нажмите, чтобы открыть внешним плеером', textAlign: TextAlign.center, style: TextStyle(color: Colors.white70, fontSize: 12)), ], ), ), @@ -1789,13 +1619,7 @@ class CircleProgressPainter extends CustomPainter { double startAngle = -math.pi / 2; double sweepAngle = 2 * math.pi * progress; - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - startAngle, - sweepAngle, - false, - progressPaint, - ); + canvas.drawArc(Rect.fromCircle(center: center, radius: radius), startAngle, sweepAngle, false, progressPaint); } @override @@ -1806,12 +1630,10 @@ class CircleProgressPainter extends CustomPainter { } } -// ========================================== -// ВСТРОЕННЫЙ ПЛЕЕР ДЛЯ ГОЛОСОВЫХ СООБЩЕНИЙ -// ========================================== class InlineVoiceNotePlayer extends StatefulWidget { final String audioPath; - const InlineVoiceNotePlayer({super.key, required this.audioPath}); + final bool isLargeScreen; + const InlineVoiceNotePlayer({super.key, required this.audioPath, required this.isLargeScreen}); @override State createState() => _InlineVoiceNotePlayerState(); @@ -1836,30 +1658,21 @@ class _InlineVoiceNotePlayerState extends State { _startFileAvailabilityPolling(); } - // Вынесем проверку в отдельный метод void _checkAndSetupSource() { - if (widget.audioPath.isEmpty || _sourceInitialized || _isInitializing) - return; + if (widget.audioPath.isEmpty || _sourceInitialized || _isInitializing) return; final file = File(widget.audioPath); - if (!file.existsSync()) { - debugPrint('[AUDIO] Файл пока отсутствует на диске, ждем обновления...'); - return; - } + if (!file.existsSync()) return; _isInitializing = true; - if (mounted) { - setState(() {}); - } + if (mounted) setState(() {}); _setupSource(widget.audioPath).whenComplete(() { if (!mounted) { _isInitializing = false; return; } - setState(() { - _isInitializing = false; - }); + setState(() { _isInitializing = false; }); }); } @@ -1869,20 +1682,12 @@ class _InlineVoiceNotePlayerState extends State { final file = File(widget.audioPath); if (file.existsSync()) { - if (!_sourceInitialized && !_isInitializing) { - _checkAndSetupSource(); - } + if (!_sourceInitialized && !_isInitializing) _checkAndSetupSource(); return; } - _fileWatchTimer = Timer.periodic(const Duration(milliseconds: 250), ( - timer, - ) { - if (!mounted) { - timer.cancel(); - return; - } - if (widget.audioPath.isEmpty) { + _fileWatchTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) { + if (!mounted || widget.audioPath.isEmpty) { timer.cancel(); return; } @@ -1903,19 +1708,13 @@ class _InlineVoiceNotePlayerState extends State { await _audioPlayer.setSource(DeviceFileSource(path)); if (!mounted) return; - setState(() { - _sourceInitialized = true; - }); + setState(() { _sourceInitialized = true; }); final d = await _audioPlayer.getDuration(); if (!mounted) return; if (d != null && d.inMilliseconds > 0) { - setState(() { - _duration = d; - }); + setState(() { _duration = d; }); } - - debugPrint('[AUDIO] Источник успешно установлен для: $path'); } catch (e) { debugPrint('[AUDIO ERROR] Ошибка установки источника: $e'); } @@ -1926,10 +1725,7 @@ class _InlineVoiceNotePlayerState extends State { super.didUpdateWidget(oldWidget); final bool pathChanged = oldWidget.audioPath != widget.audioPath; - final bool fileJustAppeared = - widget.audioPath.isNotEmpty && - !_sourceInitialized && - File(widget.audioPath).existsSync(); + final bool fileJustAppeared = widget.audioPath.isNotEmpty && !_sourceInitialized && File(widget.audioPath).existsSync(); if (pathChanged) { _sourceInitialized = false; @@ -1939,12 +1735,8 @@ class _InlineVoiceNotePlayerState extends State { } if (pathChanged || fileJustAppeared) { - debugPrint('[AUDIO_UPDATE] Реактивное обновление источника звука.'); _checkAndSetupSource(); _startFileAvailabilityPolling(); - if (_isFileAvailable && !_sourceInitialized && mounted) { - setState(() {}); - } } } @@ -1961,18 +1753,13 @@ class _InlineVoiceNotePlayerState extends State { }); _audioPlayer.onDurationChanged.listen((newDuration) { - // ЗАЩИТА ОТ ДЕРГАНИЯ: игнорируем пустые или некорректные ивенты от движка if (mounted && newDuration.inMilliseconds > 0) { - setState(() { - _duration = newDuration; - }); + setState(() { _duration = newDuration; }); } }); _audioPlayer.onPositionChanged.listen((newPosition) { if (!mounted) return; - - // СЛЕПАЯ ЗОНА (150мс): сглаживаем рывок ползунка в самом начале воспроизведения if (_isPlaying && newPosition.inMilliseconds < 150) return; setState(() { @@ -1986,13 +1773,9 @@ class _InlineVoiceNotePlayerState extends State { } void _togglePlay() async { - // Блокируем клик, если файл физически еще не скачан - if (widget.audioPath.isEmpty || !File(widget.audioPath).existsSync()) - return; + if (widget.audioPath.isEmpty || !File(widget.audioPath).existsSync()) return; - if (!_sourceInitialized) { - _checkAndSetupSource(); - } + if (!_sourceInitialized) _checkAndSetupSource(); if (_isPlaying) { await _audioPlayer.pause(); @@ -2001,8 +1784,7 @@ class _InlineVoiceNotePlayerState extends State { } } - bool get _isFileAvailable => - widget.audioPath.isNotEmpty && File(widget.audioPath).existsSync(); + bool get _isFileAvailable => widget.audioPath.isNotEmpty && File(widget.audioPath).existsSync(); @override void dispose() { @@ -2021,41 +1803,35 @@ class _InlineVoiceNotePlayerState extends State { Widget build(BuildContext context) { final bool fileAvailable = _isFileAvailable; final bool hasDuration = _hasValidDuration; - final bool isReady = fileAvailable && _sourceInitialized && hasDuration; final String statusText; + if (!fileAvailable) { statusText = 'Загрузка...'; } else if (!_sourceInitialized || !hasDuration) { statusText = 'Подготовка...'; } else { - statusText = - "${_formatDuration(_position)} / ${_formatDuration(_duration)}"; + statusText = "${_formatDuration(_position)} / ${_formatDuration(_duration)}"; } final double durationMs = _duration.inMilliseconds.toDouble(); final double positionMs = _position.inMilliseconds.toDouble(); final bool canSeek = hasDuration; final double safeMax = durationMs > 0 ? durationMs : 1.0; - final double safeValue = durationMs > 0 - ? positionMs.clamp(0.0, safeMax) - : 0.0; + final double safeValue = durationMs > 0 ? positionMs.clamp(0.0, safeMax) : 0.0; + + final double playerWidth = widget.isLargeScreen ? 280.0 : 240.0; return Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), - width: 240, - decoration: BoxDecoration( - color: Colors.black12, - borderRadius: BorderRadius.circular(10), - ), + width: playerWidth, + decoration: BoxDecoration(color: Colors.black12, borderRadius: BorderRadius.circular(10)), child: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: Icon( - _isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled, - ), - iconSize: 36, + icon: Icon(_isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled), + iconSize: widget.isLargeScreen ? 40 : 36, color: fileAvailable ? Colors.white : Colors.white38, onPressed: fileAvailable ? _togglePlay : null, ), @@ -2068,13 +1844,8 @@ class _InlineVoiceNotePlayerState extends State { data: SliderTheme.of(context).copyWith( trackHeight: 3, padding: EdgeInsets.zero, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 5, - elevation: 0, - ), - overlayShape: const RoundSliderOverlayShape( - overlayRadius: 8, - ), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5, elevation: 0), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 8), ), child: Container( height: 20, @@ -2088,27 +1859,15 @@ class _InlineVoiceNotePlayerState extends State { value: safeValue, onChanged: canSeek ? (value) async { - await _audioPlayer.seek( - Duration(milliseconds: value.toInt()), - ); + await _audioPlayer.seek(Duration(milliseconds: value.toInt())); } : null, ), ), ), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - child: Text( - statusText, - style: const TextStyle( - fontSize: 11, - color: Colors.white70, - fontWeight: FontWeight.w500, - ), - ), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Text(statusText, style: TextStyle(fontSize: widget.isLargeScreen ? 12 : 11, color: Colors.white70, fontWeight: FontWeight.w500)), ), ], ), @@ -2117,4 +1876,4 @@ class _InlineVoiceNotePlayerState extends State { ), ); } -} +} \ No newline at end of file diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 6f62215..8e1eb50 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,7 +9,9 @@ #include #include #include +#include #include +#include #include 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); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index c0e31b7..1fe4ea1 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -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 ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 76220df..f3be0f6 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/pubspec.lock b/pubspec.lock index 9ed6682..c173dea 100644 --- a/pubspec.lock +++ b/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" diff --git a/pubspec.yaml b/pubspec.yaml index 10a94ab..81c35d1 100644 --- a/pubspec.yaml +++ b/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 diff --git a/srv/app/api/endpoints/admin.py b/srv/app/api/endpoints/admin.py new file mode 100644 index 0000000..ab620fc --- /dev/null +++ b/srv/app/api/endpoints/admin.py @@ -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": "Профиль успешно изменен администратором"} diff --git a/srv/app/api/endpoints/auth.py b/srv/app/api/endpoints/auth.py index 0eb84be..d424c40 100644 --- a/srv/app/api/endpoints/auth.py +++ b/srv/app/api/endpoints/auth.py @@ -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,8 +68,9 @@ 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,10 +165,11 @@ 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="Пользователь не найден") - + if not user.totp_temp_secret: raise HTTPException(status_code=400, detail="TOTP не включен") @@ -138,8 +177,9 @@ 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 user.totp_temp_secret = None @@ -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() diff --git a/srv/app/api/endpoints/media.py b/srv/app/api/endpoints/media.py index dddc09e..706a068 100644 --- a/srv/app/api/endpoints/media.py +++ b/srv/app/api/endpoints/media.py @@ -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} diff --git a/srv/app/api/endpoints/users.py b/srv/app/api/endpoints/users.py index 313bd33..8f144fe 100644 --- a/srv/app/api/endpoints/users.py +++ b/srv/app/api/endpoints/users.py @@ -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,14 +320,14 @@ 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="Пользователь не найден") - + profile_data = { "id": user.id, "public_key": user.public_key, diff --git a/srv/app/api/schemas.py b/srv/app/api/schemas.py index 1185d8d..bd7c1b3 100644 --- a/srv/app/api/schemas.py +++ b/srv/app/api/schemas.py @@ -80,4 +80,24 @@ class UserContactResponse(BaseModel): public_key: Optional[str] = None class Config: - from_attributes = True \ No newline at end of file + 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 \ No newline at end of file diff --git a/srv/app/core/config.py b/srv/app/core/config.py index 4d05b9f..c03c3db 100644 --- a/srv/app/core/config.py +++ b/srv/app/core/config.py @@ -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(",") diff --git a/srv/app/core/security.py b/srv/app/core/security.py index a4dec68..0d251e3 100644 --- a/srv/app/core/security.py +++ b/srv/app/core/security.py @@ -15,34 +15,45 @@ if not SECRET_KEY: raise RuntimeError("JWT_KEY environment variable not set") SECRET_KEY = SECRET_KEY.strip() ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 30 +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: yield db - finally: + 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, @@ -62,13 +75,16 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De raise credentials_exception except JWTError: raise credentials_exception - + user_id = int(id) 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, @@ -81,4 +97,4 @@ async def test_token(token: str): raise credentials_exception return id except JWTError: - raise credentials_exception \ No newline at end of file + raise credentials_exception diff --git a/srv/app/db/models.py b/srv/app/db/models.py index d00f849..18da8b1 100644 --- a/srv/app/db/models.py +++ b/srv/app/db/models.py @@ -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,22 +24,27 @@ 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) fcm_token = Column(String, nullable=True) avatar_file_id = Column(String, nullable=True) - + # Privacy settings - show_email = Column(Integer, nullable=False, server_default="1") # 1 = true, 0 = false + 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" id = Column(Integer, primary_key=True, index=True) @@ -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() diff --git a/srv/app/websocket/connection_manager.py b/srv/app/websocket/connection_manager.py index 8f225e2..919016f 100644 --- a/srv/app/websocket/connection_manager.py +++ b/srv/app/websocket/connection_manager.py @@ -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: @@ -310,7 +339,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db: {"last_online": datetime.now(timezone.utc)}, synchronize_session="fetch") db.commit() print("ОТКЛЮЧЕНИЕ") - + await manager.broadcast({ "type": "user_offline", "user_id": user_id, diff --git a/srv/cloud_media_cache/33524fb9e6704a0e86d6fd9b3311fa06.enc b/srv/cloud_media_cache/33524fb9e6704a0e86d6fd9b3311fa06.enc new file mode 100644 index 0000000..57c3aba Binary files /dev/null and b/srv/cloud_media_cache/33524fb9e6704a0e86d6fd9b3311fa06.enc differ diff --git a/srv/cloud_media_cache/629ec080961b4728961038f9d84de5cb.enc b/srv/cloud_media_cache/629ec080961b4728961038f9d84de5cb.enc new file mode 100644 index 0000000..bb41a04 Binary files /dev/null and b/srv/cloud_media_cache/629ec080961b4728961038f9d84de5cb.enc differ diff --git a/srv/cloud_media_cache/67da7307265349ca856644bbbe0331a1.enc b/srv/cloud_media_cache/67da7307265349ca856644bbbe0331a1.enc new file mode 100644 index 0000000..402bfc1 Binary files /dev/null and b/srv/cloud_media_cache/67da7307265349ca856644bbbe0331a1.enc differ diff --git a/srv/cloud_media_cache/cfcd4e2fafeb4e579547f15205e0bd33.enc b/srv/cloud_media_cache/cfcd4e2fafeb4e579547f15205e0bd33.enc new file mode 100644 index 0000000..e00643f Binary files /dev/null and b/srv/cloud_media_cache/cfcd4e2fafeb4e579547f15205e0bd33.enc differ diff --git a/srv/cloud_media_cache/fc0927b8b9b74ea3884b67d55b2fd4c7.enc b/srv/cloud_media_cache/fc0927b8b9b74ea3884b67d55b2fd4c7.enc new file mode 100644 index 0000000..9147a48 Binary files /dev/null and b/srv/cloud_media_cache/fc0927b8b9b74ea3884b67d55b2fd4c7.enc differ diff --git a/srv/get_token.py b/srv/get_token.py new file mode 100644 index 0000000..e2a4ae7 --- /dev/null +++ b/srv/get_token.py @@ -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) \ No newline at end of file diff --git a/srv/main.py b/srv/main.py index 8aa21a7..d8cbeb6 100644 --- a/srv/main.py +++ b/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) diff --git a/srv/uploads/3fb1f3a9160f4bafbba39e8bc21bc9a8.enc b/srv/uploads/3fb1f3a9160f4bafbba39e8bc21bc9a8.enc new file mode 100644 index 0000000..9f337dc Binary files /dev/null and b/srv/uploads/3fb1f3a9160f4bafbba39e8bc21bc9a8.enc differ diff --git a/srv/uploads/ab5e49e2155c4d2e9e979c8ced7931c2.enc b/srv/uploads/ab5e49e2155c4d2e9e979c8ced7931c2.enc new file mode 100644 index 0000000..3f55968 Binary files /dev/null and b/srv/uploads/ab5e49e2155c4d2e9e979c8ced7931c2.enc differ diff --git a/srv/uploads/c0b6af86-b23c-4182-96cd-de9cc6fbd9b5.enc b/srv/uploads/c0b6af86-b23c-4182-96cd-de9cc6fbd9b5.enc new file mode 100644 index 0000000..557edd9 Binary files /dev/null and b/srv/uploads/c0b6af86-b23c-4182-96cd-de9cc6fbd9b5.enc differ diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index faa71ca..529d4ae 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,24 +7,31 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include +#include #include #include #include #include +#include #include 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")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index fa538b4..92604a8 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -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 ) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 03a9333..fcb7881 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -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 diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 52ae32c..420b18a 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -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); diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index c04e20c..0eb0275 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ