Обновление интерфейса

This commit is contained in:
Artur 2026-06-03 23:33:32 +05:00
parent 966b1a6b84
commit 11340bdca1
55 changed files with 8004 additions and 3913 deletions

2
.gitignore vendored
View File

@ -16,6 +16,8 @@ migrate_working_dir/
venv/
.venv/
chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json
chepuhagram-497610-47c286108afd.json
client_secret_338589490139-9ocvlhs270l5hqj3sdrru14ampiacv0s.apps.googleusercontent.com.json
.firebaserc
firebase-tools-instant-win.exe
google-services.json

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"cmake.sourceDirectory": "D:/FlutterProjects/chepuhagram/linux",
"chat.tools.terminal.autoApprove": {
"flutter": true
}
}

View File

@ -48,7 +48,7 @@ flutter {
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation(platform("com.google.firebase:firebase-bom:34.12.0"))
implementation("com.google.firebase:firebase-messaging")
}

View File

@ -1,220 +1,243 @@
import 'package:sqflite/sqflite.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'dart:io';
import 'package:chepuhagram/data/models/message_model.dart';
import 'package:drift/drift.dart';
import 'package:drift_sqflite/drift_sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
class LocalDbService {
static final LocalDbService _instance = LocalDbService._internal();
static Database? _database;
part 'local_db_service.g.dart';
factory LocalDbService() => _instance;
LocalDbService._internal();
class Messages extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get senderId => integer().named('sender_id')();
IntColumn get receiverId => integer().named('receiver_id')();
TextColumn get content => text().named('content')();
TextColumn get timestamp => text().named('timestamp')();
TextColumn get deliveredAt => text().named('delivered_at').nullable()();
TextColumn get readAt => text().named('read_at').nullable()();
IntColumn get replyToId => integer().named('reply_to_id').nullable()();
TextColumn get replyToText => text().named('reply_to_text').nullable()();
TextColumn get editedAt => text().named('edited_at').nullable()();
TextColumn get messageType =>
text().named('message_type').withDefault(const Constant('text'))();
TextColumn get fileId => text().named('file_id').nullable()();
TextColumn get encryptedKey => text().named('encrypted_key').nullable()();
TextColumn get fileName => text().named('file_name').nullable()();
IntColumn get fileSize => integer().named('file_size').nullable()();
}
static const int _dbVersion = 8;
class FileNameMappings extends Table {
TextColumn get fileId => text().named('file_id')();
TextColumn get originalFileName => text().named('original_file_name')();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDb();
return _database!;
@override
Set<Column> get primaryKey => {fileId};
}
@DriftDatabase(tables: [Messages, FileNameMappings])
class LocalDbService extends _$LocalDbService {
factory LocalDbService() => instance;
LocalDbService._internal() : super(_openConnection()) {
print('LocalDbService constructor called');
}
Future<void> _createMessagesTable(Database db) async {
await db.execute('''
CREATE TABLE messages(
id INTEGER PRIMARY KEY,
sender_id INTEGER,
receiver_id INTEGER,
content TEXT,
timestamp TEXT,
delivered_at TEXT,
read_at TEXT,
reply_to_id INTEGER,
reply_to_text TEXT,
edited_at TEXT,
message_type TEXT DEFAULT 'text',
file_id TEXT,
encrypted_key TEXT,
file_name TEXT,
file_size INTEGER
)
''');
}
static final LocalDbService instance = LocalDbService._internal();
Future<Database> _initDb() async {
String path = join(await getDatabasesPath(), 'chat_app.db');
return await openDatabase(
path,
version: _dbVersion,
onCreate: (db, version) async {
await _createMessagesTable(db);
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 8) {
// v8: stop storing media bytes in SQLite; rebuild messages table.
await db.execute('DROP TABLE IF EXISTS messages');
await _createMessagesTable(db);
return;
}
if (oldVersion < 2) {
await db.execute('ALTER TABLE messages ADD COLUMN delivered_at TEXT');
await db.execute('ALTER TABLE messages ADD COLUMN read_at TEXT');
}
if (oldVersion < 3) {
await db.execute(
'ALTER TABLE messages ADD COLUMN reply_to_id INTEGER',
);
await db.execute(
'ALTER TABLE messages ADD COLUMN reply_to_text TEXT',
);
}
if (oldVersion < 4) {
await db.execute('ALTER TABLE messages ADD COLUMN edited_at TEXT');
}
if (oldVersion < 5) {
try {
await db.execute(
'ALTER TABLE messages ADD COLUMN message_type TEXT',
);
} catch (e) {
print('message_type column already exists: $e');
}
try {
await db.execute('ALTER TABLE messages ADD COLUMN file_id TEXT');
} catch (e) {
print('file_id column already exists: $e');
}
}
if (oldVersion < 6) {
try {
await db.execute(
'ALTER TABLE messages ADD COLUMN encrypted_key TEXT',
);
} catch (e) {
print('encrypted_key column already exists: $e');
}
}
// old migrations kept for safety, but v8 rebuild returns early.
},
);
}
@override
int get schemaVersion => 9;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
if (from < 8) {
await m.deleteTable('messages');
await m.createAll();
return;
}
if (from < 9) {
await m.createTable(fileNameMappings);
}
},
);
Future<void> clearDatabase() async {
final db = await database;
await db.delete('messages');
await delete(messages).go();
}
Future<void> saveMessages(List<dynamic> messages) async {
final db = await database;
final List<int> incomingIds = messages.map((msg) {
return (msg is MessageModel) ? msg.id! : (msg['id'] as int);
Future<void> saveMessages(List<dynamic> messageList) async {
if (messageList.isEmpty) return;
final List<int> incomingIds = messageList
.map<int?>(
(msg) => (msg is MessageModel)
? msg.id
: (msg['id'] == null ? null : int.tryParse(msg['id'].toString())),
)
.whereType<int>()
.toList();
// Преобразуем входящие данные в компаньоны заранее
final companions = messageList.map<MessagesCompanion>((msg) {
final int? id;
final int senderId;
final int receiverId;
final String content;
final String timestamp;
final int? replyToId;
final String? replyToText;
final String? editedAt;
final String messageType;
final String? fileId;
final String? encryptedKey;
final String? fileName;
final int? fileSize;
if (msg is MessageModel) {
id = msg.id;
senderId = msg.senderId;
receiverId = msg.receiverId;
content = msg.text;
timestamp = msg.createdAt.toIso8601String();
replyToId = msg.replyToId;
replyToText = msg.replyToText;
editedAt = msg.editedAt?.toIso8601String();
messageType = msg.messageType.name;
fileId = msg.fileId;
encryptedKey = msg.encryptedFileKey;
fileName = msg.fileName;
fileSize = msg.fileSize;
} else {
id = msg['id'] == null ? null : int.tryParse(msg['id'].toString());
senderId = int.parse(msg['sender_id'].toString());
receiverId = int.parse(msg['receiver_id'].toString());
content = msg['content']?.toString() ?? '';
timestamp =
msg['timestamp']?.toString() ?? DateTime.now().toIso8601String();
replyToId = msg['reply_to_id'] == null
? null
: int.tryParse(msg['reply_to_id'].toString());
replyToText = msg['reply_to_text']?.toString();
editedAt = msg['edited_at']?.toString();
messageType = msg['message_type']?.toString() ?? 'text';
fileId = msg['file_id']?.toString();
encryptedKey = msg['encrypted_key']?.toString();
fileName = msg['file_name']?.toString();
fileSize = msg['file_size'] == null
? null
: int.tryParse(msg['file_size'].toString());
}
return MessagesCompanion(
id: id == null ? const Value.absent() : Value(id),
senderId: Value(senderId),
receiverId: Value(receiverId),
content: Value(content),
timestamp: Value(timestamp),
deliveredAt: const Value(null),
readAt: const Value(null),
replyToId: Value(replyToId),
replyToText: Value(replyToText),
editedAt: Value(editedAt),
messageType: Value(messageType),
fileId: Value(fileId),
encryptedKey: Value(encryptedKey),
fileName: Value(fileName),
fileSize: Value(fileSize),
);
}).toList();
Batch batch = db.batch();
if (incomingIds.isNotEmpty) {
batch.delete('messages', where: 'id NOT IN (${incomingIds.join(',')})');
}
for (var msg in messages) {
if (msg is MessageModel) {
batch.insert('messages', {
'id': msg.id,
'sender_id': msg.senderId,
'receiver_id': msg.receiverId,
'content': msg.text,
'timestamp': msg.createdAt.toIso8601String(),
'delivered_at': null,
'read_at': null,
'reply_to_id': msg.replyToId,
'reply_to_text': msg.replyToText,
'edited_at': msg.editedAt?.toIso8601String(),
'message_type': msg.messageType.name,
'file_id': msg.fileId,
'encrypted_key': msg.encryptedFileKey,
'file_name': msg.fileName,
'file_size': msg.fileSize,
}, conflictAlgorithm: ConflictAlgorithm.replace);
} else {
// Если это Map из API
batch.insert('messages', {
'id': msg['id'],
'sender_id': msg['sender_id'],
'receiver_id':
msg['receiver_id'], // Убедись, что ключ совпадает с API
'content': msg['content'],
'timestamp': msg['timestamp'],
'delivered_at': msg['delivered_at'],
'read_at': msg['read_at'],
'reply_to_id': msg['reply_to_id'],
'reply_to_text': msg['reply_to_text'],
'edited_at': msg['edited_at'],
'message_type': msg['message_type'] ?? 'text',
'file_id': msg['file_id'],
'encrypted_key': msg['encrypted_key'],
'file_name': msg['file_name'],
'file_size': msg['file_size'],
}, conflictAlgorithm: ConflictAlgorithm.replace);
// Выполняем все операции в рамках ОДНОЙ транзакции БД
await transaction(() async {
if (incomingIds.isNotEmpty) {
// ВНИМАНИЕ: Ограничьте удаление только текущим чатом,
// иначе эта строка очистит сообщения из всех остальных диалогов!
final first = companions.first;
await (delete(messages)..where(
(tbl) =>
((tbl.senderId.equals(first.senderId.value) &
tbl.receiverId.equals(first.receiverId.value)) |
(tbl.senderId.equals(first.receiverId.value) &
tbl.receiverId.equals(first.senderId.value))) &
tbl.id.isNotIn(incomingIds),
))
.go();
}
}
await batch.commit(noResult: true);
// Быстрая пакетная вставка/обновление
await batch((b) {
b.insertAll(
messages,
companions,
mode: InsertMode
.insertOrReplace, // Безопасный аналог insertOnConflictUpdate для batch
);
});
});
}
// Получение сообщений конкретного чата
Future<List<Map<String, dynamic>>> getChatHistory(
int contactId,
int myId,
) async {
final db = await database;
return await db.query(
'messages',
where:
'(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)',
whereArgs: [contactId, myId, myId, contactId],
orderBy: 'timestamp ASC',
);
final query = select(messages)
..where(
(tbl) =>
(tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) |
(tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId)),
)
..orderBy([(tbl) => OrderingTerm(expression: tbl.timestamp)]);
final rows = await query.get();
return rows.map((row) => row.toJson()).toList();
}
Future<int> deleteChatHistory(int contactId, int myId) async {
final db = await database;
return await db.delete(
'messages',
where:
'(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)',
whereArgs: [contactId, myId, myId, contactId],
);
return await (delete(messages)..where(
(tbl) =>
(tbl.senderId.equals(contactId) & tbl.receiverId.equals(myId)) |
(tbl.senderId.equals(myId) & tbl.receiverId.equals(contactId)),
))
.go();
}
Future<Map<String, dynamic>?> getLastMessage(int contactId, int myId) async {
final db = await database;
final rows = await db.query(
'messages',
columns: ['sender_id', 'receiver_id', 'content', 'timestamp'],
where:
'(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)',
whereArgs: [contactId, myId, myId, contactId],
orderBy: 'timestamp DESC',
limit: 1,
);
final query =
(select(messages)
..where(
(tbl) =>
(tbl.senderId.equals(contactId) &
tbl.receiverId.equals(myId)) |
(tbl.senderId.equals(myId) &
tbl.receiverId.equals(contactId)),
)
..orderBy([
(tbl) => OrderingTerm(
expression: tbl.timestamp,
mode: OrderingMode.desc,
),
])
..limit(1))
.get();
final rows = await query;
if (rows.isEmpty) return null;
return rows.first;
return rows.first.toJson();
}
Future<void> updateDeliveredAt(int messageId, DateTime deliveredAt) async {
final db = await database;
await db.update(
'messages',
{'delivered_at': deliveredAt.toIso8601String()},
where: 'id = ?',
whereArgs: [messageId],
await (update(messages)..where((tbl) => tbl.id.equals(messageId))).write(
MessagesCompanion(deliveredAt: Value(deliveredAt.toIso8601String())),
);
}
Future<void> updateReadAt(int messageId, DateTime readAt) async {
final db = await database;
await db.update(
'messages',
{'read_at': readAt.toIso8601String()},
where: 'id = ?',
whereArgs: [messageId],
await (update(messages)..where((tbl) => tbl.id.equals(messageId))).write(
MessagesCompanion(readAt: Value(readAt.toIso8601String())),
);
}
@ -223,17 +246,42 @@ class LocalDbService {
String content,
DateTime? editedAt,
) async {
final db = await database;
await db.update(
'messages',
{'content': content, 'edited_at': editedAt?.toIso8601String()},
where: 'id = ?',
whereArgs: [messageId],
await (update(messages)..where((tbl) => tbl.id.equals(messageId))).write(
MessagesCompanion(
content: Value(content),
editedAt: Value(editedAt?.toIso8601String()),
),
);
}
Future<void> saveOriginalFileNameForFileId(
String fileId,
String fileName,
) async {
await into(fileNameMappings).insertOnConflictUpdate(
FileNameMappingsCompanion(
fileId: Value(fileId),
originalFileName: Value(fileName),
),
);
}
Future<String?> getOriginalFileNameForFileId(String fileId) async {
final query = select(fileNameMappings)
..where((tbl) => tbl.fileId.equals(fileId));
final result = await query.getSingleOrNull();
return result?.originalFileName;
}
Future<void> deleteMessage(int messageId) async {
final db = await database;
await db.delete('messages', where: 'id = ?', whereArgs: [messageId]);
await (delete(messages)..where((tbl) => tbl.id.equals(messageId))).go();
}
}
QueryExecutor _openConnection() {
// This now uses the SqfliteQueryExecutor, which will respect the global
// databaseFactory set in `main.dart`. This fixes the 'unable to open file'
// error on Windows. The database file will be 'chat_app.db' inside the
// path configured in `main.dart`.
return SqfliteQueryExecutor.inDatabaseFolder(path: 'chat_app.db');
}

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ import 'package:web_socket_channel/status.dart' as status;
import 'package:web_socket_channel/io.dart';
import 'package:chepuhagram/core/constants.dart';
import 'package:flutter/widgets.dart';
import 'package:chepuhagram/domain/services/webrtc_service.dart';
class SocketService with WidgetsBindingObserver {
static final SocketService _instance = SocketService._internal();
@ -35,6 +36,15 @@ class SocketService with WidgetsBindingObserver {
}
}
void _initMessageListener() {
messages.listen((data) {
if (data['type'] == 'call_accepted') {
WebRTCService().handleOffer(data['call_id'], data['sdp']);
}
});
}
Future<void> startConnect(ApiService apiService) async {
if (_connectTimer != null && _connectTimer!.isActive)
return; // Уже запущено
@ -61,13 +71,11 @@ class SocketService with WidgetsBindingObserver {
// В FastAPI эндпоинт ожидает токен в URL-параметре
final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token");
//_channel = WebSocketChannel.connect(uri);
_channel = IOWebSocketChannel.connect(
uri,
connectTimeout: Duration(seconds: 10),
);
if (_channel == null) return;
await _channel!.ready;
_channel!.stream.listen(
(data) {

View File

@ -3,33 +3,10 @@ import 'package:http/http.dart' as http;
import '/core/constants.dart';
import '/data/models/contact_model.dart';
import '/domain/services/api_service.dart';
import 'package:flutter_http_cache/flutter_http_cache.dart';
class ContactRepository {
late final CachedHttpClient _client;
bool _isCacheInitialized = false;
final ApiService _apiService = ApiService();
ContactRepository() {
_initCachedClient();
}
// Единая инициализация кэша для всех запросов репозитория
void _initCachedClient() {
final cache = _apiService.cache;
_client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
}
Future<void> _ensureCacheReady() async {
if (!_isCacheInitialized) {
await _apiService.cache.initialize();
_isCacheInitialized = true;
}
}
Future<List<Contact>> fetchChatContacts({bool forceRefresh = false}) async {
final token = await _apiService.getAccessToken();
@ -45,10 +22,10 @@ class ContactRepository {
requestHeaders['Cache-Control'] = 'no-cache';
}
await _ensureCacheReady();
//await _ensureCacheReady();
try {
final response = await _client.get(
final response = await http.get(
Uri.parse('${AppConstants.baseUrl}/users/chats'),
headers: requestHeaders,
);
@ -70,27 +47,8 @@ class ContactRepository {
throw Exception('Failed to load contacts');
}
} catch (e) {
print(
'⚠️ Ошибка сети при загрузке контактов: $e. Пробуем строгий кэш...',
);
// FALLBACK: Если сеть упала, принудительно создаем запрос с политикой cacheOnly
final offlineClient = CachedHttpClient(
cache: _apiService.cache,
defaultCachePolicy: CachePolicy.cacheOnly, // Читаем строго из кэша
);
try {
final response = await offlineClient.get(
Uri.parse('${AppConstants.baseUrl}/users/chats'),
headers: requestHeaders,
);
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
return data.map((json) => Contact.fromJson(json)).toList();
} catch (cacheError) {
throw Exception('Нет доступа к сети. Проверте подключение к интернету.');
}
print('⚠️ Ошибка сети при загрузке контактов: $e.');
throw Exception('Нет доступа к сети. Проверте подключение к интернету.');
}
}
@ -112,8 +70,7 @@ class ContactRepository {
if (forceRefresh) {
requestHeaders['Cache-Control'] = 'no-cache';
}
await _ensureCacheReady();
final response = await _client.get(
final response = await http.get(
Uri.parse('${AppConstants.baseUrl}/users/all'),
headers: requestHeaders,
);
@ -148,8 +105,8 @@ class ContactRepository {
if (forceRefresh) {
requestHeaders['Cache-Control'] = 'no-cache';
}
await _ensureCacheReady();
final response = await _client.get(
final response = await http.get(
Uri.parse('${AppConstants.baseUrl}/users/$userId'),
headers: requestHeaders,
);
@ -181,8 +138,7 @@ class ContactRepository {
requestHeaders['Cache-Control'] = 'no-cache';
}
await _ensureCacheReady();
final response = await _client.get(
final response = await http.get(
Uri.parse(
'${AppConstants.baseUrl}/messages/last?contact_id=$contactId&limit=$limit',
),

View File

@ -7,7 +7,6 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:chepuhagram/core/constants.dart';
import 'package:flutter_http_cache/flutter_http_cache.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'dart:io';
@ -16,27 +15,12 @@ class ApiService extends ChangeNotifier {
final _client = http.Client();
final _storage = const FlutterSecureStorage();
bool _isRefreshing = false;
bool _isCacheInitialized = false;
final cache = HttpCache(
config: const CacheConfig(
maxMemorySize: 100 * 1024 * 1024, // 100MB
maxDiskSize: 500 * 1024 * 1024, // 500MB
),
);
Future<void> _ensureCacheReady() async {
if (!_isCacheInitialized) {
await cache.initialize();
_isCacheInitialized = true;
}
}
/// Получает данные пользователя (включая его публичный ключ E2EE) по username
Future<Contact?> getUserByUsername(String username) async {
try {
// Подставляй свой эндпоинт, например: /users/by-username/
final response = await Dio().get('/users/by-username/$username');
final response = await Dio().get('${AppConstants.baseUrl}//users/by-username/$username');
if (response.statusCode == 200 && response.data != null) {
// Парсим полученные данные в модель контакта.
@ -375,12 +359,8 @@ class ApiService extends ChangeNotifier {
Future<Map<String, dynamic>> getMe() async {
final token = await getAccessToken();
await cache.initialize();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final response = await client.get(
final response = await http.get(
Uri.parse('${AppConstants.baseUrl}/users/me'),
headers: {
'Content-Type': 'application/json',
@ -434,12 +414,6 @@ class ApiService extends ChangeNotifier {
bool forceRefresh = false,
}) async {
final token = await getAccessToken();
await _ensureCacheReady();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final Map<String, String> requestHeaders = {
'Authorization': 'Bearer $token',
@ -449,7 +423,7 @@ class ApiService extends ChangeNotifier {
requestHeaders['Cache-Control'] = 'no-cache';
}
final response = await client.get(
final response = await http.get(
Uri.parse(
'${AppConstants.baseUrl}/messages/history/${contactId.toString()}',
),
@ -467,17 +441,12 @@ class ApiService extends ChangeNotifier {
}) async {
try {
final token = await getAccessToken();
await _ensureCacheReady();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final client = http.Client();
final uri = Uri.parse('${AppConstants.baseUrl}/media/$fileId');
if (onProgress == null) {
final response = await client.get(
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer $token'},
);
@ -551,12 +520,7 @@ class ApiService extends ChangeNotifier {
Future<Map<String, dynamic>> getUserById(int userId) async {
final token = await getAccessToken();
await _ensureCacheReady();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final response = await client.get(
final response = await http.get(
Uri.parse('${AppConstants.baseUrl}/users/$userId'),
headers: {
'Content-Type': 'application/json',
@ -601,13 +565,7 @@ class ApiService extends ChangeNotifier {
Future<Map<String, dynamic>> getPrivacySettings() async {
final token = await getAccessToken();
await _ensureCacheReady();
final client = CachedHttpClient(
cache: cache,
defaultCachePolicy: CachePolicy.networkFirst,
);
final response = await client.get(
final response = await http.get(
Uri.parse('${AppConstants.baseUrl}/users/me/privacy'),
headers: {
'Content-Type': 'application/json',

View File

@ -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();
}
}

View File

@ -75,6 +75,8 @@ class AuthProvider extends ChangeNotifier {
bool get hasPublicKeyOnServer => _hasPublicKeyOnServer;
final _storage = const FlutterSecureStorage();
FlutterSecureStorage get storage => _storage;
final _client = http.Client();
final ApiService _apiService = ApiService();
final SocketService _socketService = SocketService();

View File

@ -3,16 +3,24 @@ import 'logic/auth_provider.dart';
import 'logic/contact_provider.dart';
import 'core/theme_manager.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:path_provider/path_provider.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'dart:convert';
import 'package:chepuhagram/data/datasources/local_db_service.dart';
import 'package:chepuhagram/presentation/screens/chat_screen.dart';
import 'package:chepuhagram/presentation/screens/contacts_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:path/path.dart' as p;
import 'presentation/screens/splash_screen.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'presentation/screens/call_screen.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
@ -122,81 +130,115 @@ void _navigateToChat(int senderId) {
}
}
bool firebaseInitialized = false;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
sqfliteFfiInit();
// ИСПОЛЬЗУЕМ СУППОРТ-ДИРЕКТОРИЮ (App Data / Roaming) вместо Документов
final appSupportDir = await getApplicationSupportDirectory();
// Создаем подпапку внутри AppData, там Windows никогда не выдаст ошибку 2
final sqfliteDbPath = p.join(appSupportDir.path, 'sqflite_databases');
await Directory(sqfliteDbPath).create(recursive: true);
// Привязываем фабрику sqflite к новому безопасному пути
databaseFactory = databaseFactoryFfi;
await databaseFactory.setDatabasesPath(sqfliteDbPath);
print('Безопасный путь для SQFlite на Windows: $sqfliteDbPath');
}
try {
print('Initializing LocalDbService in main...');
final db = LocalDbService();
print('База данных сохранена по пути: ${db.connection.connectionData}');
print('LocalDbService initialized successfully.');
} catch (e, st) {
print('LocalDbService init failed in main: $e');
print(st);
}
if (Platform.isAndroid || Platform.isIOS) {
await Firebase.initializeApp();
firebaseInitialized = true;
initialMessage = await FirebaseMessaging.instance.getInitialMessage();
print('Initial message from main() after delay: $initialMessage');
// Сохраняем информацию в SharedPreferences для надежности
final prefs = await SharedPreferences.getInstance();
if (initialMessage != null) {
print('App launched from notification: ${initialMessage!.data}');
print('Message type: ${initialMessage!.data['type']}');
print('Sender ID: ${initialMessage!.data['sender_id']}');
print('Initial message from main(): $initialMessage');
} else {
print('Skipping Firebase initialization on desktop.');
}
final payloadString = jsonEncode(initialMessage!.data);
final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey,
);
if (lastHandled != payloadString) {
// Сохраняем данные уведомления
await prefs.setString(_notificationLaunchKey, payloadString);
await prefs.setString(
_lastHandledNotificationLaunchPayloadKey,
payloadString,
);
print('Saved notification data to SharedPreferences');
} else {
print('InitialMessage payload already handled earlier, skipping');
}
} else {
print('No initial message - app launched normally');
// Очищаем сохраненные данные, если приложение запущено нормально
await prefs.remove(_notificationLaunchKey);
}
// Сохраняем информацию в SharedPreferences для надежности
final prefs = await SharedPreferences.getInstance();
if (initialMessage != null) {
print('App launched from notification: ${initialMessage!.data}');
print('Message type: ${initialMessage!.data['type']}');
print('Sender ID: ${initialMessage!.data['sender_id']}');
// Initialize local notifications
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
final InitializationSettings initializationSettings =
InitializationSettings(android: initializationSettingsAndroid);
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: _onSelectNotification,
final payloadString = jsonEncode(initialMessage!.data);
final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey,
);
if (lastHandled != payloadString) {
// Сохраняем данные уведомления
await prefs.setString(_notificationLaunchKey, payloadString);
await prefs.setString(
_lastHandledNotificationLaunchPayloadKey,
payloadString,
);
print('Saved notification data to SharedPreferences');
} else {
print('InitialMessage payload already handled earlier, skipping');
}
} else {
print('No initial message - app launched normally');
// Очищаем сохраненные данные, если приложение запущено нормально
await prefs.remove(_notificationLaunchKey);
}
// Если приложение было запущено из локального уведомления, сохраним payload
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
final payload =
notificationAppLaunchDetails?.notificationResponse?.payload;
print('App launched from local notification, payload: $payload');
if (payload != null && payload.isNotEmpty) {
try {
final lastHandled = prefs.getString(
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const WindowsInitializationSettings initializationSettingsWindows =
WindowsInitializationSettings(
appName: 'Chepuhagram',
appUserModelId: 'ru.ArturKarasevich.Chepuhagram',
guid: '6c0af055-e0b5-4f10-9aed-c12dc078f949',
);
final InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
windows: initializationSettingsWindows,
);
await flutterLocalNotificationsPlugin.initialize(
settings: initializationSettings,
onDidReceiveNotificationResponse: _onSelectNotification,
);
// Если приложение было запущено из локального уведомления, сохраним payload
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
final payload = notificationAppLaunchDetails?.notificationResponse?.payload;
print('App launched from local notification, payload: $payload');
if (payload != null && payload.isNotEmpty) {
try {
final lastHandled = prefs.getString(
_lastHandledNotificationLaunchPayloadKey,
);
if (lastHandled != payload) {
final data = jsonDecode(payload);
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
await prefs.setString(
_lastHandledNotificationLaunchPayloadKey,
payload,
);
if (lastHandled != payload) {
final data = jsonDecode(payload);
await prefs.setString(_notificationLaunchKey, jsonEncode(data));
await prefs.setString(
_lastHandledNotificationLaunchPayloadKey,
payload,
);
print(
'Saved local notification launch payload to SharedPreferences',
);
} else {
print(
'Local notification payload already handled earlier, skipping',
);
}
} catch (e) {
print('Failed to save notification launch payload: $e');
print('Saved local notification launch payload to SharedPreferences');
} else {
print('Local notification payload already handled earlier, skipping');
}
} catch (e) {
print('Failed to save notification launch payload: $e');
}
}
@ -214,9 +256,17 @@ void main() async {
>()
?.createNotificationChannel(channel);
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
} catch (e) {
print('Уведосления не были инициальзированы: $e');
if (firebaseInitialized) {
FirebaseMessaging.onBackgroundMessage(
_firebaseMessagingBackgroundHandler,
);
}
}
if (Platform.isAndroid || Platform.isIOS) {
initCallkitListener();
} else {
print('Skipping CallKit listener on desktop platform.');
}
runApp(
@ -237,17 +287,102 @@ void main() async {
);
}
void initCallkitListener() {
if (!(Platform.isAndroid || Platform.isIOS)) {
print('Skipping CallKit event listener on non-mobile platform.');
return;
}
try {
FlutterCallkitIncoming.onEvent.listen((event) {
if (event == null) return;
switch (event.event) {
case Event.actionCallIncoming:
// Звонок получен, но CallKit уже показал экран.
// Здесь можно логировать или обновить статус в БД.
print("Incoming call: ${event.body['id']}");
break;
case Event.actionCallStart:
// Исходящий звонок начат
break;
case Event.actionCallAccept:
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ПРИНЯТЬ"
// 1. Уведомляем сервер (чтобы другая сторона узнала о начале звонка)
SocketService().sendMessage({
"type": "call_accept",
"call_id": event.body['id'],
});
// 2. Переходим на экран звонка
navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (_) => CallScreen(
callId: event.body['id'],
isIncoming: true,
callerName: event.body['nameCaller'] ?? 'Unknown',
onAccept: () {},
onHangup: () => _handleHangupGlobal(event.body['id']),
),
),
);
break;
case Event.actionCallDecline:
// ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ОТКЛОНИТЬ"
SocketService().sendMessage({
"type": "decline",
"call_id": event.body['id'],
});
break;
case Event.actionCallEnded:
case Event.actionCallTimeout:
// Звонок завершен или пропущен
print("Call ended or timeout");
break;
default:
print("Event unhandled: ${event.event}");
break;
}
});
} catch (e, st) {
print('CallKit listener initialization failed: $e');
print(st);
}
}
void _handleHangupGlobal(String callId) {
SocketService().sendMessage({"type": "hangup", "call_id": callId});
}
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
print("Фоновый пуш получен: ${message.data}");
if (message.data['type'] == 'enc_message') {
try {
// Initialize notifications for background
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const InitializationSettings initializationSettings =
InitializationSettings(android: initializationSettingsAndroid);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
const WindowsInitializationSettings initializationSettingsWindows =
WindowsInitializationSettings(
appName: 'Chepuhagram',
appUserModelId: 'ru.ArturKarasevich.Chepuhagram',
guid: '6c0af055-e0b5-4f10-9aed-c12dc078f949',
);
final InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
windows: initializationSettingsWindows,
);
await flutterLocalNotificationsPlugin.initialize(
settings: initializationSettings,
onDidReceiveNotificationResponse: _onSelectNotification,
);
// Create notification channel
const AndroidNotificationChannel channel = AndroidNotificationChannel(
@ -301,10 +436,10 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
final String groupKey = 'ru.chepuhagram.app.$senderId';
await flutterLocalNotificationsPlugin.show(
senderId!,
'',
'',
NotificationDetails(
id: senderId!,
title: '',
body: '',
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
'Messages',
'Новые сообщения',
@ -317,10 +452,10 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
),
);
await flutterLocalNotificationsPlugin.show(
message.hashCode,
message.data['username'] ?? 'Unknown',
notificationText,
NotificationDetails(
id: message.hashCode,
title: message.data['username'] ?? 'Unknown',
body: notificationText,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
'chat_id',
'Messages',

View File

@ -62,13 +62,13 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Сохранено')),
const SnackBar(content: Text('Сохранено'), behavior: SnackBarBehavior.floating),
);
Navigator.of(context).pop();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
SnackBar(content: Text(e.toString().replaceAll('Exception: ', '')), behavior: SnackBarBehavior.floating),
);
} finally {
if (mounted) setState(() => _isSaving = false);
@ -77,91 +77,131 @@ class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.background,
appBar: AppBar(
title: const Text('Аккаунт'),
title: const Text('Редактировать аккаунт', style: TextStyle(fontWeight: FontWeight.bold)),
elevation: 0,
backgroundColor: Colors.transparent,
actions: [
TextButton(
onPressed: _isSaving ? null : _save,
child: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text(
'Сохранить',
style: TextStyle(color: Colors.white),
),
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Center(
child: _isSaving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2.5, color: colorScheme.primary),
)
: TextButton.icon(
onPressed: _save,
icon: const Icon(Icons.done_rounded, size: 18),
label: const Text('Готово', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
TextFormField(
_buildInputField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Имя пользователя',
hintText: 'Латиница, цифры, подчеркивания',
),
label: 'Имя пользователя',
hint: 'Латиница, цифры, подчеркивания',
icon: Icons.alternate_email_rounded,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Введите имя пользователя';
if (!RegExp(r'^[a-zA-Z0-9_]{3,20}$').hasMatch(v.trim())) {
return 'Имя пользователя должно содержать от 3 до 20 символов (латиница, цифры, подчеркивания)';
return 'От 3 до 20 символов (A-Z, 0-9, _)';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
_buildInputField(
controller: _firstNameController,
decoration: const InputDecoration(
labelText: 'Имя',
),
label: 'Имя',
hint: 'Введите ваше имя',
icon: Icons.person_outline_rounded,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Введите имя';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
_buildInputField(
controller: _lastNameController,
decoration: const InputDecoration(
labelText: 'Фамилия',
),
label: 'Фамилия',
hint: 'Введите вашу фамилию',
icon: Icons.people_outline_rounded,
),
const SizedBox(height: 12),
TextFormField(
_buildInputField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: 'Телефон',
),
label: 'Телефон',
hint: 'Номер телефона',
icon: Icons.phone_android_rounded,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
TextFormField(
_buildInputField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Почта',
),
label: 'Почта',
hint: 'Электронный адрес',
icon: Icons.mail_outline_rounded,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
TextFormField(
_buildInputField(
controller: _aboutController,
decoration: const InputDecoration(
labelText: 'О себе',
),
minLines: 1,
maxLines: 10,
label: 'О себе',
hint: 'Расскажите немного о себе',
icon: Icons.short_text_rounded,
maxLines: 4,
),
],
),
),
);
}
}
Widget _buildInputField({
required TextEditingController controller,
required String label,
required String hint,
required IconData icon,
int maxLines = 1,
TextInputType? keyboardType,
String? Function(String?)? validator,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: TextFormField(
controller: controller,
maxLines: maxLines,
keyboardType: keyboardType,
validator: validator,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
decoration: InputDecoration(
icon: Icon(icon, color: colorScheme.primary, size: 22),
labelText: label,
labelStyle: TextStyle(color: colorScheme.outline, fontSize: 14),
hintText: hint,
hintStyle: TextStyle(color: colorScheme.outline.withOpacity(0.5)),
border: InputBorder.none,
errorStyle: TextStyle(color: colorScheme.error, fontWeight: FontWeight.w500),
),
),
),
);
}
}

View File

@ -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('Сохранить'),
),
],
),
);
}
}

View File

@ -24,104 +24,136 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
@override
Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>();
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text("Оформление")),
backgroundColor: colorScheme.background,
appBar: AppBar(
title: const Text("Оформление", style: TextStyle(fontWeight: FontWeight.bold)),
elevation: 0,
backgroundColor: Colors.transparent,
),
body: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
// Ночной режим
SwitchListTile(
secondary: const Icon(Icons.dark_mode),
title: const Text("Ночной режим"),
value: themeProv.themeMode == ThemeMode.dark,
onChanged: (val) => themeProv.toggleTheme(val),
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
secondary: Icon(Icons.dark_mode_rounded, color: colorScheme.primary),
title: const Text("Ночной режим", style: TextStyle(fontWeight: FontWeight.w600)),
value: themeProv.themeMode == ThemeMode.dark,
onChanged: (val) => themeProv.toggleTheme(val),
),
),
const Divider(),
const SizedBox(height: 20),
// Выбор цвета акцента
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// Цветовые акценты темы
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.2),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.palette_outlined, color: colorScheme.primary, size: 22),
const SizedBox(width: 12),
const Text("Цвет интерфейса", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Icon(
Icons.palette_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 10),
const Text("Цвет темы"),
const Spacer(),
_colorCircle(context, const Color(0xFF24A1DE), themeProv),
_colorCircle(context, const Color(0xFF3E8E7E), themeProv),
_colorCircle(context, const Color(0xFF8E3E7E), themeProv),
_colorCircle(context, const Color(0xFFFF9800), themeProv),
_colorCircle(context, const Color(0xFFF44336), themeProv),
_colorCircle(const Color(0xFF24A1DE), themeProv),
_colorCircle(const Color(0xFF3E8E7E), themeProv),
_colorCircle(const Color(0xFF8E3E7E), themeProv),
_colorCircle(const Color(0xFFFF9800), themeProv),
_colorCircle(const Color(0xFFF44336), themeProv),
],
),
],
),
),
const Divider(),
const SizedBox(height: 20),
// Обои чата
ListTile(
leading: const Icon(Icons.wallpaper),
title: const Text('Обои чата'),
subtitle: const Text('Выбрать изображение из галереи'),
trailing: const Icon(Icons.chevron_right),
onTap: _pickWallpaper,
),
// Показать текущие обои, если есть
if (themeProv.wallpaperPath != null)
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Текущие обои:'),
const SizedBox(height: 8),
Container(
height: 150,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.2),
borderRadius: BorderRadius.circular(24),
),
child: Column(
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
leading: Icon(Icons.wallpaper_rounded, color: colorScheme.primary),
title: const Text('Обои чата', style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('Установить фоновое изображение'),
trailing: Icon(Icons.chevron_right_rounded, color: colorScheme.outline),
onTap: _pickWallpaper,
),
if (themeProv.wallpaperPath != null) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
alignment: Alignment.bottomRight,
children: [
Container(
height: 160,
width: double.infinity,
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: IconButton.filled(
color: colorScheme.error,
icon: const Icon(Icons.delete_outline_rounded, color: Colors.white),
onPressed: () => themeProv.updateWallpaper(null),
),
)
],
),
),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => themeProv.updateWallpaper(null),
child: const Text('Удалить обои'),
),
],
),
],
),
),
],
),
);
}
Widget _colorCircle(BuildContext context, Color color, ThemeProvider prov) {
Widget _colorCircle(Color color, ThemeProvider prov) {
bool isSelected = prov.accentColor == color;
return GestureDetector(
onTap: () => prov.updateAccentColor(color),
child: Container(
padding: const EdgeInsets.all(2),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? color : Colors.transparent,
width: 2,
),
border: Border.all(color: isSelected ? color : Colors.transparent, width: 2),
),
child: CircleAvatar(backgroundColor: color, radius: 15),
child: CircleAvatar(backgroundColor: color, radius: 16),
),
);
}

View File

@ -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

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '/core/constants.dart';
import '/data/models/message_model.dart';
import '/data/models/contact_model.dart';
@ -11,16 +10,15 @@ import '/domain/services/api_service.dart';
class ForwardContactPickerScreen extends StatefulWidget {
final MessageModel message;
const ForwardContactPickerScreen({
super.key,
required this.message,
});
const ForwardContactPickerScreen({super.key, required this.message});
@override
State<ForwardContactPickerScreen> createState() => _ForwardContactPickerScreenState();
State<ForwardContactPickerScreen> createState() =>
_ForwardContactPickerScreenState();
}
class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen> {
class _ForwardContactPickerScreenState
extends State<ForwardContactPickerScreen> {
Contact? _selectedContact;
bool _isInitLoading = true;
SharedPreferences? _prefs;
@ -36,11 +34,11 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
try {
final contactProvider = context.read<ContactProvider>();
await contactProvider.loadContacts();
final apiService = ApiService();
final accessToken = await apiService.getAccessToken();
final shared = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_prefs = shared;
@ -151,11 +149,12 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
final bool isDecrypted = contact.isLastMsgDecrypted ?? false;
final String subtitleText = isDecrypted
? (contact.lastMessage == null
? "Нет сообщений"
: "${contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : ''} ${contact.lastMessage}".trim())
? "Нет сообщений"
: "${contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : ''} ${contact.lastMessage}"
.trim())
: (contact.lastMessage != null
? "Ожидание дешифровки..."
: "Нет сообщений");
? "Ожидание дешифровки..."
: "Нет сообщений");
// Логика формирования URL аватарки
final avatarUrl = contact.effectiveAvatarUrl;
@ -172,10 +171,15 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
});
},
child: Container(
color: isSelected ? primaryColor.withOpacity(0.08) : Colors.transparent,
color: isSelected
? primaryColor.withOpacity(0.08)
: Colors.transparent,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
// 1. АВАТАРКА
leading: Stack(
children: [
@ -184,20 +188,50 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
radius: 24,
backgroundColor: Colors.grey[200],
child: ClipOval(
child: CachedNetworkImage(
imageUrl: avatarUrl,
width: 48,
height: 48,
fit: BoxFit.cover,
httpHeaders: token != null ? {'Authorization': 'Bearer $token'} : null,
placeholder: (context, url) => const CircularProgressIndicator(strokeWidth: 2),
errorWidget: (context, url, error) => CircleAvatar(
radius: 24,
backgroundColor: primaryColor.withOpacity(0.1),
child: Text(
_getDisplayName(contact).isNotEmpty ? _getDisplayName(contact)[0].toUpperCase() : '?',
style: TextStyle(color: primaryColor, fontWeight: FontWeight.bold),
),
child: ClipOval(
child: Image.network(
avatarUrl, // Первым аргументом идет строка, без "imageUrl:"
width: 48,
height: 48,
fit: BoxFit.cover,
headers: token != null
? {'Authorization': 'Bearer $token'}
: null, // Заменено на headers
// Аналог placeholder
loadingBuilder:
(context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const SizedBox(
width: 48,
height: 48,
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
);
},
// Аналог errorWidget
errorBuilder: (context, error, stackTrace) {
return CircleAvatar(
radius: 24, // 24 радиус = 48 ширина/высота
backgroundColor: primaryColor.withOpacity(
0.1,
),
child: Text(
_getDisplayName(contact).isNotEmpty
? _getDisplayName(
contact,
)[0].toUpperCase()
: '?',
style: TextStyle(
color: primaryColor,
fontWeight: FontWeight.bold,
),
),
);
},
),
),
),
@ -207,11 +241,16 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
radius: 24,
backgroundColor: primaryColor.withOpacity(0.1),
child: Text(
_getDisplayName(contact).isNotEmpty ? _getDisplayName(contact)[0].toUpperCase() : '?',
style: TextStyle(color: primaryColor, fontWeight: FontWeight.bold),
_getDisplayName(contact).isNotEmpty
? _getDisplayName(contact)[0].toUpperCase()
: '?',
style: TextStyle(
color: primaryColor,
fontWeight: FontWeight.bold,
),
),
),
if (contact.isOnline == true)
Positioned(
right: 0,
@ -222,7 +261,12 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.all(color: Theme.of(context).scaffoldBackgroundColor, width: 2),
border: Border.all(
color: Theme.of(
context,
).scaffoldBackgroundColor,
width: 2,
),
),
),
),
@ -234,7 +278,10 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
_getDisplayName(contact),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
// 3. ПОСЛЕДНЕЕ СООБЩЕНИЕ
@ -248,9 +295,13 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
// 4. ПРАВАЯ ЧАСТЬ (Анимация переключения Время <-> Галочка)
trailing: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
transitionBuilder:
(Widget child, Animation<double> animation) {
return ScaleTransition(
scale: animation,
child: child,
);
},
child: isSelected
? Container(
key: const ValueKey('checkmark'),
@ -260,7 +311,11 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
color: primaryColor,
shape: BoxShape.circle,
),
child: const Icon(Icons.check_rounded, color: Colors.white, size: 16),
child: const Icon(
Icons.check_rounded,
color: Colors.white,
size: 16,
),
)
: Column(
key: const ValueKey('time_and_badge'),
@ -270,19 +325,27 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
children: [
Text(
_formatTime(contact.lastMessageTime),
style: const TextStyle(color: Colors.grey, fontSize: 12),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
if (contact.unreadCount > 0) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: primaryColor.withAlpha((0.5 * 255).round()),
color: primaryColor.withAlpha(
(0.5 * 255).round(),
),
shape: BoxShape.circle,
),
child: Text(
'${contact.unreadCount}',
style: const TextStyle(color: Colors.white, fontSize: 10),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
],
@ -297,4 +360,4 @@ class _ForwardContactPickerScreenState extends State<ForwardContactPickerScreen>
}(),
);
}
}
}

View File

@ -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),
),
],
);
}
}

View File

@ -7,38 +7,72 @@ class PrivacySettingsMenuScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Конфиденциальность')),
backgroundColor: colorScheme.background,
appBar: AppBar(
title: const Text('Конфиденциальность', style: TextStyle(fontWeight: FontWeight.bold)),
elevation: 0,
backgroundColor: Colors.transparent,
),
body: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
const SizedBox(height: 12),
ListTile(
leading: const Icon(Icons.security_outlined),
title: const Text('Безопасность'),
subtitle: const Text('Сменить пароль, пароль шифрования, TOTP'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SecuritySettingsScreen()),
);
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.privacy_tip_outlined),
title: const Text('Конфиденциальность'),
subtitle: const Text('Кто может видеть почту, телефон, аватар и информацию о вас'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const PrivacySettingsScreen()),
);
},
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.2),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
),
child: Column(
children: [
_buildMenuTile(
context: context,
icon: Icons.security_rounded,
title: 'Безопасность',
subtitle: 'Смена паролей, ключи шифрования, TOTP защита',
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SecuritySettingsScreen())),
),
Divider(height: 1, indent: 68, color: colorScheme.outlineVariant.withOpacity(0.2)),
_buildMenuTile(
context: context,
icon: Icons.privacy_tip_rounded,
title: 'Конфиденциальность',
subtitle: 'Видимость почты, телефона, аватара и онлайна',
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const PrivacySettingsScreen())),
),
],
),
),
],
),
);
}
}
Widget _buildMenuTile({
required BuildContext context,
required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: colorScheme.primary, size: 22),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.outline, fontSize: 12)),
trailing: Icon(Icons.chevron_right_rounded, color: colorScheme.outline),
onTap: onTap,
);
}
}

View File

@ -52,14 +52,12 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
_showAbout = data['show_about'] ?? true;
_showLastOnline = data['show_last_online'] ?? true;
});
// Сохраняем локально для быстрого доступа
await _savePreference(_showEmailKey, _showEmail);
await _savePreference(_showPhoneKey, _showPhone);
await _savePreference(_showAvatarKey, _showAvatar);
await _savePreference(_showAboutKey, _showAbout);
await _savePreference(_showLastOnlineKey, _showLastOnline);
} catch (e) {
// Если не удалось загрузить с сервера, используем локальные настройки
print('Ошибка загрузки настроек с сервера: $e');
}
}
@ -71,7 +69,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
Future<void> _saveToServer() async {
if (_isSaving) return;
setState(() => _isSaving = true);
try {
@ -85,7 +82,6 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
);
if (success) {
// Сохраняем локально только после успешного сохранения на сервере
await _savePreference(_showEmailKey, _showEmail);
await _savePreference(_showPhoneKey, _showPhone);
await _savePreference(_showAvatarKey, _showAvatar);
@ -94,97 +90,108 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Настройки сохранены')),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось сохранить настройки')),
const SnackBar(content: Text('Настройки видимости сохранены'), behavior: SnackBarBehavior.floating),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: ${e.toString().replaceAll('Exception: ', '')}')),
SnackBar(content: Text('Ошибка: ${e.toString().replaceAll('Exception: ', '')}'), behavior: SnackBarBehavior.floating),
);
}
} finally {
if (mounted) {
setState(() => _isSaving = false);
}
if (mounted) setState(() => _isSaving = false);
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.background,
appBar: AppBar(
title: const Text('Конфиденциальность'),
title: const Text('Видимость данных', style: TextStyle(fontWeight: FontWeight.bold)),
elevation: 0,
backgroundColor: Colors.transparent,
actions: [
TextButton(
onPressed: _isSaving ? null : _saveToServer,
child: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text(
'Сохранить',
style: TextStyle(color: Colors.white),
),
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Center(
child: _isSaving
? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary))
: TextButton.icon(
onPressed: _saveToServer,
icon: const Icon(Icons.save_rounded, size: 18),
label: const Text('Сохранить'),
),
),
),
],
),
body: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
const Text('Настройки видимости', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
SwitchListTile(
title: const Text('Показывать почту другим'),
value: _showEmail,
onChanged: (value) {
setState(() => _showEmail = value);
},
const Padding(
padding: EdgeInsets.only(left: 8.0, bottom: 12),
child: Text('Кто видит мою информацию:', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, letterSpacing: 0.5)),
),
SwitchListTile(
title: const Text('Показывать телефон другим'),
value: _showPhone,
onChanged: (value) {
setState(() => _showPhone = value);
},
),
SwitchListTile(
title: const Text('Показывать аватар другим'),
value: _showAvatar,
onChanged: (value) {
setState(() => _showAvatar = value);
},
),
SwitchListTile(
title: const Text('Показывать информацию «О себе»'),
value: _showAbout,
onChanged: (value) {
setState(() => _showAbout = value);
},
),
SwitchListTile(
title: const Text('Показывать последний онлайн'),
value: _showLastOnline,
onChanged: (value) {
setState(() => _showLastOnline = value);
},
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.2),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
),
child: Column(
children: [
_buildSwitchTile('Показывать почту другим', _showEmail, (v) => setState(() => _showEmail = v)),
_buildDivider(),
_buildSwitchTile('Показывать телефон другим', _showPhone, (v) => setState(() => _showPhone = v)),
_buildDivider(),
_buildSwitchTile('Показывать аватар другим', _showAvatar, (v) => setState(() => _showAvatar = v)),
_buildDivider(),
_buildSwitchTile('Показывать информацию «О себе»', _showAbout, (v) => setState(() => _showAbout = v)),
_buildDivider(),
_buildSwitchTile('Показывать последний онлайн', _showLastOnline, (v) => setState(() => _showLastOnline = v)),
],
),
),
const SizedBox(height: 24),
const Text(
'Эти настройки влияют на то, какую информацию о вас видят другие пользователи приложения.',
style: TextStyle(color: Colors.grey),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.04),
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.lock_person_rounded, color: colorScheme.primary, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
'Эти настройки напрямую влияют на то, какие персональные данные будут доступны другим участникам в глобальном поиске и карточках чатов.',
style: TextStyle(color: colorScheme.outline, fontSize: 13, height: 1.4),
),
),
],
),
),
],
),
);
}
}
Widget _buildSwitchTile(String title, bool value, ValueChanged<bool> onChanged) {
return SwitchListTile(
title: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
value: value,
onChanged: onChanged,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
);
}
Widget _buildDivider() => Divider(height: 1, indent: 20, endIndent: 20, color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.2));
}

View File

@ -15,7 +15,6 @@ class SecuritySettingsScreen extends StatefulWidget {
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
final _passwordFormKey = GlobalKey<FormState>();
final _encryptionFormKey = GlobalKey<FormState>();
//final _totpFormKey = GlobalKey<FormState>();
final _currentPasswordController = TextEditingController();
final _newPasswordController = TextEditingController();
@ -64,9 +63,7 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
});
} catch (_) {
if (!mounted) return;
setState(() {
_isBiometricAvailable = false;
});
setState(() => _isBiometricAvailable = false);
}
}
@ -74,15 +71,11 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
try {
final api = ApiService();
final userData = await api.getMe();
print('TOTP status from getMe: ${userData['totp_enabled']}');
if (!mounted) return;
setState(() {
_isTotpEnabled = userData['totp_enabled'] ?? false;
});
print('TOTP status set to: $_isTotpEnabled');
} catch (e) {
print('Error loading TOTP status: $e');
// Ignore errors, assume TOTP is disabled
if (!mounted) return;
setState(() => _isTotpEnabled = false);
}
@ -91,16 +84,14 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
Future<bool> _authenticateBiometric() async {
try {
return await _localAuth.authenticate(
localizedReason: 'Подтвердите личность для смены пароля шифрования',
localizedReason: 'Подтвердите личность для изменения крипто-пароля',
options: const AuthenticationOptions(
biometricOnly: false,
stickyAuth: false,
useErrorDialogs: true,
sensitiveTransaction: true,
stickyAuth: false,
),
);
} catch (error) {
debugPrint('Biometric authentication error: $error');
return false;
}
}
@ -116,31 +107,33 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
_newPasswordController.text.trim(),
);
if (!success) {
throw Exception('Не удалось изменить пароль');
}
if (!success) throw Exception('Не удалось изменить пароль');
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Пароль успешно изменён')));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Основной пароль успешно обновлен'),
behavior: SnackBarBehavior.floating,
),
);
_currentPasswordController.clear();
_newPasswordController.clear();
_confirmPasswordController.clear();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
SnackBar(
content: Text(e.toString().replaceAll('Exception: ', '')),
behavior: SnackBarBehavior.floating,
),
);
} finally {
if (!mounted) return;
setState(() => _isSavingPassword = false);
if (mounted) setState(() => _isSavingPassword = false);
}
}
Future<void> _saveEncryptionPassword() async {
await _checkBiometricSupport();
if (!_encryptionFormKey.currentState!.validate()) return;
setState(() => _isSavingEncryption = true);
@ -151,29 +144,22 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
String privateKeyBase64;
if (currentPassword.isEmpty) {
if (!_isBiometricAvailable) {
throw Exception('Биометрия не настроена. Введите текущий пароль.');
}
if (!_isBiometricAvailable)
throw Exception('Биометрия недоступна. Введите пароль.');
final authenticated = await _authenticateBiometric();
if (!authenticated) {
throw Exception('Биометрическая аутентификация не пройдена.');
}
if (!authenticated) throw Exception('Аутентификация отменена.');
final localPrivateKey = await cryptoService.getPrivateKey();
if (localPrivateKey == null || localPrivateKey.isEmpty) {
throw Exception('Локальный приватный ключ не найден.');
}
if (localPrivateKey == null || localPrivateKey.isEmpty)
throw Exception('Локальный ключ отсутствует.');
privateKeyBase64 = localPrivateKey;
} else {
final api = ApiService();
final userData = await api.getMe();
final encryptedPrivateKey = userData['encrypted_private_key']
?.toString();
if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty) {
throw Exception('Зашифрованный ключ не найден на сервере.');
}
if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty)
throw Exception('Ключ не найден на сервере.');
privateKeyBase64 = await cryptoService.decryptPrivateKey(
encryptedPrivateKey,
@ -184,17 +170,17 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
final updatedEncryptedPrivateKey = await cryptoService
.encryptPrivateKeyWithPassword(privateKeyBase64, newPassword);
final success = await ApiService().updateEncryptedPrivateKey(
updatedEncryptedPrivateKey,
);
if (!success) {
throw Exception('Не удалось обновить пароль шифрования на сервере.');
}
if (!success) throw Exception('Сервер отклонил обновление крипто-ключа.');
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Пароль шифрования успешно обновлён')),
const SnackBar(
content: Text('Крипто-пароль успешно изменен'),
behavior: SnackBarBehavior.floating,
),
);
_currentEncryptPasswordController.clear();
_newEncryptPasswordController.clear();
@ -202,20 +188,20 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
SnackBar(
content: Text(e.toString().replaceAll('Exception: ', '')),
behavior: SnackBarBehavior.floating,
),
);
} finally {
if (!mounted) return;
setState(() => _isSavingEncryption = false);
if (mounted) setState(() => _isSavingEncryption = false);
}
}
Future<void> _setupTotp() async {
if (_isTotpEnabled) {
// Показываем диалог с опциями
_showTotpOptionsDialog();
} else {
// Enable TOTP
setState(() => _isSavingTotp = true);
try {
final api = ApiService();
@ -224,11 +210,13 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
_totpSecret = data['secret'];
_totpQrCode = data['qr_code'];
});
// Show dialog to scan QR and enter code
_showTotpSetupDialog();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
SnackBar(
content: Text(e.toString().replaceAll('Exception: ', '')),
behavior: SnackBarBehavior.floating,
),
);
} finally {
setState(() => _isSavingTotp = false);
@ -240,57 +228,35 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('TOTP'),
content: const Text('TOTP включён. Выберите действие:'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Защита TOTP активна'),
content: const Text(
'Выберите необходимое действие для управления двухфакторной аутентификацией.',
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_reissueTotp();
},
child: const Text('Перевыпустить ключ'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
Navigator.pop(context);
_disableTotp();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
backgroundColor: Theme.of(context).colorScheme.error,
elevation: 0,
),
child: const Text(
'Отключить 2FA',
style: TextStyle(color: Colors.white),
),
child: const Text('Отключить TOTP'),
),
],
),
);
}
Future<void> _reissueTotp() async {
setState(() => _isSavingTotp = true);
try {
final api = ApiService();
final data = await api.enableTotp();
setState(() {
_totpSecret = data['secret'];
_totpQrCode = data['qr_code'];
});
// Show dialog to scan QR and enter code
_showTotpSetupDialog(isReissue: true);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
} finally {
setState(() => _isSavingTotp = false);
}
}
Future<void> _disableTotp() async {
setState(() => _isSavingTotp = true);
try {
@ -303,122 +269,130 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
_totpQrCode = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('TOTP отключён')),
const SnackBar(
content: Text('Двухфакторная защита отключена'),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
SnackBar(
content: Text(e.toString().replaceAll('Exception: ', '')),
behavior: SnackBarBehavior.floating,
),
);
} finally {
setState(() => _isSavingTotp = false);
}
}
void _showTotpSetupDialog({bool isReissue = false}) {
void _showTotpSetupDialog() {
final codeController = TextEditingController();
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(isReissue ? 'Перевыпуск ключа TOTP' : 'Настройка TOTP'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
title: const Text('Активация TOTP 2FA'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(isReissue
? 'Отсканируйте новый QR-код в приложении аутентификатора:'
: 'Отсканируйте QR-код в приложении аутентификатора:'),
const Text(
'Сканируйте код приложением аутентификатора (Google Authenticator / Aegis):',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
if (_totpQrCode != null)
Builder(
builder: (context) {
final base64String = _totpQrCode!.split(',').last;
final bytes = base64Decode(base64String);
return Image.memory(bytes, width: 200, height: 200);
},
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.memory(
base64Decode(_totpQrCode!.split(',').last),
width: 180,
height: 180,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text(
'Ключ: ${_totpSecret ?? ''}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
overflow: TextOverflow.ellipsis,
maxLines: 1,
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: Text(
_totpSecret ?? '',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () {
if (_totpSecret != null) {
Clipboard.setData(ClipboardData(text: _totpSecret!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ключ скопирован')),
);
}
},
tooltip: 'Скопировать ключ',
),
],
IconButton(
icon: const Icon(Icons.copy_rounded, size: 16),
onPressed: () {
if (_totpSecret != null) {
Clipboard.setData(ClipboardData(text: _totpSecret!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ключ скопирован в буфер'),
duration: Duration(seconds: 1),
),
);
}
},
),
],
),
),
const SizedBox(height: 16),
TextField(
controller: codeController,
decoration: const InputDecoration(
labelText: 'Введите код из приложения',
helperText: 'Обычно это 6 цифр',
),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '6-значный одноразовый код',
border: OutlineInputBorder(),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
setState(() {
_totpSecret = null;
_totpQrCode = null;
});
},
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () async {
final code = codeController.text.trim();
if (code.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Введите код')),
);
return;
}
if (codeController.text.trim().isEmpty) return;
try {
final api = ApiService();
final success = await api.verifyTotp(code);
final success = await ApiService().verifyTotp(
codeController.text.trim(),
);
if (success) {
Navigator.of(context).pop();
setState(() {
_isTotpEnabled = true;
_totpSecret = null;
_totpQrCode = null;
});
Navigator.pop(context);
setState(() => _isTotpEnabled = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(isReissue ? 'Ключ перевыпущен' : 'TOTP включён')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Неверный код')),
const SnackBar(
content: Text('Двухфакторный ключ успешно привязан'),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
SnackBar(
content: Text(e.toString().replaceAll('Exception: ', '')),
),
);
}
},
@ -429,153 +403,253 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
);
}
String? _currentEncryptionPasswordValidator(String? value) {
if (value == null || value.isEmpty) {
if (!_isBiometricAvailable) {
return 'Введите текущий пароль';
}
}
return null;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Безопасность')),
backgroundColor: colorScheme.background,
appBar: AppBar(
title: const Text('Безопасность'),
elevation: 0,
backgroundColor: Colors.transparent,
),
body: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
const Text(
'Смена пароля аккаунта',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
// Модуль 1: Основной пароль
_buildCardSection(
title: 'Смена пароля аккаунта',
child: Form(
key: _passwordFormKey,
child: Column(
children: [
_buildFormInput(
_currentPasswordController,
'Текущий пароль',
true,
),
_buildFormInput(_newPasswordController, 'Новый пароль', true),
_buildFormInput(
_confirmPasswordController,
'Повторите новый пароль',
true,
validator: (v) {
if (v != _newPasswordController.text)
return 'Пароли не совпадают';
return null;
},
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isSavingPassword ? null : _savePassword,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isSavingPassword
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Обновить основной пароль'),
),
),
],
),
),
),
const SizedBox(height: 12),
Form(
key: _passwordFormKey,
child: Column(
const SizedBox(height: 20),
// Модуль 2: Сквозное шифрование
_buildCardSection(
title: 'Пароль сквозного шифрования (E2EE)',
child: Form(
key: _encryptionFormKey,
child: Column(
children: [
_buildFormInput(
_currentEncryptPasswordController,
_isBiometricAvailable
? 'Оставьте пустым и подтвердите биометрией'
: 'Текущий крипто-пароль',
true,
hint: _isBiometricAvailable
? 'Подтвердите биометрией'
: null,
validator: (v) {
// Если биометрия доступна на устройстве, поле МОЖЕТ быть пустым
if (_isBiometricAvailable) return null;
// Если биометрии нет, то поле становится строго обязательным
if (v == null || v.isEmpty)
return 'Введите текущий пароль';
return null;
},
),
_buildFormInput(
_newEncryptPasswordController,
'Новый крипто-пароль',
true,
),
_buildFormInput(
_confirmEncryptPasswordController,
'Повторите новый крипто-пароль',
true,
validator: (v) {
if (v != _newEncryptPasswordController.text)
return 'Пароли не совпадают';
return null;
},
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isSavingEncryption
? null
: _saveEncryptionPassword,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isSavingEncryption
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Обновить ключ шифрования'),
),
),
],
),
),
),
const SizedBox(height: 20),
// Модуль 3: Двухфакторная аутентификация
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.2),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.1),
),
),
child: Row(
children: [
TextFormField(
controller: _currentPasswordController,
decoration: const InputDecoration(
labelText: 'Текущий пароль',
Icon(
Icons.lock_clock_rounded,
color: colorScheme.primary,
size: 28,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Двухфакторная защита (TOTP)',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
const SizedBox(height: 2),
Text(
_isTotpEnabled
? 'Статус: Активна'
: 'Статус: Отключена',
style: TextStyle(
color: _isTotpEnabled
? Colors.green
: colorScheme.outline,
fontSize: 13,
),
),
],
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty)
return 'Введите текущий пароль';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _newPasswordController,
decoration: const InputDecoration(labelText: 'Новый пароль'),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty)
return 'Введите новый пароль';
if (value.length < 6) return 'Пароль слишком короткий';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _confirmPasswordController,
decoration: const InputDecoration(
labelText: 'Повторите пароль',
),
obscureText: true,
validator: (value) {
if (value != _newPasswordController.text)
return 'Пароли не совпадают';
return null;
},
),
const SizedBox(height: 14),
ElevatedButton(
onPressed: _isSavingPassword ? null : _savePassword,
child: _isSavingPassword
? const CircularProgressIndicator(color: Colors.white)
: const Text('Сохранить пароль'),
onPressed: _isSavingTotp ? null : _setupTotp,
style: ElevatedButton.styleFrom(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(_isTotpEnabled ? 'Опции' : 'Включить'),
),
],
),
),
const SizedBox(height: 24),
const Text(
'Пароль шифрования сообщений',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Form(
key: _encryptionFormKey,
child: Column(
children: [
TextFormField(
controller: _currentEncryptPasswordController,
decoration: InputDecoration(
labelText: 'Текущий пароль шифрования',
helperText: _isBiometricAvailable
? 'Оставьте поле пустым и подтвердите биометрией'
: 'Требуется текущий пароль',
),
obscureText: true,
validator: _currentEncryptionPasswordValidator,
),
const SizedBox(height: 12),
TextFormField(
controller: _newEncryptPasswordController,
decoration: const InputDecoration(
labelText: 'Новый пароль шифрования',
),
obscureText: true,
validator: (value) {
if (value == null || value.length < 6)
return 'Пароль слишком короткий';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _confirmEncryptPasswordController,
decoration: const InputDecoration(
labelText: 'Повторите новый пароль',
),
obscureText: true,
validator: (value) {
if (value != _newEncryptPasswordController.text)
return 'Пароли не совпадают';
return null;
},
),
const SizedBox(height: 14),
ElevatedButton(
onPressed: _isSavingEncryption
? null
: _saveEncryptionPassword,
child: _isSavingEncryption
? const CircularProgressIndicator(color: Colors.white)
: const Text('Сохранить пароль шифрования'),
),
],
),
),
const SizedBox(height: 24),
const Text(
'TOTP',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Text(_isTotpEnabled ? 'TOTP включён' : 'TOTP отключён'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _isSavingTotp ? null : _setupTotp,
child: _isSavingTotp
? const CircularProgressIndicator(color: Colors.white)
: Text(_isTotpEnabled ? 'Отключить TOTP' : 'Включить TOTP'),
),
],
),
);
}
Widget _buildCardSection({required String title, required Widget child}) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.2),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.1)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
letterSpacing: -0.2,
),
),
const SizedBox(height: 16),
child,
],
),
);
}
Widget _buildFormInput(
TextEditingController controller,
String label,
bool obscure, {
String? hint,
String? Function(String?)? validator,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: TextFormField(
controller: controller,
obscureText: obscure,
validator:
validator ??
(v) => (v == null || v.isEmpty) ? 'Обязательное поле' : null,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
),
),
);
}
}

View File

@ -8,9 +8,11 @@ import '/logic/auth_provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
import 'admin_panel_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
final bool isFromList;
const SettingsScreen({super.key, this.isFromList = true});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
@ -19,6 +21,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
String? versionCode;
final ImagePicker _picker = ImagePicker();
bool _isAvatarExpanded = false;
@override
void initState() {
@ -38,8 +41,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _pickAvatar() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image != null) {
final success = await context.read<AuthProvider>().updateAvatar(image.path);
if (!success) {
final success = await context.read<AuthProvider>().updateAvatar(
image.path,
);
if (!success && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ошибка загрузки аватарки')),
);
@ -50,168 +55,337 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
final authProv = context.watch<AuthProvider>();
final colorScheme = Theme.of(context).colorScheme;
final screenWidth = MediaQuery.of(context).size.width;
final accountUsername = authProv.username?.isNotEmpty == true
? '@${authProv.username!}'
: 'Не указано';
String platformName = Platform.isAndroid
? 'Android'
: Platform.isIOS
? 'iOS'
: Platform.isWindows
? 'Windows'
: Platform.isLinux
? 'Linux'
: Platform.isMacOS
? 'macOS'
: 'Unknown';
final username = authProv.username;
final displayName = authProv.displayName;
final initials = (displayName.isNotEmpty ? displayName : (username ?? 'U'))
.trim()
.split(RegExp(r'\s+'))
.where((p) => p.isNotEmpty)
.take(2)
.map((p) => p[0].toUpperCase())
.join();
final String fullName =
'${authProv.firstName ?? ''} ${authProv.lastName ?? ''}'.trim();
final String username = authProv.username ?? '';
ImageProvider? avatarImage;
if (authProv.avatarUrl != null) {
avatarImage = NetworkImage(authProv.avatarUrl!);
} else if (authProv.avatarPath != null) {
avatarImage = FileImage(File(authProv.avatarPath!));
}
final initials =
(authProv.displayName.isNotEmpty
? authProv.displayName
: (username.isNotEmpty ? username : 'U'))
.trim()
.split(RegExp(r'\s+'))
.where((p) => p.isNotEmpty)
.take(2)
.map((p) => p[0].toUpperCase())
.join();
return Scaffold(
appBar: AppBar(title: const Text("Настройки")),
body: Column(
backgroundColor: colorScheme.background,
appBar: (Platform.isWindows || !widget.isFromList)
? AppBar(
title: const Text(
'Настройки',
style: TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Colors.transparent,
)
: null,
body: ListView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.zero,
children: [
// Секция Профиля
UserAccountsDrawerHeader(
accountName: Text(
authProv.displayName,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
accountEmail: Text(
accountUsername,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
currentAccountPicture: GestureDetector(
onTap: _pickAvatar,
child: SizedBox(
width: 80,
height: 80,
child: Stack(
children: [
authProv.avatarUrl != null
? CircleAvatar(
radius: 40,
backgroundImage: NetworkImage(authProv.avatarUrl!),
)
: authProv.avatarPath != null
? CircleAvatar(
radius: 40,
backgroundImage: FileImage(File(authProv.avatarPath!)),
)
: CircleAvatar(
radius: 40,
child: Text(
initials.isEmpty ? 'U' : initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
Positioned(
bottom: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
// Анимированный интерактивный аватар как в MyProfileScreen
GestureDetector(
onTap: () => setState(() => _isAvatarExpanded = !_isAvatarExpanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 350),
curve: Curves.fastOutSlowIn,
width: _isAvatarExpanded ? screenWidth : 130.0,
height: _isAvatarExpanded ? screenWidth : 130.0,
margin: _isAvatarExpanded
? EdgeInsets.zero
: const EdgeInsets.only(top: 16, bottom: 8),
decoration: BoxDecoration(
shape: _isAvatarExpanded ? BoxShape.rectangle : BoxShape.circle,
color: colorScheme.primaryContainer.withOpacity(0.4),
boxShadow: _isAvatarExpanded
? []
: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
child: Icon(
Icons.camera_alt,
size: 16,
color: Theme.of(context).colorScheme.onPrimary,
],
image: avatarImage != null
? DecorationImage(image: avatarImage, fit: BoxFit.cover)
: null,
),
child: avatarImage == null
? Center(
child: Text(
initials.isEmpty ? 'U' : initials,
style: TextStyle(
fontSize: _isAvatarExpanded ? 80 : 38,
fontWeight: FontWeight.w700,
color: colorScheme.onPrimaryContainer,
),
),
)
: null,
),
),
// Имя пользователя
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
fullName.isNotEmpty
? fullName
: (authProv.displayName.isNotEmpty
? authProv.displayName
: 'Имя не указано'),
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
textAlign: TextAlign.center,
),
),
if (username.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4, bottom: 24),
child: Text(
'@$username',
style: TextStyle(
color: colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
)
else
const SizedBox(height: 24),
// Секция навигации меню (Сгруппированный контейнер)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.2),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.1),
),
),
child: Column(
children: [
_buildMenuTile(
context,
Icons.person_outline_rounded,
'Аккаунт',
'Имя, телефон, почта, о себе',
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AccountSettingsScreen(),
),
),
],
),
_buildDivider(context),
_buildMenuTile(
context,
Icons.shield_outlined,
'Конфиденциальность',
'Безопасность и видимость данных',
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PrivacySettingsMenuScreen(),
),
),
),
_buildDivider(context),
_buildMenuTile(
context,
Icons.palette_outlined,
'Оформление',
'Тема, цвета, обои чата',
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AppearanceSettingsScreen(),
),
),
),
],
),
),
),
if (authProv.currentUserId == 1) ...[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: colorScheme.primary.withOpacity(0.2),
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
),
leading: Icon(
Icons.admin_panel_settings_rounded,
color: colorScheme.primary,
),
title: Text(
"Админ-панель",
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
subtitle: const Text("Управление пользователями системы"),
trailing: Icon(
Icons.chevron_right_rounded,
color: colorScheme.primary,
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AdminPanelScreen(),
),
);
},
),
),
),
decoration: const BoxDecoration(color: Colors.transparent),
),
],
const Divider(),
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('Аккаунт'),
subtitle: const Text('Имя, телефон, почта, информация о себе'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AccountSettingsScreen(),
),
);
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.shield_outlined),
title: const Text('Конфиденциальность'),
subtitle: const Text('Безопасность и видимость данных профиля'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PrivacySettingsMenuScreen(),
),
);
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.palette),
title: const Text('Оформление'),
subtitle: const Text('Тема, цвета, обои'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AppearanceSettingsScreen(),
),
);
},
),
const Divider(),
const SizedBox(height: 16),
const Divider(),
// Выход
ListTile(
leading: const Icon(Icons.exit_to_app, color: Colors.red),
title: const Text(
"Выйти из аккаунта",
style: TextStyle(color: Colors.red),
// Кнопка выхода из аккаунта
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
decoration: BoxDecoration(
color: colorScheme.errorContainer.withOpacity(0.15),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: colorScheme.errorContainer.withOpacity(0.2),
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
),
leading: Icon(Icons.logout_rounded, color: colorScheme.error),
title: Text(
"Выйти из аккаунта",
style: TextStyle(
color: colorScheme.error,
fontWeight: FontWeight.w600,
),
),
trailing: Icon(
Icons.chevron_right_rounded,
color: colorScheme.error,
),
onTap: () async {
await authProv.logout();
if (context.mounted) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => const LoginScreen()),
(r) => false,
);
}
},
),
),
onTap: () async {
await authProv.logout();
if (context.mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
},
),
const Spacer(),
const SizedBox(height: 40),
Center(
child: Text(
"Chepuhagram for Android v$versionCode",
style: TextStyle(color: Colors.grey, fontSize: 12),
"Chepuhagram for $platformName v${versionCode ?? '1.0.0'}",
style: TextStyle(color: colorScheme.outline, fontSize: 12),
),
),
const Center(
const SizedBox(height: 4),
Center(
child: Text(
"Made by ArturKarasevich",
style: TextStyle(color: Colors.grey, fontSize: 12),
style: TextStyle(
color: colorScheme.outline.withOpacity(0.6),
fontSize: 11,
),
),
),
const Spacer(),
const SizedBox(height: 40),
],
),
);
}
Widget _buildMenuTile(
BuildContext context,
IconData icon,
String title,
String subtitle,
VoidCallback onTap,
) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: colorScheme.primary, size: 22),
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
subtitle: Text(
subtitle,
style: TextStyle(color: colorScheme.outline, fontSize: 13),
),
trailing: Icon(Icons.chevron_right_rounded, color: colorScheme.outline),
onTap: onTap,
);
}
Widget _buildDivider(BuildContext context) => Divider(
height: 1,
indent: 68,
color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.15),
);
}

View File

@ -5,18 +5,20 @@ import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'package:provider/provider.dart';
import '/core/constants.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'dart:io';
class UserProfileScreen extends StatefulWidget {
final int userId;
final String username;
final String name;
final VoidCallback? onClose;
const UserProfileScreen({
super.key,
required this.userId,
required this.username,
required this.name,
this.onClose,
});
@override
@ -38,9 +40,7 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
super.initState();
_loadUserData();
startOnlineUpdates();
DateTime now = DateTime.now();
offset = now.timeZoneOffset;
final socketService = Provider.of<SocketService>(context, listen: false);
@ -54,19 +54,12 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
}
Future<void> _loadUserData() async {
_error = null;
_isLoading = true;
try {
final api = ApiService();
final data = await api.getUserById(widget.userId);
final prefs = await SharedPreferences.getInstance();
firstName = prefs.containsKey('firstname_${widget.userId}')
? prefs.getString('firstname_${widget.userId}')
: null;
lastName = prefs.containsKey('lastname_${widget.userId}')
? prefs.getString('lastname_${widget.userId}')
: null;
firstName = prefs.getString('firstname_${widget.userId}');
lastName = prefs.getString('lastname_${widget.userId}');
if (mounted) {
setState(() {
_userData = data;
@ -76,18 +69,12 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
} catch (e) {
if (mounted) {
setState(() {
if (e.toString().contains('SocketFailed')) {
_error =
'Ошибка соединения с сервером. Проверьте интернет соединение.';
} else {
_error = e.toString().replaceAll('Exception: ', '');
}
_error = e.toString().contains('SocketFailed')
? 'Соединение разорвано'
: e.toString().replaceAll('Exception: ', '');
_isLoading = false;
});
}
Future.delayed(Duration(seconds: 2), () {
_loadUserData();
});
}
}
@ -98,289 +85,29 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Информация о пользователе')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(_error!, textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadUserData,
child: const Text('Повторить'),
),
],
),
)
: _buildUserInfo(),
);
}
String _formatLastSeen(String? lastSeenStr) {
if (lastSeenStr == null) return 'Был(а) недавно';
final lastSeen = DateTime.tryParse(lastSeenStr);
if (lastSeen == null) return 'Был(а) недавно';
Widget _buildUserInfo() {
if (_userData == null) return const SizedBox.shrink();
final String displayFN = firstName ?? _userData?['first_name'] ?? '';
final String displayLN = lastName ?? _userData?['last_name'] ?? '';
final String username = _userData?['username'] ?? '';
final rawAvatarUrl = _userData?['avatar_url']?.toString();
final avatarUrl = rawAvatarUrl != null && rawAvatarUrl.startsWith('/')
? '${AppConstants.baseUrl}$rawAvatarUrl'
: rawAvatarUrl;
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Avatar placeholder
Center(
child: CircleAvatar(
radius: 50,
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
backgroundImage:
(avatarUrl != null && _userData?['show_avatar'] == true)
? CachedNetworkImageProvider(avatarUrl)
: null,
child: (avatarUrl == null || _userData?['show_avatar'] != true)
? Text(
(displayFN.isNotEmpty && displayLN.isNotEmpty)
? '${displayFN[0]}${displayLN[0]}'.toUpperCase()
: (displayFN.isNotEmpty)
? displayFN[0].toUpperCase()
: (username.isNotEmpty)
? username[0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
)
: null,
),
),
const SizedBox(height: 24),
// Name
GestureDetector(
onTap: () => {_editUserName(displayFN, displayLN)},
child: Row(
children: [
const Spacer(),
if ((displayFN.isNotEmpty) || (displayLN.isNotEmpty))
Text(
'$displayFN $displayLN'.trim(),
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(width: 5),
Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurface),
const Spacer(),
],
),
),
const SizedBox(height: 8),
// Username
if (_userData!['username'] != null && _userData!['username'].isNotEmpty)
Text(
'@${_userData!['username']}',
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: Colors.grey[600]),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Last online status
if (_userData!['online'] == true)
const Text(
'Онлайн',
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
textAlign: TextAlign.center,
)
else if (_userData!['last_online'] != null &&
DateTime.tryParse(_userData!['last_online']) != null)
Text(
'Был(а) в сети ${_formatLastOnline(DateTime.tryParse(_userData!['last_online'])!.add(offset != null ? offset! : Duration.zero))}',
style: const TextStyle(
fontSize: 12,
color: Color.fromARGB(255, 161, 161, 161),
),
textAlign: TextAlign.center,
)
else
const Text(
'Был(а) недавно',
style: TextStyle(
fontSize: 12,
color: Color.fromARGB(255, 161, 161, 161),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// User ID
_buildInfoTile('ID пользователя', _userData!['id'].toString()),
// Public Key (if available)
if (_userData!['public_key'] != null)
_buildInfoTile(
'Публичный ключ',
_userData!['public_key'],
maxLines: 3,
),
// About
if (_userData!['about'] != null && _userData!['about'].isNotEmpty)
_buildInfoTile('О себе', _userData!['about'], maxLines: 5),
// Phone
if (_userData!['phone'] != null && _userData!['phone'].isNotEmpty)
_buildInfoTile('Телефон', _userData!['phone']),
// Email
if (_userData!['email'] != null && _userData!['email'].isNotEmpty)
_buildInfoTile('Почта', _userData!['email']),
const SizedBox(height: 16),
if ((_userData!['username'] == null ||
_userData!['username'].isEmpty) &&
(_userData!['first_name'] == null ||
_userData!['first_name'].isEmpty) &&
(_userData!['last_name'] == null ||
_userData!['last_name'].isEmpty) &&
(_userData!['about'] == null || _userData!['about'].isEmpty) &&
(_userData!['phone'] == null || _userData!['phone'].isEmpty) &&
(_userData!['email'] == null || _userData!['email'].isEmpty))
const Text(
'Пользователь скрыл дополнительную информацию',
style: TextStyle(color: Colors.grey),
textAlign: TextAlign.center,
),
],
);
}
Future<void> _editUserName(String firstname, String lastname) async {
final firstnameController = TextEditingController(text: firstname);
final lastnameController = TextEditingController(text: lastname);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Изменить имя пользователя'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: firstnameController,
minLines: 1,
maxLines: 5,
autofocus: true,
decoration: const InputDecoration(hintText: 'Имя'),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 8),
TextField(
controller: lastnameController,
minLines: 1,
maxLines: 5,
decoration: const InputDecoration(hintText: 'Фамилия'),
textCapitalization: TextCapitalization.words,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Сбрость'),
),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Сохранить'),
),
],
),
);
final prefs = await SharedPreferences.getInstance();
if (result == true) {
if (firstname != firstnameController.text) {
prefs.setString('firstname_${widget.userId}', firstnameController.text);
}
if (lastname != lastnameController.text) {
prefs.setString('lastname_${widget.userId}', lastnameController.text);
}
if (mounted) {
setState(() {});
}
_loadUserData();
} else {
prefs.remove('firstname_${widget.userId}');
prefs.remove('lastname_${widget.userId}');
if (mounted) {
setState(() {});
}
_loadUserData();
}
}
void _handleIncomingMessage(Map<String, dynamic> data) async {
if (data['type'] == 'user_online') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId == widget.userId) {
if (mounted) {
setState(() {
_userData = _userData?..['online'] = true;
});
}
}
}
if (data['type'] == 'user_offline') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId == widget.userId) {
setState(() {
_userData = _userData?..['online'] = false;
_userData = _userData
?..['last_online'] = DateTime.now().toIso8601String();
});
}
}
if (data['type'] == 'user_updated') {
print('User updated message received, refreshing contact list');
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null && userId == widget.userId) {
_loadUserData();
}
}
}
String _formatLastOnline(DateTime lastOnline) {
// Применяем локальный офсет часового пояса, если необходимо
final localLastSeen = offset != null ? lastSeen.add(offset!) : lastSeen;
final now = DateTime.now();
final difference = now.difference(lastOnline);
final difference = now.difference(localLastSeen);
if (difference.inSeconds < 60) {
return 'только что';
if (difference.inMinutes < 1) {
return 'Был(а) только что';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes} минут${_pluralize(difference.inMinutes, "у", "ы", "")} назад';
return 'Был(а) ${difference.inMinutes} ${_pluralize(difference.inMinutes, "минуту", "минуты", "минут")} назад';
} else if (difference.inHours < 24) {
return '${difference.inHours} час${_pluralize(difference.inHours, "", "а", "ов")} назад';
return 'Был(а) ${difference.inHours} ${_pluralize(difference.inHours, "час", "часа", "часов")} назад';
} else if (difference.inDays < 7) {
return '${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
return 'Был(а) ${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
} else if (difference.inDays < 30) {
final weeks = (difference.inDays / 7).floor();
return '$weeks ${_pluralize(weeks, "неделю", "недели", "недель")} назад';
return 'Был(а) $weeks ${_pluralize(weeks, "неделю", "недели", "недель")} назад';
} else {
return 'давно';
return 'Был(а) давно';
}
}
@ -396,30 +123,388 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
}
}
Widget _buildInfoTile(String label, String value, {int maxLines = 1}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Colors.grey,
),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(fontSize: 16),
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
),
const Divider(),
],
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colorScheme.background,
body: SafeArea(
child: Stack(
children: [
// Основное содержимое экрана
_buildMainContent(colorScheme),
if (Platform.isWindows) ...[
Positioned(
top: 12,
right: 16,
child: ClipOval(
child: Material(
child: IconButton(
icon: const Icon(Icons.close_rounded),
color: colorScheme.onSurfaceVariant,
onPressed: () {
if (widget.onClose != null) {
widget.onClose!();
} else if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
),
),
),
),
] else if (Platform.isAndroid) ...[
Positioned(
top: 12,
left: 16,
child: ClipOval(
child: Material(
child: IconButton(
icon: const Icon(Icons.arrow_back),
color: colorScheme.onSurfaceVariant,
onPressed: () {
if (widget.onClose != null) {
widget.onClose!();
} else if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
),
),
),
),
],
],
),
),
);
}
Widget _buildMainContent(ColorScheme colorScheme) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
_error!,
style: TextStyle(
color: colorScheme.error,
fontSize: 16,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
);
}
return _buildUserInfo();
}
Widget _buildUserInfo() {
if (_userData == null) return const SizedBox.shrink();
final colorScheme = Theme.of(context).colorScheme;
final String displayFN = firstName ?? _userData?['first_name'] ?? '';
final String displayLN = lastName ?? _userData?['last_name'] ?? '';
final String combinedName = '$displayFN $displayLN'.trim();
final String username = _userData?['username'] ?? '';
final rawAvatarUrl = _userData?['avatar_url']?.toString();
final avatarUrl = rawAvatarUrl != null && rawAvatarUrl.startsWith('/')
? '${AppConstants.baseUrl}$rawAvatarUrl'
: rawAvatarUrl;
return ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.only(top: 44, bottom: 24),
children: [
Center(
child: Container(
width: 110,
height: 110,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer.withOpacity(0.5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 16,
offset: const Offset(0, 8),
),
],
image: (avatarUrl != null && _userData?['show_avatar'] == true)
? DecorationImage(
image: NetworkImage(avatarUrl),
fit: BoxFit.cover,
)
: null,
),
child: (avatarUrl == null || _userData?['show_avatar'] != true)
? Center(
child: Text(
combinedName.isNotEmpty
? combinedName[0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
)
: null,
),
),
const SizedBox(height: 20),
Center(
child: InkWell(
onTap: () => _editUserName(displayFN, displayLN),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
combinedName.isNotEmpty ? combinedName : 'Без имени',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Icon(
Icons.edit_rounded,
size: 16,
color: colorScheme.outline,
),
],
),
),
),
),
if (username.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'@$username',
style: TextStyle(
color: colorScheme.primary,
fontSize: 15,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 6),
_buildOnlineStatus(),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.2),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.1),
),
),
child: Column(
children: [
_buildInfoRow(
Icons.fingerprint_rounded,
_userData!['id'].toString(),
'ID пользователя',
true,
),
if (_userData!['about'] != null &&
_userData!['about'].toString().isNotEmpty)
_buildInfoRow(
Icons.info_outline_rounded,
_userData!['about'],
'О себе',
true,
),
if (_userData!['phone'] != null &&
_userData!['phone'].toString().isNotEmpty)
_buildInfoRow(
Icons.phone_android_rounded,
_userData!['phone'],
'Номер телефона',
true,
),
if (_userData!['email'] != null &&
_userData!['email'].toString().isNotEmpty)
_buildInfoRow(
Icons.mail_outline_rounded,
_userData!['email'],
'Электронная почта',
true,
),
_buildInfoRow(
Icons.key_rounded,
_userData!['public_key'] ?? 'Отсутствует',
'Публичный E2EE ключ',
false,
maxLines: 2,
),
],
),
),
),
],
);
}
Widget _buildOnlineStatus() {
if (_userData?['online'] == true) {
return const Text(
'В сети',
style: TextStyle(
color: Colors.green,
fontSize: 13,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
);
}
// Получаем строку последнего онлайна из данных сервера
final String? lastSeenStr = _userData?['last_online']?.toString();
return Text(
_formatLastSeen(lastSeenStr),
style: TextStyle(
color: Theme.of(context).colorScheme.outline,
fontSize: 13,
),
textAlign: TextAlign.center,
);
}
Widget _buildInfoRow(
IconData icon,
String value,
String label,
bool showDivider, {
int maxLines = 1,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 2,
),
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.06),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: colorScheme.primary, size: 18),
),
title: Text(
value,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
label,
style: TextStyle(fontSize: 12, color: colorScheme.outline),
),
),
if (showDivider)
Divider(
height: 1,
indent: 68,
color: colorScheme.outlineVariant.withOpacity(0.15),
),
],
);
}
Future<void> _editUserName(String firstname, String lastname) async {
final firstnameController = TextEditingController(text: firstname);
final lastnameController = TextEditingController(text: lastname);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('Задать локальное имя'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: firstnameController,
decoration: const InputDecoration(labelText: 'Имя'),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 8),
TextField(
controller: lastnameController,
decoration: const InputDecoration(labelText: 'Фамилия'),
textCapitalization: TextCapitalization.words,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Сбросить'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Сохранить'),
),
],
),
);
final prefs = await SharedPreferences.getInstance();
if (result == true) {
await prefs.setString(
'firstname_${widget.userId}',
firstnameController.text.trim(),
);
await prefs.setString(
'lastname_${widget.userId}',
lastnameController.text.trim(),
);
} else if (result == false) {
await prefs.remove('firstname_${widget.userId}');
await prefs.remove('lastname_${widget.userId}');
}
_loadUserData();
}
void _handleIncomingMessage(Map<String, dynamic> data) {
if (data['type'] == 'user_online' && data['user_id'] == widget.userId) {
if (mounted) setState(() => _userData?['online'] = true);
}
if (data['type'] == 'user_offline' && data['user_id'] == widget.userId) {
if (mounted) {
setState(() {
_userData?['online'] = false;
_userData?['last_online'] = DateTime.now().toIso8601String();
});
}
}
if (data['type'] == 'user_updated' && data['user_id'] == widget.userId) {
_loadUserData();
}
}
}

View File

@ -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

View File

@ -9,7 +9,9 @@
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
@ -22,9 +24,15 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar);
g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@ -6,7 +6,9 @@ list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux
flutter_secure_storage_linux
flutter_webrtc
record_linux
sqlite3_flutter_libs
url_launcher_linux
)

View File

@ -15,6 +15,7 @@ import firebase_messaging
import flutter_image_compress_macos
import flutter_local_notifications
import flutter_secure_storage_darwin
import flutter_webrtc
import gal
import local_auth_darwin
import package_info_plus
@ -23,6 +24,7 @@ import photo_manager
import record_macos
import shared_preferences_foundation
import sqflite_darwin
import sqlite3_flutter_libs
import url_launcher_macos
import video_compress
import video_player_avfoundation
@ -38,6 +40,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
@ -46,6 +49,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))

View File

@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "3b19a47f6ea7c2632760777c78174f47f6aec1e05f0cd611380d4593b8af1dbc"
url: "https://pub.dev"
source: hosted
version: "96.0.0"
_flutterfire_internals:
dependency: transitive
description:
@ -9,6 +17,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.35"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "0c516bc4ad36a1a75759e54d5047cb9d15cded4459df01aa35a0b5ec7db2c2a0"
url: "https://pub.dev"
source: hosted
version: "10.2.0"
archive:
dependency: transitive
description:
@ -97,30 +113,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
build:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
name: build
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
url: "https://pub.dev"
source: hosted
version: "4.0.6"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
build_runner:
dependency: "direct dev"
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
name: build_runner
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "2.15.0"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
url: "https://pub.dev"
source: hosted
version: "8.12.6"
camera:
dependency: "direct main"
description:
@ -161,6 +201,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.5+3"
camera_windows:
dependency: "direct main"
description:
name: camera_windows
sha256: "35d85c9eb707f6f43d08b82e9aba7b5f0347428f6334fd5d68ae9bd1bc9262af"
url: "https://pub.dev"
source: hosted
version: "0.2.6+4"
characters:
dependency: transitive
description:
@ -169,6 +217,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@ -249,6 +305,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
url: "https://pub.dev"
source: hosted
version: "3.1.7"
dart_webrtc:
dependency: transitive
description:
name: dart_webrtc
sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2
url: "https://pub.dev"
source: hosted
version: "1.8.1"
dbus:
dependency: transitive
description:
@ -273,6 +345,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
drift:
dependency: "direct main"
description:
name: drift
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
url: "https://pub.dev"
source: hosted
version: "2.31.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
url: "https://pub.dev"
source: hosted
version: "2.31.0"
drift_flutter:
dependency: "direct main"
description:
name: drift_flutter
sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7
url: "https://pub.dev"
source: hosted
version: "0.2.8"
drift_sqflite:
dependency: "direct main"
description:
name: drift_sqflite
sha256: dd1afbd72555b7a72ebf053926078d8c302059af4f1eb22040fc27a056429acb
url: "https://pub.dev"
source: hosted
version: "2.0.1"
extended_image:
dependency: transitive
description:
@ -462,14 +566,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_http_cache:
flutter_callkit_incoming:
dependency: "direct main"
description:
name: flutter_http_cache
sha256: "2227f5694d730622d6dad580b0e4fdfec6b5884868148101d13c61a09661fa78"
name: flutter_callkit_incoming
sha256: "3589deb8b71e43f2d520a9c8a5240243f611062a8b246cdca4b1fda01fbbf9b8"
url: "https://pub.dev"
source: hosted
version: "0.0.3"
version: "3.0.0"
flutter_image_compress:
dependency: "direct main"
description:
@ -546,26 +650,34 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
url: "https://pub.dev"
source: hosted
version: "17.2.4"
version: "21.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
url: "https://pub.dev"
source: hosted
version: "4.0.1"
version: "8.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
url: "https://pub.dev"
source: hosted
version: "7.2.0"
version: "11.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@ -632,6 +744,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: "direct main"
description:
name: flutter_webrtc
sha256: c7b0a67ca2c878575fc5c146d801cd874f58f5f1ef5fa6e8eb0c93d413beb948
url: "https://pub.dev"
source: hosted
version: "1.4.1"
gal:
dependency: "direct main"
description:
@ -640,6 +760,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
html:
dependency: transitive
description:
@ -664,6 +800,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
@ -752,6 +896,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
jni:
dependency: transitive
description:
@ -872,6 +1024,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.11"
logger:
dependency: transitive
description:
name: logger
sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@ -912,14 +1080,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
open_filex:
dependency: "direct main"
description:
@ -1104,6 +1264,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
posix:
dependency: transitive
description:
@ -1120,6 +1288,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
record:
dependency: "direct main"
description:
@ -1248,11 +1440,35 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
url: "https://pub.dev"
source: hosted
version: "4.2.3"
source_span:
dependency: transitive
description:
@ -1262,7 +1478,7 @@ packages:
source: hosted
version: "1.10.2"
sqflite:
dependency: "direct main"
dependency: transitive
description:
name: sqflite
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
@ -1281,10 +1497,18 @@ packages:
dependency: transitive
description:
name: sqflite_common
sha256: "5e8377564d95166761a968ed96104e0569b6b6cc611faac92a36ab8a169112c3"
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
url: "https://pub.dev"
source: hosted
version: "2.5.6+1"
version: "2.5.8"
sqflite_common_ffi:
dependency: "direct main"
description:
name: sqflite_common_ffi
sha256: "8d7b8749a516cbf6e9057f9b480b716ad14fc4f3d3873ca6938919cc626d9025"
url: "https://pub.dev"
source: hosted
version: "2.3.7+1"
sqflite_darwin:
dependency: transitive
description:
@ -1301,6 +1525,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
url: "https://pub.dev"
source: hosted
version: "0.5.42"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
url: "https://pub.dev"
source: hosted
version: "0.43.1"
stack_trace:
dependency: transitive
description:
@ -1361,10 +1609,10 @@ packages:
dependency: transitive
description:
name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
version: "0.11.0"
typed_data:
dependency: transitive
description:
@ -1525,6 +1773,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.1.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
@ -1549,6 +1805,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webrtc_interface:
dependency: transitive
description:
name: webrtc_interface
sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f
url: "https://pub.dev"
source: hosted
version: "1.5.1"
wechat_assets_picker:
dependency: "direct main"
description:
@ -1599,4 +1863,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0"
flutter: ">=3.38.1"

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2.0.2+1
version: 2.0.3+1
environment:
sdk: ^3.10.0
@ -41,11 +41,10 @@ dependencies:
jwt_decoder: ^2.0.1
web_socket_channel: ^3.0.3
cryptography: ^2.5.0
sqflite: ^2.3.0
path: ^1.9.0
firebase_core: ^2.24.2
firebase_messaging: ^14.7.10
flutter_local_notifications: ^17.2.2
flutter_local_notifications: ^21.0.0
firebase_analytics: ^10.10.7
shared_preferences: ^2.5.5
flutter_linkify: ^6.0.0
@ -56,13 +55,16 @@ dependencies:
package_info_plus: ^9.0.1
open_filex: ^4.7.0
convert: ^3.1.2
cached_network_image: ^3.3.1
flutter_cache_manager: ^3.0.2
path_provider: ^2.1.3
drift: ^2.17.0
drift_flutter: ^0.2.8
sqlite3_flutter_libs: ^0.5.24
sqflite_common_ffi: ^2.3.3
drift_sqflite: ^2.0.0
file_picker: ^11.0.2
video_compress: ^3.1.0
video_player: ^2.11.1
flutter_http_cache: ^0.0.3
image_picker: ^1.2.2
permission_handler: ^12.0.1
wechat_assets_picker: ^9.0.0
@ -74,10 +76,15 @@ dependencies:
record: ^6.2.0
audioplayers: ^6.6.0
ffmpeg_kit_flutter_new_min_gpl: ^2.1.1
flutter_callkit_incoming: ^3.0.0
flutter_webrtc: ^1.4.1
camera_windows: ^0.2.6+4
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.0
drift_dev: ^2.17.0
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
@ -90,6 +97,10 @@ dev_dependencies:
flutter_launcher_icons:
android: "launcher_icon"
windows:
generate: true
image_path: "assets/images/icon.png"
icon_size: 48
ios: true
image_path: "assets/images/icon.png"
remove_alpha_channel_ios: true

View File

@ -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": "Профиль успешно изменен администратором"}

View File

@ -1,4 +1,4 @@
from fastapi import FastAPI, Depends, HTTPException, status, APIRouter
from fastapi import FastAPI, Depends, HTTPException, status, APIRouter, Form
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core import security
@ -11,6 +11,7 @@ import qrcode
import base64
from io import BytesIO
from fastapi.responses import StreamingResponse
from typing import Optional
# бд
@ -67,8 +68,9 @@ async def register(password: str):
@authRouter.post("/login")
async def login(data: schemas.LoginRequest, db: Session = Depends(get_db)):
print(f"Login attempt: username={data.username}, totp_code provided={bool(data.totp_code)}")
print(
f"Login attempt: username={data.username}, totp_code provided={bool(data.totp_code)}")
user = db.query(models.User).filter(
models.User.username == data.username).first()
@ -96,15 +98,51 @@ async def login(data: schemas.LoginRequest, db: Session = Depends(get_db)):
}
@authRouter.post("/login-oauth")
async def login(form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)):
totp_code = form_data.client_secret if form_data.client_secret else None
print(
f"Login attempt: username={form_data}, totp_code provided={bool(totp_code)}")
user = db.query(models.User).filter(
models.User.username == form_data.username).first()
if not user or not security.verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный логин или пароль",
headers={"WWW-Authenticate": "Bearer"},
)
if user.totp_secret:
if not totp_code:
raise HTTPException(status_code=400, detail="TOTP код требуется")
totp = pyotp.TOTP(user.totp_secret)
if not totp.verify(totp_code):
raise HTTPException(status_code=400, detail="Неверный TOTP код")
access_token = security.create_access_token(data={"sub": str(user.id)})
refresh_token = security.create_refresh_token(data={"sub": str(user.id)})
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"user_id": user.id
}
@authRouter.post("/totp/enable")
async def enable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
# Загружаем свежую копию user из БД
user = db.query(models.User).filter(models.User.id == current_user.id).first()
user = db.query(models.User).filter(
models.User.id == current_user.id).first()
if not user:
raise HTTPException(status_code=400, detail="Пользователь не найден")
#if user.totp_secret:
#raise HTTPException(status_code=400, detail="TOTP уже включен")
# if user.totp_secret:
# raise HTTPException(status_code=400, detail="TOTP уже включен")
secret = pyotp.random_base32()
user.totp_temp_secret = secret
@ -127,10 +165,11 @@ async def enable_totp(current_user: models.User = Depends(get_current_user), db:
@authRouter.post("/totp/verify")
async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
# Загружаем свежую копию user из БД
user = db.query(models.User).filter(models.User.id == current_user.id).first()
user = db.query(models.User).filter(
models.User.id == current_user.id).first()
if not user:
raise HTTPException(status_code=400, detail="Пользователь не найден")
if not user.totp_temp_secret:
raise HTTPException(status_code=400, detail="TOTP не включен")
@ -138,8 +177,9 @@ async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User
totp = pyotp.TOTP(user.totp_temp_secret)
code_str = str(data.code).strip()
is_valid = totp.verify(code_str)
print(f"TOTP verify: user_id={user.id}, code={code_str}, secret_set={bool(user.totp_temp_secret)}, valid={is_valid}")
print(
f"TOTP verify: user_id={user.id}, code={code_str}, secret_set={bool(user.totp_temp_secret)}, valid={is_valid}")
if is_valid:
user.totp_secret = user.totp_temp_secret
user.totp_temp_secret = None
@ -151,12 +191,14 @@ async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User
raise
except Exception as e:
print(f"TOTP verify error: {str(e)}")
raise HTTPException(status_code=500, detail=f"Ошибка верификации: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Ошибка верификации: {str(e)}")
@authRouter.post("/totp/disable")
async def disable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == current_user.id).first()
user = db.query(models.User).filter(
models.User.id == current_user.id).first()
if user:
user.totp_secret = None
db.commit()

View File

@ -1,584 +1,412 @@
import shutil
from fastapi import Depends, FastAPI, HTTPException, status, APIRouter, File, UploadFile, Request, Form
from fastapi.responses import FileResponse, StreamingResponse
import io
import os
import uuid
import urllib.parse
from fastapi import Depends, HTTPException, status, APIRouter, File, UploadFile, Form
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy.sql import func
from google.oauth2.credentials import Credentials
import asyncio
from googleapiclient.discovery import build
from googleapiclient.http import MediaIoBaseUpload, MediaIoBaseDownload
from app.core.security import get_current_user
from app.db import models
from app.core.config import config
import os
import re
import uuid
import urllib.request
import urllib.error
from io import BytesIO
import asyncio
def _ensure_directory(path: str):
if not os.path.exists(path):
os.makedirs(path, exist_ok=True)
UPLOAD_FOLDER = 'uploads'
def _parse_multipart_body(body: bytes):
try:
if not body.startswith(b"--"):
return None
boundary, _ = body.split(b"\r\n", 1)
parts = body.split(boundary)
for part in parts:
if not part or part in (b"--", b"--\r\n"):
continue
part = part.strip(b"\r\n")
if not part:
continue
headers, _, content = part.partition(b"\r\n\r\n")
if not headers or content is None:
continue
disposition_match = re.search(
br'Content-Disposition:\s*form-data;\s*name="([^"]+)"(?:;\s*filename="([^"]+)")?',
headers,
re.IGNORECASE,
)
if not disposition_match:
continue
field_name = disposition_match.group(
1).decode('utf-8', errors='ignore')
filename = disposition_match.group(2)
if field_name != 'file':
continue
filename = filename.decode(
'utf-8', errors='ignore') if filename else 'upload.bin'
content_type_match = re.search(
br'Content-Type:\s*([\w\-\/]+)', headers, re.IGNORECASE)
content_type = (
content_type_match.group(1).decode('utf-8', errors='ignore')
if content_type_match
else 'application/octet-stream'
)
return filename, content.rstrip(b'\r\n'), content_type
except Exception:
return None
return None
async def _get_upload_file(request: Request, uploaded_file: UploadFile | None):
if uploaded_file is not None:
return uploaded_file
raw_body = await request.body()
parsed = _parse_multipart_body(raw_body)
if parsed is None:
return None
filename, content, content_type = parsed
return UploadFile(filename=filename, file=BytesIO(content), content_type=content_type)
def _encode_multipart_formdata(fields, files):
boundary = uuid.uuid4().hex
body = BytesIO()
for name, value in fields.items():
body.write(f"--{boundary}\r\n".encode('utf-8'))
body.write(
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode('utf-8'))
body.write(str(value).encode('utf-8'))
body.write(b"\r\n")
for field_name, filename, content_type, file_bytes in files:
body.write(f"--{boundary}\r\n".encode('utf-8'))
body.write(
f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'.encode(
'utf-8')
)
body.write(f"Content-Type: {content_type}\r\n\r\n".encode('utf-8'))
body.write(file_bytes)
body.write(b"\r\n")
body.write(f"--{boundary}--\r\n".encode('utf-8'))
return body.getvalue(), boundary
def _get_cloud_cache_size_bytes(db: Session) -> int:
total = db.query(func.sum(models.CloudMediaItem.size_bytes)).filter(
models.CloudMediaItem.status.in_(['pending', 'sending']),
models.CloudMediaItem.is_avatar == 0,
).scalar()
return int(total or 0)
def _find_local_media_path(file_id: str) -> str | None:
candidates = [
os.path.join(config.CLOUD_MEDIA_CACHE_FOLDER, f"{file_id}.enc"),
os.path.join('uploads', f"{file_id}.enc"),
os.path.join(config.HOME_MEDIA_FOLDER, f"{file_id}.enc"),
]
for path in candidates:
if os.path.exists(path):
return path
return None
def _stream_response_from_remote(url: str):
try:
request = urllib.request.Request(url)
response = urllib.request.urlopen(request, timeout=45)
except urllib.error.HTTPError as exc:
if exc.code == 404:
raise HTTPException(status_code=404, detail='File not found')
raise HTTPException(
status_code=502, detail=f'Error fetching media from home server: {exc.code}')
except Exception as exc:
raise HTTPException(
status_code=502, detail=f'Could not reach home server: {exc}')
headers = {k.lower(): v for k, v in response.getheaders()}
content_type = headers.get('content-type', 'application/octet-stream')
return StreamingResponse(
iter(lambda: response.read(8192), b""),
media_type=content_type,
headers={
'Content-Disposition': headers.get('content-disposition', f'attachment; filename="{os.path.basename(url)}"')
},
)
def _post_file_to_home(item: models.CloudMediaItem) -> tuple[bool, str]:
file_path = os.path.join(
config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if not os.path.exists(file_path):
return False, 'Local cache file not found'
with open(file_path, 'rb') as f:
content = f.read()
fields = {
'owner_id': item.owner_id or '',
'cloud_file_id': item.file_id,
'original_filename': item.original_filename or item.local_filename,
}
files = [
('file', item.original_filename or item.local_filename,
item.content_type or 'application/octet-stream', content),
]
body, boundary = _encode_multipart_formdata(fields, files)
request = urllib.request.Request(
f"{config.HOME_SERVER_URL}/media/receive",
data=body,
headers={
'Content-Type': f'multipart/form-data; boundary={boundary}',
'X-Media-Forwarding-Secret': config.MEDIA_FORWARDING_SECRET,
},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
if response.status == 200:
return True, ''
return False, f'Home server returned {response.status}'
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors='ignore')
return False, f'Home server HTTP error {exc.code}: {body}'
except Exception as exc:
return False, str(exc)
def _cleanup_home_quota(db: Session, owner_id: int | None):
if owner_id is None:
return
total = db.query(func.sum(models.HomeMediaFile.size_bytes)).filter(
models.HomeMediaFile.owner_id == owner_id
).scalar() or 0
total = int(total)
if total <= config.HOME_USER_QUOTA_BYTES:
return
files = db.query(models.HomeMediaFile).filter(
models.HomeMediaFile.owner_id == owner_id
).order_by(models.HomeMediaFile.created_at.asc()).all()
for file_record in files:
if total <= config.HOME_USER_QUOTA_BYTES:
break
path = os.path.join(config.HOME_MEDIA_FOLDER,
file_record.storage_filename)
if os.path.exists(path):
os.remove(path)
total -= file_record.size_bytes
db.delete(file_record)
db.commit()
def _cleanup_all_home_storage():
db = models.SessionLocal()
try:
owner_ids = db.query(models.HomeMediaFile.owner_id).filter(
models.HomeMediaFile.owner_id.isnot(None)).distinct().all()
for owner_id_tuple in owner_ids:
_cleanup_home_quota(db, owner_id_tuple[0])
finally:
db.close()
async def forward_pending_media_loop():
while True:
if config.SERVER_ROLE != 'cloud':
await asyncio.sleep(10)
continue
db = models.SessionLocal()
try:
total_cache = _get_cloud_cache_size_bytes(db)
if total_cache >= config.CLOUD_CACHE_MAX_BYTES:
await asyncio.sleep(config.MEDIA_FORWARD_INTERVAL_SECONDS)
continue
pending_items = db.query(models.CloudMediaItem).filter(
models.CloudMediaItem.status == 'pending',
models.CloudMediaItem.is_avatar == 0,
).order_by(models.CloudMediaItem.created_at.asc()).limit(5).all()
for item in pending_items:
item.status = 'sending'
item.attempts += 1
db.commit()
success, error = _post_file_to_home(item)
if success:
item.status = 'sent'
item.sent_at = func.now()
item.error_message = None
db.commit()
cache_path = os.path.join(
config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if os.path.exists(cache_path):
os.remove(cache_path)
else:
item.status = 'failed'
item.error_message = error
db.commit()
except Exception:
pass
finally:
db.close()
await asyncio.sleep(config.MEDIA_FORWARD_INTERVAL_SECONDS)
async def home_storage_maintenance_loop():
while True:
if config.SERVER_ROLE != 'home':
await asyncio.sleep(10)
continue
_cleanup_all_home_storage()
await asyncio.sleep(600)
mediaRouter = APIRouter(
prefix='/media',
tags=['media'],
)
# ---------------------------------------------------------------------------
# Инициализация клиента Google Drive
# ---------------------------------------------------------------------------
_ensure_directory(UPLOAD_FOLDER)
_ensure_directory(config.CLOUD_MEDIA_CACHE_FOLDER)
_ensure_directory(config.HOME_MEDIA_FOLDER)
def _get_drive_service():
try:
credentials = Credentials(
token=None,
refresh_token=config.GOOGLE_REFRESH_TOKEN,
token_uri='https://oauth2.googleapis.com/token',
client_id=config.GOOGLE_CLIENT_ID,
client_secret=config.GOOGLE_CLIENT_SECRET,
scopes=['https://www.googleapis.com/auth/drive']
)
return build('drive', 'v3', credentials=credentials)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to initialize Google Drive service: {str(e)}"
)
# ---------------------------------------------------------------------------
# Контроль квоты пользователя (с удалением старых файлов)
# ---------------------------------------------------------------------------
def _cleanup_google_drive_quota(db: Session, owner_id: int | None, new_file_size: int):
"""
Проверяет квоту пользователя (из .env). Если лимит превышен,
автоматически удаляет самые старые файлы из Google Drive и таблицы media_items,
пока не освободится достаточно места для нового файла.
"""
if owner_id is None:
return
# Получаем лимит квоты из конфигурации приложения (.env)
user_quota = getattr(config, "HOME_USER_QUOTA_BYTES",
10737418240) # 10 ГБ по умолчанию
user = db.query(models.User).filter(models.User.id == owner_id).first()
active_avatar_id = user.avatar_file_id if user else None
sum_query = db.query(func.sum(models.MediaItem.size_bytes)).filter(
models.MediaItem.owner_id == owner_id
)
if active_avatar_id:
sum_query = sum_query.filter(models.MediaItem.file_id != active_avatar_id)
total_used = sum_query.scalar() or 0
total_used = int(total_used)
# Если вместе с новым файлом мы укладываемся в квоту — чистка не требуется
if total_used + new_file_size <= user_quota:
return
# 2. Если места не хватает, выбираем файлы пользователя от старых к новым
files = db.query(models.MediaItem).filter(
models.MediaItem.owner_id == owner_id
).order_by(models.MediaItem.created_at.asc()).all()
service = _get_drive_service()
for file_record in files:
# Удаляем старые файлы до тех пор, пока новый файл не поместится в квоту
if total_used + new_file_size <= user_quota:
break
# Физическое удаление файла из Google Drive
try:
drive_id = file_record.storage_file_id
service.files().delete(fileId=drive_id).execute()
except Exception:
# Если файл уже удален из Google Drive вручную, игнорируем ошибку
pass
# Корректируем счетчик занятого места и удаляем запись из БД
total_used -= file_record.size_bytes
db.delete(file_record)
db.commit()
# ---------------------------------------------------------------------------
# Эндпоинты: Загрузка (Upload)
# ---------------------------------------------------------------------------
class SeekableFastAPIStream(io.RawIOBase):
"""
Обертка над синхронным внутренним файлом FastAPI.
Удовлетворяет требованиям Google SDK по наличию методов seek/tell.
"""
def __init__(self, raw_file):
self.raw_file = raw_file
self._position = 0
def readinto(self, b):
# file.file.read в FastAPI работает СИНХРОННО
chunk = self.raw_file.read(len(b))
if not chunk:
return 0
n = len(chunk)
b[:n] = chunk
self._position += n
return n
def seek(self, offset, whence=io.SEEK_SET):
if whence == io.SEEK_SET and offset == 0:
try:
self.raw_file.seek(0)
except Exception:
pass
self._position = 0
return 0
elif whence == io.SEEK_CUR:
return self._position
elif whence == io.SEEK_END:
return self._position
return self._position
def tell(self):
return self._position
def seekable(self):
return True
def readable(self):
return True
@mediaRouter.post('/upload')
async def upload_file(
request: Request,
file: UploadFile = File(None),
):
uploaded_file = await _get_upload_file(request, file)
if uploaded_file is None or not uploaded_file.filename:
raise HTTPException(status_code=400, detail='No selected file')
content = await uploaded_file.read()
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
raise HTTPException(
status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
file_id = uuid.uuid4().hex
filename = f"{file_id}.enc"
file_path = os.path.join(UPLOAD_FOLDER, filename)
with open(file_path, 'wb') as f:
f.write(content)
return {
'status': 'ok',
'file_id': file_id,
}
@mediaRouter.post('/v2/upload')
async def upload_file_v2(
request: Request,
file: UploadFile = File(None),
purpose: str = Form('media'),
async def upload_file(
file: UploadFile = File(...),
current_user: models.User = Depends(get_current_user),
):
if config.SERVER_ROLE != 'cloud':
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Upload endpoint is available only on cloud server')
uploaded_file = await _get_upload_file(request, file)
if uploaded_file is None or not uploaded_file.filename:
"""
Загружает файл на Google Drive в режиме стриминга без блокировки RAM.
"""
if not file.filename:
raise HTTPException(status_code=400, detail='No selected file')
content = await uploaded_file.read()
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
max_upload_size = getattr(config, "MEDIA_UPLOAD_MAX_BYTES", 52428800)
file_size = file.size
if file_size and file_size > max_upload_size:
raise HTTPException(
status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
status_code=400,
detail=f'File too large (max {max_upload_size} bytes)'
)
db = models.SessionLocal()
try:
cache_size = _get_cloud_cache_size_bytes(db)
is_avatar = purpose == 'avatar'
if cache_size >= config.CLOUD_CACHE_MAX_BYTES and not is_avatar:
raise HTTPException(
status_code=503,
detail='Cloud media cache is full; new uploads are temporarily paused until pending files are forwarded.',
)
if file_size:
_cleanup_google_drive_quota(db, current_user.id, file_size)
service = _get_drive_service()
file_id = uuid.uuid4().hex
local_filename = f"{file_id}.enc"
storage_path = os.path.join(
config.CLOUD_MEDIA_CACHE_FOLDER, local_filename)
with open(storage_path, 'wb') as f:
f.write(content)
file_metadata = {
'name': f"{file_id}.enc",
'parents': [config.GOOGLE_DRIVE_FOLDER_ID]
}
item = models.CloudMediaItem(
# Передаем file.file (синхронный поток FastAPI)
stream = SeekableFastAPIStream(file.file)
# 1. Задаем размер чанка (строго кратен 256 КБ).
# Если файлы тяжелые, можно увеличить до 5 * 1024 * 1024 (5 МБ) для стабильности SSL
chunk_size = 5 * 1024 * 1024 # 1 МБ
# 2. Инициализируем медиа-загрузчик
# Если file_size известен (из заголовков), обязательно передаем его Google.
# Если не передан (None), Google будет ждать закрывающий чанк, что часто вызывает сброс SSL.
media = MediaIoBaseUpload(
stream,
mimetype=file.content_type or 'application/octet-stream',
chunksize=chunk_size,
resumable=True
)
if file_size:
media._size = file_size
# 3. Переписываем логику выполнения загрузки.
# Вместо одного слепого вызова .execute() мы инициализируем запрос
# и пошагово отправляем чанки. Это предотвращает таймауты SSL-сессии.
def _execute_resumable_upload():
request = service.files().create(
body=file_metadata,
media_body=media,
fields='id,size',
supportsAllDrives=True
)
response = None
retries = 0
max_retries = 3
while response is None:
try:
status, response = request.next_chunk()
if status:
print(f"Uploaded {int(status.progress() * 100)}%...")
retries = 0 # Сбрасываем попытки при успешном чанке
except Exception as e:
retries += 1
print(
f"Ошибка при загрузке чанка: {e}. Попытка {retries} из {max_retries}")
if retries >= max_retries:
raise e # Если интернет совсем пропал — падаем окончательно
import time
time.sleep(1) # Ждем секунду перед повторной попыткой
return response
# Запускаем пошаговый стриминг в пуле потоков
drive_file = await asyncio.to_thread(_execute_resumable_upload)
drive_id = drive_file.get('id')
final_size = int(drive_file.get('size', 0)
) if file_size is None else file_size
media_item = models.MediaItem(
file_id=file_id,
owner_id=current_user.id,
original_filename=uploaded_file.filename,
content_type=uploaded_file.content_type or 'application/octet-stream',
local_filename=local_filename,
size_bytes=len(content),
status='avatar' if is_avatar else 'pending',
is_avatar=1 if is_avatar else 0,
original_filename=file.filename,
content_type=file.content_type or 'application/octet-stream',
storage_file_id=drive_id,
size_bytes=final_size,
)
db.add(item)
db.add(media_item)
db.commit()
except Exception as e:
print(f"Upload operation failed: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Upload operation failed: {str(e)}"
)
finally:
db.close()
await file.close()
return {'status': 'ok', 'file_id': file_id}
@mediaRouter.post('/receive')
async def receive_media(
request: Request,
file: UploadFile = File(None),
owner_id: int | None = Form(None),
cloud_file_id: str | None = Form(None),
original_filename: str | None = Form(None),
):
secret = request.headers.get('X-Media-Forwarding-Secret')
if secret != config.MEDIA_FORWARDING_SECRET:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid forwarding secret')
uploaded_file = await _get_upload_file(request, file)
if uploaded_file is None or not uploaded_file.filename:
raise HTTPException(status_code=400, detail='No selected file')
content = await uploaded_file.read()
if len(content) > config.MEDIA_UPLOAD_MAX_BYTES:
raise HTTPException(
status_code=400, detail=f'File too large (max {config.MEDIA_UPLOAD_MAX_BYTES} bytes)')
file_id = cloud_file_id or uuid.uuid4().hex
storage_filename = f"{file_id}.enc"
file_path = os.path.join(config.HOME_MEDIA_FOLDER, storage_filename)
with open(file_path, 'wb') as f:
f.write(content)
db = models.SessionLocal()
try:
home_record = models.HomeMediaFile(
file_id=file_id,
owner_id=owner_id,
original_filename=original_filename or uploaded_file.filename,
content_type=uploaded_file.content_type or 'application/octet-stream',
storage_filename=storage_filename,
size_bytes=len(content),
)
db.add(home_record)
db.commit()
_cleanup_home_quota(db, owner_id)
finally:
db.close()
return {'status': 'ok', 'file_id': file_id}
# ---------------------------------------------------------------------------
# Эндпоинты: Получение метаданных и скачивание (Size / Download)
# ---------------------------------------------------------------------------
@mediaRouter.get('/size/{file_id}')
async def get_file_size(file_id: str):
"""
Возвращает информацию о размере и типе файла из таблицы media_items.
"""
db = models.SessionLocal()
db_file = None
try:
db_file = db.query(models.HomeMediaFile).filter(
models.HomeMediaFile.file_id == file_id).first()
db_file = db.query(models.MediaItem).filter(
models.MediaItem.file_id == file_id
).first()
finally:
db.close()
# 1. Проверяем наличие файла локально на этом сервере
local_path = _find_local_media_path(file_id)
if local_path and os.path.exists(local_path):
file_size = os.path.getsize(local_path)
filename = db_file.original_filename if db_file else f"file_{file_id}"
content_type = db_file.content_type if db_file else 'application/octet-stream'
encoded_filename = urllib.parse.quote(filename)
return {"file_id": file_id, "size": file_size, "file_name": encoded_filename, "content_type": content_type}
# 2. Если роль сервера 'cloud', запрашиваем размер у домашнего сервера
if config.SERVER_ROLE == 'cloud':
remote_url = f"{config.HOME_SERVER_URL}/media/size/{file_id}"
try:
# Выполняем синхронный легковесный подзапрос к домашнему серверу в треде,
# чтобы не блокировать асинхронный цикл FastAPI (по аналогии с деплоем стримов)
def _fetch_remote_size():
req = urllib.request.Request(remote_url, method='GET')
with urllib.request.urlopen(req, timeout=5.0) as response:
if response.status == 200:
import json
return json.loads(response.read().decode('utf-8'))
return None
if not db_file:
raise HTTPException(status_code=404, detail='File not found')
remote_data = await asyncio.to_thread(_fetch_remote_size)
if remote_data:
return remote_data
except urllib.error.HTTPError as e:
if e.code == 404:
raise HTTPException(
status_code=404, detail='File not found on home server')
raise HTTPException(status_code=e.code, detail='Home server error')
except Exception as e:
print(f"Ошибка подключения к домашнему серверу: {e}")
raise HTTPException(
status_code=502, detail='Home server is unavailable')
# 3. Если файл не найден ни локально, ни на удаленном сервере
raise HTTPException(status_code=404, detail='File not found')
encoded_filename = urllib.parse.quote(db_file.original_filename)
return {
"file_id": file_id,
"size": db_file.size_bytes,
"file_name": encoded_filename,
"content_type": db_file.content_type
}
@mediaRouter.get('/{file_id}')
async def get_file(file_id: str):
db = models.SessionLocal()
db_file = None
try:
db_file = db.query(models.HomeMediaFile).filter(
models.HomeMediaFile.file_id == file_id).first()
db_file = db.query(models.MediaItem).filter(
models.MediaItem.file_id == file_id
).first()
finally:
db.close()
local_path = _find_local_media_path(file_id)
if local_path:
filename = db_file.original_filename if db_file else f"file_{file_id}"
content_type = db_file.content_type if db_file else 'application/octet-stream'
encoded_filename = urllib.parse.quote(filename)
if not db_file:
raise HTTPException(status_code=404, detail='File not found')
drive_id = db_file.storage_file_id
try:
service = _get_drive_service()
request = service.files().get_media(fileId=drive_id)
async def _async_stream_drive_file():
fh = io.BytesIO()
downloader = MediaIoBaseDownload(
fh, request, chunksize=1024 * 1024)
done = False
last_position = 0
while not done:
# Оборачиваем синхронный сетевой запрос Google SDK в asyncio.to_thread
status, done = await asyncio.to_thread(downloader.next_chunk)
fh.seek(last_position)
chunk = fh.read()
if chunk:
yield chunk
last_position = fh.tell()
encoded_filename = urllib.parse.quote(db_file.original_filename)
headers = {
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
return FileResponse(
local_path,
media_type=content_type,
# StreamingResponse отлично работает с async генераторами
return StreamingResponse(
_async_stream_drive_file(),
media_type=db_file.content_type,
headers=headers
)
if config.SERVER_ROLE == 'cloud':
return _stream_response_from_remote(f"{config.HOME_SERVER_URL}/media/{file_id}")
raise HTTPException(status_code=404, detail='File not found')
@mediaRouter.post('/copy_internal')
async def copy_file_internal(
request: Request,
file_id: str = Form(...),
owner_id: int = Form(...), # ID нового владельца (получателя)
):
# Проверка секрета
secret = request.headers.get('X-Media-Forwarding-Secret')
if secret != config.MEDIA_FORWARDING_SECRET:
raise HTTPException(status_code=401, detail='Unauthorized')
# 1. Находим файл
source_path = _find_local_media_path(file_id)
if not source_path:
raise HTTPException(status_code=404, detail='Source file not found')
# 2. Создаем новый ID и путь
new_file_id = uuid.uuid4().hex
new_storage_filename = f"{new_file_id}.enc"
dest_path = os.path.join(config.HOME_MEDIA_FOLDER, new_storage_filename)
# 3. Физическое копирование
shutil.copyfile(source_path, dest_path)
# 4. Обновляем БД
db = models.SessionLocal()
try:
old_record = db.query(models.HomeMediaFile).filter(
models.HomeMediaFile.file_id == file_id).first()
new_record = models.HomeMediaFile(
file_id=new_file_id,
owner_id=owner_id,
original_filename=old_record.original_filename if old_record else "copy.enc",
content_type=old_record.content_type if old_record else 'application/octet-stream',
storage_filename=new_storage_filename,
size_bytes=os.path.getsize(dest_path),
except Exception as e:
raise HTTPException(
status_code=502,
detail=f"Error fetching file from Google Drive: {str(e)}"
)
db.add(new_record)
db.commit()
finally:
db.close()
return {"status": "ok", "new_file_id": new_file_id}
# ---------------------------------------------------------------------------
# Эндпоинты: Копирование файлов (Copy)
# ---------------------------------------------------------------------------
@mediaRouter.post('/copy')
async def copy(
file_id: str = Form(...),
current_user: models.User = Depends(get_current_user),
):
if config.SERVER_ROLE != 'cloud':
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Upload endpoint is available only on cloud server')
# Делаем запрос к домашнему серверу
url = f"{config.HOME_SERVER_URL}/media/copy_internal"
# Используем FormData для передачи параметров на домашний сервер
body_data = f"file_id={file_id}&owner_id={current_user.id}".encode('utf-8')
request = urllib.request.Request(
url,
data=body_data,
headers={
'X-Media-Forwarding-Secret': config.MEDIA_FORWARDING_SECRET,
'Content-Type': 'application/x-www-form-urlencoded'
},
method='POST'
)
"""
Копирует файл внутри Google Drive новому пользователю с валидацией его личной квоты.
"""
db = models.SessionLocal()
old_record = None
try:
with urllib.request.urlopen(request, timeout=10) as response:
if response.status == 200:
import json
return json.loads(response.read().decode('utf-8'))
old_record = db.query(models.MediaItem).filter(
models.MediaItem.file_id == file_id
).first()
finally:
db.close()
if not old_record:
raise HTTPException(status_code=404, detail='Source file not found')
db = models.SessionLocal()
try:
# Проверяем квоту для получателя копии (и удаляем его старые файлы при необходимости)
_cleanup_google_drive_quota(db, current_user.id, old_record.size_bytes)
new_file_id = uuid.uuid4().hex
service = _get_drive_service()
def _execute_copy():
return service.files().copy(
fileId=old_record.storage_file_id,
body={'name': f"{new_file_id}.enc"},
fields='id'
).execute()
drive_file = await asyncio.to_thread(_execute_copy)
new_drive_id = drive_file.get('id')
# Сохранение информации о скопированном файле
new_record = models.MediaItem(
file_id=new_file_id,
owner_id=current_user.id,
original_filename=old_record.original_filename,
content_type=old_record.content_type,
storage_file_id=new_drive_id,
size_bytes=old_record.size_bytes,
)
db.add(new_record)
db.commit()
except Exception as e:
raise HTTPException(
status_code=502, detail=f'Failed to copy on home server: {e}')
status_code=500, detail=f"Copy operation failed: {str(e)}")
finally:
db.close()
raise HTTPException(status_code=500, detail='Copying failed')
return {"status": "ok", "new_file_id": new_file_id}

View File

@ -22,28 +22,50 @@ def get_db():
db.close()
def _get_drive_service_for_users():
credentials = Credentials(
token=None,
refresh_token=config.GOOGLE_REFRESH_TOKEN,
token_uri='https://oauth2.googleapis.com/token',
client_id=config.GOOGLE_CLIENT_ID,
client_secret=config.GOOGLE_CLIENT_SECRET,
scopes=['https://www.googleapis.com/auth/drive']
)
return build('drive', 'v3', credentials=credentials)
# Полностью обновленная функция удаления старых аватарок
def _delete_old_avatar_file(file_id: str, db: Session):
upload_path = os.path.join('uploads', f"{file_id}.enc")
if os.path.exists(upload_path):
try:
os.remove(upload_path)
except OSError:
pass
cloud_item = db.query(models.CloudMediaItem).filter(
models.CloudMediaItem.file_id == file_id,
# Ищем старую аватарку в базе данных
cloud_items = db.query(models.MediaItem).filter(
models.MediaItem.file_id == file_id,
).all()
for item in cloud_item:
cloud_path = os.path.join(
config.CLOUD_MEDIA_CACHE_FOLDER, item.local_filename)
if os.path.exists(cloud_path):
try:
os.remove(cloud_path)
except OSError:
pass
db.delete(item)
db.commit()
if not cloud_items:
return
try:
service = _get_drive_service_for_users()
except Exception as e:
print(
f"Не удалось инициализировать Google Drive для удаления аватарки: {e}")
return
for item in cloud_items:
# 1. Удаляем физический файл из Google Drive
try:
service.files().delete(fileId=item.storage_file_id).execute()
print(
f"Старая аватарка {item.storage_file_id} успешно удалена из Google Drive")
except Exception as e:
print(f"Ошибка физического удаления аватарки из Google Drive: {e}")
# 2. Удаляем запись из базы данных (это возвращает место в квоту пользователя)
db.delete(item)
db.commit()
print(f"Старая аватарка {file_id} успешно удалена из базы данных")
usersRouter = APIRouter(
prefix="/users",
@ -207,7 +229,6 @@ async def get_privacy_settings(current_user: models.User = Depends(get_current_u
}
@usersRouter.get("/all")
async def read_users_all(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
users = db.query(models.User).all()
@ -299,14 +320,14 @@ async def read_users_chats(
return result
@usersRouter.get("/by-username/{username}", response_model=schemas.UserContactResponse)
def get_user_by_username(username: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user)):
user = db.query(models.User).filter(models.User.username == username).first()
user = db.query(models.User).filter(
models.User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден")
profile_data = {
"id": user.id,
"public_key": user.public_key,

View File

@ -80,4 +80,24 @@ class UserContactResponse(BaseModel):
public_key: Optional[str] = None
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

View File

@ -15,18 +15,24 @@ class Config:
# Firebase
FIREBASE_CREDENTIALS_PATH: str = os.getenv("FIREBASE_CREDENTIALS_PATH", "chepuhagram-6ca5d-firebase-adminsdk-fbsvc-cf8a5ad2f3.json")
# Google Cloud / Google Drive Integration
# Путь к скачанному JSON-ключу вашего сервисного аккаунта Google
GOOGLE_APPLICATION_CREDENTIALS: str = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", "google-credentials.json")
GOOGLE_DRIVE_FOLDER_ID: str = os.getenv("GOOGLE_DRIVE_FOLDER_ID", None)
GOOGLE_REFRESH_TOKEN: str = os.getenv("GOOGLE_REFRESH_TOKEN", None)
GOOGLE_CLIENT_ID: str = os.getenv("GOOGLE_CLIENT_ID", None)
GOOGLE_CLIENT_SECRET: str = os.getenv("GOOGLE_CLIENT_SECRET", None)
# Server
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8000"))
SERVER_ROLE: str = os.getenv("SERVER_ROLE", "cloud").lower()
HOME_SERVER_URL: str = os.getenv("HOME_SERVER_URL", "http://home-server.local:8000")
MEDIA_FORWARDING_SECRET: str = os.getenv("MEDIA_FORWARDING_SECRET", "changeme")
CLOUD_MEDIA_CACHE_FOLDER: str = os.getenv("CLOUD_MEDIA_CACHE_FOLDER", "cloud_media_cache")
HOME_MEDIA_FOLDER: str = os.getenv("HOME_MEDIA_FOLDER", "home_media_store")
CLOUD_CACHE_MAX_BYTES: int = int(os.getenv("CLOUD_CACHE_MAX_BYTES", str(5 * 1024 * 1024 * 1024)))
PORT: int = int(os.getenv("PORT", "8587"))
# Media Storage & Quotas (Google Drive)
# 10 ГБ лимита на пользователя (при превышении удаляются старые медиафайлы)
HOME_USER_QUOTA_BYTES: int = int(os.getenv("HOME_USER_QUOTA_BYTES", str(10 * 1024 * 1024 * 1024)))
MEDIA_UPLOAD_MAX_BYTES: int = int(os.getenv("MEDIA_UPLOAD_MAX_BYTES", str(100 * 1024 * 1024)))
MEDIA_FORWARD_INTERVAL_SECONDS: int = int(os.getenv("MEDIA_FORWARD_INTERVAL_SECONDS", "12"))
# Максимальный размер одного загружаемого файла (500 МБ)
MEDIA_UPLOAD_MAX_BYTES: int = int(os.getenv("MEDIA_UPLOAD_MAX_BYTES", str(500 * 1024 * 1024)))
# CORS
ALLOWED_ORIGINS: list = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000").split(",")

View File

@ -15,34 +15,45 @@ if not SECRET_KEY:
raise RuntimeError("JWT_KEY environment variable not set")
SECRET_KEY = SECRET_KEY.strip()
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 60
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login-oauth", description=(
"### Инструкция по авторизации\n\n"
"1. Введите ваш **username** и **password**.\n"
"2. В поле **client_secret** введите текущий **6-значный код TOTP**, если он подключен\n"
"3. Поле *client_id* оставьте пустым."
))
# бд
def get_db():
db = models.SessionLocal()
try:
yield db
finally:
finally:
db.close()
def verify_password(plain_password, hashed_password):
try:
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password)
except TypeError:
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
def get_password_hash(password):
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=REFRESH_TOKEN_EXPIRE_MINUTES)
@ -50,6 +61,8 @@ def create_refresh_token(data: dict):
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
# проверка токена
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -62,13 +75,16 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
raise credentials_exception
except JWTError:
raise credentials_exception
user_id = int(id)
user = db.query(models.User).filter(models.User.id == user_id).first()
if user is None:
raise credentials_exception
if getattr(user, "is_blocked", 0) == 1:
raise HTTPException(status_code=403, detail="Ваш аккаунт заблокирован")
return user
async def test_token(token: str):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -81,4 +97,4 @@ async def test_token(token: str):
raise credentials_exception
return id
except JWTError:
raise credentials_exception
raise credentials_exception

View File

@ -1,19 +1,22 @@
from sqlalchemy import Column, Integer, String, Sequence, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime
from sqlalchemy import Column, Integer, Text, ForeignKey, DateTime, BigInteger
from sqlalchemy.sql import func
from sqlalchemy import text
from app.core.config import config
SQLALCHEMY_DATABASE_URL = config.DATABASE_URL
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={
"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, Sequence('user_id_seq', start=100), primary_key=True, index=True)
id = Column(Integer, Sequence('user_id_seq', start=100),
primary_key=True, index=True)
first_name = Column(String(50), nullable=False, server_default="User")
last_name = Column(String(50), nullable=True)
username = Column(String, unique=True, index=True)
@ -21,22 +24,27 @@ class User(Base):
phone = Column(String(20), unique=True, nullable=True)
email = Column(String(255), unique=True, nullable=True)
totp_secret = Column(String(32), nullable=True)
totp_temp_secret = Column(String(32), nullable=True) # Temporary secret until verified
# Temporary secret until verified
totp_temp_secret = Column(String(32), nullable=True)
hashed_password = Column(String)
public_key = Column(String, nullable=True)
encrypted_private_key = Column(String, nullable=True)
fcm_token = Column(String, nullable=True)
avatar_file_id = Column(String, nullable=True)
# Privacy settings
show_email = Column(Integer, nullable=False, server_default="1") # 1 = true, 0 = false
show_email = Column(Integer, nullable=False,
server_default="1") # 1 = true, 0 = false
show_phone = Column(Integer, nullable=False, server_default="1")
show_avatar = Column(Integer, nullable=False, server_default="1")
show_about = Column(Integer, nullable=False, server_default="1")
show_username = Column(Integer, nullable=False, server_default="1")
show_last_online = Column(Integer, nullable=False, server_default="1")
last_online = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
last_online = Column(DateTime(timezone=True),
server_default=func.now(), onupdate=func.now())
is_blocked = Column(Integer, default=0, server_default="0")
class Message(Base):
__tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True)
@ -53,34 +61,30 @@ class Message(Base):
file_id = Column(String, nullable=True)
encrypted_key = Column(String, nullable=True)
class CloudMediaItem(Base):
__tablename__ = "cloud_media_items"
id = Column(Integer, primary_key=True, index=True)
file_id = Column(String, unique=True, nullable=False, index=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
original_filename = Column(String, nullable=True)
content_type = Column(String, nullable=True)
local_filename = Column(String, nullable=False)
size_bytes = Column(Integer, nullable=False)
status = Column(String, nullable=False, server_default="pending")
is_avatar = Column(Integer, nullable=False, server_default="0")
attempts = Column(Integer, nullable=False, server_default="0")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
sent_at = Column(DateTime(timezone=True), nullable=True)
error_message = Column(Text, nullable=True)
class HomeMediaFile(Base):
__tablename__ = "home_media_files"
class MediaItem(Base):
__tablename__ = "media_items"
id = Column(Integer, primary_key=True, index=True)
file_id = Column(String, unique=True, nullable=False, index=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
original_filename = Column(String, nullable=True)
content_type = Column(String, nullable=True)
storage_filename = Column(String, nullable=False)
size_bytes = Column(Integer, nullable=False)
# Уникальный внутренний UUID файла (используется в эндпоинтах API)
file_id = Column(String(32), unique=True, nullable=False, index=True)
# ID владельца из таблицы пользователей
owner_id = Column(Integer, ForeignKey(
"users.id"), nullable=True, index=True)
# Оригинальное имя файла (например, "photo.jpg")
original_filename = Column(String(255), nullable=True)
# MIME-тип файла (например, "image/jpeg")
content_type = Column(String(100), nullable=True)
# ID файла внутри Google Drive (заменяет старый storage_filename)
storage_file_id = Column(String(255), nullable=False)
# Размер файла в байтах (BigInteger обязателен для поддержки квот > 2 ГБ)
size_bytes = Column(BigInteger, nullable=False)
# Таймстампы
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
updated_at = Column(DateTime(timezone=True),
server_default=func.now(), onupdate=func.now())
Base.metadata.create_all(bind=engine)
@ -93,21 +97,29 @@ def _ensure_sqlite_message_columns():
existing = {row[1] for row in cols} # row[1] = name
if "delivered_at" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN delivered_at DATETIME"))
conn.execute(
text("ALTER TABLE messages ADD COLUMN delivered_at DATETIME"))
if "read_at" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN read_at DATETIME"))
conn.execute(
text("ALTER TABLE messages ADD COLUMN read_at DATETIME"))
if "reply_to_id" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_id INTEGER REFERENCES messages(id)"))
conn.execute(text(
"ALTER TABLE messages ADD COLUMN reply_to_id INTEGER REFERENCES messages(id)"))
if "reply_to_text" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT"))
conn.execute(
text("ALTER TABLE messages ADD COLUMN reply_to_text TEXT"))
if "edited_at" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN edited_at DATETIME"))
conn.execute(
text("ALTER TABLE messages ADD COLUMN edited_at DATETIME"))
if "message_type" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN message_type VARCHAR(32) DEFAULT 'text' NOT NULL"))
conn.execute(text(
"ALTER TABLE messages ADD COLUMN message_type VARCHAR(32) DEFAULT 'text' NOT NULL"))
if "file_id" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN file_id VARCHAR(255)"))
conn.execute(
text("ALTER TABLE messages ADD COLUMN file_id VARCHAR(255)"))
if "encrypted_key" not in existing:
conn.execute(text("ALTER TABLE messages ADD COLUMN encrypted_key VARCHAR(1024)"))
conn.execute(
text("ALTER TABLE messages ADD COLUMN encrypted_key VARCHAR(1024)"))
conn.commit()
@ -122,28 +134,43 @@ def _ensure_sqlite_user_columns():
if "about" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN about TEXT"))
if "phone" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN phone VARCHAR(20)"))
conn.execute(
text("ALTER TABLE users ADD COLUMN phone VARCHAR(20)"))
if "email" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN email VARCHAR(255)"))
conn.execute(
text("ALTER TABLE users ADD COLUMN email VARCHAR(255)"))
if "show_email" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN show_email INTEGER DEFAULT 1"))
conn.execute(
text("ALTER TABLE users ADD COLUMN show_email INTEGER DEFAULT 1"))
if "show_phone" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN show_phone INTEGER DEFAULT 1"))
conn.execute(
text("ALTER TABLE users ADD COLUMN show_phone INTEGER DEFAULT 1"))
if "show_avatar" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN show_avatar INTEGER DEFAULT 1"))
conn.execute(
text("ALTER TABLE users ADD COLUMN show_avatar INTEGER DEFAULT 1"))
if "show_about" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN show_about INTEGER DEFAULT 1"))
conn.execute(
text("ALTER TABLE users ADD COLUMN show_about INTEGER DEFAULT 1"))
if "show_username" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN show_username INTEGER DEFAULT 1"))
conn.execute(
text("ALTER TABLE users ADD COLUMN show_username INTEGER DEFAULT 1"))
if "show_last_online" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN show_last_online INTEGER DEFAULT 1"))
conn.execute(
text("ALTER TABLE users ADD COLUMN show_last_online INTEGER DEFAULT 1"))
if "last_online" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN last_online DATETIME"))
conn.execute(text("UPDATE users SET last_online = datetime('now')"))
conn.execute(
text("ALTER TABLE users ADD COLUMN last_online DATETIME"))
conn.execute(
text("UPDATE users SET last_online = datetime('now')"))
if "avatar_file_id" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN avatar_file_id VARCHAR(255)"))
conn.execute(
text("ALTER TABLE users ADD COLUMN avatar_file_id VARCHAR(255)"))
if "totp_temp_secret" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN totp_temp_secret VARCHAR(32)"))
conn.execute(
text("ALTER TABLE users ADD COLUMN totp_temp_secret VARCHAR(32)"))
if "is_blocked" not in existing:
conn.execute(
text("ALTER TABLE users ADD COLUMN is_blocked INTEGER DEFAULT 0"))
conn.commit()

View File

@ -8,6 +8,7 @@ from app.db import models
from firebase_admin import messaging, credentials, exceptions
import firebase_admin
from app.core.config import config
import uuid
cred = credentials.Certificate(config.FIREBASE_CREDENTIALS_PATH)
firebase_admin.initialize_app(cred)
@ -53,11 +54,12 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
while True:
print("ОЖИДАНИЕ СООБЩЕНИЙ")
data = await websocket.receive_text()
message_data = json.loads(data)
print(f"DEBUG: Получены данные: {message_data}")
db.query(models.User).filter(models.User.id == user_id).update({"last_online": datetime.now(timezone.utc)},
synchronize_session="fetch")
synchronize_session="fetch")
db.commit()
if message_data.get("type") == "private_message":
@ -157,7 +159,8 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
# Пересылаем получателю, если он в сети
sent_to_receiver = await manager.send_personal_message(outgoing_message, str(receiver_id))
print(f"DEBUG send_personal_message returned: {sent_to_receiver}")
print(
f"DEBUG send_personal_message returned: {sent_to_receiver}")
# Если сообщение реально ушло по сокету получателю — отмечаем delivered_at.
if sent_to_receiver:
@ -302,6 +305,32 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"type": "stop_typing",
"sender_id": user_id,
}, str(receiver_id))
# 1. Инициация звонка (Клиент А -> Сервер -> Клиент Б)
if message_data.get("type") == "call_init":
receiver_id = message_data.get("receiver_id")
# Генерируем UUID на сервере
call_id = str(uuid.uuid4())
# Сообщаем Клиенту Б, что ему звонят
call_data = {
"type": "call_init",
"call_id": call_id,
"caller_username": message_data.get("caller_username"),
"caller_id": str(user_id)
}
# Отправляем Клиенту Б
sent = await manager.send_personal_message(call_data, str(receiver_id))
# Возвращаем Клиенту А подтверждение с созданным ID
if sent:
await manager.send_personal_message({"type": "call_created", "call_id": call_id}, str(user_id))
# 2. Обработка сигналов (offer, answer, ice_candidate)
elif message_data.get("type") in ["offer", "answer", "ice_candidate", "hangup", "decline"]:
receiver_id = message_data.get("receiver_id")
# Просто прокидываем сообщение дальше
await manager.send_personal_message(message_data, str(receiver_id))
except WebSocketDisconnect:
pass
finally:
@ -310,7 +339,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
{"last_online": datetime.now(timezone.utc)}, synchronize_session="fetch")
db.commit()
print("ОТКЛЮЧЕНИЕ")
await manager.broadcast({
"type": "user_offline",
"user_id": user_id,

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

17
srv/get_token.py Normal file
View File

@ -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)

View File

@ -1,6 +1,6 @@
from fastapi import FastAPI
from fastapi.responses import FileResponse
from app.api.endpoints import users, auth, messages, media
from app.api.endpoints import users, auth, messages, media, admin
from app.websocket.connection_manager import wsRouter
from fastapi.middleware.cors import CORSMiddleware
import os
@ -15,6 +15,7 @@ app.include_router(users.usersRouter)
app.include_router(messages.messagesRouter)
app.include_router(media.mediaRouter)
app.include_router(wsRouter)
app.include_router(admin.adminRouter)
app.add_middleware(
CORSMiddleware,
@ -28,7 +29,7 @@ app.add_middleware(
@app.get("/check-update")
async def check_update():
return {
"latest_version": "2.0.1",
"latest_version": "2.0.2",
"apk_url": "https://api.chepuhagram.ru/get-update",
"force_update": False
}
@ -53,39 +54,6 @@ async def head_image():
return FileResponse(path=file_path, filename="chepuhagram-release.apk",
media_type="application/vnd.android.package-archive",)
@app.on_event("startup")
async def startup_event():
asyncio.create_task(cleanup_uploads())
if config.SERVER_ROLE == 'cloud':
asyncio.create_task(media.forward_pending_media_loop())
elif config.SERVER_ROLE == 'home':
asyncio.create_task(media.home_storage_maintenance_loop())
async def cleanup_uploads():
while True:
try:
db = models.SessionLocal()
# Получить все используемые file_id из avatar_file_id
file_ids = db.query(models.User.avatar_file_id).filter(models.User.avatar_file_id.isnot(None)).all()
used_files = set(f[0] for f in file_ids)
db.close()
# Проверить файлы в uploads
uploads_dir = 'uploads'
if os.path.exists(uploads_dir):
for filename in os.listdir(uploads_dir):
if filename.endswith('.enc'):
file_id = filename[:-4] # убрать .enc
if file_id not in used_files:
file_path = os.path.join(uploads_dir, filename)
os.remove(file_path)
print(f"Удален неиспользуемый файл: {file_path}")
except Exception as e:
print(f"Ошибка в cleanup: {e}")
await asyncio.sleep(300) # каждые 5 минут
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8587)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -7,24 +7,31 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <camera_windows/camera_windows.h>
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <gal/gal_plugin_c_api.h>
#include <local_auth_windows/local_auth_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
CameraWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("CameraWindows"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
FlutterWebRTCPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
GalPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GalPluginCApi"));
LocalAuthPluginRegisterWithRegistrar(
@ -33,6 +40,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@ -4,17 +4,21 @@
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
camera_windows
file_selector_windows
firebase_core
flutter_secure_storage_windows
flutter_webrtc
gal
local_auth_windows
permission_handler_windows
record_windows
sqlite3_flutter_libs
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_local_notifications_windows
jni
)

View File

@ -89,13 +89,13 @@ BEGIN
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "com.example" "\0"
VALUE "FileDescription", "chepuhagram" "\0"
VALUE "CompanyName", "ArturKarasevich" "\0"
VALUE "FileDescription", "Chepuhagram" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "chepuhagram" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
VALUE "OriginalFilename", "chepuhagram.exe" "\0"
VALUE "ProductName", "chepuhagram" "\0"
VALUE "InternalName", "Chepuhagram" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 ArturKarasevich. All rights reserved." "\0"
VALUE "OriginalFilename", "Chepuhagram.exe" "\0"
VALUE "ProductName", "Chepuhagram" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"
END
END

View File

@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
if (!window.Create(L"chepuhagram", origin, size)) {
if (!window.Create(L"Chepuhagram", origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB