Обновление интерфейса
This commit is contained in:
parent
966b1a6b84
commit
11340bdca1
|
|
@ -16,6 +16,8 @@ migrate_working_dir/
|
||||||
venv/
|
venv/
|
||||||
.venv/
|
.venv/
|
||||||
chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json
|
chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json
|
||||||
|
chepuhagram-497610-47c286108afd.json
|
||||||
|
client_secret_338589490139-9ocvlhs270l5hqj3sdrru14ampiacv0s.apps.googleusercontent.com.json
|
||||||
.firebaserc
|
.firebaserc
|
||||||
firebase-tools-instant-win.exe
|
firebase-tools-instant-win.exe
|
||||||
google-services.json
|
google-services.json
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"cmake.sourceDirectory": "D:/FlutterProjects/chepuhagram/linux",
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"flutter": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,7 +48,7 @@ flutter {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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(platform("com.google.firebase:firebase-bom:34.12.0"))
|
||||||
implementation("com.google.firebase:firebase-messaging")
|
implementation("com.google.firebase:firebase-messaging")
|
||||||
}
|
}
|
||||||
|
|
@ -1,220 +1,243 @@
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'dart:io';
|
||||||
import 'package:sqflite/sqflite.dart';
|
|
||||||
import 'package:path/path.dart';
|
|
||||||
import 'package:chepuhagram/data/models/message_model.dart';
|
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 {
|
part 'local_db_service.g.dart';
|
||||||
static final LocalDbService _instance = LocalDbService._internal();
|
|
||||||
static Database? _database;
|
|
||||||
|
|
||||||
factory LocalDbService() => _instance;
|
class Messages extends Table {
|
||||||
LocalDbService._internal();
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
IntColumn get senderId => integer().named('sender_id')();
|
||||||
|
IntColumn get receiverId => integer().named('receiver_id')();
|
||||||
|
TextColumn get content => text().named('content')();
|
||||||
|
TextColumn get timestamp => text().named('timestamp')();
|
||||||
|
TextColumn get deliveredAt => text().named('delivered_at').nullable()();
|
||||||
|
TextColumn get readAt => text().named('read_at').nullable()();
|
||||||
|
IntColumn get replyToId => integer().named('reply_to_id').nullable()();
|
||||||
|
TextColumn get replyToText => text().named('reply_to_text').nullable()();
|
||||||
|
TextColumn get editedAt => text().named('edited_at').nullable()();
|
||||||
|
TextColumn get messageType =>
|
||||||
|
text().named('message_type').withDefault(const Constant('text'))();
|
||||||
|
TextColumn get fileId => text().named('file_id').nullable()();
|
||||||
|
TextColumn get encryptedKey => text().named('encrypted_key').nullable()();
|
||||||
|
TextColumn get fileName => text().named('file_name').nullable()();
|
||||||
|
IntColumn get fileSize => integer().named('file_size').nullable()();
|
||||||
|
}
|
||||||
|
|
||||||
static const int _dbVersion = 8;
|
class FileNameMappings extends Table {
|
||||||
|
TextColumn get fileId => text().named('file_id')();
|
||||||
|
TextColumn get originalFileName => text().named('original_file_name')();
|
||||||
|
|
||||||
Future<Database> get database async {
|
@override
|
||||||
if (_database != null) return _database!;
|
Set<Column> get primaryKey => {fileId};
|
||||||
_database = await _initDb();
|
}
|
||||||
return _database!;
|
|
||||||
|
@DriftDatabase(tables: [Messages, FileNameMappings])
|
||||||
|
class LocalDbService extends _$LocalDbService {
|
||||||
|
factory LocalDbService() => instance;
|
||||||
|
|
||||||
|
LocalDbService._internal() : super(_openConnection()) {
|
||||||
|
print('LocalDbService constructor called');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _createMessagesTable(Database db) async {
|
static final LocalDbService instance = LocalDbService._internal();
|
||||||
await db.execute('''
|
|
||||||
CREATE TABLE messages(
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
sender_id INTEGER,
|
|
||||||
receiver_id INTEGER,
|
|
||||||
content TEXT,
|
|
||||||
timestamp TEXT,
|
|
||||||
delivered_at TEXT,
|
|
||||||
read_at TEXT,
|
|
||||||
reply_to_id INTEGER,
|
|
||||||
reply_to_text TEXT,
|
|
||||||
edited_at TEXT,
|
|
||||||
message_type TEXT DEFAULT 'text',
|
|
||||||
file_id TEXT,
|
|
||||||
encrypted_key TEXT,
|
|
||||||
file_name TEXT,
|
|
||||||
file_size INTEGER
|
|
||||||
)
|
|
||||||
''');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Database> _initDb() async {
|
@override
|
||||||
String path = join(await getDatabasesPath(), 'chat_app.db');
|
int get schemaVersion => 9;
|
||||||
return await openDatabase(
|
|
||||||
path,
|
@override
|
||||||
version: _dbVersion,
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
onCreate: (db, version) async {
|
onCreate: (Migrator m) async {
|
||||||
await _createMessagesTable(db);
|
await m.createAll();
|
||||||
},
|
},
|
||||||
onUpgrade: (db, oldVersion, newVersion) async {
|
onUpgrade: (Migrator m, int from, int to) async {
|
||||||
if (oldVersion < 8) {
|
if (from < 8) {
|
||||||
// v8: stop storing media bytes in SQLite; rebuild messages table.
|
await m.deleteTable('messages');
|
||||||
await db.execute('DROP TABLE IF EXISTS messages');
|
await m.createAll();
|
||||||
await _createMessagesTable(db);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (from < 9) {
|
||||||
if (oldVersion < 2) {
|
await m.createTable(fileNameMappings);
|
||||||
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.
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> clearDatabase() async {
|
Future<void> clearDatabase() async {
|
||||||
final db = await database;
|
await delete(messages).go();
|
||||||
await db.delete('messages');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveMessages(List<dynamic> messages) async {
|
Future<void> saveMessages(List<dynamic> messageList) async {
|
||||||
final db = await database;
|
if (messageList.isEmpty) return;
|
||||||
final List<int> incomingIds = messages.map((msg) {
|
|
||||||
return (msg is MessageModel) ? msg.id! : (msg['id'] as int);
|
final List<int> incomingIds = messageList
|
||||||
|
.map<int?>(
|
||||||
|
(msg) => (msg is MessageModel)
|
||||||
|
? msg.id
|
||||||
|
: (msg['id'] == null ? null : int.tryParse(msg['id'].toString())),
|
||||||
|
)
|
||||||
|
.whereType<int>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Преобразуем входящие данные в компаньоны заранее
|
||||||
|
final companions = messageList.map<MessagesCompanion>((msg) {
|
||||||
|
final int? id;
|
||||||
|
final int senderId;
|
||||||
|
final int receiverId;
|
||||||
|
final String content;
|
||||||
|
final String timestamp;
|
||||||
|
final int? replyToId;
|
||||||
|
final String? replyToText;
|
||||||
|
final String? editedAt;
|
||||||
|
final String messageType;
|
||||||
|
final String? fileId;
|
||||||
|
final String? encryptedKey;
|
||||||
|
final String? fileName;
|
||||||
|
final int? fileSize;
|
||||||
|
|
||||||
|
if (msg is MessageModel) {
|
||||||
|
id = msg.id;
|
||||||
|
senderId = msg.senderId;
|
||||||
|
receiverId = msg.receiverId;
|
||||||
|
content = msg.text;
|
||||||
|
timestamp = msg.createdAt.toIso8601String();
|
||||||
|
replyToId = msg.replyToId;
|
||||||
|
replyToText = msg.replyToText;
|
||||||
|
editedAt = msg.editedAt?.toIso8601String();
|
||||||
|
messageType = msg.messageType.name;
|
||||||
|
fileId = msg.fileId;
|
||||||
|
encryptedKey = msg.encryptedFileKey;
|
||||||
|
fileName = msg.fileName;
|
||||||
|
fileSize = msg.fileSize;
|
||||||
|
} else {
|
||||||
|
id = msg['id'] == null ? null : int.tryParse(msg['id'].toString());
|
||||||
|
senderId = int.parse(msg['sender_id'].toString());
|
||||||
|
receiverId = int.parse(msg['receiver_id'].toString());
|
||||||
|
content = msg['content']?.toString() ?? '';
|
||||||
|
timestamp =
|
||||||
|
msg['timestamp']?.toString() ?? DateTime.now().toIso8601String();
|
||||||
|
replyToId = msg['reply_to_id'] == null
|
||||||
|
? null
|
||||||
|
: int.tryParse(msg['reply_to_id'].toString());
|
||||||
|
replyToText = msg['reply_to_text']?.toString();
|
||||||
|
editedAt = msg['edited_at']?.toString();
|
||||||
|
messageType = msg['message_type']?.toString() ?? 'text';
|
||||||
|
fileId = msg['file_id']?.toString();
|
||||||
|
encryptedKey = msg['encrypted_key']?.toString();
|
||||||
|
fileName = msg['file_name']?.toString();
|
||||||
|
fileSize = msg['file_size'] == null
|
||||||
|
? null
|
||||||
|
: int.tryParse(msg['file_size'].toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return MessagesCompanion(
|
||||||
|
id: id == null ? const Value.absent() : Value(id),
|
||||||
|
senderId: Value(senderId),
|
||||||
|
receiverId: Value(receiverId),
|
||||||
|
content: Value(content),
|
||||||
|
timestamp: Value(timestamp),
|
||||||
|
deliveredAt: const Value(null),
|
||||||
|
readAt: const Value(null),
|
||||||
|
replyToId: Value(replyToId),
|
||||||
|
replyToText: Value(replyToText),
|
||||||
|
editedAt: Value(editedAt),
|
||||||
|
messageType: Value(messageType),
|
||||||
|
fileId: Value(fileId),
|
||||||
|
encryptedKey: Value(encryptedKey),
|
||||||
|
fileName: Value(fileName),
|
||||||
|
fileSize: Value(fileSize),
|
||||||
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
Batch batch = db.batch();
|
// Выполняем все операции в рамках ОДНОЙ транзакции БД
|
||||||
|
await transaction(() async {
|
||||||
if (incomingIds.isNotEmpty) {
|
if (incomingIds.isNotEmpty) {
|
||||||
batch.delete('messages', where: 'id NOT IN (${incomingIds.join(',')})');
|
// ВНИМАНИЕ: Ограничьте удаление только текущим чатом,
|
||||||
}
|
// иначе эта строка очистит сообщения из всех остальных диалогов!
|
||||||
for (var msg in messages) {
|
final first = companions.first;
|
||||||
if (msg is MessageModel) {
|
await (delete(messages)..where(
|
||||||
batch.insert('messages', {
|
(tbl) =>
|
||||||
'id': msg.id,
|
((tbl.senderId.equals(first.senderId.value) &
|
||||||
'sender_id': msg.senderId,
|
tbl.receiverId.equals(first.receiverId.value)) |
|
||||||
'receiver_id': msg.receiverId,
|
(tbl.senderId.equals(first.receiverId.value) &
|
||||||
'content': msg.text,
|
tbl.receiverId.equals(first.senderId.value))) &
|
||||||
'timestamp': msg.createdAt.toIso8601String(),
|
tbl.id.isNotIn(incomingIds),
|
||||||
'delivered_at': null,
|
))
|
||||||
'read_at': null,
|
.go();
|
||||||
'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 batch.commit(noResult: true);
|
// Быстрая пакетная вставка/обновление
|
||||||
|
await batch((b) {
|
||||||
|
b.insertAll(
|
||||||
|
messages,
|
||||||
|
companions,
|
||||||
|
mode: InsertMode
|
||||||
|
.insertOrReplace, // Безопасный аналог insertOnConflictUpdate для batch
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение сообщений конкретного чата
|
|
||||||
Future<List<Map<String, dynamic>>> getChatHistory(
|
Future<List<Map<String, dynamic>>> getChatHistory(
|
||||||
int contactId,
|
int contactId,
|
||||||
int myId,
|
int myId,
|
||||||
) async {
|
) async {
|
||||||
final db = await database;
|
final query = select(messages)
|
||||||
return await db.query(
|
..where(
|
||||||
'messages',
|
(tbl) =>
|
||||||
where:
|
(tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) |
|
||||||
'(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)',
|
(tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId)),
|
||||||
whereArgs: [contactId, myId, myId, contactId],
|
)
|
||||||
orderBy: 'timestamp ASC',
|
..orderBy([(tbl) => OrderingTerm(expression: tbl.timestamp)]);
|
||||||
);
|
|
||||||
|
final rows = await query.get();
|
||||||
|
return rows.map((row) => row.toJson()).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> deleteChatHistory(int contactId, int myId) async {
|
Future<int> deleteChatHistory(int contactId, int myId) async {
|
||||||
final db = await database;
|
return await (delete(messages)..where(
|
||||||
return await db.delete(
|
(tbl) =>
|
||||||
'messages',
|
(tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) |
|
||||||
where:
|
(tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId)),
|
||||||
'(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)',
|
))
|
||||||
whereArgs: [contactId, myId, myId, contactId],
|
.go();
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> getLastMessage(int contactId, int myId) async {
|
Future<Map<String, dynamic>?> getLastMessage(int contactId, int myId) async {
|
||||||
final db = await database;
|
final query =
|
||||||
final rows = await db.query(
|
(select(messages)
|
||||||
'messages',
|
..where(
|
||||||
columns: ['sender_id', 'receiver_id', 'content', 'timestamp'],
|
(tbl) =>
|
||||||
where:
|
(tbl.senderId.equals(contactId) &
|
||||||
'(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)',
|
tbl.receiverId.equals(myId)) |
|
||||||
whereArgs: [contactId, myId, myId, contactId],
|
(tbl.senderId.equals(myId) &
|
||||||
orderBy: 'timestamp DESC',
|
tbl.receiverId.equals(contactId)),
|
||||||
limit: 1,
|
)
|
||||||
);
|
..orderBy([
|
||||||
|
(tbl) => OrderingTerm(
|
||||||
|
expression: tbl.timestamp,
|
||||||
|
mode: OrderingMode.desc,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
..limit(1))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
final rows = await query;
|
||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
return rows.first;
|
return rows.first.toJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateDeliveredAt(int messageId, DateTime deliveredAt) async {
|
Future<void> updateDeliveredAt(int messageId, DateTime deliveredAt) async {
|
||||||
final db = await database;
|
await (update(messages)..where((tbl) => tbl.id.equals(messageId))).write(
|
||||||
await db.update(
|
MessagesCompanion(deliveredAt: Value(deliveredAt.toIso8601String())),
|
||||||
'messages',
|
|
||||||
{'delivered_at': deliveredAt.toIso8601String()},
|
|
||||||
where: 'id = ?',
|
|
||||||
whereArgs: [messageId],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateReadAt(int messageId, DateTime readAt) async {
|
Future<void> updateReadAt(int messageId, DateTime readAt) async {
|
||||||
final db = await database;
|
await (update(messages)..where((tbl) => tbl.id.equals(messageId))).write(
|
||||||
await db.update(
|
MessagesCompanion(readAt: Value(readAt.toIso8601String())),
|
||||||
'messages',
|
|
||||||
{'read_at': readAt.toIso8601String()},
|
|
||||||
where: 'id = ?',
|
|
||||||
whereArgs: [messageId],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,17 +246,42 @@ class LocalDbService {
|
||||||
String content,
|
String content,
|
||||||
DateTime? editedAt,
|
DateTime? editedAt,
|
||||||
) async {
|
) async {
|
||||||
final db = await database;
|
await (update(messages)..where((tbl) => tbl.id.equals(messageId))).write(
|
||||||
await db.update(
|
MessagesCompanion(
|
||||||
'messages',
|
content: Value(content),
|
||||||
{'content': content, 'edited_at': editedAt?.toIso8601String()},
|
editedAt: Value(editedAt?.toIso8601String()),
|
||||||
where: 'id = ?',
|
),
|
||||||
whereArgs: [messageId],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveOriginalFileNameForFileId(
|
||||||
|
String fileId,
|
||||||
|
String fileName,
|
||||||
|
) async {
|
||||||
|
await into(fileNameMappings).insertOnConflictUpdate(
|
||||||
|
FileNameMappingsCompanion(
|
||||||
|
fileId: Value(fileId),
|
||||||
|
originalFileName: Value(fileName),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> getOriginalFileNameForFileId(String fileId) async {
|
||||||
|
final query = select(fileNameMappings)
|
||||||
|
..where((tbl) => tbl.fileId.equals(fileId));
|
||||||
|
final result = await query.getSingleOrNull();
|
||||||
|
return result?.originalFileName;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> deleteMessage(int messageId) async {
|
Future<void> deleteMessage(int messageId) async {
|
||||||
final db = await database;
|
await (delete(messages)..where((tbl) => tbl.id.equals(messageId))).go();
|
||||||
await db.delete('messages', where: 'id = ?', whereArgs: [messageId]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryExecutor _openConnection() {
|
||||||
|
// This now uses the SqfliteQueryExecutor, which will respect the global
|
||||||
|
// databaseFactory set in `main.dart`. This fixes the 'unable to open file'
|
||||||
|
// error on Windows. The database file will be 'chat_app.db' inside the
|
||||||
|
// path configured in `main.dart`.
|
||||||
|
return SqfliteQueryExecutor.inDatabaseFolder(path: 'chat_app.db');
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,6 +6,7 @@ import 'package:web_socket_channel/status.dart' as status;
|
||||||
import 'package:web_socket_channel/io.dart';
|
import 'package:web_socket_channel/io.dart';
|
||||||
import 'package:chepuhagram/core/constants.dart';
|
import 'package:chepuhagram/core/constants.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:chepuhagram/domain/services/webrtc_service.dart';
|
||||||
|
|
||||||
class SocketService with WidgetsBindingObserver {
|
class SocketService with WidgetsBindingObserver {
|
||||||
static final SocketService _instance = SocketService._internal();
|
static final SocketService _instance = SocketService._internal();
|
||||||
|
|
@ -35,6 +36,15 @@ class SocketService with WidgetsBindingObserver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _initMessageListener() {
|
||||||
|
messages.listen((data) {
|
||||||
|
if (data['type'] == 'call_accepted') {
|
||||||
|
WebRTCService().handleOffer(data['call_id'], data['sdp']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> startConnect(ApiService apiService) async {
|
Future<void> startConnect(ApiService apiService) async {
|
||||||
if (_connectTimer != null && _connectTimer!.isActive)
|
if (_connectTimer != null && _connectTimer!.isActive)
|
||||||
return; // Уже запущено
|
return; // Уже запущено
|
||||||
|
|
@ -61,13 +71,11 @@ class SocketService with WidgetsBindingObserver {
|
||||||
// В FastAPI эндпоинт ожидает токен в URL-параметре
|
// В FastAPI эндпоинт ожидает токен в URL-параметре
|
||||||
final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token");
|
final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token");
|
||||||
|
|
||||||
//_channel = WebSocketChannel.connect(uri);
|
|
||||||
|
|
||||||
_channel = IOWebSocketChannel.connect(
|
_channel = IOWebSocketChannel.connect(
|
||||||
uri,
|
uri,
|
||||||
connectTimeout: Duration(seconds: 10),
|
connectTimeout: Duration(seconds: 10),
|
||||||
);
|
);
|
||||||
|
if (_channel == null) return;
|
||||||
await _channel!.ready;
|
await _channel!.ready;
|
||||||
_channel!.stream.listen(
|
_channel!.stream.listen(
|
||||||
(data) {
|
(data) {
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,10 @@ import 'package:http/http.dart' as http;
|
||||||
import '/core/constants.dart';
|
import '/core/constants.dart';
|
||||||
import '/data/models/contact_model.dart';
|
import '/data/models/contact_model.dart';
|
||||||
import '/domain/services/api_service.dart';
|
import '/domain/services/api_service.dart';
|
||||||
import 'package:flutter_http_cache/flutter_http_cache.dart';
|
|
||||||
|
|
||||||
class ContactRepository {
|
class ContactRepository {
|
||||||
late final CachedHttpClient _client;
|
|
||||||
bool _isCacheInitialized = false;
|
|
||||||
final ApiService _apiService = ApiService();
|
final ApiService _apiService = ApiService();
|
||||||
|
|
||||||
ContactRepository() {
|
|
||||||
_initCachedClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Единая инициализация кэша для всех запросов репозитория
|
|
||||||
void _initCachedClient() {
|
|
||||||
final cache = _apiService.cache;
|
|
||||||
_client = CachedHttpClient(
|
|
||||||
cache: cache,
|
|
||||||
defaultCachePolicy: CachePolicy.networkFirst,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _ensureCacheReady() async {
|
|
||||||
if (!_isCacheInitialized) {
|
|
||||||
await _apiService.cache.initialize();
|
|
||||||
_isCacheInitialized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Contact>> fetchChatContacts({bool forceRefresh = false}) async {
|
Future<List<Contact>> fetchChatContacts({bool forceRefresh = false}) async {
|
||||||
final token = await _apiService.getAccessToken();
|
final token = await _apiService.getAccessToken();
|
||||||
|
|
||||||
|
|
@ -45,10 +22,10 @@ class ContactRepository {
|
||||||
requestHeaders['Cache-Control'] = 'no-cache';
|
requestHeaders['Cache-Control'] = 'no-cache';
|
||||||
}
|
}
|
||||||
|
|
||||||
await _ensureCacheReady();
|
//await _ensureCacheReady();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await _client.get(
|
final response = await http.get(
|
||||||
Uri.parse('${AppConstants.baseUrl}/users/chats'),
|
Uri.parse('${AppConstants.baseUrl}/users/chats'),
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
);
|
);
|
||||||
|
|
@ -70,27 +47,8 @@ class ContactRepository {
|
||||||
throw Exception('Failed to load contacts');
|
throw Exception('Failed to load contacts');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(
|
print('⚠️ Ошибка сети при загрузке контактов: $e.');
|
||||||
'⚠️ Ошибка сети при загрузке контактов: $e. Пробуем строгий кэш...',
|
throw Exception('Нет доступа к сети. Проверте подключение к интернету.');
|
||||||
);
|
|
||||||
|
|
||||||
// FALLBACK: Если сеть упала, принудительно создаем запрос с политикой cacheOnly
|
|
||||||
final offlineClient = CachedHttpClient(
|
|
||||||
cache: _apiService.cache,
|
|
||||||
defaultCachePolicy: CachePolicy.cacheOnly, // Читаем строго из кэша
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final response = await offlineClient.get(
|
|
||||||
Uri.parse('${AppConstants.baseUrl}/users/chats'),
|
|
||||||
headers: requestHeaders,
|
|
||||||
);
|
|
||||||
|
|
||||||
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
|
|
||||||
return data.map((json) => Contact.fromJson(json)).toList();
|
|
||||||
} catch (cacheError) {
|
|
||||||
throw Exception('Нет доступа к сети. Проверте подключение к интернету.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,8 +70,7 @@ class ContactRepository {
|
||||||
if (forceRefresh) {
|
if (forceRefresh) {
|
||||||
requestHeaders['Cache-Control'] = 'no-cache';
|
requestHeaders['Cache-Control'] = 'no-cache';
|
||||||
}
|
}
|
||||||
await _ensureCacheReady();
|
final response = await http.get(
|
||||||
final response = await _client.get(
|
|
||||||
Uri.parse('${AppConstants.baseUrl}/users/all'),
|
Uri.parse('${AppConstants.baseUrl}/users/all'),
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
);
|
);
|
||||||
|
|
@ -148,8 +105,8 @@ class ContactRepository {
|
||||||
if (forceRefresh) {
|
if (forceRefresh) {
|
||||||
requestHeaders['Cache-Control'] = 'no-cache';
|
requestHeaders['Cache-Control'] = 'no-cache';
|
||||||
}
|
}
|
||||||
await _ensureCacheReady();
|
|
||||||
final response = await _client.get(
|
final response = await http.get(
|
||||||
Uri.parse('${AppConstants.baseUrl}/users/$userId'),
|
Uri.parse('${AppConstants.baseUrl}/users/$userId'),
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
);
|
);
|
||||||
|
|
@ -181,8 +138,7 @@ class ContactRepository {
|
||||||
requestHeaders['Cache-Control'] = 'no-cache';
|
requestHeaders['Cache-Control'] = 'no-cache';
|
||||||
}
|
}
|
||||||
|
|
||||||
await _ensureCacheReady();
|
final response = await http.get(
|
||||||
final response = await _client.get(
|
|
||||||
Uri.parse(
|
Uri.parse(
|
||||||
'${AppConstants.baseUrl}/messages/last?contact_id=$contactId&limit=$limit',
|
'${AppConstants.baseUrl}/messages/last?contact_id=$contactId&limit=$limit',
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:chepuhagram/core/constants.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:http/http.dart' as http;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
@ -16,27 +15,12 @@ class ApiService extends ChangeNotifier {
|
||||||
final _client = http.Client();
|
final _client = http.Client();
|
||||||
final _storage = const FlutterSecureStorage();
|
final _storage = const FlutterSecureStorage();
|
||||||
bool _isRefreshing = false;
|
bool _isRefreshing = false;
|
||||||
bool _isCacheInitialized = false;
|
|
||||||
|
|
||||||
final cache = HttpCache(
|
|
||||||
config: const CacheConfig(
|
|
||||||
maxMemorySize: 100 * 1024 * 1024, // 100MB
|
|
||||||
maxDiskSize: 500 * 1024 * 1024, // 500MB
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _ensureCacheReady() async {
|
|
||||||
if (!_isCacheInitialized) {
|
|
||||||
await cache.initialize();
|
|
||||||
_isCacheInitialized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получает данные пользователя (включая его публичный ключ E2EE) по username
|
/// Получает данные пользователя (включая его публичный ключ E2EE) по username
|
||||||
Future<Contact?> getUserByUsername(String username) async {
|
Future<Contact?> getUserByUsername(String username) async {
|
||||||
try {
|
try {
|
||||||
// Подставляй свой эндпоинт, например: /users/by-username/
|
// Подставляй свой эндпоинт, например: /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) {
|
if (response.statusCode == 200 && response.data != null) {
|
||||||
// Парсим полученные данные в модель контакта.
|
// Парсим полученные данные в модель контакта.
|
||||||
|
|
@ -375,12 +359,8 @@ class ApiService extends ChangeNotifier {
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getMe() async {
|
Future<Map<String, dynamic>> getMe() async {
|
||||||
final token = await getAccessToken();
|
final token = await getAccessToken();
|
||||||
await cache.initialize();
|
|
||||||
final client = CachedHttpClient(
|
final response = await http.get(
|
||||||
cache: cache,
|
|
||||||
defaultCachePolicy: CachePolicy.networkFirst,
|
|
||||||
);
|
|
||||||
final response = await client.get(
|
|
||||||
Uri.parse('${AppConstants.baseUrl}/users/me'),
|
Uri.parse('${AppConstants.baseUrl}/users/me'),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -434,12 +414,6 @@ class ApiService extends ChangeNotifier {
|
||||||
bool forceRefresh = false,
|
bool forceRefresh = false,
|
||||||
}) async {
|
}) async {
|
||||||
final token = await getAccessToken();
|
final token = await getAccessToken();
|
||||||
await _ensureCacheReady();
|
|
||||||
|
|
||||||
final client = CachedHttpClient(
|
|
||||||
cache: cache,
|
|
||||||
defaultCachePolicy: CachePolicy.networkFirst,
|
|
||||||
);
|
|
||||||
|
|
||||||
final Map<String, String> requestHeaders = {
|
final Map<String, String> requestHeaders = {
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $token',
|
||||||
|
|
@ -449,7 +423,7 @@ class ApiService extends ChangeNotifier {
|
||||||
requestHeaders['Cache-Control'] = 'no-cache';
|
requestHeaders['Cache-Control'] = 'no-cache';
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await client.get(
|
final response = await http.get(
|
||||||
Uri.parse(
|
Uri.parse(
|
||||||
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}',
|
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}',
|
||||||
),
|
),
|
||||||
|
|
@ -467,17 +441,12 @@ class ApiService extends ChangeNotifier {
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final token = await getAccessToken();
|
final token = await getAccessToken();
|
||||||
await _ensureCacheReady();
|
final client = http.Client();
|
||||||
|
|
||||||
final client = CachedHttpClient(
|
|
||||||
cache: cache,
|
|
||||||
defaultCachePolicy: CachePolicy.networkFirst,
|
|
||||||
);
|
|
||||||
|
|
||||||
final uri = Uri.parse('${AppConstants.baseUrl}/media/$fileId');
|
final uri = Uri.parse('${AppConstants.baseUrl}/media/$fileId');
|
||||||
|
|
||||||
if (onProgress == null) {
|
if (onProgress == null) {
|
||||||
final response = await client.get(
|
final response = await http.get(
|
||||||
uri,
|
uri,
|
||||||
headers: {'Authorization': 'Bearer $token'},
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
);
|
);
|
||||||
|
|
@ -551,12 +520,7 @@ class ApiService extends ChangeNotifier {
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getUserById(int userId) async {
|
Future<Map<String, dynamic>> getUserById(int userId) async {
|
||||||
final token = await getAccessToken();
|
final token = await getAccessToken();
|
||||||
await _ensureCacheReady();
|
final response = await http.get(
|
||||||
final client = CachedHttpClient(
|
|
||||||
cache: cache,
|
|
||||||
defaultCachePolicy: CachePolicy.networkFirst,
|
|
||||||
);
|
|
||||||
final response = await client.get(
|
|
||||||
Uri.parse('${AppConstants.baseUrl}/users/$userId'),
|
Uri.parse('${AppConstants.baseUrl}/users/$userId'),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -601,13 +565,7 @@ class ApiService extends ChangeNotifier {
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getPrivacySettings() async {
|
Future<Map<String, dynamic>> getPrivacySettings() async {
|
||||||
final token = await getAccessToken();
|
final token = await getAccessToken();
|
||||||
|
final response = await http.get(
|
||||||
await _ensureCacheReady();
|
|
||||||
final client = CachedHttpClient(
|
|
||||||
cache: cache,
|
|
||||||
defaultCachePolicy: CachePolicy.networkFirst,
|
|
||||||
);
|
|
||||||
final response = await client.get(
|
|
||||||
Uri.parse('${AppConstants.baseUrl}/users/me/privacy'),
|
Uri.parse('${AppConstants.baseUrl}/users/me/privacy'),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||||||
|
|
||||||
|
class WebRTCService {
|
||||||
|
RTCPeerConnection? _peerConnection;
|
||||||
|
MediaStream? _localStream;
|
||||||
|
|
||||||
|
// Конфигурация STUN-серверов (Google STUN)
|
||||||
|
final Map<String, dynamic> _config = {
|
||||||
|
"iceServers": [
|
||||||
|
{"urls": "stun:stun.l.google.com:19302"},
|
||||||
|
{"urls": "stun:stun1.l.google.com:19302"},
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Инициализация PeerConnection
|
||||||
|
Future<void> initPeerConnection(String callId, Function(MediaStream) onRemoteStream) async {
|
||||||
|
_peerConnection = await createPeerConnection(_config);
|
||||||
|
|
||||||
|
// Слушаем удаленный поток
|
||||||
|
_peerConnection!.onAddStream = (stream) {
|
||||||
|
onRemoteStream(stream);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Отправляем ICE-кандидаты на сервер через SocketService
|
||||||
|
_peerConnection!.onIceCandidate = (candidate) {
|
||||||
|
SocketService().sendMessage({
|
||||||
|
"type": "ice_candidate",
|
||||||
|
"call_id": callId,
|
||||||
|
"candidate": candidate.toMap(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Получаем локальный поток (микрофон + камера)
|
||||||
|
_localStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
'audio': true,
|
||||||
|
'video': true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем треки в соединение
|
||||||
|
_localStream!.getTracks().forEach((track) {
|
||||||
|
_peerConnection!.addTrack(track, _localStream!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> handleOffer(String callId, String remoteSdp) async {
|
||||||
|
// 1. Инициализируем соединение, если оно еще не создано
|
||||||
|
if (_peerConnection == null) {
|
||||||
|
await initPeerConnection(callId, (stream) {
|
||||||
|
// Здесь можно добавить callback для отрисовки видео, если нужно
|
||||||
|
print("Remote stream received");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Создаем ответ (это вызывает ваш метод createAnswer)
|
||||||
|
await createAnswer(callId, remoteSdp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создание Offer (вызывает инициатор звонка)
|
||||||
|
Future<void> createOffer(String callId) async {
|
||||||
|
RTCSessionDescription offer = await _peerConnection!.createOffer();
|
||||||
|
await _peerConnection!.setLocalDescription(offer);
|
||||||
|
|
||||||
|
SocketService().sendMessage({
|
||||||
|
"type": "offer",
|
||||||
|
"call_id": callId,
|
||||||
|
"sdp": offer.sdp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создание Answer (вызывает получатель звонка)
|
||||||
|
Future<void> createAnswer(String callId, String remoteSdp) async {
|
||||||
|
await _peerConnection!.setRemoteDescription(
|
||||||
|
RTCSessionDescription(remoteSdp, 'offer'),
|
||||||
|
);
|
||||||
|
|
||||||
|
RTCSessionDescription answer = await _peerConnection!.createAnswer();
|
||||||
|
await _peerConnection!.setLocalDescription(answer);
|
||||||
|
|
||||||
|
SocketService().sendMessage({
|
||||||
|
"type": "answer",
|
||||||
|
"call_id": callId,
|
||||||
|
"sdp": answer.sdp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка ICE кандидатов от удаленного собеседника
|
||||||
|
Future<void> addRemoteIceCandidate(Map<String, dynamic> candidateMap) async {
|
||||||
|
await _peerConnection!.addCandidate(
|
||||||
|
RTCIceCandidate(
|
||||||
|
candidateMap['candidate'],
|
||||||
|
candidateMap['sdpMid'],
|
||||||
|
candidateMap['sdpMLineIndex'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Очистка ресурсов
|
||||||
|
void dispose() {
|
||||||
|
_localStream?.getTracks().forEach((track) => track.stop());
|
||||||
|
_localStream?.dispose();
|
||||||
|
_peerConnection?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,8 @@ class AuthProvider extends ChangeNotifier {
|
||||||
bool get hasPublicKeyOnServer => _hasPublicKeyOnServer;
|
bool get hasPublicKeyOnServer => _hasPublicKeyOnServer;
|
||||||
|
|
||||||
final _storage = const FlutterSecureStorage();
|
final _storage = const FlutterSecureStorage();
|
||||||
|
FlutterSecureStorage get storage => _storage;
|
||||||
|
|
||||||
final _client = http.Client();
|
final _client = http.Client();
|
||||||
final ApiService _apiService = ApiService();
|
final ApiService _apiService = ApiService();
|
||||||
final SocketService _socketService = SocketService();
|
final SocketService _socketService = SocketService();
|
||||||
|
|
|
||||||
287
lib/main.dart
287
lib/main.dart
|
|
@ -3,16 +3,24 @@ import 'logic/auth_provider.dart';
|
||||||
import 'logic/contact_provider.dart';
|
import 'logic/contact_provider.dart';
|
||||||
import 'core/theme_manager.dart';
|
import 'core/theme_manager.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
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:chepuhagram/domain/services/crypto_service.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'dart:convert';
|
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/chat_screen.dart';
|
||||||
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
|
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
import 'presentation/screens/splash_screen.dart';
|
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 =
|
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||||
FlutterLocalNotificationsPlugin();
|
FlutterLocalNotificationsPlugin();
|
||||||
|
|
@ -122,81 +130,115 @@ void _navigateToChat(int senderId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool firebaseInitialized = false;
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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 {
|
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();
|
await Firebase.initializeApp();
|
||||||
|
firebaseInitialized = true;
|
||||||
|
|
||||||
initialMessage = await FirebaseMessaging.instance.getInitialMessage();
|
initialMessage = await FirebaseMessaging.instance.getInitialMessage();
|
||||||
print('Initial message from main() after delay: $initialMessage');
|
print('Initial message from main(): $initialMessage');
|
||||||
// Сохраняем информацию в SharedPreferences для надежности
|
} else {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
print('Skipping Firebase initialization on desktop.');
|
||||||
if (initialMessage != null) {
|
}
|
||||||
print('App launched from notification: ${initialMessage!.data}');
|
|
||||||
print('Message type: ${initialMessage!.data['type']}');
|
|
||||||
print('Sender ID: ${initialMessage!.data['sender_id']}');
|
|
||||||
|
|
||||||
final payloadString = jsonEncode(initialMessage!.data);
|
// Сохраняем информацию в SharedPreferences для надежности
|
||||||
final lastHandled = prefs.getString(
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_lastHandledNotificationLaunchPayloadKey,
|
if (initialMessage != null) {
|
||||||
);
|
print('App launched from notification: ${initialMessage!.data}');
|
||||||
if (lastHandled != payloadString) {
|
print('Message type: ${initialMessage!.data['type']}');
|
||||||
// Сохраняем данные уведомления
|
print('Sender ID: ${initialMessage!.data['sender_id']}');
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize local notifications
|
final payloadString = jsonEncode(initialMessage!.data);
|
||||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
final lastHandled = prefs.getString(
|
||||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
_lastHandledNotificationLaunchPayloadKey,
|
||||||
final InitializationSettings initializationSettings =
|
|
||||||
InitializationSettings(android: initializationSettingsAndroid);
|
|
||||||
await flutterLocalNotificationsPlugin.initialize(
|
|
||||||
initializationSettings,
|
|
||||||
onDidReceiveNotificationResponse: _onSelectNotification,
|
|
||||||
);
|
);
|
||||||
|
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
|
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||||
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
|
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
.getNotificationAppLaunchDetails();
|
const WindowsInitializationSettings initializationSettingsWindows =
|
||||||
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
|
WindowsInitializationSettings(
|
||||||
final payload =
|
appName: 'Chepuhagram',
|
||||||
notificationAppLaunchDetails?.notificationResponse?.payload;
|
appUserModelId: 'ru.ArturKarasevich.Chepuhagram',
|
||||||
print('App launched from local notification, payload: $payload');
|
guid: '6c0af055-e0b5-4f10-9aed-c12dc078f949',
|
||||||
if (payload != null && payload.isNotEmpty) {
|
);
|
||||||
try {
|
final InitializationSettings initializationSettings = InitializationSettings(
|
||||||
final lastHandled = prefs.getString(
|
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,
|
_lastHandledNotificationLaunchPayloadKey,
|
||||||
|
payload,
|
||||||
);
|
);
|
||||||
if (lastHandled != payload) {
|
print('Saved local notification launch payload to SharedPreferences');
|
||||||
final data = jsonDecode(payload);
|
} else {
|
||||||
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
|
print('Local notification payload already handled earlier, skipping');
|
||||||
await prefs.setString(
|
|
||||||
_lastHandledNotificationLaunchPayloadKey,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
print(
|
|
||||||
'Saved local notification launch payload to SharedPreferences',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
print(
|
|
||||||
'Local notification payload already handled earlier, skipping',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('Failed to save notification launch payload: $e');
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Failed to save notification launch payload: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,9 +256,17 @@ void main() async {
|
||||||
>()
|
>()
|
||||||
?.createNotificationChannel(channel);
|
?.createNotificationChannel(channel);
|
||||||
|
|
||||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
if (firebaseInitialized) {
|
||||||
} catch (e) {
|
FirebaseMessaging.onBackgroundMessage(
|
||||||
print('Уведосления не были инициальзированы: $e');
|
_firebaseMessagingBackgroundHandler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
|
initCallkitListener();
|
||||||
|
} else {
|
||||||
|
print('Skipping CallKit listener on desktop platform.');
|
||||||
}
|
}
|
||||||
|
|
||||||
runApp(
|
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')
|
@pragma('vm:entry-point')
|
||||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
print("Фоновый пуш получен: ${message.data}");
|
print("Фоновый пуш получен: ${message.data}");
|
||||||
if (message.data['type'] == 'enc_message') {
|
if (message.data['type'] == 'enc_message') {
|
||||||
try {
|
try {
|
||||||
// Initialize notifications for background
|
// Initialize notifications for background
|
||||||
|
|
||||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
const InitializationSettings initializationSettings =
|
const WindowsInitializationSettings initializationSettingsWindows =
|
||||||
InitializationSettings(android: initializationSettingsAndroid);
|
WindowsInitializationSettings(
|
||||||
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
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
|
// Create notification channel
|
||||||
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||||
|
|
@ -301,10 +436,10 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
final String groupKey = 'ru.chepuhagram.app.$senderId';
|
final String groupKey = 'ru.chepuhagram.app.$senderId';
|
||||||
|
|
||||||
await flutterLocalNotificationsPlugin.show(
|
await flutterLocalNotificationsPlugin.show(
|
||||||
senderId!,
|
id: senderId!,
|
||||||
'',
|
title: '',
|
||||||
'',
|
body: '',
|
||||||
NotificationDetails(
|
notificationDetails: NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
'Messages',
|
'Messages',
|
||||||
'Новые сообщения',
|
'Новые сообщения',
|
||||||
|
|
@ -317,10 +452,10 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await flutterLocalNotificationsPlugin.show(
|
await flutterLocalNotificationsPlugin.show(
|
||||||
message.hashCode,
|
id: message.hashCode,
|
||||||
message.data['username'] ?? 'Unknown',
|
title: message.data['username'] ?? 'Unknown',
|
||||||
notificationText,
|
body: notificationText,
|
||||||
NotificationDetails(
|
notificationDetails: NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
'chat_id',
|
'chat_id',
|
||||||
'Messages',
|
'Messages',
|
||||||
|
|
|
||||||
|
|
@ -62,13 +62,13 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Сохранено')),
|
const SnackBar(content: Text('Сохранено'), behavior: SnackBarBehavior.floating),
|
||||||
);
|
);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
SnackBar(content: Text(e.toString().replaceAll('Exception: ', '')), behavior: SnackBarBehavior.floating),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isSaving = false);
|
if (mounted) setState(() => _isSaving = false);
|
||||||
|
|
@ -77,91 +77,131 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: colorScheme.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Аккаунт'),
|
title: const Text('Редактировать аккаунт', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
Padding(
|
||||||
onPressed: _isSaving ? null : _save,
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
child: _isSaving
|
child: Center(
|
||||||
? const SizedBox(
|
child: _isSaving
|
||||||
width: 16,
|
? SizedBox(
|
||||||
height: 16,
|
width: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
height: 20,
|
||||||
)
|
child: CircularProgressIndicator(strokeWidth: 2.5, color: colorScheme.primary),
|
||||||
: const Text(
|
)
|
||||||
'Сохранить',
|
: TextButton.icon(
|
||||||
style: TextStyle(color: Colors.white),
|
onPressed: _save,
|
||||||
),
|
icon: const Icon(Icons.done_rounded, size: 18),
|
||||||
|
label: const Text('Готово', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Form(
|
body: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
_buildInputField(
|
||||||
controller: _usernameController,
|
controller: _usernameController,
|
||||||
decoration: const InputDecoration(
|
label: 'Имя пользователя',
|
||||||
labelText: 'Имя пользователя',
|
hint: 'Латиница, цифры, подчеркивания',
|
||||||
hintText: 'Латиница, цифры, подчеркивания',
|
icon: Icons.alternate_email_rounded,
|
||||||
),
|
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
if (v == null || v.trim().isEmpty) return 'Введите имя пользователя';
|
if (v == null || v.trim().isEmpty) return 'Введите имя пользователя';
|
||||||
if (!RegExp(r'^[a-zA-Z0-9_]{3,20}$').hasMatch(v.trim())) {
|
if (!RegExp(r'^[a-zA-Z0-9_]{3,20}$').hasMatch(v.trim())) {
|
||||||
return 'Имя пользователя должно содержать от 3 до 20 символов (латиница, цифры, подчеркивания)';
|
return 'От 3 до 20 символов (A-Z, 0-9, _)';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
_buildInputField(
|
||||||
TextFormField(
|
|
||||||
controller: _firstNameController,
|
controller: _firstNameController,
|
||||||
decoration: const InputDecoration(
|
label: 'Имя',
|
||||||
labelText: 'Имя',
|
hint: 'Введите ваше имя',
|
||||||
),
|
icon: Icons.person_outline_rounded,
|
||||||
validator: (v) {
|
validator: (v) {
|
||||||
if (v == null || v.trim().isEmpty) return 'Введите имя';
|
if (v == null || v.trim().isEmpty) return 'Введите имя';
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
_buildInputField(
|
||||||
TextFormField(
|
|
||||||
controller: _lastNameController,
|
controller: _lastNameController,
|
||||||
decoration: const InputDecoration(
|
label: 'Фамилия',
|
||||||
labelText: 'Фамилия',
|
hint: 'Введите вашу фамилию',
|
||||||
),
|
icon: Icons.people_outline_rounded,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
_buildInputField(
|
||||||
TextFormField(
|
|
||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
decoration: const InputDecoration(
|
label: 'Телефон',
|
||||||
labelText: 'Телефон',
|
hint: 'Номер телефона',
|
||||||
),
|
icon: Icons.phone_android_rounded,
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
_buildInputField(
|
||||||
TextFormField(
|
|
||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
decoration: const InputDecoration(
|
label: 'Почта',
|
||||||
labelText: 'Почта',
|
hint: 'Электронный адрес',
|
||||||
),
|
icon: Icons.mail_outline_rounded,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
_buildInputField(
|
||||||
TextFormField(
|
|
||||||
controller: _aboutController,
|
controller: _aboutController,
|
||||||
decoration: const InputDecoration(
|
label: 'О себе',
|
||||||
labelText: 'О себе',
|
hint: 'Расскажите немного о себе',
|
||||||
),
|
icon: Icons.short_text_rounded,
|
||||||
minLines: 1,
|
maxLines: 4,
|
||||||
maxLines: 10,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
Widget _buildInputField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required String label,
|
||||||
|
required String hint,
|
||||||
|
required IconData icon,
|
||||||
|
int maxLines = 1,
|
||||||
|
TextInputType? keyboardType,
|
||||||
|
String? Function(String?)? validator,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
maxLines: maxLines,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
validator: validator,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
icon: Icon(icon, color: colorScheme.primary, size: 22),
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: TextStyle(color: colorScheme.outline, fontSize: 14),
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: TextStyle(color: colorScheme.outline.withOpacity(0.5)),
|
||||||
|
border: InputBorder.none,
|
||||||
|
errorStyle: TextStyle(color: colorScheme.error, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:chepuhagram/core/constants.dart';
|
||||||
|
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||||
|
|
||||||
|
class AdminPanelScreen extends StatefulWidget {
|
||||||
|
const AdminPanelScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminPanelScreen> createState() => _AdminPanelScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminPanelScreenState extends State<AdminPanelScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
final ApiService _apiService = ApiService();
|
||||||
|
|
||||||
|
List<dynamic> _users = [];
|
||||||
|
bool _isLoadingUsers = true;
|
||||||
|
|
||||||
|
// Контроллеры формы создания пользователя
|
||||||
|
final _idController = TextEditingController();
|
||||||
|
final _createFormKey = GlobalKey<FormState>();
|
||||||
|
final _usernameController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
final _firstNameController = TextEditingController();
|
||||||
|
final _lastNameController = TextEditingController();
|
||||||
|
bool _isCreating = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
_loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadUsers() async {
|
||||||
|
setState(() => _isLoadingUsers = true);
|
||||||
|
try {
|
||||||
|
final token = await _apiService.getAccessToken();
|
||||||
|
final response = await Dio().get(
|
||||||
|
'${AppConstants.baseUrl}/admin/users',
|
||||||
|
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_users = response.data;
|
||||||
|
_isLoadingUsers = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _isLoadingUsers = false);
|
||||||
|
_showSnackBar('Ошибка загрузки пользователей: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleBlock(int userId, bool currentBlockStatus) async {
|
||||||
|
final action = currentBlockStatus ? 'unblock' : 'block';
|
||||||
|
try {
|
||||||
|
final token = await _apiService.getAccessToken();
|
||||||
|
await Dio().post(
|
||||||
|
'${AppConstants.baseUrl}/admin/users/$userId/$action',
|
||||||
|
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||||
|
);
|
||||||
|
_showSnackBar(
|
||||||
|
currentBlockStatus
|
||||||
|
? 'Пользователь разблокирован'
|
||||||
|
: 'Пользователь заблокирован',
|
||||||
|
);
|
||||||
|
_loadUsers();
|
||||||
|
} catch (e) {
|
||||||
|
_showSnackBar('Ошибка изменения статуса: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createUser() async {
|
||||||
|
if (!_createFormKey.currentState!.validate()) return;
|
||||||
|
setState(() => _isCreating = true);
|
||||||
|
try {
|
||||||
|
final token = await _apiService.getAccessToken();
|
||||||
|
final Map<String, dynamic> requestData = {
|
||||||
|
'username': _usernameController.text.trim(),
|
||||||
|
'password': _passwordController.text.trim(),
|
||||||
|
'first_name': _firstNameController.text.trim(),
|
||||||
|
'last_name': _lastNameController.text.trim(),
|
||||||
|
};
|
||||||
|
final idText = _idController.text.trim();
|
||||||
|
if (idText.isNotEmpty) {
|
||||||
|
requestData['id'] = int.tryParse(idText);
|
||||||
|
}
|
||||||
|
await Dio().post(
|
||||||
|
'${AppConstants.baseUrl}/admin/users',
|
||||||
|
data: requestData,
|
||||||
|
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||||
|
);
|
||||||
|
_showSnackBar('Пользователь успешно создан!');
|
||||||
|
_idController.clear();
|
||||||
|
_usernameController.clear();
|
||||||
|
_passwordController.clear();
|
||||||
|
_firstNameController.clear();
|
||||||
|
_lastNameController.clear();
|
||||||
|
_loadUsers();
|
||||||
|
_tabController.animateTo(0);
|
||||||
|
} catch (e) {
|
||||||
|
_showSnackBar('Ошибка создания: $e');
|
||||||
|
} finally {
|
||||||
|
setState(() => _isCreating = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(String text) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(text), behavior: SnackBarBehavior.floating),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: colorScheme.background,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text(
|
||||||
|
'Панель администратора',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: const [
|
||||||
|
Tab(icon: Icon(Icons.people_alt_rounded), text: 'Пользователи'),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(Icons.person_add_alt_1_rounded),
|
||||||
|
text: 'Создать аккаунт',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [_buildUsersTab(colorScheme), _buildCreateTab(colorScheme)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUsersTab(ColorScheme colorScheme) {
|
||||||
|
if (_isLoadingUsers)
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _loadUsers,
|
||||||
|
child: ListView.builder(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _users.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final user = _users[index];
|
||||||
|
final bool isBlocked = user['is_blocked'] == 1;
|
||||||
|
final int userId = user['id'];
|
||||||
|
|
||||||
|
if (userId == 1)
|
||||||
|
return const SizedBox.shrink(); // Скрываем супер-админа из списка менеджмента
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||||
|
elevation: 0,
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'${user['first_name']} ${user['last_name'] ?? ''}'.trim(),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'@${user['username']}\nID: $userId',
|
||||||
|
style: TextStyle(color: colorScheme.outline, fontSize: 13),
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_note_rounded),
|
||||||
|
onPressed: () => _showEditUserDialog(user),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
isBlocked
|
||||||
|
? Icons.lock_open_rounded
|
||||||
|
: Icons.lock_person_rounded,
|
||||||
|
),
|
||||||
|
color: isBlocked ? Colors.green : colorScheme.error,
|
||||||
|
onPressed: () => _toggleBlock(userId, isBlocked),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCreateTab(ColorScheme colorScheme) {
|
||||||
|
return Form(
|
||||||
|
key: _createFormKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
children: [
|
||||||
|
_buildAdminInputField(
|
||||||
|
_idController,
|
||||||
|
'ID пользователя (оставьте пустым для автогенерации)',
|
||||||
|
Icons.fingerprint_rounded,
|
||||||
|
(v) {
|
||||||
|
if (v != null && v.isNotEmpty && int.tryParse(v) == null) {
|
||||||
|
return 'ID должен быть числом';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
_buildAdminInputField(
|
||||||
|
_usernameController,
|
||||||
|
'Имя пользователя (username)',
|
||||||
|
Icons.alternate_email_rounded,
|
||||||
|
(v) => v!.isEmpty ? 'Заполните юзернейм' : null,
|
||||||
|
),
|
||||||
|
_buildAdminInputField(
|
||||||
|
_passwordController,
|
||||||
|
'Временный пароль аккаунта',
|
||||||
|
Icons.password_rounded,
|
||||||
|
(v) => v!.length < 6 ? 'Минимум 6 символов' : null,
|
||||||
|
obscure: true,
|
||||||
|
),
|
||||||
|
_buildAdminInputField(
|
||||||
|
_firstNameController,
|
||||||
|
'Имя',
|
||||||
|
Icons.person_rounded,
|
||||||
|
(v) => v!.isEmpty ? 'Введите имя' : null,
|
||||||
|
),
|
||||||
|
_buildAdminInputField(
|
||||||
|
_lastNameController,
|
||||||
|
'Фамилия',
|
||||||
|
Icons.people_rounded,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isCreating ? null : _createUser,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _isCreating
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: const Text(
|
||||||
|
'Зарегистрировать пользователя',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAdminInputField(
|
||||||
|
TextEditingController controller,
|
||||||
|
String label,
|
||||||
|
IconData icon,
|
||||||
|
String? Function(String?)? validator, {
|
||||||
|
bool obscure = false,
|
||||||
|
TextInputType keyboardType = TextInputType.text,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscure,
|
||||||
|
validator: validator,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
icon: Icon(icon, color: colorScheme.primary),
|
||||||
|
labelText: label,
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showEditUserDialog(Map<String, dynamic> user) {
|
||||||
|
final fNameController = TextEditingController(text: user['first_name']);
|
||||||
|
final lNameController = TextEditingController(text: user['last_name']);
|
||||||
|
final aboutController = TextEditingController(text: user['about']);
|
||||||
|
final phoneController = TextEditingController(text: user['phone']);
|
||||||
|
final emailController = TextEditingController(text: user['email']);
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
title: Text('Редактирование ID: ${user['id']}'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: fNameController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Имя'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: lNameController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Фамилия'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: aboutController,
|
||||||
|
decoration: const InputDecoration(labelText: 'О себе'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: phoneController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Телефон'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: emailController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Почта'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
final token = await _apiService.getAccessToken();
|
||||||
|
await Dio().put(
|
||||||
|
'${AppConstants.baseUrl}/admin/users/${user['id']}/profile',
|
||||||
|
data: {
|
||||||
|
'first_name': fNameController.text.trim(),
|
||||||
|
'last_name': lNameController.text.trim(),
|
||||||
|
'about': aboutController.text.trim(),
|
||||||
|
'phone': phoneController.text.trim(),
|
||||||
|
'email': emailController.text.trim(),
|
||||||
|
},
|
||||||
|
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||||
|
);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_showSnackBar('Профиль успешно обновлен!');
|
||||||
|
_loadUsers();
|
||||||
|
} catch (e) {
|
||||||
|
_showSnackBar('Ошибка обновления: $e');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Сохранить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,104 +24,136 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final themeProv = context.watch<ThemeProvider>();
|
final themeProv = context.watch<ThemeProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
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(
|
body: ListView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
// Ночной режим
|
// Ночной режим
|
||||||
SwitchListTile(
|
Container(
|
||||||
secondary: const Icon(Icons.dark_mode),
|
decoration: BoxDecoration(
|
||||||
title: const Text("Ночной режим"),
|
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||||
value: themeProv.themeMode == ThemeMode.dark,
|
borderRadius: BorderRadius.circular(20),
|
||||||
onChanged: (val) => themeProv.toggleTheme(val),
|
),
|
||||||
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
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(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
_colorCircle(const Color(0xFF24A1DE), themeProv),
|
||||||
Icons.palette_outlined,
|
_colorCircle(const Color(0xFF3E8E7E), themeProv),
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
_colorCircle(const Color(0xFF8E3E7E), themeProv),
|
||||||
),
|
_colorCircle(const Color(0xFFFF9800), themeProv),
|
||||||
const SizedBox(width: 10),
|
_colorCircle(const Color(0xFFF44336), themeProv),
|
||||||
const Text("Цвет темы"),
|
|
||||||
const Spacer(),
|
|
||||||
_colorCircle(context, const Color(0xFF24A1DE), themeProv),
|
|
||||||
_colorCircle(context, const Color(0xFF3E8E7E), themeProv),
|
|
||||||
_colorCircle(context, const Color(0xFF8E3E7E), themeProv),
|
|
||||||
_colorCircle(context, const Color(0xFFFF9800), themeProv),
|
|
||||||
_colorCircle(context, const Color(0xFFF44336), themeProv),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Обои чата
|
// Обои чата
|
||||||
ListTile(
|
Container(
|
||||||
leading: const Icon(Icons.wallpaper),
|
decoration: BoxDecoration(
|
||||||
title: const Text('Обои чата'),
|
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||||
subtitle: const Text('Выбрать изображение из галереи'),
|
borderRadius: BorderRadius.circular(24),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
),
|
||||||
onTap: _pickWallpaper,
|
child: Column(
|
||||||
),
|
children: [
|
||||||
|
ListTile(
|
||||||
// Показать текущие обои, если есть
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||||
if (themeProv.wallpaperPath != null)
|
leading: Icon(Icons.wallpaper_rounded, color: colorScheme.primary),
|
||||||
Padding(
|
title: const Text('Обои чата', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
padding: const EdgeInsets.all(16),
|
subtitle: const Text('Установить фоновое изображение'),
|
||||||
child: Column(
|
trailing: Icon(Icons.chevron_right_rounded, color: colorScheme.outline),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onTap: _pickWallpaper,
|
||||||
children: [
|
),
|
||||||
const Text('Текущие обои:'),
|
if (themeProv.wallpaperPath != null) ...[
|
||||||
const SizedBox(height: 8),
|
Padding(
|
||||||
Container(
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
height: 150,
|
child: ClipRRect(
|
||||||
width: double.infinity,
|
borderRadius: BorderRadius.circular(16),
|
||||||
decoration: BoxDecoration(
|
child: Stack(
|
||||||
borderRadius: BorderRadius.circular(8),
|
alignment: Alignment.bottomRight,
|
||||||
image: DecorationImage(
|
children: [
|
||||||
image: FileImage(File(themeProv.wallpaperPath!)),
|
Container(
|
||||||
fit: BoxFit.cover,
|
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;
|
bool isSelected = prov.accentColor == color;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => prov.updateAccentColor(color),
|
onTap: () => prov.updateAccentColor(color),
|
||||||
child: Container(
|
child: AnimatedContainer(
|
||||||
padding: const EdgeInsets.all(2),
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(color: isSelected ? color : Colors.transparent, width: 2),
|
||||||
color: isSelected ? color : Colors.transparent,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: CircleAvatar(backgroundColor: color, radius: 15),
|
child: CircleAvatar(backgroundColor: color, radius: 16),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
|
||||||
|
class CallScreen extends StatefulWidget {
|
||||||
|
final String callId;
|
||||||
|
final bool isIncoming;
|
||||||
|
final String callerName;
|
||||||
|
final VoidCallback onAccept;
|
||||||
|
final VoidCallback onHangup;
|
||||||
|
|
||||||
|
const CallScreen({
|
||||||
|
super.key,
|
||||||
|
required this.callId,
|
||||||
|
required this.isIncoming,
|
||||||
|
required this.callerName,
|
||||||
|
required this.onAccept,
|
||||||
|
required this.onHangup,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CallScreen> createState() => _CallScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CallScreenState extends State<CallScreen> {
|
||||||
|
// Рендереры для видеопотока
|
||||||
|
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
|
||||||
|
final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();
|
||||||
|
|
||||||
|
bool _isAccepted = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_localRenderer.initialize();
|
||||||
|
_remoteRenderer.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_localRenderer.dispose();
|
||||||
|
_remoteRenderer.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
// Основной контент (Видео или Аватар)
|
||||||
|
Positioned.fill(
|
||||||
|
child: _isAccepted
|
||||||
|
? RTCVideoView(_remoteRenderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover)
|
||||||
|
: Center(child: Text(widget.callerName, style: const TextStyle(color: Colors.white, fontSize: 24))),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Панель управления
|
||||||
|
Positioned(
|
||||||
|
bottom: 50,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
FloatingActionButton(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
onPressed: () {
|
||||||
|
widget.onHangup();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.call_end, color: Colors.white),
|
||||||
|
),
|
||||||
|
if (widget.isIncoming && !_isAccepted)
|
||||||
|
FloatingActionButton(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _isAccepted = true);
|
||||||
|
widget.onAccept();
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.call, color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import '/core/constants.dart';
|
import '/core/constants.dart';
|
||||||
import '/data/models/message_model.dart';
|
import '/data/models/message_model.dart';
|
||||||
import '/data/models/contact_model.dart';
|
import '/data/models/contact_model.dart';
|
||||||
|
|
@ -11,16 +10,15 @@ import '/domain/services/api_service.dart';
|
||||||
class ForwardContactPickerScreen extends StatefulWidget {
|
class ForwardContactPickerScreen extends StatefulWidget {
|
||||||
final MessageModel message;
|
final MessageModel message;
|
||||||
|
|
||||||
const ForwardContactPickerScreen({
|
const ForwardContactPickerScreen({super.key, required this.message});
|
||||||
super.key,
|
|
||||||
required this.message,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ForwardContactPickerScreen> createState() => _ForwardContactPickerScreenState();
|
State<ForwardContactPickerScreen> createState() =>
|
||||||
|
_ForwardContactPickerScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen> {
|
class _ForwardContactPickerScreenState
|
||||||
|
extends State<ForwardContactPickerScreen> {
|
||||||
Contact? _selectedContact;
|
Contact? _selectedContact;
|
||||||
bool _isInitLoading = true;
|
bool _isInitLoading = true;
|
||||||
SharedPreferences? _prefs;
|
SharedPreferences? _prefs;
|
||||||
|
|
@ -36,11 +34,11 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
||||||
try {
|
try {
|
||||||
final contactProvider = context.read<ContactProvider>();
|
final contactProvider = context.read<ContactProvider>();
|
||||||
await contactProvider.loadContacts();
|
await contactProvider.loadContacts();
|
||||||
|
|
||||||
final apiService = ApiService();
|
final apiService = ApiService();
|
||||||
final accessToken = await apiService.getAccessToken();
|
final accessToken = await apiService.getAccessToken();
|
||||||
final shared = await SharedPreferences.getInstance();
|
final shared = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_prefs = shared;
|
_prefs = shared;
|
||||||
|
|
@ -151,11 +149,12 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
||||||
final bool isDecrypted = contact.isLastMsgDecrypted ?? false;
|
final bool isDecrypted = contact.isLastMsgDecrypted ?? false;
|
||||||
final String subtitleText = isDecrypted
|
final String subtitleText = isDecrypted
|
||||||
? (contact.lastMessage == null
|
? (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
|
: (contact.lastMessage != null
|
||||||
? "Ожидание дешифровки..."
|
? "Ожидание дешифровки..."
|
||||||
: "Нет сообщений");
|
: "Нет сообщений");
|
||||||
|
|
||||||
// Логика формирования URL аватарки
|
// Логика формирования URL аватарки
|
||||||
final avatarUrl = contact.effectiveAvatarUrl;
|
final avatarUrl = contact.effectiveAvatarUrl;
|
||||||
|
|
@ -172,10 +171,15 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
color: isSelected ? primaryColor.withOpacity(0.08) : Colors.transparent,
|
color: isSelected
|
||||||
|
? primaryColor.withOpacity(0.08)
|
||||||
|
: Colors.transparent,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
|
||||||
// 1. АВАТАРКА
|
// 1. АВАТАРКА
|
||||||
leading: Stack(
|
leading: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -184,20 +188,50 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
||||||
radius: 24,
|
radius: 24,
|
||||||
backgroundColor: Colors.grey[200],
|
backgroundColor: Colors.grey[200],
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: CachedNetworkImage(
|
child: ClipOval(
|
||||||
imageUrl: avatarUrl,
|
child: Image.network(
|
||||||
width: 48,
|
avatarUrl, // Первым аргументом идет строка, без "imageUrl:"
|
||||||
height: 48,
|
width: 48,
|
||||||
fit: BoxFit.cover,
|
height: 48,
|
||||||
httpHeaders: token != null ? {'Authorization': 'Bearer $token'} : null,
|
fit: BoxFit.cover,
|
||||||
placeholder: (context, url) => const CircularProgressIndicator(strokeWidth: 2),
|
headers: token != null
|
||||||
errorWidget: (context, url, error) => CircleAvatar(
|
? {'Authorization': 'Bearer $token'}
|
||||||
radius: 24,
|
: null, // Заменено на headers
|
||||||
backgroundColor: primaryColor.withOpacity(0.1),
|
// Аналог placeholder
|
||||||
child: Text(
|
loadingBuilder:
|
||||||
_getDisplayName(contact).isNotEmpty ? _getDisplayName(contact)[0].toUpperCase() : '?',
|
(context, child, loadingProgress) {
|
||||||
style: TextStyle(color: primaryColor, fontWeight: FontWeight.bold),
|
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<ForwardContactPickerScreen>
|
||||||
radius: 24,
|
radius: 24,
|
||||||
backgroundColor: primaryColor.withOpacity(0.1),
|
backgroundColor: primaryColor.withOpacity(0.1),
|
||||||
child: Text(
|
child: Text(
|
||||||
_getDisplayName(contact).isNotEmpty ? _getDisplayName(contact)[0].toUpperCase() : '?',
|
_getDisplayName(contact).isNotEmpty
|
||||||
style: TextStyle(color: primaryColor, fontWeight: FontWeight.bold),
|
? _getDisplayName(contact)[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: TextStyle(
|
||||||
|
color: primaryColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (contact.isOnline == true)
|
if (contact.isOnline == true)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 0,
|
right: 0,
|
||||||
|
|
@ -222,7 +261,12 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Theme.of(context).scaffoldBackgroundColor, width: 2),
|
border: Border.all(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).scaffoldBackgroundColor,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -234,7 +278,10 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
||||||
_getDisplayName(contact),
|
_getDisplayName(contact),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 3. ПОСЛЕДНЕЕ СООБЩЕНИЕ
|
// 3. ПОСЛЕДНЕЕ СООБЩЕНИЕ
|
||||||
|
|
@ -248,9 +295,13 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
||||||
// 4. ПРАВАЯ ЧАСТЬ (Анимация переключения Время <-> Галочка)
|
// 4. ПРАВАЯ ЧАСТЬ (Анимация переключения Время <-> Галочка)
|
||||||
trailing: AnimatedSwitcher(
|
trailing: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
transitionBuilder:
|
||||||
return ScaleTransition(scale: animation, child: child);
|
(Widget child, Animation<double> animation) {
|
||||||
},
|
return ScaleTransition(
|
||||||
|
scale: animation,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
child: isSelected
|
child: isSelected
|
||||||
? Container(
|
? Container(
|
||||||
key: const ValueKey('checkmark'),
|
key: const ValueKey('checkmark'),
|
||||||
|
|
@ -260,7 +311,11 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
||||||
color: primaryColor,
|
color: primaryColor,
|
||||||
shape: BoxShape.circle,
|
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(
|
: Column(
|
||||||
key: const ValueKey('time_and_badge'),
|
key: const ValueKey('time_and_badge'),
|
||||||
|
|
@ -270,19 +325,27 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_formatTime(contact.lastMessageTime),
|
_formatTime(contact.lastMessageTime),
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (contact.unreadCount > 0) ...[
|
if (contact.unreadCount > 0) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: primaryColor.withAlpha((0.5 * 255).round()),
|
color: primaryColor.withAlpha(
|
||||||
|
(0.5 * 255).round(),
|
||||||
|
),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${contact.unreadCount}',
|
'${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<ForwardContactPickerScreen>
|
||||||
}(),
|
}(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,338 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '/logic/auth_provider.dart';
|
||||||
|
import 'account_settings_screen.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
|
class MyProfileScreen extends StatefulWidget {
|
||||||
|
const MyProfileScreen({super.key, required this.isFromList});
|
||||||
|
final bool isFromList;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyProfileScreen> createState() => _MyProfileScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyProfileScreenState extends State<MyProfileScreen> {
|
||||||
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
bool _isAvatarExpanded = false;
|
||||||
|
String? privKey;
|
||||||
|
|
||||||
|
Future<void> _pickAvatar() async {
|
||||||
|
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
|
||||||
|
if (image != null) {
|
||||||
|
final success = await context.read<AuthProvider>().updateAvatar(
|
||||||
|
image.path,
|
||||||
|
);
|
||||||
|
if (!success && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Ошибка загрузки аватарки')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadPrivKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPrivKey() async {
|
||||||
|
final storage = const FlutterSecureStorage();
|
||||||
|
privKey = await storage.read(key: 'private_key');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final authProv = context.watch<AuthProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
|
||||||
|
final String fullName =
|
||||||
|
'${authProv.firstName ?? ''} ${authProv.lastName ?? ''}'.trim();
|
||||||
|
final String username = authProv.username ?? '';
|
||||||
|
|
||||||
|
ImageProvider? avatarImage;
|
||||||
|
if (authProv.avatarUrl != null) {
|
||||||
|
avatarImage = NetworkImage(authProv.avatarUrl!);
|
||||||
|
} else if (authProv.avatarPath != null) {
|
||||||
|
avatarImage = FileImage(File(authProv.avatarPath!));
|
||||||
|
}
|
||||||
|
|
||||||
|
final initials =
|
||||||
|
(authProv.displayName.isNotEmpty
|
||||||
|
? authProv.displayName
|
||||||
|
: (username.isNotEmpty ? username : 'U'))
|
||||||
|
.trim()
|
||||||
|
.split(RegExp(r'\s+'))
|
||||||
|
.where((p) => p.isNotEmpty)
|
||||||
|
.take(2)
|
||||||
|
.map((p) => p[0].toUpperCase())
|
||||||
|
.join();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: colorScheme.background,
|
||||||
|
appBar: (Platform.isWindows || !widget.isFromList)
|
||||||
|
? AppBar(
|
||||||
|
title: const Text(
|
||||||
|
'Профиль',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
body: ListView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
// Анимированный интерактивный аватар
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _isAvatarExpanded = !_isAvatarExpanded),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 350),
|
||||||
|
curve: Curves.fastOutSlowIn,
|
||||||
|
width: _isAvatarExpanded ? screenWidth : 130.0,
|
||||||
|
height: _isAvatarExpanded ? screenWidth : 130.0,
|
||||||
|
margin: _isAvatarExpanded
|
||||||
|
? EdgeInsets.zero
|
||||||
|
: const EdgeInsets.only(top: 16, bottom: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: _isAvatarExpanded ? BoxShape.rectangle : BoxShape.circle,
|
||||||
|
color: colorScheme.primaryContainer.withOpacity(0.4),
|
||||||
|
boxShadow: _isAvatarExpanded
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
image: avatarImage != null
|
||||||
|
? DecorationImage(image: avatarImage, fit: BoxFit.cover)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: avatarImage == null
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
initials.isEmpty ? 'U' : initials,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: _isAvatarExpanded ? 80 : 38,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Имя пользователя
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Text(
|
||||||
|
fullName.isNotEmpty ? fullName : 'Имя не указано',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (username.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4, bottom: 24),
|
||||||
|
child: Text(
|
||||||
|
'@$username',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Блок кнопок (Выбрать фото, Изменить, Настройки)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildActionButton(
|
||||||
|
icon: Icons.photo_camera_rounded,
|
||||||
|
label: 'Фото',
|
||||||
|
onTap: _pickAvatar,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _buildActionButton(
|
||||||
|
icon: Icons.edit_note_rounded,
|
||||||
|
label: 'Изменить',
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const AccountSettingsScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _buildActionButton(
|
||||||
|
icon: Icons.settings_suggest_rounded,
|
||||||
|
label: 'Настройки',
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const SettingsScreen(isFromList: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Блок с личной информацией
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.fingerprint_rounded,
|
||||||
|
authProv.currentUserId.toString(),
|
||||||
|
'ID пользователя',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.info_outline_rounded,
|
||||||
|
authProv.about,
|
||||||
|
'О себе',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.phone_android_rounded,
|
||||||
|
authProv.phone,
|
||||||
|
'Номер телефона',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.mail_outline_rounded,
|
||||||
|
authProv.email,
|
||||||
|
'Электронная почта',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.key_rounded,
|
||||||
|
privKey ?? 'Отсутствует',
|
||||||
|
'Публичный E2EE ключ',
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionButton({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.4),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: colorScheme.primary, size: 22),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(
|
||||||
|
BuildContext context,
|
||||||
|
IconData icon,
|
||||||
|
String? value,
|
||||||
|
String label,
|
||||||
|
bool showDivider,
|
||||||
|
) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: colorScheme.primary, size: 20),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
value?.isNotEmpty == true ? value! : 'Не указано',
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(fontSize: 12, color: colorScheme.outline),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 70,
|
||||||
|
color: colorScheme.outlineVariant.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,38 +7,72 @@ class PrivacySettingsMenuScreen extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
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(
|
body: ListView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 12),
|
Container(
|
||||||
ListTile(
|
decoration: BoxDecoration(
|
||||||
leading: const Icon(Icons.security_outlined),
|
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||||
title: const Text('Безопасность'),
|
borderRadius: BorderRadius.circular(24),
|
||||||
subtitle: const Text('Сменить пароль, пароль шифрования, TOTP'),
|
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
),
|
||||||
onTap: () {
|
child: Column(
|
||||||
Navigator.push(
|
children: [
|
||||||
context,
|
_buildMenuTile(
|
||||||
MaterialPageRoute(builder: (_) => const SecuritySettingsScreen()),
|
context: context,
|
||||||
);
|
icon: Icons.security_rounded,
|
||||||
},
|
title: 'Безопасность',
|
||||||
),
|
subtitle: 'Смена паролей, ключи шифрования, TOTP защита',
|
||||||
const Divider(height: 1),
|
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SecuritySettingsScreen())),
|
||||||
ListTile(
|
),
|
||||||
leading: const Icon(Icons.privacy_tip_outlined),
|
Divider(height: 1, indent: 68, color: colorScheme.outlineVariant.withOpacity(0.2)),
|
||||||
title: const Text('Конфиденциальность'),
|
_buildMenuTile(
|
||||||
subtitle: const Text('Кто может видеть почту, телефон, аватар и информацию о вас'),
|
context: context,
|
||||||
trailing: const Icon(Icons.chevron_right),
|
icon: Icons.privacy_tip_rounded,
|
||||||
onTap: () {
|
title: 'Конфиденциальность',
|
||||||
Navigator.push(
|
subtitle: 'Видимость почты, телефона, аватара и онлайна',
|
||||||
context,
|
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const PrivacySettingsScreen())),
|
||||||
MaterialPageRoute(builder: (_) => const PrivacySettingsScreen()),
|
),
|
||||||
);
|
],
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Widget _buildMenuTile({
|
||||||
|
required BuildContext context,
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: colorScheme.primary, size: 22),
|
||||||
|
),
|
||||||
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
|
||||||
|
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.outline, fontSize: 12)),
|
||||||
|
trailing: Icon(Icons.chevron_right_rounded, color: colorScheme.outline),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,14 +52,12 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
||||||
_showAbout = data['show_about'] ?? true;
|
_showAbout = data['show_about'] ?? true;
|
||||||
_showLastOnline = data['show_last_online'] ?? true;
|
_showLastOnline = data['show_last_online'] ?? true;
|
||||||
});
|
});
|
||||||
// Сохраняем локально для быстрого доступа
|
|
||||||
await _savePreference(_showEmailKey, _showEmail);
|
await _savePreference(_showEmailKey, _showEmail);
|
||||||
await _savePreference(_showPhoneKey, _showPhone);
|
await _savePreference(_showPhoneKey, _showPhone);
|
||||||
await _savePreference(_showAvatarKey, _showAvatar);
|
await _savePreference(_showAvatarKey, _showAvatar);
|
||||||
await _savePreference(_showAboutKey, _showAbout);
|
await _savePreference(_showAboutKey, _showAbout);
|
||||||
await _savePreference(_showLastOnlineKey, _showLastOnline);
|
await _savePreference(_showLastOnlineKey, _showLastOnline);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Если не удалось загрузить с сервера, используем локальные настройки
|
|
||||||
print('Ошибка загрузки настроек с сервера: $e');
|
print('Ошибка загрузки настроек с сервера: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +69,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
||||||
|
|
||||||
Future<void> _saveToServer() async {
|
Future<void> _saveToServer() async {
|
||||||
if (_isSaving) return;
|
if (_isSaving) return;
|
||||||
|
|
||||||
setState(() => _isSaving = true);
|
setState(() => _isSaving = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -85,7 +82,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Сохраняем локально только после успешного сохранения на сервере
|
|
||||||
await _savePreference(_showEmailKey, _showEmail);
|
await _savePreference(_showEmailKey, _showEmail);
|
||||||
await _savePreference(_showPhoneKey, _showPhone);
|
await _savePreference(_showPhoneKey, _showPhone);
|
||||||
await _savePreference(_showAvatarKey, _showAvatar);
|
await _savePreference(_showAvatarKey, _showAvatar);
|
||||||
|
|
@ -94,97 +90,108 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Настройки сохранены')),
|
const SnackBar(content: Text('Настройки видимости сохранены'), behavior: SnackBarBehavior.floating),
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Не удалось сохранить настройки')),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Ошибка: ${e.toString().replaceAll('Exception: ', '')}')),
|
SnackBar(content: Text('Ошибка: ${e.toString().replaceAll('Exception: ', '')}'), behavior: SnackBarBehavior.floating),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) setState(() => _isSaving = false);
|
||||||
setState(() => _isSaving = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: colorScheme.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Конфиденциальность'),
|
title: const Text('Видимость данных', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
Padding(
|
||||||
onPressed: _isSaving ? null : _saveToServer,
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
child: _isSaving
|
child: Center(
|
||||||
? const SizedBox(
|
child: _isSaving
|
||||||
width: 16,
|
? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary))
|
||||||
height: 16,
|
: TextButton.icon(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
onPressed: _saveToServer,
|
||||||
)
|
icon: const Icon(Icons.save_rounded, size: 18),
|
||||||
: const Text(
|
label: const Text('Сохранить'),
|
||||||
'Сохранить',
|
),
|
||||||
style: TextStyle(color: Colors.white),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
const Text('Настройки видимости', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
const Padding(
|
||||||
const SizedBox(height: 12),
|
padding: EdgeInsets.only(left: 8.0, bottom: 12),
|
||||||
SwitchListTile(
|
child: Text('Кто видит мою информацию:', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, letterSpacing: 0.5)),
|
||||||
title: const Text('Показывать почту другим'),
|
|
||||||
value: _showEmail,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() => _showEmail = value);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
SwitchListTile(
|
Container(
|
||||||
title: const Text('Показывать телефон другим'),
|
decoration: BoxDecoration(
|
||||||
value: _showPhone,
|
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||||
onChanged: (value) {
|
borderRadius: BorderRadius.circular(24),
|
||||||
setState(() => _showPhone = value);
|
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
|
||||||
},
|
),
|
||||||
),
|
child: Column(
|
||||||
SwitchListTile(
|
children: [
|
||||||
title: const Text('Показывать аватар другим'),
|
_buildSwitchTile('Показывать почту другим', _showEmail, (v) => setState(() => _showEmail = v)),
|
||||||
value: _showAvatar,
|
_buildDivider(),
|
||||||
onChanged: (value) {
|
_buildSwitchTile('Показывать телефон другим', _showPhone, (v) => setState(() => _showPhone = v)),
|
||||||
setState(() => _showAvatar = value);
|
_buildDivider(),
|
||||||
},
|
_buildSwitchTile('Показывать аватар другим', _showAvatar, (v) => setState(() => _showAvatar = v)),
|
||||||
),
|
_buildDivider(),
|
||||||
SwitchListTile(
|
_buildSwitchTile('Показывать информацию «О себе»', _showAbout, (v) => setState(() => _showAbout = v)),
|
||||||
title: const Text('Показывать информацию «О себе»'),
|
_buildDivider(),
|
||||||
value: _showAbout,
|
_buildSwitchTile('Показывать последний онлайн', _showLastOnline, (v) => setState(() => _showLastOnline = v)),
|
||||||
onChanged: (value) {
|
],
|
||||||
setState(() => _showAbout = value);
|
),
|
||||||
},
|
|
||||||
),
|
|
||||||
SwitchListTile(
|
|
||||||
title: const Text('Показывать последний онлайн'),
|
|
||||||
value: _showLastOnline,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() => _showLastOnline = value);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
Container(
|
||||||
'Эти настройки влияют на то, какую информацию о вас видят другие пользователи приложения.',
|
padding: const EdgeInsets.all(16),
|
||||||
style: TextStyle(color: Colors.grey),
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withOpacity(0.04),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lock_person_rounded, color: colorScheme.primary, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Эти настройки напрямую влияют на то, какие персональные данные будут доступны другим участникам в глобальном поиске и карточках чатов.',
|
||||||
|
style: TextStyle(color: colorScheme.outline, fontSize: 13, height: 1.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Widget _buildSwitchTile(String title, bool value, ValueChanged<bool> onChanged) {
|
||||||
|
return SwitchListTile(
|
||||||
|
title: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDivider() => Divider(height: 1, indent: 20, endIndent: 20, color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.2));
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,6 @@ class SecuritySettingsScreen extends StatefulWidget {
|
||||||
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
final _passwordFormKey = GlobalKey<FormState>();
|
final _passwordFormKey = GlobalKey<FormState>();
|
||||||
final _encryptionFormKey = GlobalKey<FormState>();
|
final _encryptionFormKey = GlobalKey<FormState>();
|
||||||
//final _totpFormKey = GlobalKey<FormState>();
|
|
||||||
|
|
||||||
final _currentPasswordController = TextEditingController();
|
final _currentPasswordController = TextEditingController();
|
||||||
final _newPasswordController = TextEditingController();
|
final _newPasswordController = TextEditingController();
|
||||||
|
|
@ -64,9 +63,7 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() => _isBiometricAvailable = false);
|
||||||
_isBiometricAvailable = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,15 +71,11 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
try {
|
try {
|
||||||
final api = ApiService();
|
final api = ApiService();
|
||||||
final userData = await api.getMe();
|
final userData = await api.getMe();
|
||||||
print('TOTP status from getMe: ${userData['totp_enabled']}');
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isTotpEnabled = userData['totp_enabled'] ?? false;
|
_isTotpEnabled = userData['totp_enabled'] ?? false;
|
||||||
});
|
});
|
||||||
print('TOTP status set to: $_isTotpEnabled');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading TOTP status: $e');
|
|
||||||
// Ignore errors, assume TOTP is disabled
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isTotpEnabled = false);
|
setState(() => _isTotpEnabled = false);
|
||||||
}
|
}
|
||||||
|
|
@ -91,16 +84,14 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
Future<bool> _authenticateBiometric() async {
|
Future<bool> _authenticateBiometric() async {
|
||||||
try {
|
try {
|
||||||
return await _localAuth.authenticate(
|
return await _localAuth.authenticate(
|
||||||
localizedReason: 'Подтвердите личность для смены пароля шифрования',
|
localizedReason: 'Подтвердите личность для изменения крипто-пароля',
|
||||||
options: const AuthenticationOptions(
|
options: const AuthenticationOptions(
|
||||||
biometricOnly: false,
|
biometricOnly: false,
|
||||||
stickyAuth: false,
|
|
||||||
useErrorDialogs: true,
|
useErrorDialogs: true,
|
||||||
sensitiveTransaction: true,
|
stickyAuth: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint('Biometric authentication error: $error');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -116,31 +107,33 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
_newPasswordController.text.trim(),
|
_newPasswordController.text.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) throw Exception('Не удалось изменить пароль');
|
||||||
throw Exception('Не удалось изменить пароль');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
const SnackBar(
|
||||||
).showSnackBar(const SnackBar(content: Text('Пароль успешно изменён')));
|
content: Text('Основной пароль успешно обновлен'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
_currentPasswordController.clear();
|
_currentPasswordController.clear();
|
||||||
_newPasswordController.clear();
|
_newPasswordController.clear();
|
||||||
_confirmPasswordController.clear();
|
_confirmPasswordController.clear();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
SnackBar(
|
||||||
|
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (!mounted) return;
|
if (mounted) setState(() => _isSavingPassword = false);
|
||||||
setState(() => _isSavingPassword = false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveEncryptionPassword() async {
|
Future<void> _saveEncryptionPassword() async {
|
||||||
await _checkBiometricSupport();
|
await _checkBiometricSupport();
|
||||||
|
|
||||||
if (!_encryptionFormKey.currentState!.validate()) return;
|
if (!_encryptionFormKey.currentState!.validate()) return;
|
||||||
setState(() => _isSavingEncryption = true);
|
setState(() => _isSavingEncryption = true);
|
||||||
|
|
||||||
|
|
@ -151,29 +144,22 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
String privateKeyBase64;
|
String privateKeyBase64;
|
||||||
|
|
||||||
if (currentPassword.isEmpty) {
|
if (currentPassword.isEmpty) {
|
||||||
if (!_isBiometricAvailable) {
|
if (!_isBiometricAvailable)
|
||||||
throw Exception('Биометрия не настроена. Введите текущий пароль.');
|
throw Exception('Биометрия недоступна. Введите пароль.');
|
||||||
}
|
|
||||||
|
|
||||||
final authenticated = await _authenticateBiometric();
|
final authenticated = await _authenticateBiometric();
|
||||||
if (!authenticated) {
|
if (!authenticated) throw Exception('Аутентификация отменена.');
|
||||||
throw Exception('Биометрическая аутентификация не пройдена.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final localPrivateKey = await cryptoService.getPrivateKey();
|
final localPrivateKey = await cryptoService.getPrivateKey();
|
||||||
if (localPrivateKey == null || localPrivateKey.isEmpty) {
|
if (localPrivateKey == null || localPrivateKey.isEmpty)
|
||||||
throw Exception('Локальный приватный ключ не найден.');
|
throw Exception('Локальный ключ отсутствует.');
|
||||||
}
|
|
||||||
privateKeyBase64 = localPrivateKey;
|
privateKeyBase64 = localPrivateKey;
|
||||||
} else {
|
} else {
|
||||||
final api = ApiService();
|
final api = ApiService();
|
||||||
final userData = await api.getMe();
|
final userData = await api.getMe();
|
||||||
final encryptedPrivateKey = userData['encrypted_private_key']
|
final encryptedPrivateKey = userData['encrypted_private_key']
|
||||||
?.toString();
|
?.toString();
|
||||||
|
if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty)
|
||||||
if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty) {
|
throw Exception('Ключ не найден на сервере.');
|
||||||
throw Exception('Зашифрованный ключ не найден на сервере.');
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKeyBase64 = await cryptoService.decryptPrivateKey(
|
privateKeyBase64 = await cryptoService.decryptPrivateKey(
|
||||||
encryptedPrivateKey,
|
encryptedPrivateKey,
|
||||||
|
|
@ -184,17 +170,17 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
|
|
||||||
final updatedEncryptedPrivateKey = await cryptoService
|
final updatedEncryptedPrivateKey = await cryptoService
|
||||||
.encryptPrivateKeyWithPassword(privateKeyBase64, newPassword);
|
.encryptPrivateKeyWithPassword(privateKeyBase64, newPassword);
|
||||||
|
|
||||||
final success = await ApiService().updateEncryptedPrivateKey(
|
final success = await ApiService().updateEncryptedPrivateKey(
|
||||||
updatedEncryptedPrivateKey,
|
updatedEncryptedPrivateKey,
|
||||||
);
|
);
|
||||||
if (!success) {
|
if (!success) throw Exception('Сервер отклонил обновление крипто-ключа.');
|
||||||
throw Exception('Не удалось обновить пароль шифрования на сервере.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Пароль шифрования успешно обновлён')),
|
const SnackBar(
|
||||||
|
content: Text('Крипто-пароль успешно изменен'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
_currentEncryptPasswordController.clear();
|
_currentEncryptPasswordController.clear();
|
||||||
_newEncryptPasswordController.clear();
|
_newEncryptPasswordController.clear();
|
||||||
|
|
@ -202,20 +188,20 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
SnackBar(
|
||||||
|
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (!mounted) return;
|
if (mounted) setState(() => _isSavingEncryption = false);
|
||||||
setState(() => _isSavingEncryption = false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setupTotp() async {
|
Future<void> _setupTotp() async {
|
||||||
if (_isTotpEnabled) {
|
if (_isTotpEnabled) {
|
||||||
// Показываем диалог с опциями
|
|
||||||
_showTotpOptionsDialog();
|
_showTotpOptionsDialog();
|
||||||
} else {
|
} else {
|
||||||
// Enable TOTP
|
|
||||||
setState(() => _isSavingTotp = true);
|
setState(() => _isSavingTotp = true);
|
||||||
try {
|
try {
|
||||||
final api = ApiService();
|
final api = ApiService();
|
||||||
|
|
@ -224,11 +210,13 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
_totpSecret = data['secret'];
|
_totpSecret = data['secret'];
|
||||||
_totpQrCode = data['qr_code'];
|
_totpQrCode = data['qr_code'];
|
||||||
});
|
});
|
||||||
// Show dialog to scan QR and enter code
|
|
||||||
_showTotpSetupDialog();
|
_showTotpSetupDialog();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
SnackBar(
|
||||||
|
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isSavingTotp = false);
|
setState(() => _isSavingTotp = false);
|
||||||
|
|
@ -240,57 +228,35 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('TOTP'),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
content: const Text('TOTP включён. Выберите действие:'),
|
title: const Text('Защита TOTP активна'),
|
||||||
|
content: const Text(
|
||||||
|
'Выберите необходимое действие для управления двухфакторной аутентификацией.',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () => Navigator.pop(context),
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
child: const Text('Отмена'),
|
child: const Text('Отмена'),
|
||||||
),
|
),
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
_reissueTotp();
|
|
||||||
},
|
|
||||||
child: const Text('Перевыпустить ключ'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.pop(context);
|
||||||
_disableTotp();
|
_disableTotp();
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Отключить 2FA',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
child: const Text('Отключить TOTP'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reissueTotp() async {
|
|
||||||
setState(() => _isSavingTotp = true);
|
|
||||||
try {
|
|
||||||
final api = ApiService();
|
|
||||||
final data = await api.enableTotp();
|
|
||||||
setState(() {
|
|
||||||
_totpSecret = data['secret'];
|
|
||||||
_totpQrCode = data['qr_code'];
|
|
||||||
});
|
|
||||||
// Show dialog to scan QR and enter code
|
|
||||||
_showTotpSetupDialog(isReissue: true);
|
|
||||||
} catch (e) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setState(() => _isSavingTotp = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _disableTotp() async {
|
Future<void> _disableTotp() async {
|
||||||
setState(() => _isSavingTotp = true);
|
setState(() => _isSavingTotp = true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -303,122 +269,130 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
_totpQrCode = null;
|
_totpQrCode = null;
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('TOTP отключён')),
|
const SnackBar(
|
||||||
|
content: Text('Двухфакторная защита отключена'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
SnackBar(
|
||||||
|
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isSavingTotp = false);
|
setState(() => _isSavingTotp = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showTotpSetupDialog({bool isReissue = false}) {
|
void _showTotpSetupDialog() {
|
||||||
final codeController = TextEditingController();
|
final codeController = TextEditingController();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(isReissue ? 'Перевыпуск ключа TOTP' : 'Настройка TOTP'),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
title: const Text('Активация TOTP 2FA'),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(isReissue
|
const Text(
|
||||||
? 'Отсканируйте новый QR-код в приложении аутентификатора:'
|
'Сканируйте код приложением аутентификатора (Google Authenticator / Aegis):',
|
||||||
: 'Отсканируйте QR-код в приложении аутентификатора:'),
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (_totpQrCode != null)
|
if (_totpQrCode != null)
|
||||||
Builder(
|
ClipRRect(
|
||||||
builder: (context) {
|
borderRadius: BorderRadius.circular(16),
|
||||||
final base64String = _totpQrCode!.split(',').last;
|
child: Image.memory(
|
||||||
final bytes = base64Decode(base64String);
|
base64Decode(_totpQrCode!.split(',').last),
|
||||||
return Image.memory(bytes, width: 200, height: 200);
|
width: 180,
|
||||||
},
|
height: 180,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Container(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
padding: const EdgeInsets.symmetric(
|
||||||
children: [
|
horizontal: 12,
|
||||||
Expanded(
|
vertical: 8,
|
||||||
child: Text(
|
),
|
||||||
'Ключ: ${_totpSecret ?? ''}',
|
decoration: BoxDecoration(
|
||||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
color: colorScheme.surfaceVariant.withOpacity(0.4),
|
||||||
overflow: TextOverflow.ellipsis,
|
borderRadius: BorderRadius.circular(12),
|
||||||
maxLines: 1,
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_totpSecret ?? '',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
IconButton(
|
||||||
const SizedBox(width: 8),
|
icon: const Icon(Icons.copy_rounded, size: 16),
|
||||||
IconButton(
|
onPressed: () {
|
||||||
icon: const Icon(Icons.copy, size: 18),
|
if (_totpSecret != null) {
|
||||||
onPressed: () {
|
Clipboard.setData(ClipboardData(text: _totpSecret!));
|
||||||
if (_totpSecret != null) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
Clipboard.setData(ClipboardData(text: _totpSecret!));
|
const SnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
content: Text('Ключ скопирован в буфер'),
|
||||||
const SnackBar(content: Text('Ключ скопирован')),
|
duration: Duration(seconds: 1),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
},
|
}
|
||||||
tooltip: 'Скопировать ключ',
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: codeController,
|
controller: codeController,
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Введите код из приложения',
|
|
||||||
helperText: 'Обычно это 6 цифр',
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '6-значный одноразовый код',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () => Navigator.pop(context),
|
||||||
Navigator.of(context).pop();
|
|
||||||
setState(() {
|
|
||||||
_totpSecret = null;
|
|
||||||
_totpQrCode = null;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: const Text('Отмена'),
|
child: const Text('Отмена'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final code = codeController.text.trim();
|
if (codeController.text.trim().isEmpty) return;
|
||||||
if (code.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Введите код')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final api = ApiService();
|
final success = await ApiService().verifyTotp(
|
||||||
final success = await api.verifyTotp(code);
|
codeController.text.trim(),
|
||||||
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
Navigator.of(context).pop();
|
Navigator.pop(context);
|
||||||
setState(() {
|
setState(() => _isTotpEnabled = true);
|
||||||
_isTotpEnabled = true;
|
|
||||||
_totpSecret = null;
|
|
||||||
_totpQrCode = null;
|
|
||||||
});
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(isReissue ? 'Ключ перевыпущен' : 'TOTP включён')),
|
const SnackBar(
|
||||||
);
|
content: Text('Двухфакторный ключ успешно привязан'),
|
||||||
} else {
|
behavior: SnackBarBehavior.floating,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
),
|
||||||
const SnackBar(content: Text('Неверный код')),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
SnackBar(
|
||||||
|
content: Text(e.toString().replaceAll('Exception: ', '')),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -429,153 +403,253 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _currentEncryptionPasswordValidator(String? value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
if (!_isBiometricAvailable) {
|
|
||||||
return 'Введите текущий пароль';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Безопасность')),
|
backgroundColor: colorScheme.background,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Безопасность'),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
// Модуль 1: Основной пароль
|
||||||
'Смена пароля аккаунта',
|
_buildCardSection(
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
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),
|
const SizedBox(height: 20),
|
||||||
Form(
|
|
||||||
key: _passwordFormKey,
|
// Модуль 2: Сквозное шифрование
|
||||||
child: Column(
|
_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: [
|
children: [
|
||||||
TextFormField(
|
Icon(
|
||||||
controller: _currentPasswordController,
|
Icons.lock_clock_rounded,
|
||||||
decoration: const InputDecoration(
|
color: colorScheme.primary,
|
||||||
labelText: 'Текущий пароль',
|
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(
|
ElevatedButton(
|
||||||
onPressed: _isSavingPassword ? null : _savePassword,
|
onPressed: _isSavingTotp ? null : _setupTotp,
|
||||||
child: _isSavingPassword
|
style: ElevatedButton.styleFrom(
|
||||||
? const CircularProgressIndicator(color: Colors.white)
|
elevation: 0,
|
||||||
: const Text('Сохранить пароль'),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(_isTotpEnabled ? 'Опции' : 'Включить'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
const Text(
|
|
||||||
'Пароль шифрования сообщений',
|
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Form(
|
|
||||||
key: _encryptionFormKey,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextFormField(
|
|
||||||
controller: _currentEncryptPasswordController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Текущий пароль шифрования',
|
|
||||||
helperText: _isBiometricAvailable
|
|
||||||
? 'Оставьте поле пустым и подтвердите биометрией'
|
|
||||||
: 'Требуется текущий пароль',
|
|
||||||
),
|
|
||||||
obscureText: true,
|
|
||||||
validator: _currentEncryptionPasswordValidator,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextFormField(
|
|
||||||
controller: _newEncryptPasswordController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Новый пароль шифрования',
|
|
||||||
),
|
|
||||||
obscureText: true,
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.length < 6)
|
|
||||||
return 'Пароль слишком короткий';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextFormField(
|
|
||||||
controller: _confirmEncryptPasswordController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Повторите новый пароль',
|
|
||||||
),
|
|
||||||
obscureText: true,
|
|
||||||
validator: (value) {
|
|
||||||
if (value != _newEncryptPasswordController.text)
|
|
||||||
return 'Пароли не совпадают';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _isSavingEncryption
|
|
||||||
? null
|
|
||||||
: _saveEncryptionPassword,
|
|
||||||
child: _isSavingEncryption
|
|
||||||
? const CircularProgressIndicator(color: Colors.white)
|
|
||||||
: const Text('Сохранить пароль шифрования'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
const Text(
|
|
||||||
'TOTP',
|
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(_isTotpEnabled ? 'TOTP включён' : 'TOTP отключён'),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _isSavingTotp ? null : _setupTotp,
|
|
||||||
child: _isSavingTotp
|
|
||||||
? const CircularProgressIndicator(color: Colors.white)
|
|
||||||
: Text(_isTotpEnabled ? 'Отключить TOTP' : 'Включить TOTP'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCardSection({required String title, required Widget child}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFormInput(
|
||||||
|
TextEditingController controller,
|
||||||
|
String label,
|
||||||
|
bool obscure, {
|
||||||
|
String? hint,
|
||||||
|
String? Function(String?)? validator,
|
||||||
|
}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12.0),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
obscureText: obscure,
|
||||||
|
validator:
|
||||||
|
validator ??
|
||||||
|
(v) => (v == null || v.isEmpty) ? 'Обязательное поле' : null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
hintText: hint,
|
||||||
|
border: const OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import '/logic/auth_provider.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'admin_panel_screen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
final bool isFromList;
|
||||||
|
const SettingsScreen({super.key, this.isFromList = true});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||||
|
|
@ -19,6 +21,7 @@ class SettingsScreen extends StatefulWidget {
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
String? versionCode;
|
String? versionCode;
|
||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
bool _isAvatarExpanded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -38,8 +41,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
Future<void> _pickAvatar() async {
|
Future<void> _pickAvatar() async {
|
||||||
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
|
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
final success = await context.read<AuthProvider>().updateAvatar(image.path);
|
final success = await context.read<AuthProvider>().updateAvatar(
|
||||||
if (!success) {
|
image.path,
|
||||||
|
);
|
||||||
|
if (!success && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Ошибка загрузки аватарки')),
|
const SnackBar(content: Text('Ошибка загрузки аватарки')),
|
||||||
);
|
);
|
||||||
|
|
@ -50,168 +55,337 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final authProv = context.watch<AuthProvider>();
|
final authProv = context.watch<AuthProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
|
||||||
final accountUsername = authProv.username?.isNotEmpty == true
|
String platformName = Platform.isAndroid
|
||||||
? '@${authProv.username!}'
|
? 'Android'
|
||||||
: 'Не указано';
|
: Platform.isIOS
|
||||||
|
? 'iOS'
|
||||||
|
: Platform.isWindows
|
||||||
|
? 'Windows'
|
||||||
|
: Platform.isLinux
|
||||||
|
? 'Linux'
|
||||||
|
: Platform.isMacOS
|
||||||
|
? 'macOS'
|
||||||
|
: 'Unknown';
|
||||||
|
|
||||||
final username = authProv.username;
|
final String fullName =
|
||||||
final displayName = authProv.displayName;
|
'${authProv.firstName ?? ''} ${authProv.lastName ?? ''}'.trim();
|
||||||
final initials = (displayName.isNotEmpty ? displayName : (username ?? 'U'))
|
final String username = authProv.username ?? '';
|
||||||
.trim()
|
|
||||||
.split(RegExp(r'\s+'))
|
ImageProvider? avatarImage;
|
||||||
.where((p) => p.isNotEmpty)
|
if (authProv.avatarUrl != null) {
|
||||||
.take(2)
|
avatarImage = NetworkImage(authProv.avatarUrl!);
|
||||||
.map((p) => p[0].toUpperCase())
|
} else if (authProv.avatarPath != null) {
|
||||||
.join();
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text("Настройки")),
|
backgroundColor: colorScheme.background,
|
||||||
body: Column(
|
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: [
|
children: [
|
||||||
// Секция Профиля
|
// Анимированный интерактивный аватар как в MyProfileScreen
|
||||||
UserAccountsDrawerHeader(
|
GestureDetector(
|
||||||
accountName: Text(
|
onTap: () => setState(() => _isAvatarExpanded = !_isAvatarExpanded),
|
||||||
authProv.displayName,
|
child: AnimatedContainer(
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
duration: const Duration(milliseconds: 350),
|
||||||
),
|
curve: Curves.fastOutSlowIn,
|
||||||
accountEmail: Text(
|
width: _isAvatarExpanded ? screenWidth : 130.0,
|
||||||
accountUsername,
|
height: _isAvatarExpanded ? screenWidth : 130.0,
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
margin: _isAvatarExpanded
|
||||||
),
|
? EdgeInsets.zero
|
||||||
currentAccountPicture: GestureDetector(
|
: const EdgeInsets.only(top: 16, bottom: 8),
|
||||||
onTap: _pickAvatar,
|
decoration: BoxDecoration(
|
||||||
child: SizedBox(
|
shape: _isAvatarExpanded ? BoxShape.rectangle : BoxShape.circle,
|
||||||
width: 80,
|
color: colorScheme.primaryContainer.withOpacity(0.4),
|
||||||
height: 80,
|
boxShadow: _isAvatarExpanded
|
||||||
child: Stack(
|
? []
|
||||||
children: [
|
: [
|
||||||
authProv.avatarUrl != null
|
BoxShadow(
|
||||||
? CircleAvatar(
|
color: Colors.black.withOpacity(0.1),
|
||||||
radius: 40,
|
blurRadius: 20,
|
||||||
backgroundImage: NetworkImage(authProv.avatarUrl!),
|
offset: const Offset(0, 10),
|
||||||
)
|
|
||||||
: authProv.avatarPath != null
|
|
||||||
? CircleAvatar(
|
|
||||||
radius: 40,
|
|
||||||
backgroundImage: FileImage(File(authProv.avatarPath!)),
|
|
||||||
)
|
|
||||||
: CircleAvatar(
|
|
||||||
radius: 40,
|
|
||||||
child: Text(
|
|
||||||
initials.isEmpty ? 'U' : initials,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
bottom: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
),
|
||||||
child: Icon(
|
],
|
||||||
Icons.camera_alt,
|
image: avatarImage != null
|
||||||
size: 16,
|
? DecorationImage(image: avatarImage, fit: BoxFit.cover)
|
||||||
color: Theme.of(context).colorScheme.onPrimary,
|
: 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(),
|
const SizedBox(height: 16),
|
||||||
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 Divider(),
|
// Кнопка выхода из аккаунта
|
||||||
|
Padding(
|
||||||
// Выход
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
ListTile(
|
child: Container(
|
||||||
leading: const Icon(Icons.exit_to_app, color: Colors.red),
|
decoration: BoxDecoration(
|
||||||
title: const Text(
|
color: colorScheme.errorContainer.withOpacity(0.15),
|
||||||
"Выйти из аккаунта",
|
borderRadius: BorderRadius.circular(24),
|
||||||
style: TextStyle(color: Colors.red),
|
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(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
"Chepuhagram for Android v$versionCode",
|
"Chepuhagram for $platformName v${versionCode ?? '1.0.0'}",
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
style: TextStyle(color: colorScheme.outline, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Center(
|
const SizedBox(height: 4),
|
||||||
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
"Made by ArturKarasevich",
|
"Made by ArturKarasevich",
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
style: TextStyle(
|
||||||
|
color: colorScheme.outline.withOpacity(0.6),
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const SizedBox(height: 40),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildMenuTile(
|
||||||
|
BuildContext context,
|
||||||
|
IconData icon,
|
||||||
|
String title,
|
||||||
|
String subtitle,
|
||||||
|
VoidCallback onTap,
|
||||||
|
) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: colorScheme.primary, size: 22),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(color: colorScheme.outline, fontSize: 13),
|
||||||
|
),
|
||||||
|
trailing: Icon(Icons.chevron_right_rounded, color: colorScheme.outline),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDivider(BuildContext context) => Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 68,
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.15),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,20 @@ import 'package:chepuhagram/domain/services/api_service.dart';
|
||||||
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '/core/constants.dart';
|
import '/core/constants.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'dart:io';
|
||||||
|
|
||||||
class UserProfileScreen extends StatefulWidget {
|
class UserProfileScreen extends StatefulWidget {
|
||||||
final int userId;
|
final int userId;
|
||||||
final String username;
|
final String username;
|
||||||
final String name;
|
final String name;
|
||||||
|
final VoidCallback? onClose;
|
||||||
|
|
||||||
const UserProfileScreen({
|
const UserProfileScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.userId,
|
required this.userId,
|
||||||
required this.username,
|
required this.username,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
this.onClose,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -38,9 +40,7 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadUserData();
|
_loadUserData();
|
||||||
startOnlineUpdates();
|
startOnlineUpdates();
|
||||||
|
|
||||||
DateTime now = DateTime.now();
|
DateTime now = DateTime.now();
|
||||||
|
|
||||||
offset = now.timeZoneOffset;
|
offset = now.timeZoneOffset;
|
||||||
|
|
||||||
final socketService = Provider.of<SocketService>(context, listen: false);
|
final socketService = Provider.of<SocketService>(context, listen: false);
|
||||||
|
|
@ -54,19 +54,12 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadUserData() async {
|
Future<void> _loadUserData() async {
|
||||||
_error = null;
|
|
||||||
_isLoading = true;
|
|
||||||
try {
|
try {
|
||||||
final api = ApiService();
|
final api = ApiService();
|
||||||
final data = await api.getUserById(widget.userId);
|
final data = await api.getUserById(widget.userId);
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
firstName = prefs.containsKey('firstname_${widget.userId}')
|
firstName = prefs.getString('firstname_${widget.userId}');
|
||||||
? prefs.getString('firstname_${widget.userId}')
|
lastName = prefs.getString('lastname_${widget.userId}');
|
||||||
: null;
|
|
||||||
lastName = prefs.containsKey('lastname_${widget.userId}')
|
|
||||||
? prefs.getString('lastname_${widget.userId}')
|
|
||||||
: null;
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_userData = data;
|
_userData = data;
|
||||||
|
|
@ -76,18 +69,12 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (e.toString().contains('SocketFailed')) {
|
_error = e.toString().contains('SocketFailed')
|
||||||
_error =
|
? 'Соединение разорвано'
|
||||||
'Ошибка соединения с сервером. Проверьте интернет соединение.';
|
: e.toString().replaceAll('Exception: ', '');
|
||||||
} else {
|
|
||||||
_error = e.toString().replaceAll('Exception: ', '');
|
|
||||||
}
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Future.delayed(Duration(seconds: 2), () {
|
|
||||||
_loadUserData();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,289 +85,29 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
String _formatLastSeen(String? lastSeenStr) {
|
||||||
Widget build(BuildContext context) {
|
if (lastSeenStr == null) return 'Был(а) недавно';
|
||||||
return Scaffold(
|
final lastSeen = DateTime.tryParse(lastSeenStr);
|
||||||
appBar: AppBar(title: const Text('Информация о пользователе')),
|
if (lastSeen == null) return 'Был(а) недавно';
|
||||||
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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildUserInfo() {
|
// Применяем локальный офсет часового пояса, если необходимо
|
||||||
if (_userData == null) return const SizedBox.shrink();
|
final localLastSeen = offset != null ? lastSeen.add(offset!) : lastSeen;
|
||||||
|
|
||||||
final String displayFN = firstName ?? _userData?['first_name'] ?? '';
|
|
||||||
final String displayLN = lastName ?? _userData?['last_name'] ?? '';
|
|
||||||
final String username = _userData?['username'] ?? '';
|
|
||||||
|
|
||||||
final rawAvatarUrl = _userData?['avatar_url']?.toString();
|
|
||||||
final avatarUrl = rawAvatarUrl != null && rawAvatarUrl.startsWith('/')
|
|
||||||
? '${AppConstants.baseUrl}$rawAvatarUrl'
|
|
||||||
: rawAvatarUrl;
|
|
||||||
|
|
||||||
return ListView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
children: [
|
|
||||||
// Avatar placeholder
|
|
||||||
Center(
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 50,
|
|
||||||
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
|
|
||||||
backgroundImage:
|
|
||||||
(avatarUrl != null && _userData?['show_avatar'] == true)
|
|
||||||
? CachedNetworkImageProvider(avatarUrl)
|
|
||||||
: null,
|
|
||||||
child: (avatarUrl == null || _userData?['show_avatar'] != true)
|
|
||||||
? Text(
|
|
||||||
(displayFN.isNotEmpty && displayLN.isNotEmpty)
|
|
||||||
? '${displayFN[0]}${displayLN[0]}'.toUpperCase()
|
|
||||||
: (displayFN.isNotEmpty)
|
|
||||||
? displayFN[0].toUpperCase()
|
|
||||||
: (username.isNotEmpty)
|
|
||||||
? username[0].toUpperCase()
|
|
||||||
: '?',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Name
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => {_editUserName(displayFN, displayLN)},
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Spacer(),
|
|
||||||
if ((displayFN.isNotEmpty) || (displayLN.isNotEmpty))
|
|
||||||
Text(
|
|
||||||
'$displayFN $displayLN'.trim(),
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 5),
|
|
||||||
Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
const Spacer(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Username
|
|
||||||
if (_userData!['username'] != null && _userData!['username'].isNotEmpty)
|
|
||||||
Text(
|
|
||||||
'@${_userData!['username']}',
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodyLarge?.copyWith(color: Colors.grey[600]),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Last online status
|
|
||||||
if (_userData!['online'] == true)
|
|
||||||
const Text(
|
|
||||||
'Онлайн',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
)
|
|
||||||
else if (_userData!['last_online'] != null &&
|
|
||||||
DateTime.tryParse(_userData!['last_online']) != null)
|
|
||||||
Text(
|
|
||||||
'Был(а) в сети ${_formatLastOnline(DateTime.tryParse(_userData!['last_online'])!.add(offset != null ? offset! : Duration.zero))}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Color.fromARGB(255, 161, 161, 161),
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const Text(
|
|
||||||
'Был(а) недавно',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Color.fromARGB(255, 161, 161, 161),
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// User ID
|
|
||||||
_buildInfoTile('ID пользователя', _userData!['id'].toString()),
|
|
||||||
|
|
||||||
// Public Key (if available)
|
|
||||||
if (_userData!['public_key'] != null)
|
|
||||||
_buildInfoTile(
|
|
||||||
'Публичный ключ',
|
|
||||||
_userData!['public_key'],
|
|
||||||
maxLines: 3,
|
|
||||||
),
|
|
||||||
|
|
||||||
// About
|
|
||||||
if (_userData!['about'] != null && _userData!['about'].isNotEmpty)
|
|
||||||
_buildInfoTile('О себе', _userData!['about'], maxLines: 5),
|
|
||||||
|
|
||||||
// Phone
|
|
||||||
if (_userData!['phone'] != null && _userData!['phone'].isNotEmpty)
|
|
||||||
_buildInfoTile('Телефон', _userData!['phone']),
|
|
||||||
|
|
||||||
// Email
|
|
||||||
if (_userData!['email'] != null && _userData!['email'].isNotEmpty)
|
|
||||||
_buildInfoTile('Почта', _userData!['email']),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if ((_userData!['username'] == null ||
|
|
||||||
_userData!['username'].isEmpty) &&
|
|
||||||
(_userData!['first_name'] == null ||
|
|
||||||
_userData!['first_name'].isEmpty) &&
|
|
||||||
(_userData!['last_name'] == null ||
|
|
||||||
_userData!['last_name'].isEmpty) &&
|
|
||||||
(_userData!['about'] == null || _userData!['about'].isEmpty) &&
|
|
||||||
(_userData!['phone'] == null || _userData!['phone'].isEmpty) &&
|
|
||||||
(_userData!['email'] == null || _userData!['email'].isEmpty))
|
|
||||||
const Text(
|
|
||||||
'Пользователь скрыл дополнительную информацию',
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _editUserName(String firstname, String lastname) async {
|
|
||||||
final firstnameController = TextEditingController(text: firstname);
|
|
||||||
final lastnameController = TextEditingController(text: lastname);
|
|
||||||
final result = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('Изменить имя пользователя'),
|
|
||||||
content: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
TextField(
|
|
||||||
controller: firstnameController,
|
|
||||||
minLines: 1,
|
|
||||||
maxLines: 5,
|
|
||||||
autofocus: true,
|
|
||||||
decoration: const InputDecoration(hintText: 'Имя'),
|
|
||||||
textCapitalization: TextCapitalization.words,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextField(
|
|
||||||
controller: lastnameController,
|
|
||||||
minLines: 1,
|
|
||||||
maxLines: 5,
|
|
||||||
decoration: const InputDecoration(hintText: 'Фамилия'),
|
|
||||||
textCapitalization: TextCapitalization.words,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
|
||||||
child: const Text('Сбрость'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
|
||||||
child: const Text('Сохранить'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
if (result == true) {
|
|
||||||
if (firstname != firstnameController.text) {
|
|
||||||
prefs.setString('firstname_${widget.userId}', firstnameController.text);
|
|
||||||
}
|
|
||||||
if (lastname != lastnameController.text) {
|
|
||||||
prefs.setString('lastname_${widget.userId}', lastnameController.text);
|
|
||||||
}
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
_loadUserData();
|
|
||||||
} else {
|
|
||||||
prefs.remove('firstname_${widget.userId}');
|
|
||||||
prefs.remove('lastname_${widget.userId}');
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
_loadUserData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleIncomingMessage(Map<String, dynamic> data) async {
|
|
||||||
if (data['type'] == 'user_online') {
|
|
||||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
|
||||||
if (userId == widget.userId) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_userData = _userData?..['online'] = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data['type'] == 'user_offline') {
|
|
||||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
|
||||||
if (userId == widget.userId) {
|
|
||||||
setState(() {
|
|
||||||
_userData = _userData?..['online'] = false;
|
|
||||||
_userData = _userData
|
|
||||||
?..['last_online'] = DateTime.now().toIso8601String();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data['type'] == 'user_updated') {
|
|
||||||
print('User updated message received, refreshing contact list');
|
|
||||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
|
||||||
if (userId != null && userId == widget.userId) {
|
|
||||||
_loadUserData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatLastOnline(DateTime lastOnline) {
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final difference = now.difference(lastOnline);
|
final difference = now.difference(localLastSeen);
|
||||||
|
|
||||||
if (difference.inSeconds < 60) {
|
if (difference.inMinutes < 1) {
|
||||||
return 'только что';
|
return 'Был(а) только что';
|
||||||
} else if (difference.inMinutes < 60) {
|
} else if (difference.inMinutes < 60) {
|
||||||
return '${difference.inMinutes} минут${_pluralize(difference.inMinutes, "у", "ы", "")} назад';
|
return 'Был(а) ${difference.inMinutes} ${_pluralize(difference.inMinutes, "минуту", "минуты", "минут")} назад';
|
||||||
} else if (difference.inHours < 24) {
|
} else if (difference.inHours < 24) {
|
||||||
return '${difference.inHours} час${_pluralize(difference.inHours, "", "а", "ов")} назад';
|
return 'Был(а) ${difference.inHours} ${_pluralize(difference.inHours, "час", "часа", "часов")} назад';
|
||||||
} else if (difference.inDays < 7) {
|
} else if (difference.inDays < 7) {
|
||||||
return '${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
|
return 'Был(а) ${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
|
||||||
} else if (difference.inDays < 30) {
|
} else if (difference.inDays < 30) {
|
||||||
final weeks = (difference.inDays / 7).floor();
|
final weeks = (difference.inDays / 7).floor();
|
||||||
return '$weeks ${_pluralize(weeks, "неделю", "недели", "недель")} назад';
|
return 'Был(а) $weeks ${_pluralize(weeks, "неделю", "недели", "недель")} назад';
|
||||||
} else {
|
} else {
|
||||||
return 'давно';
|
return 'Был(а) давно';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,30 +123,388 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoTile(String label, String value, {int maxLines = 1}) {
|
@override
|
||||||
return Padding(
|
Widget build(BuildContext context) {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
return Scaffold(
|
||||||
children: [
|
backgroundColor: colorScheme.background,
|
||||||
Text(
|
body: SafeArea(
|
||||||
label,
|
child: Stack(
|
||||||
style: const TextStyle(
|
children: [
|
||||||
fontWeight: FontWeight.bold,
|
// Основное содержимое экрана
|
||||||
fontSize: 14,
|
_buildMainContent(colorScheme),
|
||||||
color: Colors.grey,
|
|
||||||
),
|
if (Platform.isWindows) ...[
|
||||||
),
|
Positioned(
|
||||||
const SizedBox(height: 4),
|
top: 12,
|
||||||
Text(
|
right: 16,
|
||||||
value,
|
child: ClipOval(
|
||||||
style: const TextStyle(fontSize: 16),
|
child: Material(
|
||||||
maxLines: maxLines,
|
child: IconButton(
|
||||||
overflow: TextOverflow.ellipsis,
|
icon: const Icon(Icons.close_rounded),
|
||||||
),
|
color: colorScheme.onSurfaceVariant,
|
||||||
const Divider(),
|
onPressed: () {
|
||||||
],
|
if (widget.onClose != null) {
|
||||||
|
widget.onClose!();
|
||||||
|
} else if (Navigator.canPop(context)) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else if (Platform.isAndroid) ...[
|
||||||
|
Positioned(
|
||||||
|
top: 12,
|
||||||
|
left: 16,
|
||||||
|
child: ClipOval(
|
||||||
|
child: Material(
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
onPressed: () {
|
||||||
|
if (widget.onClose != null) {
|
||||||
|
widget.onClose!();
|
||||||
|
} else if (Navigator.canPop(context)) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildMainContent(ColorScheme colorScheme) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildUserInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUserInfo() {
|
||||||
|
if (_userData == null) return const SizedBox.shrink();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
final String displayFN = firstName ?? _userData?['first_name'] ?? '';
|
||||||
|
final String displayLN = lastName ?? _userData?['last_name'] ?? '';
|
||||||
|
final String combinedName = '$displayFN $displayLN'.trim();
|
||||||
|
final String username = _userData?['username'] ?? '';
|
||||||
|
final rawAvatarUrl = _userData?['avatar_url']?.toString();
|
||||||
|
final avatarUrl = rawAvatarUrl != null && rawAvatarUrl.startsWith('/')
|
||||||
|
? '${AppConstants.baseUrl}$rawAvatarUrl'
|
||||||
|
: rawAvatarUrl;
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.only(top: 44, bottom: 24),
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 110,
|
||||||
|
height: 110,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: colorScheme.primaryContainer.withOpacity(0.5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.08),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
image: (avatarUrl != null && _userData?['show_avatar'] == true)
|
||||||
|
? DecorationImage(
|
||||||
|
image: NetworkImage(avatarUrl),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: (avatarUrl == null || _userData?['show_avatar'] != true)
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
combinedName.isNotEmpty
|
||||||
|
? combinedName[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
Center(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _editUserName(displayFN, displayLN),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
combinedName.isNotEmpty ? combinedName : 'Без имени',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Icon(
|
||||||
|
Icons.edit_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.outline,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (username.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
'@$username',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildOnlineStatus(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildInfoRow(
|
||||||
|
Icons.fingerprint_rounded,
|
||||||
|
_userData!['id'].toString(),
|
||||||
|
'ID пользователя',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
if (_userData!['about'] != null &&
|
||||||
|
_userData!['about'].toString().isNotEmpty)
|
||||||
|
_buildInfoRow(
|
||||||
|
Icons.info_outline_rounded,
|
||||||
|
_userData!['about'],
|
||||||
|
'О себе',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
if (_userData!['phone'] != null &&
|
||||||
|
_userData!['phone'].toString().isNotEmpty)
|
||||||
|
_buildInfoRow(
|
||||||
|
Icons.phone_android_rounded,
|
||||||
|
_userData!['phone'],
|
||||||
|
'Номер телефона',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
if (_userData!['email'] != null &&
|
||||||
|
_userData!['email'].toString().isNotEmpty)
|
||||||
|
_buildInfoRow(
|
||||||
|
Icons.mail_outline_rounded,
|
||||||
|
_userData!['email'],
|
||||||
|
'Электронная почта',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
Icons.key_rounded,
|
||||||
|
_userData!['public_key'] ?? 'Отсутствует',
|
||||||
|
'Публичный E2EE ключ',
|
||||||
|
false,
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOnlineStatus() {
|
||||||
|
if (_userData?['online'] == true) {
|
||||||
|
return const Text(
|
||||||
|
'В сети',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем строку последнего онлайна из данных сервера
|
||||||
|
final String? lastSeenStr = _userData?['last_online']?.toString();
|
||||||
|
return Text(
|
||||||
|
_formatLastSeen(lastSeenStr),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(
|
||||||
|
IconData icon,
|
||||||
|
String value,
|
||||||
|
String label,
|
||||||
|
bool showDivider, {
|
||||||
|
int maxLines = 1,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
leading: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withOpacity(0.06),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: colorScheme.primary, size: 18),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||||
|
maxLines: maxLines,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(fontSize: 12, color: colorScheme.outline),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 68,
|
||||||
|
color: colorScheme.outlineVariant.withOpacity(0.15),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editUserName(String firstname, String lastname) async {
|
||||||
|
final firstnameController = TextEditingController(text: firstname);
|
||||||
|
final lastnameController = TextEditingController(text: lastname);
|
||||||
|
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
title: const Text('Задать локальное имя'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: firstnameController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Имя'),
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: lastnameController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Фамилия'),
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Сбросить'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Сохранить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (result == true) {
|
||||||
|
await prefs.setString(
|
||||||
|
'firstname_${widget.userId}',
|
||||||
|
firstnameController.text.trim(),
|
||||||
|
);
|
||||||
|
await prefs.setString(
|
||||||
|
'lastname_${widget.userId}',
|
||||||
|
lastnameController.text.trim(),
|
||||||
|
);
|
||||||
|
} else if (result == false) {
|
||||||
|
await prefs.remove('firstname_${widget.userId}');
|
||||||
|
await prefs.remove('lastname_${widget.userId}');
|
||||||
|
}
|
||||||
|
_loadUserData();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleIncomingMessage(Map<String, dynamic> data) {
|
||||||
|
if (data['type'] == 'user_online' && data['user_id'] == widget.userId) {
|
||||||
|
if (mounted) setState(() => _userData?['online'] = true);
|
||||||
|
}
|
||||||
|
if (data['type'] == 'user_offline' && data['user_id'] == widget.userId) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_userData?['online'] = false;
|
||||||
|
_userData?['last_online'] = DateTime.now().toIso8601String();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data['type'] == 'user_updated' && data['user_id'] == widget.userId) {
|
||||||
|
_loadUserData();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
import 'package:chepuhagram/domain/services/aPI_service.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import '/data/models/contact_model.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:chepuhagram/data/models/message_model.dart';
|
|
||||||
|
|
||||||
class ContactTile extends StatefulWidget {
|
|
||||||
final Contact contact;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
ContactTile({super.key, required this.contact, this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ContactTile> createState() => _ContactTileState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ContactTileState extends State<ContactTile> {
|
|
||||||
SharedPreferences? _prefs;
|
|
||||||
String? token;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_initPrefs();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initPrefs() async {
|
|
||||||
final apiService = ApiService();
|
|
||||||
final accessToken = await apiService.getAccessToken();
|
|
||||||
final shared = await SharedPreferences.getInstance();
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_prefs = shared;
|
|
||||||
token = accessToken;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String get displayName {
|
|
||||||
if (_prefs == null) return widget.contact.name;
|
|
||||||
|
|
||||||
final id = widget.contact.id;
|
|
||||||
final savedName = _prefs!.getString('firstname_$id');
|
|
||||||
final savedSurname = _prefs!.getString('lastname_$id');
|
|
||||||
|
|
||||||
final name = savedName ?? widget.contact.name;
|
|
||||||
final surname = savedSurname ?? widget.contact.surname;
|
|
||||||
|
|
||||||
final full =
|
|
||||||
'${name != 'Unknown' ? name : ''} ${surname != 'Unknown' ? surname : ''}'
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if (full.isNotEmpty) return full;
|
|
||||||
if (widget.contact.username != 'Unknown') return widget.contact.username;
|
|
||||||
return 'User';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final primary = Theme.of(context).colorScheme.primary;
|
|
||||||
final username = widget.contact.username; //
|
|
||||||
|
|
||||||
final initials =
|
|
||||||
(displayName.isNotEmpty
|
|
||||||
? displayName
|
|
||||||
: (username != 'Unknown' ? username : 'U'))
|
|
||||||
.trim()
|
|
||||||
.split(RegExp(r'\s+'))
|
|
||||||
.where((p) => p.isNotEmpty)
|
|
||||||
.take(2)
|
|
||||||
.map((p) => p[0].toUpperCase())
|
|
||||||
.join(); //
|
|
||||||
debugPrint(
|
|
||||||
'=== CONTACT DEBUG: ${widget.contact.name} -> URL: ${widget.contact.effectiveAvatarUrl}',
|
|
||||||
);
|
|
||||||
return ListTile(
|
|
||||||
onTap: widget.onTap, //
|
|
||||||
contentPadding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 4,
|
|
||||||
), //
|
|
||||||
// Переписываем ведущий виджет (аватарку)
|
|
||||||
leading: SizedBox(
|
|
||||||
width: 56, // Соответствует радиусу 28 * 2
|
|
||||||
height: 56,
|
|
||||||
child:
|
|
||||||
widget.contact.effectiveAvatarUrl !=
|
|
||||||
null //
|
|
||||||
? CachedNetworkImage(
|
|
||||||
imageUrl: widget.contact.effectiveAvatarUrl!, //
|
|
||||||
// Передаем токен для FastAPI, чтобы сервер разрешил скачивание файла
|
|
||||||
httpHeaders: {
|
|
||||||
if (token != null) 'Authorization': 'Bearer $token',
|
|
||||||
},
|
|
||||||
imageBuilder: (context, imageProvider) => Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
image: DecorationImage(
|
|
||||||
image: imageProvider,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Пока картинка качается — показываем цветной круг с инициалами
|
|
||||||
placeholder: (context, url) => CircleAvatar(
|
|
||||||
radius: 28,
|
|
||||||
backgroundColor: primary.withAlpha((0.1 * 255).round()),
|
|
||||||
child: Text(
|
|
||||||
initials,
|
|
||||||
style: TextStyle(
|
|
||||||
color: primary,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Ошибка 401, 404 или упал интернет? Без паники, плавно вернем инициалы
|
|
||||||
errorWidget: (context, url, error) => CircleAvatar(
|
|
||||||
radius: 28,
|
|
||||||
backgroundColor: primary.withAlpha((0.1 * 255).round()),
|
|
||||||
child: Text(
|
|
||||||
initials,
|
|
||||||
style: TextStyle(
|
|
||||||
color: primary,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: CircleAvatar(
|
|
||||||
radius: 28,
|
|
||||||
backgroundColor: primary.withAlpha((0.1 * 255).round()), //
|
|
||||||
child: Text(
|
|
||||||
initials,
|
|
||||||
style: TextStyle(color: primary, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
title: Text(
|
|
||||||
displayName, //
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), //
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
widget.contact.isLastMsgDecrypted
|
|
||||||
? widget.contact.lastMessage == null
|
|
||||||
? "Нет сообщений"
|
|
||||||
: "${widget.contact.lastMessageType != null ? MessageModel.getMediaPreview(widget.contact.lastMessageType!) : ''} ${widget.contact.lastMessage}"
|
|
||||||
: (widget.contact.lastMessage != null
|
|
||||||
? "Ожидание дешифровки..."
|
|
||||||
: "Нет сообщений"),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: Colors.grey), //
|
|
||||||
),
|
|
||||||
trailing: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_formatTime(widget.contact.lastMessageTime), //
|
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12), //
|
|
||||||
),
|
|
||||||
SizedBox(height: 4), //
|
|
||||||
if (widget.contact.unreadCount > 0) //
|
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.all(6), //
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: primary.withAlpha((0.5 * 255).round()), //
|
|
||||||
shape: BoxShape.circle, //
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'${widget.contact.unreadCount}', //
|
|
||||||
style: TextStyle(color: Colors.white, fontSize: 10), //
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatTime(DateTime? time) {
|
|
||||||
if (time == null) return "";
|
|
||||||
return "${time.hour}:${time.minute.toString().padLeft(2, '0')}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,7 +9,9 @@
|
||||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||||
#include <record_linux/record_linux_plugin.h>
|
#include <record_linux/record_linux_plugin.h>
|
||||||
|
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
|
@ -22,9 +24,15 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
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 =
|
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
||||||
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
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 =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
audioplayers_linux
|
audioplayers_linux
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
|
flutter_webrtc
|
||||||
record_linux
|
record_linux
|
||||||
|
sqlite3_flutter_libs
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import firebase_messaging
|
||||||
import flutter_image_compress_macos
|
import flutter_image_compress_macos
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import flutter_secure_storage_darwin
|
import flutter_secure_storage_darwin
|
||||||
|
import flutter_webrtc
|
||||||
import gal
|
import gal
|
||||||
import local_auth_darwin
|
import local_auth_darwin
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
|
|
@ -23,6 +24,7 @@ import photo_manager
|
||||||
import record_macos
|
import record_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
|
import sqlite3_flutter_libs
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
import video_compress
|
import video_compress
|
||||||
import video_player_avfoundation
|
import video_player_avfoundation
|
||||||
|
|
@ -38,6 +40,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
|
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||||
|
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||||
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
||||||
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
|
|
@ -46,6 +49,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
|
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin"))
|
VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin"))
|
||||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||||
|
|
|
||||||
344
pubspec.lock
344
pubspec.lock
|
|
@ -1,6 +1,14 @@
|
||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
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:
|
_flutterfire_internals:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -9,6 +17,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.35"
|
version: "1.3.35"
|
||||||
|
analyzer:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: analyzer
|
||||||
|
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.2.0"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -97,30 +113,54 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
cached_network_image:
|
build:
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: cached_network_image
|
|
||||||
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.4.1"
|
|
||||||
cached_network_image_platform_interface:
|
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: cached_network_image_platform_interface
|
name: build
|
||||||
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.1"
|
version: "4.1.1"
|
||||||
cached_network_image_web:
|
build_runner:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: cached_network_image_web
|
name: build_runner
|
||||||
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
camera:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -161,6 +201,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5+3"
|
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:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -169,6 +217,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
charcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: charcode
|
||||||
|
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -249,6 +305,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
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:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -273,6 +345,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
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:
|
extended_image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -462,14 +566,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.1"
|
version: "3.4.1"
|
||||||
flutter_http_cache:
|
flutter_callkit_incoming:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_http_cache
|
name: flutter_callkit_incoming
|
||||||
sha256: "2227f5694d730622d6dad580b0e4fdfec6b5884868148101d13c61a09661fa78"
|
sha256: "3589deb8b71e43f2d520a9c8a5240243f611062a8b246cdca4b1fda01fbbf9b8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.3"
|
version: "3.0.0"
|
||||||
flutter_image_compress:
|
flutter_image_compress:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -546,26 +650,34 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "17.2.4"
|
version: "21.0.0"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_linux
|
name: flutter_local_notifications_linux
|
||||||
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
|
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.1"
|
version: "8.0.0"
|
||||||
flutter_local_notifications_platform_interface:
|
flutter_local_notifications_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_platform_interface
|
name: flutter_local_notifications_platform_interface
|
||||||
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
|
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -632,6 +744,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
gal:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -640,6 +760,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
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:
|
html:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -664,6 +800,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
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:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -752,6 +896,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.20.2"
|
version: "0.20.2"
|
||||||
|
io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: io
|
||||||
|
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.5"
|
||||||
jni:
|
jni:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -872,6 +1024,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.11"
|
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:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -912,14 +1080,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
open_filex:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1104,6 +1264,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.9.1"
|
version: "3.9.1"
|
||||||
|
pool:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pool
|
||||||
|
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.2"
|
||||||
posix:
|
posix:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1120,6 +1288,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
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:
|
record:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1248,11 +1440,35 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1262,7 +1478,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.2"
|
||||||
sqflite:
|
sqflite:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite
|
name: sqflite
|
||||||
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
|
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
|
||||||
|
|
@ -1281,10 +1497,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_common
|
name: sqflite_common
|
||||||
sha256: "5e8377564d95166761a968ed96104e0569b6b6cc611faac92a36ab8a169112c3"
|
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
sqflite_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1301,6 +1525,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
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:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1361,10 +1609,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: timezone
|
name: timezone
|
||||||
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.9.4"
|
version: "0.11.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1525,6 +1773,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.1.0"
|
version: "15.1.0"
|
||||||
|
watcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: watcher
|
||||||
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1549,6 +1805,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
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:
|
wechat_assets_picker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1599,4 +1863,4 @@ packages:
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.0 <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.38.0"
|
flutter: ">=3.38.1"
|
||||||
|
|
|
||||||
23
pubspec.yaml
23
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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|
@ -41,11 +41,10 @@ dependencies:
|
||||||
jwt_decoder: ^2.0.1
|
jwt_decoder: ^2.0.1
|
||||||
web_socket_channel: ^3.0.3
|
web_socket_channel: ^3.0.3
|
||||||
cryptography: ^2.5.0
|
cryptography: ^2.5.0
|
||||||
sqflite: ^2.3.0
|
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
firebase_core: ^2.24.2
|
firebase_core: ^2.24.2
|
||||||
firebase_messaging: ^14.7.10
|
firebase_messaging: ^14.7.10
|
||||||
flutter_local_notifications: ^17.2.2
|
flutter_local_notifications: ^21.0.0
|
||||||
firebase_analytics: ^10.10.7
|
firebase_analytics: ^10.10.7
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
flutter_linkify: ^6.0.0
|
flutter_linkify: ^6.0.0
|
||||||
|
|
@ -56,13 +55,16 @@ dependencies:
|
||||||
package_info_plus: ^9.0.1
|
package_info_plus: ^9.0.1
|
||||||
open_filex: ^4.7.0
|
open_filex: ^4.7.0
|
||||||
convert: ^3.1.2
|
convert: ^3.1.2
|
||||||
cached_network_image: ^3.3.1
|
|
||||||
flutter_cache_manager: ^3.0.2
|
flutter_cache_manager: ^3.0.2
|
||||||
path_provider: ^2.1.3
|
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
|
file_picker: ^11.0.2
|
||||||
video_compress: ^3.1.0
|
video_compress: ^3.1.0
|
||||||
video_player: ^2.11.1
|
video_player: ^2.11.1
|
||||||
flutter_http_cache: ^0.0.3
|
|
||||||
image_picker: ^1.2.2
|
image_picker: ^1.2.2
|
||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
wechat_assets_picker: ^9.0.0
|
wechat_assets_picker: ^9.0.0
|
||||||
|
|
@ -74,10 +76,15 @@ dependencies:
|
||||||
record: ^6.2.0
|
record: ^6.2.0
|
||||||
audioplayers: ^6.6.0
|
audioplayers: ^6.6.0
|
||||||
ffmpeg_kit_flutter_new_min_gpl: ^2.1.1
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
build_runner: ^2.4.0
|
||||||
|
drift_dev: ^2.17.0
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
|
|
@ -90,6 +97,10 @@ dev_dependencies:
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: "launcher_icon"
|
android: "launcher_icon"
|
||||||
|
windows:
|
||||||
|
generate: true
|
||||||
|
image_path: "assets/images/icon.png"
|
||||||
|
icon_size: 48
|
||||||
ios: true
|
ios: true
|
||||||
image_path: "assets/images/icon.png"
|
image_path: "assets/images/icon.png"
|
||||||
remove_alpha_channel_ios: true
|
remove_alpha_channel_ios: true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from app.db import models
|
||||||
|
from app.api import schemas
|
||||||
|
# Используем вашу функцию хэширования из security.py
|
||||||
|
from app.core.security import get_current_user, get_password_hash
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
adminRouter = APIRouter(prefix="/admin", tags=["admin"], include_in_schema=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = models.SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Зависимость: пропускает только аккаунт с ID 1
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(current_user: models.User = Depends(get_current_user)):
|
||||||
|
if current_user.id != 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Доступ запрещен. Требуются права супер-администратора.")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@adminRouter.get("/users", response_model=List[schemas.AdminUserListItem])
|
||||||
|
async def get_all_users(db: Session = Depends(get_db), admin: models.User = Depends(require_admin)):
|
||||||
|
return db.query(models.User).all()
|
||||||
|
|
||||||
|
|
||||||
|
@adminRouter.post("/users")
|
||||||
|
async def admin_create_user(user_data: schemas.AdminCreateUser, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)):
|
||||||
|
existing = db.query(models.User).filter(
|
||||||
|
models.User.username == user_data.username).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Пользователь с таким именем уже существует")
|
||||||
|
|
||||||
|
if user_data.id is not None:
|
||||||
|
existing_id = db.query(models.User).filter(
|
||||||
|
models.User.id == user_data.id).first()
|
||||||
|
if existing_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Пользователь с таким ID уже существует")
|
||||||
|
|
||||||
|
hashed_pw = get_password_hash(user_data.password)
|
||||||
|
|
||||||
|
new_user = models.User(
|
||||||
|
username=user_data.username,
|
||||||
|
hashed_password=hashed_pw,
|
||||||
|
first_name=user_data.first_name,
|
||||||
|
last_name=user_data.last_name,
|
||||||
|
is_blocked=0
|
||||||
|
)
|
||||||
|
if user_data.id is not None:
|
||||||
|
new_user.id = user_data.id
|
||||||
|
db.add(new_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_user)
|
||||||
|
return {"status": "ok", "user_id": new_user.id}
|
||||||
|
|
||||||
|
|
||||||
|
@adminRouter.post("/users/{user_id}/block")
|
||||||
|
async def block_user(user_id: int, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)):
|
||||||
|
if user_id == 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Нельзя заблокировать главного администратора")
|
||||||
|
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
|
user.is_blocked = 1
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok", "message": f"Пользователь {user_id} заблокирован"}
|
||||||
|
|
||||||
|
|
||||||
|
@adminRouter.post("/users/{user_id}/unblock")
|
||||||
|
async def unblock_user(user_id: int, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)):
|
||||||
|
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
|
user.is_blocked = 0
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok", "message": f"Пользователь {user_id} разблокирован"}
|
||||||
|
|
||||||
|
|
||||||
|
@adminRouter.put("/users/{user_id}/profile")
|
||||||
|
async def admin_update_user_profile(user_id: int, profile_data: schemas.UpdateMe, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)):
|
||||||
|
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
|
|
||||||
|
if profile_data.username is not None and profile_data.username != '':
|
||||||
|
dup = db.query(models.User).filter(models.User.username ==
|
||||||
|
profile_data.username, models.User.id != user_id).first()
|
||||||
|
if dup:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Имя пользователя уже занято")
|
||||||
|
user.username = profile_data.username
|
||||||
|
|
||||||
|
if profile_data.first_name is not None and profile_data.last_name != '':
|
||||||
|
user.first_name = profile_data.first_name
|
||||||
|
if profile_data.last_name is not None and profile_data.last_name != '':
|
||||||
|
user.last_name = profile_data.last_name
|
||||||
|
if profile_data.phone is not None and profile_data.phone != '':
|
||||||
|
user.phone = profile_data.phone
|
||||||
|
if profile_data.email is not None and profile_data.email != '':
|
||||||
|
user.email = profile_data.email
|
||||||
|
if profile_data.about is not None and profile_data.about != '':
|
||||||
|
user.about = profile_data.about
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError as e:
|
||||||
|
db.rollback() # Обязательно откатываем транзакцию при ошибке
|
||||||
|
raise HTTPException(status_code=400, detail="Ошибка: такой телефон или email уже используется другим аккаунтом")
|
||||||
|
return {"status": "ok", "message": "Профиль успешно изменен администратором"}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from fastapi import FastAPI, Depends, HTTPException, status, APIRouter
|
from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, Form
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.core import security
|
from app.core import security
|
||||||
|
|
@ -11,6 +11,7 @@ import qrcode
|
||||||
import base64
|
import base64
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from typing import Optional
|
||||||
# бд
|
# бд
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -67,8 +68,9 @@ async def register(password: str):
|
||||||
|
|
||||||
@authRouter.post("/login")
|
@authRouter.post("/login")
|
||||||
async def login(data: schemas.LoginRequest, db: Session = Depends(get_db)):
|
async def login(data: schemas.LoginRequest, db: Session = Depends(get_db)):
|
||||||
print(f"Login attempt: username={data.username}, totp_code provided={bool(data.totp_code)}")
|
print(
|
||||||
|
f"Login attempt: username={data.username}, totp_code provided={bool(data.totp_code)}")
|
||||||
|
|
||||||
user = db.query(models.User).filter(
|
user = db.query(models.User).filter(
|
||||||
models.User.username == data.username).first()
|
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")
|
@authRouter.post("/totp/enable")
|
||||||
async def enable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
async def enable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
# Загружаем свежую копию user из БД
|
# Загружаем свежую копию 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:
|
if not user:
|
||||||
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
||||||
|
|
||||||
#if user.totp_secret:
|
# if user.totp_secret:
|
||||||
#raise HTTPException(status_code=400, detail="TOTP уже включен")
|
# raise HTTPException(status_code=400, detail="TOTP уже включен")
|
||||||
|
|
||||||
secret = pyotp.random_base32()
|
secret = pyotp.random_base32()
|
||||||
user.totp_temp_secret = secret
|
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")
|
@authRouter.post("/totp/verify")
|
||||||
async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
# Загружаем свежую копию user из БД
|
# Загружаем свежую копию 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:
|
if not user:
|
||||||
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
||||||
|
|
||||||
if not user.totp_temp_secret:
|
if not user.totp_temp_secret:
|
||||||
raise HTTPException(status_code=400, detail="TOTP не включен")
|
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)
|
totp = pyotp.TOTP(user.totp_temp_secret)
|
||||||
code_str = str(data.code).strip()
|
code_str = str(data.code).strip()
|
||||||
is_valid = totp.verify(code_str)
|
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:
|
if is_valid:
|
||||||
user.totp_secret = user.totp_temp_secret
|
user.totp_secret = user.totp_temp_secret
|
||||||
user.totp_temp_secret = None
|
user.totp_temp_secret = None
|
||||||
|
|
@ -151,12 +191,14 @@ async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"TOTP verify error: {str(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")
|
@authRouter.post("/totp/disable")
|
||||||
async def disable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
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:
|
if user:
|
||||||
user.totp_secret = None
|
user.totp_secret = None
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -1,584 +1,412 @@
|
||||||
import shutil
|
import io
|
||||||
from fastapi import Depends, FastAPI, HTTPException, status, APIRouter, File, UploadFile, Request, Form
|
import os
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
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.orm import Session
|
||||||
from sqlalchemy.sql import func
|
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.core.security import get_current_user
|
||||||
from app.db import models
|
from app.db import models
|
||||||
from app.core.config import config
|
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(
|
mediaRouter = APIRouter(
|
||||||
prefix='/media',
|
prefix='/media',
|
||||||
tags=['media'],
|
tags=['media'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Инициализация клиента Google Drive
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_ensure_directory(UPLOAD_FOLDER)
|
|
||||||
_ensure_directory(config.CLOUD_MEDIA_CACHE_FOLDER)
|
def _get_drive_service():
|
||||||
_ensure_directory(config.HOME_MEDIA_FOLDER)
|
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')
|
@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')
|
@mediaRouter.post('/v2/upload')
|
||||||
async def upload_file_v2(
|
async def upload_file(
|
||||||
request: Request,
|
file: UploadFile = File(...),
|
||||||
file: UploadFile = File(None),
|
|
||||||
purpose: str = Form('media'),
|
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
if config.SERVER_ROLE != 'cloud':
|
"""
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
Загружает файл на Google Drive в режиме стриминга без блокировки RAM.
|
||||||
detail='Upload endpoint is available only on cloud server')
|
"""
|
||||||
|
if not file.filename:
|
||||||
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')
|
raise HTTPException(status_code=400, detail='No selected file')
|
||||||
|
|
||||||
content = await uploaded_file.read()
|
max_upload_size = getattr(config, "MEDIA_UPLOAD_MAX_BYTES", 52428800)
|
||||||
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
|
file_size = file.size
|
||||||
|
|
||||||
|
if file_size and file_size > max_upload_size:
|
||||||
raise HTTPException(
|
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()
|
db = models.SessionLocal()
|
||||||
try:
|
try:
|
||||||
cache_size = _get_cloud_cache_size_bytes(db)
|
if file_size:
|
||||||
is_avatar = purpose == 'avatar'
|
_cleanup_google_drive_quota(db, current_user.id, file_size)
|
||||||
if cache_size >= config.CLOUD_CACHE_MAX_BYTES and not is_avatar:
|
|
||||||
raise HTTPException(
|
service = _get_drive_service()
|
||||||
status_code=503,
|
|
||||||
detail='Cloud media cache is full; new uploads are temporarily paused until pending files are forwarded.',
|
|
||||||
)
|
|
||||||
|
|
||||||
file_id = uuid.uuid4().hex
|
file_id = uuid.uuid4().hex
|
||||||
local_filename = f"{file_id}.enc"
|
file_metadata = {
|
||||||
storage_path = os.path.join(
|
'name': f"{file_id}.enc",
|
||||||
config.CLOUD_MEDIA_CACHE_FOLDER, local_filename)
|
'parents': [config.GOOGLE_DRIVE_FOLDER_ID]
|
||||||
with open(storage_path, 'wb') as f:
|
}
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
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,
|
file_id=file_id,
|
||||||
owner_id=current_user.id,
|
owner_id=current_user.id,
|
||||||
original_filename=uploaded_file.filename,
|
original_filename=file.filename,
|
||||||
content_type=uploaded_file.content_type or 'application/octet-stream',
|
content_type=file.content_type or 'application/octet-stream',
|
||||||
local_filename=local_filename,
|
storage_file_id=drive_id,
|
||||||
size_bytes=len(content),
|
size_bytes=final_size,
|
||||||
status='avatar' if is_avatar else 'pending',
|
|
||||||
is_avatar=1 if is_avatar else 0,
|
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(media_item)
|
||||||
db.commit()
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
await file.close()
|
||||||
|
|
||||||
return {'status': 'ok', 'file_id': file_id}
|
return {'status': 'ok', 'file_id': file_id}
|
||||||
|
|
||||||
|
|
||||||
@mediaRouter.post('/receive')
|
# ---------------------------------------------------------------------------
|
||||||
async def receive_media(
|
# Эндпоинты: Получение метаданных и скачивание (Size / Download)
|
||||||
request: Request,
|
# ---------------------------------------------------------------------------
|
||||||
file: UploadFile = File(None),
|
|
||||||
owner_id: int | None = Form(None),
|
|
||||||
cloud_file_id: str | None = Form(None),
|
|
||||||
original_filename: str | None = Form(None),
|
|
||||||
):
|
|
||||||
secret = request.headers.get('X-Media-Forwarding-Secret')
|
|
||||||
if secret != config.MEDIA_FORWARDING_SECRET:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid forwarding secret')
|
|
||||||
|
|
||||||
uploaded_file = await _get_upload_file(request, file)
|
|
||||||
if uploaded_file is None or not uploaded_file.filename:
|
|
||||||
raise HTTPException(status_code=400, detail='No selected file')
|
|
||||||
|
|
||||||
content = await uploaded_file.read()
|
|
||||||
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
|
|
||||||
|
|
||||||
file_id = cloud_file_id or uuid.uuid4().hex
|
|
||||||
storage_filename = f"{file_id}.enc"
|
|
||||||
file_path = os.path.join(config.HOME_MEDIA_FOLDER, storage_filename)
|
|
||||||
with open(file_path, 'wb') as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
db = models.SessionLocal()
|
|
||||||
try:
|
|
||||||
home_record = models.HomeMediaFile(
|
|
||||||
file_id=file_id,
|
|
||||||
owner_id=owner_id,
|
|
||||||
original_filename=original_filename or uploaded_file.filename,
|
|
||||||
content_type=uploaded_file.content_type or 'application/octet-stream',
|
|
||||||
storage_filename=storage_filename,
|
|
||||||
size_bytes=len(content),
|
|
||||||
)
|
|
||||||
db.add(home_record)
|
|
||||||
db.commit()
|
|
||||||
_cleanup_home_quota(db, owner_id)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return {'status': 'ok', 'file_id': file_id}
|
|
||||||
|
|
||||||
|
|
||||||
@mediaRouter.get('/size/{file_id}')
|
@mediaRouter.get('/size/{file_id}')
|
||||||
async def get_file_size(file_id: str):
|
async def get_file_size(file_id: str):
|
||||||
|
"""
|
||||||
|
Возвращает информацию о размере и типе файла из таблицы media_items.
|
||||||
|
"""
|
||||||
db = models.SessionLocal()
|
db = models.SessionLocal()
|
||||||
db_file = None
|
db_file = None
|
||||||
try:
|
try:
|
||||||
db_file = db.query(models.HomeMediaFile).filter(
|
db_file = db.query(models.MediaItem).filter(
|
||||||
models.HomeMediaFile.file_id == file_id).first()
|
models.MediaItem.file_id == file_id
|
||||||
|
).first()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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 not db_file:
|
||||||
if config.SERVER_ROLE == 'cloud':
|
raise HTTPException(status_code=404, detail='File not found')
|
||||||
remote_url = f"{config.HOME_SERVER_URL}/media/size/{file_id}"
|
|
||||||
try:
|
|
||||||
# Выполняем синхронный легковесный подзапрос к домашнему серверу в треде,
|
|
||||||
# чтобы не блокировать асинхронный цикл FastAPI (по аналогии с деплоем стримов)
|
|
||||||
def _fetch_remote_size():
|
|
||||||
req = urllib.request.Request(remote_url, method='GET')
|
|
||||||
with urllib.request.urlopen(req, timeout=5.0) as response:
|
|
||||||
if response.status == 200:
|
|
||||||
import json
|
|
||||||
return json.loads(response.read().decode('utf-8'))
|
|
||||||
return None
|
|
||||||
|
|
||||||
remote_data = await asyncio.to_thread(_fetch_remote_size)
|
encoded_filename = urllib.parse.quote(db_file.original_filename)
|
||||||
if remote_data:
|
return {
|
||||||
return remote_data
|
"file_id": file_id,
|
||||||
|
"size": db_file.size_bytes,
|
||||||
except urllib.error.HTTPError as e:
|
"file_name": encoded_filename,
|
||||||
if e.code == 404:
|
"content_type": db_file.content_type
|
||||||
raise HTTPException(
|
}
|
||||||
status_code=404, detail='File not found on home server')
|
|
||||||
raise HTTPException(status_code=e.code, detail='Home server error')
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Ошибка подключения к домашнему серверу: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502, detail='Home server is unavailable')
|
|
||||||
|
|
||||||
# 3. Если файл не найден ни локально, ни на удаленном сервере
|
|
||||||
raise HTTPException(status_code=404, detail='File not found')
|
|
||||||
|
|
||||||
|
|
||||||
@mediaRouter.get('/{file_id}')
|
@mediaRouter.get('/{file_id}')
|
||||||
async def get_file(file_id: str):
|
async def get_file(file_id: str):
|
||||||
db = models.SessionLocal()
|
db = models.SessionLocal()
|
||||||
db_file = None
|
|
||||||
try:
|
try:
|
||||||
db_file = db.query(models.HomeMediaFile).filter(
|
db_file = db.query(models.MediaItem).filter(
|
||||||
models.HomeMediaFile.file_id == file_id).first()
|
models.MediaItem.file_id == file_id
|
||||||
|
).first()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
local_path = _find_local_media_path(file_id)
|
|
||||||
if local_path:
|
if not db_file:
|
||||||
filename = db_file.original_filename if db_file else f"file_{file_id}"
|
raise HTTPException(status_code=404, detail='File not found')
|
||||||
content_type = db_file.content_type if db_file else 'application/octet-stream'
|
|
||||||
encoded_filename = urllib.parse.quote(filename)
|
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 = {
|
headers = {
|
||||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||||
}
|
}
|
||||||
|
|
||||||
return FileResponse(
|
# StreamingResponse отлично работает с async генераторами
|
||||||
local_path,
|
return StreamingResponse(
|
||||||
media_type=content_type,
|
_async_stream_drive_file(),
|
||||||
|
media_type=db_file.content_type,
|
||||||
headers=headers
|
headers=headers
|
||||||
)
|
)
|
||||||
|
|
||||||
if config.SERVER_ROLE == 'cloud':
|
except Exception as e:
|
||||||
return _stream_response_from_remote(f"{config.HOME_SERVER_URL}/media/{file_id}")
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
raise HTTPException(status_code=404, detail='File not found')
|
detail=f"Error fetching file from Google Drive: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
@mediaRouter.post('/copy_internal')
|
|
||||||
async def copy_file_internal(
|
|
||||||
request: Request,
|
|
||||||
file_id: str = Form(...),
|
|
||||||
owner_id: int = Form(...), # ID нового владельца (получателя)
|
|
||||||
):
|
|
||||||
# Проверка секрета
|
|
||||||
secret = request.headers.get('X-Media-Forwarding-Secret')
|
|
||||||
if secret != config.MEDIA_FORWARDING_SECRET:
|
|
||||||
raise HTTPException(status_code=401, detail='Unauthorized')
|
|
||||||
|
|
||||||
# 1. Находим файл
|
|
||||||
source_path = _find_local_media_path(file_id)
|
|
||||||
if not source_path:
|
|
||||||
raise HTTPException(status_code=404, detail='Source file not found')
|
|
||||||
|
|
||||||
# 2. Создаем новый ID и путь
|
|
||||||
new_file_id = uuid.uuid4().hex
|
|
||||||
new_storage_filename = f"{new_file_id}.enc"
|
|
||||||
dest_path = os.path.join(config.HOME_MEDIA_FOLDER, new_storage_filename)
|
|
||||||
|
|
||||||
# 3. Физическое копирование
|
|
||||||
shutil.copyfile(source_path, dest_path)
|
|
||||||
|
|
||||||
# 4. Обновляем БД
|
|
||||||
db = models.SessionLocal()
|
|
||||||
try:
|
|
||||||
old_record = db.query(models.HomeMediaFile).filter(
|
|
||||||
models.HomeMediaFile.file_id == file_id).first()
|
|
||||||
new_record = models.HomeMediaFile(
|
|
||||||
file_id=new_file_id,
|
|
||||||
owner_id=owner_id,
|
|
||||||
original_filename=old_record.original_filename if old_record else "copy.enc",
|
|
||||||
content_type=old_record.content_type if old_record else 'application/octet-stream',
|
|
||||||
storage_filename=new_storage_filename,
|
|
||||||
size_bytes=os.path.getsize(dest_path),
|
|
||||||
)
|
)
|
||||||
db.add(new_record)
|
|
||||||
db.commit()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return {"status": "ok", "new_file_id": new_file_id}
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Эндпоинты: Копирование файлов (Copy)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
@mediaRouter.post('/copy')
|
@mediaRouter.post('/copy')
|
||||||
async def copy(
|
async def copy(
|
||||||
file_id: str = Form(...),
|
file_id: str = Form(...),
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
if config.SERVER_ROLE != 'cloud':
|
"""
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
Копирует файл внутри Google Drive новому пользователю с валидацией его личной квоты.
|
||||||
detail='Upload endpoint is available only on cloud server')
|
"""
|
||||||
|
db = models.SessionLocal()
|
||||||
# Делаем запрос к домашнему серверу
|
old_record = None
|
||||||
url = f"{config.HOME_SERVER_URL}/media/copy_internal"
|
|
||||||
|
|
||||||
# Используем FormData для передачи параметров на домашний сервер
|
|
||||||
body_data = f"file_id={file_id}&owner_id={current_user.id}".encode('utf-8')
|
|
||||||
request = urllib.request.Request(
|
|
||||||
url,
|
|
||||||
data=body_data,
|
|
||||||
headers={
|
|
||||||
'X-Media-Forwarding-Secret': config.MEDIA_FORWARDING_SECRET,
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
|
||||||
},
|
|
||||||
method='POST'
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=10) as response:
|
old_record = db.query(models.MediaItem).filter(
|
||||||
if response.status == 200:
|
models.MediaItem.file_id == file_id
|
||||||
import json
|
).first()
|
||||||
return json.loads(response.read().decode('utf-8'))
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=502, detail=f'Failed to copy on home server: {e}')
|
status_code=500, detail=f"Copy operation failed: {str(e)}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
raise HTTPException(status_code=500, detail='Copying failed')
|
return {"status": "ok", "new_file_id": new_file_id}
|
||||||
|
|
|
||||||
|
|
@ -22,28 +22,50 @@ def get_db():
|
||||||
db.close()
|
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):
|
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):
|
cloud_items = db.query(models.MediaItem).filter(
|
||||||
try:
|
models.MediaItem.file_id == file_id,
|
||||||
os.remove(upload_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
cloud_item = db.query(models.CloudMediaItem).filter(
|
|
||||||
models.CloudMediaItem.file_id == file_id,
|
|
||||||
).all()
|
).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(
|
usersRouter = APIRouter(
|
||||||
prefix="/users",
|
prefix="/users",
|
||||||
|
|
@ -207,7 +229,6 @@ async def get_privacy_settings(current_user: models.User = Depends(get_current_u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@usersRouter.get("/all")
|
@usersRouter.get("/all")
|
||||||
async def read_users_all(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
async def read_users_all(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
users = db.query(models.User).all()
|
users = db.query(models.User).all()
|
||||||
|
|
@ -299,14 +320,14 @@ async def read_users_chats(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@usersRouter.get("/by-username/{username}", response_model=schemas.UserContactResponse)
|
@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)):
|
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:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
|
|
||||||
profile_data = {
|
profile_data = {
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
"public_key": user.public_key,
|
"public_key": user.public_key,
|
||||||
|
|
|
||||||
|
|
@ -80,4 +80,24 @@ class UserContactResponse(BaseModel):
|
||||||
public_key: Optional[str] = None
|
public_key: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
class AdminUserListItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
first_name: str
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
is_blocked: int
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
about: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class AdminCreateUser(BaseModel):
|
||||||
|
id: Optional[int] = None
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
first_name: str
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
|
@ -15,18 +15,24 @@ class Config:
|
||||||
# Firebase
|
# Firebase
|
||||||
FIREBASE_CREDENTIALS_PATH: str = os.getenv("FIREBASE_CREDENTIALS_PATH", "chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json")
|
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
|
# Server
|
||||||
HOST: str = os.getenv("HOST", "0.0.0.0")
|
HOST: str = os.getenv("HOST", "0.0.0.0")
|
||||||
PORT: int = int(os.getenv("PORT", "8000"))
|
PORT: int = int(os.getenv("PORT", "8587"))
|
||||||
SERVER_ROLE: str = os.getenv("SERVER_ROLE", "cloud").lower()
|
|
||||||
HOME_SERVER_URL: str = os.getenv("HOME_SERVER_URL", "http://home-server.local:8000")
|
# Media Storage & Quotas (Google Drive)
|
||||||
MEDIA_FORWARDING_SECRET: str = os.getenv("MEDIA_FORWARDING_SECRET", "changeme")
|
# 10 ГБ лимита на пользователя (при превышении удаляются старые медиафайлы)
|
||||||
CLOUD_MEDIA_CACHE_FOLDER: str = os.getenv("CLOUD_MEDIA_CACHE_FOLDER", "cloud_media_cache")
|
|
||||||
HOME_MEDIA_FOLDER: str = os.getenv("HOME_MEDIA_FOLDER", "home_media_store")
|
|
||||||
CLOUD_CACHE_MAX_BYTES: int = int(os.getenv("CLOUD_CACHE_MAX_BYTES", str(5 * 1024 * 1024 * 1024)))
|
|
||||||
HOME_USER_QUOTA_BYTES: int = int(os.getenv("HOME_USER_QUOTA_BYTES", str(10 * 1024 * 1024 * 1024)))
|
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)))
|
# Максимальный размер одного загружаемого файла (500 МБ)
|
||||||
MEDIA_FORWARD_INTERVAL_SECONDS: int = int(os.getenv("MEDIA_FORWARD_INTERVAL_SECONDS", "12"))
|
MEDIA_UPLOAD_MAX_BYTES: int = int(os.getenv("MEDIA_UPLOAD_MAX_BYTES", str(500 * 1024 * 1024)))
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
|
ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")
|
||||||
|
|
|
||||||
|
|
@ -15,34 +15,45 @@ if not SECRET_KEY:
|
||||||
raise RuntimeError("JWT_KEY environment variable not set")
|
raise RuntimeError("JWT_KEY environment variable not set")
|
||||||
SECRET_KEY = SECRET_KEY.strip()
|
SECRET_KEY = SECRET_KEY.strip()
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60
|
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():
|
def get_db():
|
||||||
db = models.SessionLocal()
|
db = models.SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password, hashed_password):
|
def verify_password(plain_password, hashed_password):
|
||||||
try:
|
try:
|
||||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password)
|
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password):
|
def get_password_hash(password):
|
||||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
|
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict):
|
def create_access_token(data: dict):
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(data: dict):
|
def create_refresh_token(data: dict):
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.utcnow() + timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES)
|
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)
|
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
# проверка токена
|
# проверка токена
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
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
|
raise credentials_exception
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
|
||||||
user_id = int(id)
|
user_id = int(id)
|
||||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
if getattr(user, "is_blocked", 0) == 1:
|
||||||
|
raise HTTPException(status_code=403, detail="Ваш аккаунт заблокирован")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def test_token(token: str):
|
async def test_token(token: str):
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|
@ -81,4 +97,4 @@ async def test_token(token: str):
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
return id
|
return id
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
from sqlalchemy import Column, Integer, String, Sequence, create_engine
|
from sqlalchemy import Column, Integer, String, Sequence, create_engine
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
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.sql import func
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from app.core.config import config
|
from app.core.config import config
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URL = config.DATABASE_URL
|
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)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__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")
|
first_name = Column(String(50), nullable=False, server_default="User")
|
||||||
last_name = Column(String(50), nullable=True)
|
last_name = Column(String(50), nullable=True)
|
||||||
username = Column(String, unique=True, index=True)
|
username = Column(String, unique=True, index=True)
|
||||||
|
|
@ -21,22 +24,27 @@ class User(Base):
|
||||||
phone = Column(String(20), unique=True, nullable=True)
|
phone = Column(String(20), unique=True, nullable=True)
|
||||||
email = Column(String(255), unique=True, nullable=True)
|
email = Column(String(255), unique=True, nullable=True)
|
||||||
totp_secret = Column(String(32), 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)
|
hashed_password = Column(String)
|
||||||
public_key = Column(String, nullable=True)
|
public_key = Column(String, nullable=True)
|
||||||
encrypted_private_key = Column(String, nullable=True)
|
encrypted_private_key = Column(String, nullable=True)
|
||||||
fcm_token = Column(String, nullable=True)
|
fcm_token = Column(String, nullable=True)
|
||||||
avatar_file_id = Column(String, nullable=True)
|
avatar_file_id = Column(String, nullable=True)
|
||||||
|
|
||||||
# Privacy settings
|
# 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_phone = Column(Integer, nullable=False, server_default="1")
|
||||||
show_avatar = 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_about = Column(Integer, nullable=False, server_default="1")
|
||||||
show_username = 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")
|
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):
|
class Message(Base):
|
||||||
__tablename__ = "messages"
|
__tablename__ = "messages"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
@ -53,34 +61,30 @@ class Message(Base):
|
||||||
file_id = Column(String, nullable=True)
|
file_id = Column(String, nullable=True)
|
||||||
encrypted_key = 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):
|
class MediaItem(Base):
|
||||||
__tablename__ = "home_media_files"
|
__tablename__ = "media_items"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
file_id = Column(String, unique=True, nullable=False, index=True)
|
# Уникальный внутренний UUID файла (используется в эндпоинтах API)
|
||||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
file_id = Column(String(32), unique=True, nullable=False, index=True)
|
||||||
original_filename = Column(String, nullable=True)
|
# ID владельца из таблицы пользователей
|
||||||
content_type = Column(String, nullable=True)
|
owner_id = Column(Integer, ForeignKey(
|
||||||
storage_filename = Column(String, nullable=False)
|
"users.id"), nullable=True, index=True)
|
||||||
size_bytes = Column(Integer, nullable=False)
|
# Оригинальное имя файла (например, "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())
|
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)
|
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
|
existing = {row[1] for row in cols} # row[1] = name
|
||||||
|
|
||||||
if "delivered_at" not in existing:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,28 +134,43 @@ def _ensure_sqlite_user_columns():
|
||||||
if "about" not in existing:
|
if "about" not in existing:
|
||||||
conn.execute(text("ALTER TABLE users ADD COLUMN about TEXT"))
|
conn.execute(text("ALTER TABLE users ADD COLUMN about TEXT"))
|
||||||
if "phone" not in existing:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
if "last_online" not in existing:
|
||||||
conn.execute(text("ALTER TABLE users ADD COLUMN last_online DATETIME"))
|
conn.execute(
|
||||||
conn.execute(text("UPDATE users SET last_online = datetime('now')"))
|
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:
|
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:
|
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()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from app.db import models
|
||||||
from firebase_admin import messaging, credentials, exceptions
|
from firebase_admin import messaging, credentials, exceptions
|
||||||
import firebase_admin
|
import firebase_admin
|
||||||
from app.core.config import config
|
from app.core.config import config
|
||||||
|
import uuid
|
||||||
|
|
||||||
cred = credentials.Certificate(config.FIREBASE_CREDENTIALS_PATH)
|
cred = credentials.Certificate(config.FIREBASE_CREDENTIALS_PATH)
|
||||||
firebase_admin.initialize_app(cred)
|
firebase_admin.initialize_app(cred)
|
||||||
|
|
@ -53,11 +54,12 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
||||||
while True:
|
while True:
|
||||||
print("ОЖИДАНИЕ СООБЩЕНИЙ")
|
print("ОЖИДАНИЕ СООБЩЕНИЙ")
|
||||||
data = await websocket.receive_text()
|
data = await websocket.receive_text()
|
||||||
|
|
||||||
message_data = json.loads(data)
|
message_data = json.loads(data)
|
||||||
print(f"DEBUG: Получены данные: {message_data}")
|
print(f"DEBUG: Получены данные: {message_data}")
|
||||||
|
|
||||||
db.query(models.User).filter(models.User.id == user_id).update({"last_online": datetime.now(timezone.utc)},
|
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()
|
db.commit()
|
||||||
|
|
||||||
if message_data.get("type") == "private_message":
|
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))
|
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.
|
# Если сообщение реально ушло по сокету получателю — отмечаем delivered_at.
|
||||||
if sent_to_receiver:
|
if sent_to_receiver:
|
||||||
|
|
@ -302,6 +305,32 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
||||||
"type": "stop_typing",
|
"type": "stop_typing",
|
||||||
"sender_id": user_id,
|
"sender_id": user_id,
|
||||||
}, str(receiver_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:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
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")
|
{"last_online": datetime.now(timezone.utc)}, synchronize_session="fetch")
|
||||||
db.commit()
|
db.commit()
|
||||||
print("ОТКЛЮЧЕНИЕ")
|
print("ОТКЛЮЧЕНИЕ")
|
||||||
|
|
||||||
await manager.broadcast({
|
await manager.broadcast({
|
||||||
"type": "user_offline",
|
"type": "user_offline",
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
|
|
@ -0,0 +1,17 @@
|
||||||
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
|
|
||||||
|
SCOPES = ['https://www.googleapis.com/auth/drive']
|
||||||
|
|
||||||
|
flow = InstalledAppFlow.from_client_secrets_file(
|
||||||
|
'client_secret_338589490139-9ocvlhs270l5hqj3sdrru14ampiacv0s.apps.googleusercontent.com.json',
|
||||||
|
SCOPES
|
||||||
|
)
|
||||||
|
|
||||||
|
creds = flow.run_local_server(port=8000)
|
||||||
|
|
||||||
|
print("REFRESH TOKEN:")
|
||||||
|
print(creds.refresh_token)
|
||||||
|
print("CLIENT ID:")
|
||||||
|
print(creds.client_id)
|
||||||
|
print("CLIENT SECRET:")
|
||||||
|
print(creds.client_secret)
|
||||||
38
srv/main.py
38
srv/main.py
|
|
@ -1,6 +1,6 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import FileResponse
|
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 app.websocket.connection_manager import wsRouter
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
import os
|
import os
|
||||||
|
|
@ -15,6 +15,7 @@ app.include_router(users.usersRouter)
|
||||||
app.include_router(messages.messagesRouter)
|
app.include_router(messages.messagesRouter)
|
||||||
app.include_router(media.mediaRouter)
|
app.include_router(media.mediaRouter)
|
||||||
app.include_router(wsRouter)
|
app.include_router(wsRouter)
|
||||||
|
app.include_router(admin.adminRouter)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|
@ -28,7 +29,7 @@ app.add_middleware(
|
||||||
@app.get("/check-update")
|
@app.get("/check-update")
|
||||||
async def check_update():
|
async def check_update():
|
||||||
return {
|
return {
|
||||||
"latest_version": "2.0.1",
|
"latest_version": "2.0.2",
|
||||||
"apk_url": "https://api.chepuhagram.ru/get-update",
|
"apk_url": "https://api.chepuhagram.ru/get-update",
|
||||||
"force_update": False
|
"force_update": False
|
||||||
}
|
}
|
||||||
|
|
@ -53,39 +54,6 @@ async def head_image():
|
||||||
return FileResponse(path=file_path, filename="chepuhagram-release.apk",
|
return FileResponse(path=file_path, filename="chepuhagram-release.apk",
|
||||||
media_type="application/vnd.android.package-archive",)
|
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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8587)
|
uvicorn.run(app, host="0.0.0.0", port=8587)
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 MiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
|
|
@ -7,24 +7,31 @@
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||||
|
#include <camera_windows/camera_windows.h>
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||||
#include <gal/gal_plugin_c_api.h>
|
#include <gal/gal_plugin_c_api.h>
|
||||||
#include <local_auth_windows/local_auth_plugin.h>
|
#include <local_auth_windows/local_auth_plugin.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
#include <record_windows/record_windows_plugin_c_api.h>
|
#include <record_windows/record_windows_plugin_c_api.h>
|
||||||
|
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
AudioplayersWindowsPluginRegisterWithRegistrar(
|
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
|
||||||
|
CameraWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("CameraWindows"));
|
||||||
FileSelectorWindowsRegisterWithRegistrar(
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
FlutterWebRTCPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
|
||||||
GalPluginCApiRegisterWithRegistrar(
|
GalPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("GalPluginCApi"));
|
registry->GetRegistrarForPlugin("GalPluginCApi"));
|
||||||
LocalAuthPluginRegisterWithRegistrar(
|
LocalAuthPluginRegisterWithRegistrar(
|
||||||
|
|
@ -33,6 +40,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||||
|
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,21 @@
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
audioplayers_windows
|
audioplayers_windows
|
||||||
|
camera_windows
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
firebase_core
|
firebase_core
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
flutter_webrtc
|
||||||
gal
|
gal
|
||||||
local_auth_windows
|
local_auth_windows
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
record_windows
|
record_windows
|
||||||
|
sqlite3_flutter_libs
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
flutter_local_notifications_windows
|
||||||
jni
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,13 +89,13 @@ BEGIN
|
||||||
BEGIN
|
BEGIN
|
||||||
BLOCK "040904e4"
|
BLOCK "040904e4"
|
||||||
BEGIN
|
BEGIN
|
||||||
VALUE "CompanyName", "com.example" "\0"
|
VALUE "CompanyName", "ArturKarasevich" "\0"
|
||||||
VALUE "FileDescription", "chepuhagram" "\0"
|
VALUE "FileDescription", "Chepuhagram" "\0"
|
||||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||||
VALUE "InternalName", "chepuhagram" "\0"
|
VALUE "InternalName", "Chepuhagram" "\0"
|
||||||
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
|
VALUE "LegalCopyright", "Copyright (C) 2026 ArturKarasevich. All rights reserved." "\0"
|
||||||
VALUE "OriginalFilename", "chepuhagram.exe" "\0"
|
VALUE "OriginalFilename", "Chepuhagram.exe" "\0"
|
||||||
VALUE "ProductName", "chepuhagram" "\0"
|
VALUE "ProductName", "Chepuhagram" "\0"
|
||||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||||
FlutterWindow window(project);
|
FlutterWindow window(project);
|
||||||
Win32Window::Point origin(10, 10);
|
Win32Window::Point origin(10, 10);
|
||||||
Win32Window::Size size(1280, 720);
|
Win32Window::Size size(1280, 720);
|
||||||
if (!window.Create(L"chepuhagram", origin, size)) {
|
if (!window.Create(L"Chepuhagram", origin, size)) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
window.SetQuitOnClose(true);
|
window.SetQuitOnClose(true);
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 3.0 KiB |
Loading…
Reference in New Issue