Добавлены аватарки, обои, TOTP и многое другое

This commit is contained in:
Artur 2026-05-02 20:02:46 +05:00
parent 15af40fc64
commit d33c41010d
32 changed files with 1828 additions and 277 deletions

View File

@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="Chepuhagram"

View File

@ -6,9 +6,11 @@ class ThemeProvider extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system;
Color _accentColor = const Color(0xFF24A1DE);
String? _wallpaperPath;
ThemeMode get themeMode => _themeMode;
Color get accentColor => _accentColor;
String? get wallpaperPath => _wallpaperPath;
bool isLight = false;
@ -20,12 +22,14 @@ class ThemeProvider extends ChangeNotifier {
Future<void> _loadSettings() async {
final mode = await _storage.read(key: 'theme_mode');
final color = await _storage.read(key: 'accent_color');
final wallpaper = await _storage.read(key: 'wallpaper_path');
if (mode != null) {
_themeMode = mode == 'dark' ? ThemeMode.dark : ThemeMode.light;
isLight = mode == 'light';
}
if (color != null) _accentColor = Color(int.parse(color));
_wallpaperPath = wallpaper;
notifyListeners();
}
@ -42,6 +46,16 @@ class ThemeProvider extends ChangeNotifier {
notifyListeners();
}
void updateWallpaper(String? path) {
_wallpaperPath = path;
if (path != null) {
_storage.write(key: 'wallpaper_path', value: path);
} else {
_storage.delete(key: 'wallpaper_path');
}
notifyListeners();
}
ThemeData get themeData => ThemeData(
useMaterial3: true,
brightness: _themeMode == ThemeMode.dark

View File

@ -56,6 +56,11 @@ class LocalDbService {
);
}
Future<void> clearDatabase() async {
final db = await database;
await db.delete('messages');
}
// Сохранение списка сообщений (из истории)
Future<void> saveMessages(List<dynamic> messages) async {
final db = await database;

View File

@ -5,14 +5,16 @@ import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status;
import 'package:web_socket_channel/io.dart';
import 'package:chepuhagram/core/constants.dart';
import 'package:flutter/widgets.dart';
class SocketService with WidgetsBindingObserver {
class SocketService {
static final SocketService _instance = SocketService._internal();
factory SocketService() => _instance;
factory SocketService() {
return _instance;
SocketService._internal() {
WidgetsBinding.instance.addObserver(this);
}
SocketService._internal();
WebSocketChannel? _channel;
final StreamController<Map<String, dynamic>> _messageController =
@ -21,6 +23,19 @@ class SocketService {
// Поток, который будут слушать провайдеры
Stream<Map<String, dynamic>> get messages => _messageController.stream;
bool allowConnect = true; // Флаг для контроля подключения
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
allowConnect = true;
} else {
allowConnect = false;
disconnect();
}
}
Future<void> connect(ApiService apiService) async {
final token = await apiService.getAccessToken();
if (_channel != null) return; // Уже подключены
@ -28,6 +43,7 @@ class SocketService {
print('❌ SocketService.connect: no access token, skipping connect');
return;
}
if (!allowConnect) return; // Не разрешаем подключение
// В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке
final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token");

View File

@ -1,9 +1,12 @@
import '/core/constants.dart';
class Contact {
final int id;
final String username;
final String name;
final String surname;
String name;
String surname;
final String? lastMessage;
final String? avatarFileId;
final String? avatarUrl;
final DateTime? lastMessageTime;
final bool isOnline;
@ -11,12 +14,15 @@ class Contact {
final String? publicKey;
final bool isLastMsgDecrypted;
String? get effectiveAvatarUrl => avatarUrl ?? (avatarFileId != null ? '${AppConstants.baseUrl}/media/$avatarFileId' : null);
Contact({
required this.id,
required this.username,
required this.name,
required this.surname,
this.lastMessage,
this.avatarFileId,
this.avatarUrl,
this.lastMessageTime,
this.isOnline = false,
@ -31,6 +37,7 @@ class Contact {
String? name,
String? surname,
String? lastMessage,
String? avatarFileId,
String? avatarUrl,
DateTime? lastMessageTime,
bool? isOnline,
@ -44,6 +51,7 @@ class Contact {
name: name ?? this.name,
surname: surname ?? this.surname,
lastMessage: lastMessage ?? this.lastMessage,
avatarFileId: avatarFileId ?? this.avatarFileId,
avatarUrl: avatarUrl ?? this.avatarUrl,
lastMessageTime: lastMessageTime ?? this.lastMessageTime,
isOnline: isOnline ?? this.isOnline,
@ -67,6 +75,7 @@ class Contact {
name: json['name'] ?? json['first_name'] ?? 'Unknown',
surname: json['surname'] ?? json['last_name'] ?? 'Unknown',
lastMessage: json['last_message'] ?? json['lastMessage'],
avatarFileId: json['avatar_file_id'] ?? json['avatarFileId'],
avatarUrl: json['avatar_url'] ?? json['avatarUrl'],
lastMessageTime: parseTime(json['last_message_time'] ?? json['lastMessageTime']),
isOnline: (json['is_online'] ?? json['isOnline']) == true,

View File

@ -30,6 +30,7 @@ class ContactRepository {
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
print(data);
List<Contact> contacts = data.map((json) => Contact.fromJson(json)).toList();
for (var item in contacts) {
if (item.lastMessageTime != null) {

View File

@ -279,6 +279,7 @@ class ApiService extends ChangeNotifier {
bool? showAvatar,
bool? showAbout,
bool? showUsername,
bool? showLastOnline,
}) async {
final token = await getAccessToken();
final response = await _client.put(
@ -293,6 +294,7 @@ class ApiService extends ChangeNotifier {
if (showAvatar != null) 'show_avatar': showAvatar,
if (showAbout != null) 'show_about': showAbout,
if (showUsername != null) 'show_username': showUsername,
if (showLastOnline != null) 'show_last_online': showLastOnline,
}),
);
@ -315,4 +317,74 @@ class ApiService extends ChangeNotifier {
}
throw Exception('Не удалось получить настройки конфиденциальности');
}
Future<bool> updateAvatar(String fileId) async {
final token = await getAccessToken();
final response = await _client.put(
Uri.parse('${AppConstants.baseUrl}/users/me/avatar'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({'avatar_file_id': fileId}),
);
return response.statusCode == 200;
}
Future<Map<String, dynamic>> enableTotp() async {
final token = await getAccessToken();
final response = await _client.post(
Uri.parse('${AppConstants.baseUrl}/auth/totp/enable'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
if (response.statusCode == 200) {
return jsonDecode(utf8.decode(response.bodyBytes))
as Map<String, dynamic>;
}
throw Exception(
(jsonDecode(response.body) as Map<String, dynamic>)['detail'] ??
'Failed to enable TOTP',
);
}
Future<bool> verifyTotp(String code) async {
final token = await getAccessToken();
final response = await _client.post(
Uri.parse('${AppConstants.baseUrl}/auth/totp/verify'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({'code': code}),
);
return response.statusCode == 200;
}
Future<bool> disableTotp() async {
final token = await getAccessToken();
final response = await _client.post(
Uri.parse('${AppConstants.baseUrl}/auth/totp/disable'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
return response.statusCode == 200;
}
Future<bool> deleteAllMessages() async {
final token = await getAccessToken();
final response = await _client.delete(
Uri.parse('${AppConstants.baseUrl}/messages/all'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
);
return response.statusCode == 200;
}
}

View File

@ -1,4 +1,3 @@
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'dart:convert';
@ -195,8 +194,8 @@ class CryptoService {
isLastMsgDecrypted: true,
),
);
} catch (_) {
result.add(contact);
} catch (e) {
result.add(contact.copyWith(lastMessage: '[не удалось расшифровать: $e]', isLastMsgDecrypted: true));
}
}
return result;

View File

@ -1,6 +1,9 @@
import 'package:chepuhagram/data/datasources/local_db_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import 'dart:io';
import '/core/constants.dart';
import 'package:http/http.dart' as http;
import 'package:chepuhagram/domain/services/api_service.dart';
@ -32,6 +35,12 @@ class AuthProvider extends ChangeNotifier {
String? _about;
String? get about => _about;
String? _avatarPath;
String? get avatarPath => _avatarPath;
String? _avatarUrl;
String? get avatarUrl => _avatarUrl;
// Privacy settings
bool? _showEmail;
bool? get showEmail => _showEmail;
@ -85,14 +94,19 @@ class AuthProvider extends ChangeNotifier {
SocketService get socketService => _socketService;
Future<bool> login(String username, String password) async {
Future<bool> login(String username, String password, {String? totpCode}) async {
_isLoading = true;
notifyListeners();
try {
final body = {'username': username, 'password': password};
if (totpCode != null) {
body['totp_code'] = totpCode;
}
final response = await _client.post(
Uri.parse('${AppConstants.baseUrl}/auth/login'),
body: {'username': username, 'password': password},
headers: {'Content-Type': 'application/json'},
body: jsonEncode(body),
);
final decodedResponse =
@ -135,7 +149,12 @@ class AuthProvider extends ChangeNotifier {
Future<void> logout() async {
final mode = await _storage.read(key: 'theme_mode');
final color = await _storage.read(key: 'accent_color');
final wallpaper = await _storage.read(key: 'wallpaper_path');
final avatar = await _storage.read(key: 'avatar_path');
await _storage.deleteAll();
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
await LocalDbService().clearDatabase();
_currentUserId = null;
_username = null;
_firstName = null;
@ -143,12 +162,20 @@ class AuthProvider extends ChangeNotifier {
_phone = null;
_email = null;
_about = null;
_avatarPath = null;
_avatarUrl = null;
if (mode != null) {
await _storage.write(key: 'theme_mode', value: mode);
}
if (color != null) {
await _storage.write(key: 'accent_color', value: color);
}
if (wallpaper != null) {
await _storage.write(key: 'wallpaper_path', value: wallpaper);
}
if (avatar != null) {
await _storage.write(key: 'avatar_path', value: avatar);
}
notifyListeners();
}
@ -253,6 +280,11 @@ class AuthProvider extends ChangeNotifier {
_phone = data['phone']?.toString();
_email = data['email']?.toString();
_about = data['about']?.toString();
final avatarFileId = data['avatar_file_id']?.toString();
_avatarUrl = avatarFileId != null ? '${AppConstants.baseUrl}/media/$avatarFileId' : null;
// Загружаем локальные настройки
_avatarPath = await _storage.read(key: 'avatar_path');
// Проверяем наличие публичного ключа на сервере
_hasPublicKeyOnServer =
@ -312,4 +344,33 @@ class AuthProvider extends ChangeNotifier {
_needsKeyRecovery = false;
notifyListeners();
}
void updateAvatarPath(String? path) {
_avatarPath = path;
if (path != null) {
_storage.write(key: 'avatar_path', value: path);
} else {
_storage.delete(key: 'avatar_path');
}
notifyListeners();
}
Future<bool> updateAvatar(String path) async {
try {
final bytes = await File(path).readAsBytes();
final fileId = await _apiService.uploadMedia(bytes);
if (fileId != null) {
final success = await _apiService.updateAvatar(fileId);
if (success) {
updateAvatarPath(path);
await refreshMe(); // Обновить данные профиля, включая avatarUrl
return true;
}
}
return false;
} catch (e) {
print('Ошибка обновления аватарки: $e');
return false;
}
}
}

View File

@ -1,16 +1,13 @@
import 'package:flutter/material.dart';
import '/data/models/contact_model.dart';
import '/data/repositories/contact_repository.dart';
import '/data/datasources/local_db_service.dart';
import '/domain/services/crypto_service.dart';
import 'dart:isolate';
import 'dart:convert';
import 'package:cryptography/cryptography.dart';
import 'package:flutter/foundation.dart';
class ContactProvider extends ChangeNotifier {
final ContactRepository _repository = ContactRepository();
final LocalDbService _localDbService = LocalDbService();
final CryptoService _cryptoService;
ContactProvider(this._cryptoService);
@ -112,7 +109,9 @@ class ContactProvider extends ChangeNotifier {
'cache': cacheCopy,
},
);
for (var contact in updatedContacts) {
print('Decrypted contact: ${contact.name} ${contact.surname}, lastMessage: ${contact.lastMessage}, isDecrypted: ${contact.isLastMsgDecrypted}');
}
_contacts = updatedContacts;
notifyListeners();
} catch (e) {
@ -120,4 +119,27 @@ class ContactProvider extends ChangeNotifier {
}
}
Future<void> updateContact(int userId) async {
try {
final updatedContact = await _repository.fetchContactById(userId);
final index = _contacts.indexWhere((c) => c.id == userId);
if (index != -1) {
// Обновляем только поля профиля, сохраняя lastMessage и т.д.
final existing = _contacts[index];
_contacts[index] = existing.copyWith(
username: updatedContact.username,
name: updatedContact.name,
surname: updatedContact.surname,
avatarUrl: updatedContact.avatarUrl,
avatarFileId: updatedContact.avatarFileId,
isOnline: updatedContact.isOnline,
publicKey: updatedContact.publicKey,
);
notifyListeners();
}
} catch (e) {
print("Error updating contact: $e");
}
}
}

View File

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'data/datasources/ws_client.dart';
import 'logic/auth_provider.dart';
import 'logic/contact_provider.dart';
@ -224,9 +222,7 @@ void main() async {
Provider(create: (_) => SocketService()),
ChangeNotifierProvider(
create: (context) => ContactProvider(
context.read<CryptoService>(),
),
create: (context) => ContactProvider(context.read<CryptoService>()),
),
],
child: const MyApp(),
@ -290,13 +286,42 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
notificationText = 'Failed to decrypt: ${e.toString()}';
}
final senderId = int.tryParse(
message.data['sender_id']?.toString() ?? '',
);
// 4. Показываем локальное уведомление
final String groupKey = 'ru.chepuhagram.app.$senderId';
await flutterLocalNotificationsPlugin.show(
senderId!,
'',
'',
NotificationDetails(
android: AndroidNotificationDetails(
'Messages',
'Новые сообщения',
groupKey: groupKey,
setAsGroupSummary: true,
importance: Importance.high,
priority: Priority.high,
groupAlertBehavior: GroupAlertBehavior.all,
),
),
);
await flutterLocalNotificationsPlugin.show(
message.hashCode,
message.data['username'] ?? 'Unknown',
notificationText,
const NotificationDetails(
android: AndroidNotificationDetails('chat_id', 'Messages'),
NotificationDetails(
android: AndroidNotificationDetails(
'chat_id',
'Messages',
groupKey: groupKey,
importance: Importance.high,
priority: Priority.high,
showWhen: true,
),
),
payload: jsonEncode({
'type': 'enc_message',

View File

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import '/core/theme_manager.dart';
import 'dart:io';
class AppearanceSettingsScreen extends StatefulWidget {
const AppearanceSettingsScreen({super.key});
@override
State<AppearanceSettingsScreen> createState() => _AppearanceSettingsScreenState();
}
class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
final ImagePicker _picker = ImagePicker();
Future<void> _pickWallpaper() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image != null) {
context.read<ThemeProvider>().updateWallpaper(image.path);
}
}
@override
Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>();
return Scaffold(
appBar: AppBar(title: const Text("Оформление")),
body: ListView(
children: [
// Ночной режим
SwitchListTile(
secondary: const Icon(Icons.dark_mode),
title: const Text("Ночной режим"),
value: themeProv.themeMode == ThemeMode.dark,
onChanged: (val) => themeProv.toggleTheme(val),
),
const Divider(),
// Выбор цвета акцента
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Icon(
Icons.palette_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 10),
const Text("Цвет темы"),
const Spacer(),
_colorCircle(context, const Color(0xFF24A1DE), themeProv),
_colorCircle(context, const Color(0xFF3E8E7E), themeProv),
_colorCircle(context, const Color(0xFF8E3E7E), themeProv),
_colorCircle(context, const Color(0xFFFF9800), themeProv),
_colorCircle(context, const Color(0xFFF44336), themeProv),
],
),
],
),
),
const Divider(),
// Обои чата
ListTile(
leading: const Icon(Icons.wallpaper),
title: const Text('Обои чата'),
subtitle: const Text('Выбрать изображение из галереи'),
trailing: const Icon(Icons.chevron_right),
onTap: _pickWallpaper,
),
// Показать текущие обои, если есть
if (themeProv.wallpaperPath != null)
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Текущие обои:'),
const SizedBox(height: 8),
Container(
height: 150,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => themeProv.updateWallpaper(null),
child: const Text('Удалить обои'),
),
],
),
),
],
),
);
}
Widget _colorCircle(BuildContext context, Color color, ThemeProvider prov) {
bool isSelected = prov.accentColor == color;
return GestureDetector(
onTap: () => prov.updateAccentColor(color),
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? color : Colors.transparent,
width: 2,
),
),
child: CircleAvatar(backgroundColor: color, radius: 15),
),
);
}
}

View File

@ -1,6 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '/data/models/message_model.dart';
import '/data/models/contact_model.dart';
@ -11,6 +9,7 @@ import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'package:provider/provider.dart';
import '/logic/contact_provider.dart';
import '../../domain/services/api_service.dart';
import 'dart:math';
import 'package:chepuhagram/data/datasources/local_db_service.dart';
import 'package:chepuhagram/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -18,6 +17,8 @@ import 'contacts_screen.dart';
import 'package:flutter/services.dart';
import 'user_profile_screen.dart';
import 'package:image_picker/image_picker.dart';
import '/core/theme_manager.dart';
import 'dart:io';
class ChatScreen extends StatefulWidget {
final Contact contact;
@ -28,11 +29,11 @@ class ChatScreen extends StatefulWidget {
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
class _ChatScreenState extends State<ChatScreen> with RouteAware {
static const String _notificationLaunchKey = 'notification_launch_data';
int myId = 0;
late Contact _currentContact;
bool _isKeyLoading = false;
bool _isKeyLoading = true;
final TextEditingController _controller = TextEditingController();
final FocusNode _inputFocusNode = FocusNode();
final ContactRepository _contactRepository = ContactRepository();
@ -44,26 +45,127 @@ class _ChatScreenState extends State<ChatScreen> {
final LocalDbService _localDbService = LocalDbService();
Uint8List? _pendingImageBytes;
MessageModel? _replyTo;
bool _isOnline = false;
DateTime? _lastOnline;
Timer? _onlineTimer;
DateTime? _lastTypingSent;
bool _isTyping = false;
Timer? _typingTimer;
late SocketService _socketService;
@override
void initState() {
super.initState();
_currentContact = widget.contact;
_socketService = Provider.of<SocketService>(context, listen: false);
currentActiveChatContactId =
_currentContact.id; // Устанавливаем активный чат
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
final contactProvider = context.read<ContactProvider>();
myId = contactProvider.getCurrentUserId() ?? 0;
// Если ключа нет, загружаем его при входе
_loadLocalName();
if (_currentContact.publicKey == null) {
_loadContactKey();
}
_loadHistory();
_loadOnlineStatus();
startOnlineUpdates();
_controller.addListener(_sendTypingStatus);
final socketService = Provider.of<SocketService>(context, listen: false);
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
}
@override
void didPopNext() {
print("Пользователь вернулся на этот экран!");
_loadLocalName();
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
}
Future<void> _loadLocalName() async {
final prefs = await SharedPreferences.getInstance();
final String? savedName = prefs.getString(
'firstname_${_currentContact.id}',
);
final String? savedSurname = prefs.getString(
'lastname_${_currentContact.id}',
);
print('Загружены имя $savedName, $savedSurname');
if (mounted) {
setState(() {
if (savedName != null) {
_currentContact.name = savedName;
}
if (savedSurname != null) {
_currentContact.surname = savedSurname;
}
});
}
}
void _sendTypingStatus() {
final now = DateTime.now();
if (_lastTypingSent == null ||
now.difference(_lastTypingSent!) > const Duration(seconds: 3)) {
_lastTypingSent = now;
final socketService = Provider.of<SocketService>(context, listen: false);
socketService.sendMessage({
'type': 'typing',
'receiver_id': _currentContact.id,
});
}
}
void _sendStopTypingStatus() {
_socketService.sendMessage({
'type': 'stop_typing',
'receiver_id': _currentContact.id,
});
}
Future<void> _loadOnlineStatus() async {
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
try {
print(
"🔍 Загружаем онлайн статус для контакта ${_currentContact.name} (ID: ${_currentContact.id})",
);
final data = await apiService.getUserById(_currentContact.id);
if (!mounted) return;
DateTime now = DateTime.now();
Duration offset = now.timeZoneOffset;
print(
"✅ Получен онлайн статус: ${data['online']}, last_online: ${data['last_online'] != null ? DateTime.tryParse(data['last_online']!)?.add(offset) : null}",
);
setState(() {
_isOnline = data['online'] ?? false;
if (data['last_online'] != null)
_lastOnline = DateTime.parse(data['last_online']).add(offset);
else
_lastOnline = null;
});
} catch (e) {
print("❌ ОШИБКА ПРИ ЗАГРУЗКЕ СТАТУСА ОНЛАЙН: $e");
// Игнорируем ошибки при загрузке статуса
}
}
void startOnlineUpdates() {
_onlineTimer = Timer.periodic(const Duration(minutes: 1), (_) {
_loadOnlineStatus();
});
}
Future<void> _loadContactKey() async {
if (!mounted) return;
setState(() => _isKeyLoading = true);
@ -84,11 +186,7 @@ class _ChatScreenState extends State<ChatScreen> {
const SnackBar(
content: Text("Не удалось получить ключ шифрования собеседника"),
behavior: SnackBarBehavior.floating, // Обязательно для margin
margin: EdgeInsets.only(
bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию)
left: 10.0,
right: 10.0,
),
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
duration: Duration(seconds: 3),
),
);
@ -97,15 +195,21 @@ class _ChatScreenState extends State<ChatScreen> {
@override
void dispose() {
currentActiveChatContactId = null; // Сбрасываем активный чат
currentActiveChatContactId = null;
_socketSubscription?.cancel();
_controller.dispose();
routeObserver.unsubscribe(this);
_inputFocusNode.dispose();
_onlineTimer?.cancel();
_typingTimer?.cancel();
_controller.removeListener(_sendTypingStatus);
_sendStopTypingStatus();
super.dispose();
}
@override
Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>();
return Scaffold(
appBar: AppBar(
leading: IconButton(
@ -133,22 +237,80 @@ class _ChatScreenState extends State<ChatScreen> {
),
);
},
child: Text(_currentContact.name),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_currentContact.name} ${_currentContact.surname != 'Unknown' ? _currentContact.surname : ''}',
),
if (_isKeyLoading == true)
const Text(
'загрузка...',
style: const TextStyle(
fontSize: 12,
color: Color.fromARGB(255, 219, 219, 219),
),
)
else if (_isTyping)
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'печатает',
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
),
const SizedBox(width: 4),
TypingIndicator(),
],
)
else if (_isOnline)
const Text(
'онлайн',
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
)
else if (_lastOnline != null)
Text(
'был(а) в сети ${_formatLastOnline(_lastOnline!)}',
style: const TextStyle(
fontSize: 12,
color: Color.fromARGB(255, 219, 219, 219),
),
)
else
const Text(
'был(а) недавно',
style: TextStyle(
fontSize: 12,
color: Color.fromARGB(255, 219, 219, 219),
),
),
],
),
),
),
body: Column(
children: [
Expanded(
child: ListView.builder(
reverse: true, // Сообщения растут снизу вверх
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index];
return MessageBubble(
message: msg,
onTap: () => _showMessageActions(msg),
);
},
child: Container(
decoration: themeProv.wallpaperPath != null
? BoxDecoration(
image: DecorationImage(
image: FileImage(File(themeProv.wallpaperPath!)),
fit: BoxFit.cover,
),
)
: null,
child: ListView.builder(
reverse: true, // Сообщения растут снизу вверх
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index];
return MessageBubble(
message: msg,
onTap: () => _showMessageActions(msg),
);
},
),
),
),
_buildMessageInput(),
@ -157,6 +319,35 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
String _formatLastOnline(DateTime lastOnline) {
final now = DateTime.now();
final difference = now.difference(lastOnline);
if (difference.inSeconds < 60) {
return 'только что';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes} минут${_pluralize(difference.inMinutes, "у", "ы", "")} назад';
} else if (difference.inHours < 24) {
return '${difference.inHours} час${_pluralize(difference.inHours, "", "а", "ов")} назад';
} else if (difference.inDays < 7) {
return '${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
} else {
return 'давно';
}
}
String _pluralize(int count, String form1, String form2, String form5) {
final mod10 = count % 10;
final mod100 = count % 100;
if (mod10 == 1 && mod100 != 11) {
return form1;
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
return form2;
} else {
return form5;
}
}
Future<void> _showMessageActions(MessageModel msg) async {
if (!mounted) return;
@ -496,7 +687,9 @@ class _ChatScreenState extends State<ChatScreen> {
});
Navigator.push(
context,
MaterialPageRoute(builder: (context) => ChatScreen(contact: targetContact,)),
MaterialPageRoute(
builder: (context) => ChatScreen(contact: targetContact),
),
);
} catch (e) {
if (!mounted) return;
@ -630,6 +823,7 @@ class _ChatScreenState extends State<ChatScreen> {
}
Future<void> _sendMessage() async {
_sendStopTypingStatus();
final rawText = _controller.text.trim();
final hasImage = _pendingImageBytes != null;
@ -908,6 +1102,10 @@ class _ChatScreenState extends State<ChatScreen> {
}
if (data['type'] == 'private_message') {
setState(() {
_typingTimer?.cancel();
_isTyping = false;
});
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
final receiverId = int.tryParse(
(data['receiver_id'] ?? data['recipient_id'])?.toString() ?? '',
@ -978,7 +1176,42 @@ class _ChatScreenState extends State<ChatScreen> {
print(
"Сообщение от другого пользователя (ID: $senderId), игнорируем в этом чате",
);
// Тут можно добавить логику уведомления для списка чатов
}
}
if (data['type'] == 'user_online') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId == widget.contact.id) {
setState(() => _isOnline = true);
}
}
if (data['type'] == 'user_offline') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId == widget.contact.id) {
setState(() {
_isOnline = false;
_lastOnline = DateTime.now();
});
_loadOnlineStatus();
}
}
if (data['type'] == 'typing' && data['sender_id'] == _currentContact.id) {
if (mounted) {
setState(() => _isTyping = true);
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 4), () {
if (mounted) setState(() => _isTyping = false);
});
}
}
if (data['type'] == 'stop_typing' &&
data['sender_id'] == _currentContact.id) {
if (mounted) {
setState(() => _isTyping = false);
_typingTimer?.cancel();
}
}
}
@ -1146,3 +1379,73 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
}
class TypingIndicator extends StatefulWidget {
const TypingIndicator({super.key});
@override
State<TypingIndicator> createState() => _TypingIndicatorState();
}
class _TypingIndicatorState extends State<TypingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
)..repeat(reverse: true); // Анимация идет туда-сюда
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Widget _buildDot(int index) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
// Рассчитываем смещение: только отрицательные значения (вверх)
double delay = index * 0.5; // Увеличили задержку для плавности
double shift = sin((_controller.value * 2 * pi) + delay);
// Используем clamp или abs, чтобы точка не уходила ниже базовой линии
double yOffset = (shift < 0 ? shift : 0) * 4;
return SizedBox(
width: 4, // Фиксированная зона для одной точки
height: 5, // Фиксированная высота зоны анимации
child: Align(
alignment: Alignment.bottomCenter, // Точка всегда прижата к низу
child: Container(
width: 2,
height: 2,
decoration: const BoxDecoration(
color: Colors.greenAccent,
shape: BoxShape.circle,
),
transform: Matrix4.translationValues(0, yOffset, 0),
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 12,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(3, (index) => _buildDot(index)),
),
);
}
}

View File

@ -20,8 +20,8 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:open_filex/open_filex.dart';
import '/data/datasources/ws_client.dart';
class ContactsScreen extends StatefulWidget {
final int? targetChatId;
@ -46,6 +46,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
super.initState();
print('ContactsScreen initState, targetChatId: ${widget.targetChatId}');
_setupPushNotifications();
final socketService = Provider.of<SocketService>(context, listen: false);
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
WidgetsBinding.instance.addPostFrameCallback((_) {
final authProvider = context.read<AuthProvider>();
final contactProvider = context.read<ContactProvider>();
@ -207,12 +209,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
});
// Listen for foreground messages
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
print('Foreground message received: ${message.data}');
if (message.data['type'] == 'enc_message') {
_handleIncomingMessage(message);
}
});
FirebaseMessaging.onMessage.listen(_handleIncomingMessage);
// Handle notification tap when app was terminated/backgrounded
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
@ -267,7 +264,24 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
}
}
Future<void> _handleIncomingMessage(RemoteMessage message) async {
Future<void> _handleIncomingMessage(dynamic data) async {
if (data is RemoteMessage) {
// FCM message
await _handleFCMMessage(data);
} else if (data is Map<String, dynamic>) {
// WebSocket message
print('WebSocket message received: $data');
if (data['type'] == 'user_updated') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null) {
final contactProvider = context.read<ContactProvider>();
contactProvider.updateContact(userId);
}
}
}
}
Future<void> _handleFCMMessage(RemoteMessage message) async {
try {
// Проверяем, не находимся ли мы уже в чате с отправителем
final senderId = int.tryParse(
@ -280,8 +294,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
// Ensure notification channel exists
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'chat_id',
'Messages',
'Новые сообщения',
description: 'Chat messages notifications',
importance: Importance.high,
);
@ -308,13 +322,53 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
sharedSecret,
);
if (senderId == null) return;
final String groupKey = 'ru.chepuhagram.app.$senderId';
final prefs = await SharedPreferences.getInstance();
final String? firstName = prefs.getString(
'firstname_${message.data['sender_id']}',
);
final String? lastName = prefs.getString(
'lastname_${message.data['sender_id']}',
);
final String localFullName = '${firstName ?? ''} ${lastName ?? ''}'
.trim();
final String title = localFullName.isNotEmpty
? localFullName
: (message.data['username'] ?? 'Unknown');
// Show local notification
await flutterLocalNotificationsPlugin.show(
senderId,
'',
'',
NotificationDetails(
android: AndroidNotificationDetails(
'Messages',
'Новые сообщения',
groupKey: groupKey,
setAsGroupSummary: true,
importance: Importance.high,
priority: Priority.high,
groupAlertBehavior: GroupAlertBehavior.all,
),
),
);
await flutterLocalNotificationsPlugin.show(
message.hashCode,
message.data['username'] ?? 'Unknown',
title,
decryptedText,
const NotificationDetails(
android: AndroidNotificationDetails('chat_id', 'Messages'),
NotificationDetails(
android: AndroidNotificationDetails(
'Messages',
'Новые сообщения',
groupKey: groupKey,
importance: Importance.high,
priority: Priority.high,
showWhen: true,
),
),
payload: jsonEncode({
'type': 'enc_message',
@ -329,7 +383,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
contactProvider.loadContacts();
}
} catch (e) {
print('Error processing foreground message: $e');
print('Error processing foreground FCM message: $e');
}
}
@ -438,15 +492,24 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
),
),
currentAccountPicture: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.onSurface,
child: Text(
initials.isEmpty ? 'U' : initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primaryContainer,
),
),
backgroundColor: authProvider.avatarUrl == null && authProvider.avatarPath == null
? Theme.of(context).colorScheme.onSurface
: null,
backgroundImage: authProvider.avatarUrl != null
? NetworkImage(authProvider.avatarUrl!)
: authProvider.avatarPath != null
? FileImage(File(authProvider.avatarPath!))
: null,
child: (authProvider.avatarUrl == null && authProvider.avatarPath == null)
? Text(
initials.isEmpty ? 'U' : initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primaryContainer,
),
)
: null,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.inversePrimary,
@ -527,14 +590,6 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
}
}
void _cancelDownload() {
_cancelToken?.cancel("Отменено");
setState(() {
_isDownloading = false;
_downloadProgress = 0.0;
});
}
Widget _buildUpdateBanner() {
return Container(
margin: const EdgeInsets.fromLTRB(

View File

@ -31,6 +31,15 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
try {
final authProvider = context.read<AuthProvider>();
// Удаляем все сообщения пользователя
try {
final api = ApiService();
await api.deleteAllMessages();
} catch (e) {
print('Ошибка при удалении сообщений: $e');
// Продолжаем даже если удаление сообщений не удалось
}
// Удаляем старые ключи и создаем новые
await authProvider.resetKeys();

View File

@ -16,6 +16,9 @@ class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _totpController = TextEditingController();
bool _showTotpField = false;
String? _errorMessage;
@override
Widget build(BuildContext context) {
@ -85,6 +88,36 @@ class _LoginScreenState extends State<LoginScreen> {
validator: (value) =>
value!.length < 6 ? "Минимум 6 символов" : null,
),
const SizedBox(height: 16),
// Поле TOTP, если требуется
if (_showTotpField)
TextFormField(
controller: _totpController,
decoration: InputDecoration(
labelText: "TOTP код",
prefixIcon: const Icon(Icons.security),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
fillColor: Theme.of(context).colorScheme.primary,
iconColor: Theme.of(context).colorScheme.primary,
hoverColor: Theme.of(context).colorScheme.primary,
focusColor: Theme.of(context).colorScheme.primary,
),
validator: (value) => value!.isEmpty ? "Введите TOTP код" : null,
),
if (_showTotpField) const SizedBox(height: 16),
// Сообщение об ошибке
if (_errorMessage != null)
Text(
_errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
if (_errorMessage != null) const SizedBox(height: 16),
const SizedBox(height: 24),
// Кнопка Входа
@ -120,6 +153,7 @@ class _LoginScreenState extends State<LoginScreen> {
final success = await authProvider.login(
_usernameController.text,
_passwordController.text,
totpCode: _showTotpField ? _totpController.text : null,
);
if (success && mounted) {
await authProvider.initRealtime();
@ -146,9 +180,25 @@ class _LoginScreenState extends State<LoginScreen> {
}
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
final error = e.toString().replaceAll('Exception: ', '');
if (error.contains('TOTP код требуется')) {
setState(() {
_showTotpField = true;
_errorMessage = error;
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
}
}
}
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_totpController.dispose();
super.dispose();
}
}

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '/data/models/contact_model.dart';
import '/logic/contact_provider.dart';
import '/logic/auth_provider.dart';
import 'chat_screen.dart';

View File

@ -15,12 +15,14 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
static const _showAvatarKey = 'privacy_show_avatar';
static const _showAboutKey = 'privacy_show_about';
static const _showUsernameKey = 'privacy_show_username';
static const _showLastOnlineKey = 'privacy_show_last_online';
bool _showEmail = true;
bool _showPhone = true;
bool _showAvatar = true;
bool _showAbout = true;
bool _showUsername = true;
bool _showLastOnline = true;
bool _isSaving = false;
@override
@ -38,6 +40,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
_showAvatar = prefs.getBool(_showAvatarKey) ?? true;
_showAbout = prefs.getBool(_showAboutKey) ?? true;
_showUsername = prefs.getBool(_showUsernameKey) ?? true;
_showLastOnline = prefs.getBool(_showLastOnlineKey) ?? true;
});
}
@ -51,6 +54,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
_showAvatar = data['show_avatar'] ?? true;
_showAbout = data['show_about'] ?? true;
_showUsername = data['show_username'] ?? true;
_showLastOnline = data['show_last_online'] ?? true;
});
// Сохраняем локально для быстрого доступа
await _savePreference(_showEmailKey, _showEmail);
@ -58,6 +62,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
await _savePreference(_showAvatarKey, _showAvatar);
await _savePreference(_showAboutKey, _showAbout);
await _savePreference(_showUsernameKey, _showUsername);
await _savePreference(_showLastOnlineKey, _showLastOnline);
} catch (e) {
// Если не удалось загрузить с сервера, используем локальные настройки
print('Ошибка загрузки настроек с сервера: $e');
@ -82,6 +87,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
showAvatar: _showAvatar,
showAbout: _showAbout,
showUsername: _showUsername,
showLastOnline: _showLastOnline,
);
if (success) {
@ -91,6 +97,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
await _savePreference(_showAvatarKey, _showAvatar);
await _savePreference(_showAboutKey, _showAbout);
await _savePreference(_showUsernameKey, _showUsername);
await _savePreference(_showLastOnlineKey, _showLastOnline);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@ -178,6 +185,13 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
setState(() => _showAbout = value);
},
),
SwitchListTile(
title: const Text('Показывать последний онлайн'),
value: _showLastOnline,
onChanged: (value) {
setState(() => _showLastOnline = value);
},
),
const SizedBox(height: 24),
const Text(
'Эти настройки влияют на то, какую информацию о вас видят другие пользователи приложения.',

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:local_auth/local_auth.dart';
import 'package:flutter/services.dart';
import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'dart:convert';
class SecuritySettingsScreen extends StatefulWidget {
const SecuritySettingsScreen({super.key});
@ -13,7 +15,7 @@ class SecuritySettingsScreen extends StatefulWidget {
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
final _passwordFormKey = GlobalKey<FormState>();
final _encryptionFormKey = GlobalKey<FormState>();
final _totpFormKey = GlobalKey<FormState>();
//final _totpFormKey = GlobalKey<FormState>();
final _currentPasswordController = TextEditingController();
final _newPasswordController = TextEditingController();
@ -28,11 +30,15 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
bool _isSavingPassword = false;
bool _isSavingEncryption = false;
bool _isSavingTotp = false;
bool _isTotpEnabled = false;
String? _totpSecret;
String? _totpQrCode;
@override
void initState() {
super.initState();
_checkBiometricSupport();
_loadTotpStatus();
}
@override
@ -53,7 +59,8 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
final availableBiometrics = await _localAuth.getAvailableBiometrics();
if (!mounted) return;
setState(() {
_isBiometricAvailable = canCheckBiometrics && isSupported && availableBiometrics.isNotEmpty;
_isBiometricAvailable =
canCheckBiometrics && isSupported && availableBiometrics.isNotEmpty;
});
} catch (_) {
if (!mounted) return;
@ -63,6 +70,24 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
}
}
Future<void> _loadTotpStatus() async {
try {
final api = ApiService();
final userData = await api.getMe();
print('TOTP status from getMe: ${userData['totp_enabled']}');
if (!mounted) return;
setState(() {
_isTotpEnabled = userData['totp_enabled'] ?? false;
});
print('TOTP status set to: $_isTotpEnabled');
} catch (e) {
print('Error loading TOTP status: $e');
// Ignore errors, assume TOTP is disabled
if (!mounted) return;
setState(() => _isTotpEnabled = false);
}
}
Future<bool> _authenticateBiometric() async {
try {
return await _localAuth.authenticate(
@ -96,9 +121,9 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Пароль успешно изменён')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Пароль успешно изменён')));
_currentPasswordController.clear();
_newPasswordController.clear();
_confirmPasswordController.clear();
@ -143,7 +168,8 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
} else {
final api = ApiService();
final userData = await api.getMe();
final encryptedPrivateKey = userData['encrypted_private_key']?.toString();
final encryptedPrivateKey = userData['encrypted_private_key']
?.toString();
if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty) {
throw Exception('Зашифрованный ключ не найден на сервере.');
@ -156,12 +182,12 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
await cryptoService.savePrivateKey(privateKeyBase64);
}
final updatedEncryptedPrivateKey = await cryptoService.encryptPrivateKeyWithPassword(
privateKeyBase64,
newPassword,
);
final updatedEncryptedPrivateKey = await cryptoService
.encryptPrivateKeyWithPassword(privateKeyBase64, newPassword);
final success = await ApiService().updateEncryptedPrivateKey(updatedEncryptedPrivateKey);
final success = await ApiService().updateEncryptedPrivateKey(
updatedEncryptedPrivateKey,
);
if (!success) {
throw Exception('Не удалось обновить пароль шифрования на сервере.');
}
@ -185,12 +211,221 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
}
Future<void> _setupTotp() async {
if (_isTotpEnabled) {
// Показываем диалог с опциями
_showTotpOptionsDialog();
} else {
// Enable TOTP
setState(() => _isSavingTotp = true);
try {
final api = ApiService();
final data = await api.enableTotp();
setState(() {
_totpSecret = data['secret'];
_totpQrCode = data['qr_code'];
});
// Show dialog to scan QR and enter code
_showTotpSetupDialog();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
} finally {
setState(() => _isSavingTotp = false);
}
}
}
void _showTotpOptionsDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('TOTP'),
content: const Text('TOTP включён. Выберите действие:'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Отмена'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_reissueTotp();
},
child: const Text('Перевыпустить ключ'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_disableTotp();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
child: const Text('Отключить TOTP'),
),
],
),
);
}
Future<void> _reissueTotp() async {
setState(() => _isSavingTotp = true);
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
setState(() => _isSavingTotp = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('TOTP пока не подключён на сервере')),
try {
final api = ApiService();
final data = await api.enableTotp();
setState(() {
_totpSecret = data['secret'];
_totpQrCode = data['qr_code'];
});
// Show dialog to scan QR and enter code
_showTotpSetupDialog(isReissue: true);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
} finally {
setState(() => _isSavingTotp = false);
}
}
Future<void> _disableTotp() async {
setState(() => _isSavingTotp = true);
try {
final api = ApiService();
final success = await api.disableTotp();
if (success) {
setState(() {
_isTotpEnabled = false;
_totpSecret = null;
_totpQrCode = null;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('TOTP отключён')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
} finally {
setState(() => _isSavingTotp = false);
}
}
void _showTotpSetupDialog({bool isReissue = false}) {
final codeController = TextEditingController();
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(isReissue ? 'Перевыпуск ключа TOTP' : 'Настройка TOTP'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(isReissue
? 'Отсканируйте новый QR-код в приложении аутентификатора:'
: 'Отсканируйте QR-код в приложении аутентификатора:'),
const SizedBox(height: 16),
if (_totpQrCode != null)
Builder(
builder: (context) {
final base64String = _totpQrCode!.split(',').last;
final bytes = base64Decode(base64String);
return Image.memory(bytes, width: 200, height: 200);
},
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text(
'Ключ: ${_totpSecret ?? ''}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () {
if (_totpSecret != null) {
Clipboard.setData(ClipboardData(text: _totpSecret!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ключ скопирован')),
);
}
},
tooltip: 'Скопировать ключ',
),
],
),
const SizedBox(height: 16),
TextField(
controller: codeController,
decoration: const InputDecoration(
labelText: 'Введите код из приложения',
helperText: 'Обычно это 6 цифр',
),
keyboardType: TextInputType.number,
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
setState(() {
_totpSecret = null;
_totpQrCode = null;
});
},
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () async {
final code = codeController.text.trim();
if (code.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Введите код')),
);
return;
}
try {
final api = ApiService();
final success = await api.verifyTotp(code);
if (success) {
Navigator.of(context).pop();
setState(() {
_isTotpEnabled = true;
_totpSecret = null;
_totpQrCode = null;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(isReissue ? 'Ключ перевыпущен' : 'TOTP включён')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Неверный код')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
);
}
},
child: const Text('Подтвердить'),
),
],
),
);
}
@ -210,7 +445,10 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text('Смена пароля аккаунта', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const Text(
'Смена пароля аккаунта',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Form(
key: _passwordFormKey,
@ -218,10 +456,13 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
children: [
TextFormField(
controller: _currentPasswordController,
decoration: const InputDecoration(labelText: 'Текущий пароль'),
decoration: const InputDecoration(
labelText: 'Текущий пароль',
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) return 'Введите текущий пароль';
if (value == null || value.isEmpty)
return 'Введите текущий пароль';
return null;
},
),
@ -231,7 +472,8 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
decoration: const InputDecoration(labelText: 'Новый пароль'),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) return 'Введите новый пароль';
if (value == null || value.isEmpty)
return 'Введите новый пароль';
if (value.length < 6) return 'Пароль слишком короткий';
return null;
},
@ -239,23 +481,31 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
const SizedBox(height: 12),
TextFormField(
controller: _confirmPasswordController,
decoration: const InputDecoration(labelText: 'Повторите пароль'),
decoration: const InputDecoration(
labelText: 'Повторите пароль',
),
obscureText: true,
validator: (value) {
if (value != _newPasswordController.text) return 'Пароли не совпадают';
if (value != _newPasswordController.text)
return 'Пароли не совпадают';
return null;
},
),
const SizedBox(height: 14),
ElevatedButton(
onPressed: _isSavingPassword ? null : _savePassword,
child: _isSavingPassword ? const CircularProgressIndicator(color: Colors.white) : const Text('Сохранить пароль'),
child: _isSavingPassword
? const CircularProgressIndicator(color: Colors.white)
: const Text('Сохранить пароль'),
),
],
),
),
const SizedBox(height: 24),
const Text('Пароль шифрования сообщений', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const Text(
'Пароль шифрования сообщений',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Form(
key: _encryptionFormKey,
@ -275,39 +525,54 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
const SizedBox(height: 12),
TextFormField(
controller: _newEncryptPasswordController,
decoration: const InputDecoration(labelText: 'Новый пароль шифрования'),
decoration: const InputDecoration(
labelText: 'Новый пароль шифрования',
),
obscureText: true,
validator: (value) {
if (value == null || value.length < 6) return 'Пароль слишком короткий';
if (value == null || value.length < 6)
return 'Пароль слишком короткий';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _confirmEncryptPasswordController,
decoration: const InputDecoration(labelText: 'Повторите новый пароль'),
decoration: const InputDecoration(
labelText: 'Повторите новый пароль',
),
obscureText: true,
validator: (value) {
if (value != _newEncryptPasswordController.text) return 'Пароли не совпадают';
if (value != _newEncryptPasswordController.text)
return 'Пароли не совпадают';
return null;
},
),
const SizedBox(height: 14),
ElevatedButton(
onPressed: _isSavingEncryption ? null : _saveEncryptionPassword,
child: _isSavingEncryption ? const CircularProgressIndicator(color: Colors.white) : const Text('Сохранить пароль шифрования'),
onPressed: _isSavingEncryption
? null
: _saveEncryptionPassword,
child: _isSavingEncryption
? const CircularProgressIndicator(color: Colors.white)
: const Text('Сохранить пароль шифрования'),
),
],
),
),
const SizedBox(height: 24),
const Text('TOTP', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const Text(
'TOTP',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
const Text('Настройка одноразового кода (TOTP) пока не подключена на сервере.'),
Text(_isTotpEnabled ? 'TOTP включён' : 'TOTP отключён'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _isSavingTotp ? null : _setupTotp,
child: _isSavingTotp ? const CircularProgressIndicator(color: Colors.white) : const Text('Установить TOTP код'),
child: _isSavingTotp
? const CircularProgressIndicator(color: Colors.white)
: Text(_isTotpEnabled ? 'Отключить TOTP' : 'Включить TOTP'),
),
],
),

View File

@ -1,11 +1,14 @@
import 'package:chepuhagram/presentation/screens/account_settings_screen.dart';
import 'package:chepuhagram/presentation/screens/login_screen.dart';
import 'package:chepuhagram/presentation/screens/privacy_settings_menu_screen.dart';
import 'package:chepuhagram/presentation/screens/appearance_settings_screen.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '/logic/auth_provider.dart';
import '/core/theme_manager.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@ -16,6 +19,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
String? versionCode;
final ImagePicker _picker = ImagePicker();
@override
void initState() {
@ -32,9 +36,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _pickAvatar() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image != null) {
final success = await context.read<AuthProvider>().updateAvatar(image.path);
if (!success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ошибка загрузки аватарки')),
);
}
}
}
@override
Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>();
final authProv = context.watch<AuthProvider>();
final accountEmail = authProv.email?.isNotEmpty == true
@ -67,13 +82,51 @@ class _SettingsScreenState extends State<SettingsScreen> {
accountEmail,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
currentAccountPicture: CircleAvatar(
child: Text(
initials.isEmpty ? 'U' : initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
currentAccountPicture: GestureDetector(
onTap: _pickAvatar,
child: SizedBox(
width: 80,
height: 80,
child: Stack(
children: [
authProv.avatarUrl != null
? CircleAvatar(
radius: 40,
backgroundImage: NetworkImage(authProv.avatarUrl!),
)
: authProv.avatarPath != null
? CircleAvatar(
radius: 40,
backgroundImage: FileImage(File(authProv.avatarPath!)),
)
: CircleAvatar(
radius: 40,
child: Text(
initials.isEmpty ? 'U' : initials,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
Positioned(
bottom: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.camera_alt,
size: 16,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
),
),
@ -111,40 +164,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
const Divider(),
SwitchListTile(
secondary: const Icon(Icons.dark_mode),
title: const Text("Ночной режим"),
value: themeProv.themeMode == ThemeMode.dark,
onChanged: (val) => themeProv.toggleTheme(val),
),
// Выбор цвета акцента
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Icon(
Icons.palette_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
SizedBox(width: 10),
const Text("Цвет темы"),
Spacer(),
_colorCircle(context, const Color(0xFF24A1DE), themeProv),
_colorCircle(context, const Color(0xFF3E8E7E), themeProv),
_colorCircle(context, const Color(0xFF8E3E7E), themeProv),
_colorCircle(context, const Color(0xFFFF9800), themeProv),
_colorCircle(context, const Color(0xFFF44336), themeProv),
],
ListTile(
leading: const Icon(Icons.palette),
title: const Text('Оформление'),
subtitle: const Text('Тема, цвета, обои'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const AppearanceSettingsScreen(),
),
],
),
);
},
),
const Divider(),
const Divider(),
@ -183,22 +217,4 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
);
}
Widget _colorCircle(BuildContext context, Color color, ThemeProvider prov) {
bool isSelected = prov.accentColor == color;
return GestureDetector(
onTap: () => prov.updateAccentColor(color),
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? color : Colors.transparent,
width: 2,
),
),
child: CircleAvatar(backgroundColor: color, radius: 15),
),
);
}
}

View File

@ -1,10 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../../logic/auth_provider.dart';
import '../../logic/contact_provider.dart';
import 'login_screen.dart';
@ -18,11 +14,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import 'package:chepuhagram/domain/services/crypto_service.dart';
import 'package:cryptography/cryptography.dart';
import 'package:chepuhagram/data/repositories/contact_repository.dart ';
import 'package:chepuhagram/data/models/contact_model.dart';
import 'dart:isolate';
import 'package:flutter/foundation.dart';
import 'package:convert/convert.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@ -140,6 +132,7 @@ class _SplashScreenState extends State<SplashScreen> {
final myPrivKeyBase64 = await context
.read<CryptoService>()
.getPrivateKey();
if (myPrivKeyBase64 != null) {
final Map<int, String> keysToCompute = {};
for (var c in contactProvider.contacts) {
@ -150,9 +143,7 @@ class _SplashScreenState extends State<SplashScreen> {
'$_contactPublicKey${c.id}',
);
if (savedKeyHex != null && savedPubKey == c.publicKey) {
final bytes = base64Decode(
savedKeyHex,
);
final bytes = base64Decode(savedKeyHex);
contactProvider.setSharedKey(c.id, SecretKey(bytes));
} else if (c.publicKey != null) {
keysToCompute[c.id] = c.publicKey!;

View File

@ -1,5 +1,10 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chepuhagram/domain/services/api_service.dart';
import 'package:chepuhagram/data/datasources/ws_client.dart';
import 'package:provider/provider.dart';
import '/core/constants.dart';
class UserProfileScreen extends StatefulWidget {
final int userId;
@ -19,19 +24,46 @@ class UserProfileScreen extends StatefulWidget {
class _UserProfileScreenState extends State<UserProfileScreen> {
Map<String, dynamic>? _userData;
StreamSubscription<dynamic>? _socketSubscription;
bool _isLoading = true;
String? _error;
Duration? offset;
Timer? _onlineTimer;
String? firstName;
String? lastName;
@override
void initState() {
super.initState();
_loadUserData();
startOnlineUpdates();
DateTime now = DateTime.now();
offset = now.timeZoneOffset;
final socketService = Provider.of<SocketService>(context, listen: false);
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
}
void startOnlineUpdates() {
_onlineTimer = Timer.periodic(const Duration(minutes: 1), (_) {
_loadUserData();
});
}
Future<void> _loadUserData() async {
try {
final api = ApiService();
final data = await api.getUserById(widget.userId);
final prefs = await SharedPreferences.getInstance();
firstName = prefs.containsKey('firstname_${widget.userId}')
? prefs.getString('firstname_${widget.userId}')
: null;
lastName = prefs.containsKey('lastname_${widget.userId}')
? prefs.getString('lastname_${widget.userId}')
: null;
if (mounted) {
setState(() {
_userData = data;
@ -48,37 +80,51 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
}
}
@override
void dispose() {
_onlineTimer?.cancel();
_socketSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Информация о пользователе'),
),
appBar: AppBar(title: const Text('Информация о пользователе')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(_error!, textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadUserData,
child: const Text('Повторить'),
),
],
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(_error!, textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadUserData,
child: const Text('Повторить'),
),
)
: _buildUserInfo(),
],
),
)
: _buildUserInfo(),
);
}
Widget _buildUserInfo() {
if (_userData == null) return const SizedBox.shrink();
final String displayFN = firstName ?? _userData?['first_name'] ?? '';
final String displayLN = lastName ?? _userData?['last_name'] ?? '';
final String username = _userData?['username'] ?? '';
final rawAvatarUrl = _userData?['avatar_url']?.toString();
final avatarUrl = rawAvatarUrl != null && rawAvatarUrl.startsWith('/')
? '${AppConstants.baseUrl}$rawAvatarUrl'
: rawAvatarUrl;
return ListView(
padding: const EdgeInsets.all(16),
children: [
@ -87,38 +133,83 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
child: CircleAvatar(
radius: 50,
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
child: Text(
(_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty &&
_userData!['last_name'] != null && _userData!['last_name'].isNotEmpty)
? '${_userData!['first_name'][0]}${_userData!['last_name'][0]}'.toUpperCase()
: (_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty)
? _userData!['first_name'][0].toUpperCase()
: (_userData!['username'] != null && _userData!['username'].isNotEmpty)
? _userData!['username'][0].toUpperCase()
: '?',
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
backgroundImage:
(avatarUrl != null && _userData?['show_avatar'] == true)
? NetworkImage(avatarUrl)
: null,
child: (avatarUrl == null || _userData?['show_avatar'] != true)
? Text(
(displayFN.isNotEmpty && displayLN.isNotEmpty)
? '${displayFN[0]}${displayLN[0]}'.toUpperCase()
: (displayFN.isNotEmpty)
? displayFN[0].toUpperCase()
: (username.isNotEmpty)
? username[0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
)
: null,
),
),
const SizedBox(height: 24),
// Name
if ((_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty) ||
(_userData!['last_name'] != null && _userData!['last_name'].isNotEmpty))
Text(
'${_userData!['first_name'] ?? ''} ${_userData!['last_name'] ?? ''}'.trim(),
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
GestureDetector(
onTap: () => {_editUserName(displayFN, displayLN)},
child: Row(
children: [
const Spacer(),
if ((displayFN.isNotEmpty) || (displayLN.isNotEmpty))
Text(
'$displayFN $displayLN'.trim(),
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(width: 5),
Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurface),
const Spacer(),
],
),
),
const SizedBox(height: 8),
// Username
if (_userData!['username'] != null && _userData!['username'].isNotEmpty)
Text(
'@${_userData!['username']}',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: Colors.grey[600]),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Last online status
if (_userData!['online'] == true)
const Text(
'Онлайн',
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
textAlign: TextAlign.center,
)
else if (DateTime.tryParse(_userData!['last_online']) != null)
Text(
'Был(а) в сети ${_formatLastOnline(DateTime.tryParse(_userData!['last_online'])!.add(offset != null ? offset! : Duration.zero))}',
style: const TextStyle(
fontSize: 12,
color: Color.fromARGB(255, 161, 161, 161),
),
textAlign: TextAlign.center,
)
else
const Text(
'Был(а) недавно',
style: TextStyle(
fontSize: 12,
color: Color.fromARGB(255, 161, 161, 161),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
@ -128,7 +219,11 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
// Public Key (if available)
if (_userData!['public_key'] != null)
_buildInfoTile('Публичный ключ', _userData!['public_key'], maxLines: 3),
_buildInfoTile(
'Публичный ключ',
_userData!['public_key'],
maxLines: 3,
),
// About
if (_userData!['about'] != null && _userData!['about'].isNotEmpty)
@ -143,9 +238,12 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
_buildInfoTile('Почта', _userData!['email']),
const SizedBox(height: 16),
if ((_userData!['username'] == null || _userData!['username'].isEmpty) &&
(_userData!['first_name'] == null || _userData!['first_name'].isEmpty) &&
(_userData!['last_name'] == null || _userData!['last_name'].isEmpty) &&
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))
@ -158,6 +256,131 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
);
}
Future<void> _editUserName(String firstname, String lastname) async {
final firstnameController = TextEditingController(text: firstname);
final lastnameController = TextEditingController(text: lastname);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Изменить имя пользователя'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: firstnameController,
minLines: 1,
maxLines: 5,
autofocus: true,
decoration: const InputDecoration(hintText: 'Имя'),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 8),
TextField(
controller: lastnameController,
minLines: 1,
maxLines: 5,
decoration: const InputDecoration(hintText: 'Фамилия'),
textCapitalization: TextCapitalization.words,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Сбрость'),
),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Сохранить'),
),
],
),
);
final prefs = await SharedPreferences.getInstance();
if (result == true) {
if (firstname != firstnameController.text) {
prefs.setString('firstname_${widget.userId}', firstnameController.text);
}
if (lastname != lastnameController.text) {
prefs.setString('lastname_${widget.userId}', lastnameController.text);
}
if (mounted) {
setState(() {});
}
_loadUserData();
} else {
prefs.remove('firstname_${widget.userId}');
prefs.remove('lastname_${widget.userId}');
if (mounted) {
setState(() {});
}
_loadUserData();
}
}
void _handleIncomingMessage(Map<String, dynamic> data) async {
if (data['type'] == 'user_online') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId == widget.userId) {
if (mounted) {
setState(() {
_userData = _userData?..['online'] = true;
});
}
}
}
if (data['type'] == 'user_offline') {
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId == widget.userId) {
setState(() {
_userData = _userData?..['online'] = false;
_userData = _userData
?..['last_online'] = DateTime.now().toIso8601String();
});
}
}
if (data['type'] == 'user_updated') {
print('User updated message received, refreshing contact list');
final userId = int.tryParse(data['user_id']?.toString() ?? '');
if (userId != null && userId == widget.userId) {
_loadUserData();
}
}
}
String _formatLastOnline(DateTime lastOnline) {
final now = DateTime.now();
final difference = now.difference(lastOnline);
if (difference.inSeconds < 60) {
return 'только что';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes} минут${_pluralize(difference.inMinutes, "у", "ы", "")} назад';
} else if (difference.inHours < 24) {
return '${difference.inHours} час${_pluralize(difference.inHours, "", "а", "ов")} назад';
} else if (difference.inDays < 7) {
return '${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
} else {
return 'давно';
}
}
String _pluralize(int count, String form1, String form2, String form5) {
final mod10 = count % 10;
final mod100 = count % 100;
if (mod10 == 1 && mod100 != 11) {
return form1;
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
return form2;
} else {
return form5;
}
}
Widget _buildInfoTile(String label, String value, {int maxLines = 1}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),

View File

@ -1,16 +1,51 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '/data/models/contact_model.dart';
class ContactTile extends StatelessWidget {
class ContactTile extends StatefulWidget {
final Contact contact;
final VoidCallback? onTap;
const ContactTile({super.key, required this.contact, this.onTap});
@override
State<ContactTile> createState() => _ContactTileState();
}
class _ContactTileState extends State<ContactTile> {
SharedPreferences? _prefs;
@override
void initState() {
super.initState();
_initPrefs();
}
Future<void> _initPrefs() async {
final shared = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_prefs = shared;
});
}
}
String get displayName {
final full = '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim();
if (_prefs == null) return widget.contact.name;
final id = widget.contact.id;
final savedName = _prefs!.getString('firstname_$id');
final savedSurname = _prefs!.getString('lastname_$id');
final name = savedName ?? widget.contact.name;
final surname = savedSurname ?? widget.contact.surname;
final full =
'${name != 'Unknown' ? name : ''} ${surname != 'Unknown' ? surname : ''}'
.trim();
if (full.isNotEmpty) return full;
if ((contact.username != 'Unknown' ? contact.username : '').isNotEmpty) return contact.username!;
if (widget.contact.username != 'Unknown') return widget.contact.username;
return 'User';
}
@ -18,35 +53,47 @@ class ContactTile extends StatelessWidget {
Widget build(BuildContext context) {
final primary = Theme.of(context).colorScheme.primary;
final username = contact.username;
final initials = (displayName.isNotEmpty ? displayName : (username != 'Unknown' ? username : 'U'))
.trim()
.split(RegExp(r'\s+'))
.where((p) => p.isNotEmpty)
.take(2)
.map((p) => p[0].toUpperCase())
.join();
final 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();
return ListTile(
onTap: onTap,
onTap: widget.onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: CircleAvatar(
radius: 28,
backgroundColor: primary.withAlpha((0.1 * 255).round()),
child: Text(
initials,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
backgroundImage: widget.contact.effectiveAvatarUrl != null
? NetworkImage(widget.contact.effectiveAvatarUrl!)
: null,
child: widget.contact.effectiveAvatarUrl == null
? Text(
initials,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
)
: null,
),
title: Text(
contact.name,
displayName,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
subtitle: Text(
contact.isLastMsgDecrypted ? contact.lastMessage ?? "Нет сообщений" : (contact.lastMessage != null ? "Ожидание дешифровки..." : "Нет сообщений"),
widget.contact.isLastMsgDecrypted
? widget.contact.lastMessage ?? "Нет сообщений"
: (widget.contact.lastMessage != null
? "Ожидание дешифровки..."
: "Нет сообщений"),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey),
@ -56,14 +103,11 @@ class ContactTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatTime(contact.lastMessageTime),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
_formatTime(widget.contact.lastMessageTime),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
const SizedBox(height: 4),
if (contact.unreadCount > 0)
if (widget.contact.unreadCount > 0)
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
@ -71,7 +115,7 @@ class ContactTile extends StatelessWidget {
shape: BoxShape.circle,
),
child: Text(
'${contact.unreadCount}',
'${widget.contact.unreadCount}',
style: const TextStyle(color: Colors.white, fontSize: 10),
),
),

View File

@ -6,6 +6,11 @@ from app.api import schemas
from app.db import models
from jose import JWTError, jwt
from app.core.security import get_current_user
import pyotp
import qrcode
import base64
from io import BytesIO
from fastapi.responses import StreamingResponse
# бд
@ -61,17 +66,26 @@ async def register(password: str):
@authRouter.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(models.User).filter(
models.User.username == form_data.username).first()
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)}")
if not user or not security.verify_password(form_data.password, user.hashed_password):
user = db.query(models.User).filter(
models.User.username == data.username).first()
if not user or not security.verify_password(data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный логин или пароль",
headers={"WWW-Authenticate": "Bearer"},
)
if user.totp_secret:
if not data.totp_code:
raise HTTPException(status_code=400, detail="TOTP код требуется")
totp = pyotp.TOTP(user.totp_secret)
if not totp.verify(data.totp_code):
raise HTTPException(status_code=400, detail="Неверный TOTP код")
access_token = security.create_access_token(data={"sub": str(user.id)})
refresh_token = security.create_refresh_token(data={"sub": str(user.id)})
return {
@ -82,6 +96,73 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session =
}
@authRouter.post("/totp/enable")
async def enable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
# Загружаем свежую копию user из БД
user = db.query(models.User).filter(models.User.id == current_user.id).first()
if not user:
raise HTTPException(status_code=400, detail="Пользователь не найден")
#if user.totp_secret:
#raise HTTPException(status_code=400, detail="TOTP уже включен")
secret = pyotp.random_base32()
user.totp_temp_secret = secret
db.commit()
print(f"TOTP enabled for user {user.id}, secret saved")
# Генерировать QR
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name=user.username, issuer_name="Chepuhagram")
img = qrcode.make(uri)
buf = BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
qr_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
qr_data_url = f"data:image/png;base64,{qr_base64}"
return {"secret": secret, "qr_code": qr_data_url}
@authRouter.post("/totp/verify")
async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
# Загружаем свежую копию user из БД
user = db.query(models.User).filter(models.User.id == current_user.id).first()
if not user:
raise HTTPException(status_code=400, detail="Пользователь не найден")
if not user.totp_temp_secret:
raise HTTPException(status_code=400, detail="TOTP не включен")
try:
totp = pyotp.TOTP(user.totp_temp_secret)
code_str = str(data.code).strip()
is_valid = totp.verify(code_str)
print(f"TOTP verify: user_id={user.id}, code={code_str}, secret_set={bool(user.totp_temp_secret)}, valid={is_valid}")
if is_valid:
user.totp_secret = user.totp_temp_secret
user.totp_temp_secret = None
db.commit()
return {"status": "ok"}
else:
raise HTTPException(status_code=400, detail="Неверный код")
except HTTPException:
raise
except Exception as e:
print(f"TOTP verify error: {str(e)}")
raise HTTPException(status_code=500, detail=f"Ошибка верификации: {str(e)}")
@authRouter.post("/totp/disable")
async def disable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == current_user.id).first()
if user:
user.totp_secret = None
db.commit()
return {"status": "ok"}
@authRouter.post("/refresh")
async def refresh_token(data: schemas.RefreshRequest):
try:

View File

@ -6,6 +6,7 @@ from app.api import schemas
from app.db import models
from jose import JWTError, jwt
from app.core.security import get_current_user
from fastapi.responses import FileResponse
import os
import uuid
# бд
@ -51,3 +52,14 @@ async def upload_file(file: UploadFile = File(...)):
"status": "ok",
"file_id": file_id
}
@mediaRouter.get('/{file_id}')
async def get_file(file_id: str):
filename = f"{file_id}.enc"
file_path = os.path.join(UPLOAD_FOLDER, filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(file_path, media_type="application/octet-stream")

View File

@ -34,3 +34,18 @@ async def get_chat_history(
return jsonable_encoder(messages)
@messagesRouter.delete("/all")
async def delete_all_messages(
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Удалить все сообщения пользователя"""
# Удаляем все сообщения, где пользователь либо отправитель, либо получатель
db.query(models.Message).filter(
(models.Message.sender_id == current_user.id) | (models.Message.receiver_id == current_user.id)
).delete()
db.commit()
return {"status": "ok", "detail": "Все сообщения удалены"}

View File

@ -1,5 +1,5 @@
from fastapi import Depends, APIRouter, HTTPException, Depends
from fastapi import Depends, APIRouter, HTTPException, Depends, Request
from sqlalchemy.orm import Session
from app.db import models
from app.core.security import get_current_user
@ -9,6 +9,8 @@ from sqlalchemy.exc import IntegrityError
from app.websocket import connection_manager
# бд
def get_db():
db = models.SessionLocal()
try:
@ -37,6 +39,8 @@ async def read_users_me(current_user: models.User = Depends(get_current_user)):
"about": current_user.about,
"public_key": current_user.public_key,
"encrypted_private_key": current_user.encrypted_private_key,
"avatar_file_id": current_user.avatar_file_id,
"totp_enabled": bool(current_user.totp_secret != None),
}
@ -69,6 +73,7 @@ async def update_users_me(
status_code=400, detail="phone/email already in use")
db.refresh(user_to_update)
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
return {
"status": "ok",
"user": {
@ -100,6 +105,7 @@ async def update_encrypted_private_key(
status_code=500, detail="Не удалось сохранить ключ шифрования")
db.refresh(user_to_update)
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
return {"status": "ok"}
@ -156,6 +162,7 @@ async def update_privacy_settings(
status_code=500, detail="Не удалось сохранить настройки конфиденциальности")
db.refresh(user_to_update)
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
return {"status": "ok"}
@ -182,6 +189,7 @@ async def read_users_all(current_user: models.User = Depends(get_current_user),
@usersRouter.get("/chats")
async def read_users_chats(
request: Request,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
@ -192,7 +200,6 @@ async def read_users_chats(
Клиент должен расшифровать превью локально.
"""
users = (
db.query(models.User)
.filter(models.User.id != current_user.id)
@ -243,6 +250,8 @@ async def read_users_chats(
"username": user.username,
"name": f"{user.first_name} {user.last_name or ''}".strip(),
"public_key": user.public_key,
"avatar_file_id": user.avatar_file_id,
"avatar_url": str(request.url_for("get_file", file_id=user.avatar_file_id)) if user.show_avatar and user.avatar_file_id else None,
"last_message": last_msg.content if last_msg else None,
"last_message_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp else None),
"unread_count": unread_count,
@ -256,6 +265,7 @@ async def read_users_chats(
@usersRouter.get("/{user_id}", response_model=schemas.UserProfile)
def get_user_by_id(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
@ -273,14 +283,20 @@ def get_user_by_id(
"public_key": user.public_key,
}
profile_data["first_name"] = user.first_name
profile_data["last_name"] = user.last_name
# Проверяем настройки конфиденциальности
if user.show_username:
profile_data["username"] = user.username
if user.show_avatar:
# Для аватара пока просто передаем имя, клиент сам сгенерирует аватар
profile_data["first_name"] = user.first_name
profile_data["last_name"] = user.last_name
profile_data["avatar_url"] = str(request.url_for(
"get_file", file_id=user.avatar_file_id)) if user.avatar_file_id else None
profile_data["show_avatar"] = bool(user.show_avatar)
profile_data["totp_enabled"] = bool(user.totp_secret)
if user.show_about:
profile_data["about"] = user.about
@ -291,11 +307,32 @@ def get_user_by_id(
if user.show_email:
profile_data["email"] = user.email
if user.id in connection_manager.active_connections:
if str(user.id) in connection_manager.manager.active_connections:
profile_data["online"] = True
else:
profile_data["online"] = False
if user.show_last_online:
profile_data["last_online"] = user.last_online.isoformat() if user.last_online else None
if user.show_last_online:
profile_data["last_online"] = user.last_online.isoformat(
) if user.last_online else None
return profile_data
@usersRouter.put("/me/avatar")
async def update_user_avatar(
data: dict,
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
user_to_update = db.merge(current_user)
avatar_file_id = data.get("avatar_file_id")
if avatar_file_id:
user_to_update.avatar_file_id = avatar_file_id
db.commit()
print(
f"Пользователь {user_to_update.id} обновил аватар: {avatar_file_id}")
else:
raise HTTPException(
status_code=400, detail="avatar_file_id is required")
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
return {"message": "Avatar updated"}

View File

@ -7,6 +7,11 @@ class SetPublicKey(BaseModel):
class RefreshRequest(BaseModel):
refresh_token: str
class LoginRequest(BaseModel):
username: str
password: str
totp_code: Optional[str] = None
class SetupAccount(BaseModel):
first_name: str
last_name: str
@ -45,6 +50,10 @@ class UpdatePrivacySettings(BaseModel):
show_avatar: Optional[bool] = None
show_about: Optional[bool] = None
show_username: Optional[bool] = None
show_last_online: Optional[bool] = None
class TOTPVerifyRequest(BaseModel):
code: str
class UserProfile(BaseModel):
id: int
@ -54,7 +63,12 @@ class UserProfile(BaseModel):
about: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
avatar_url: Optional[str] = None
public_key: Optional[str] = None
online: bool = False
last_online: Optional[str] = None
show_avatar: bool = False
totp_enabled: bool = False
class Config:
from_attributes = True

View File

@ -21,10 +21,12 @@ class User(Base):
phone = Column(String(20), unique=True, nullable=True)
email = Column(String(255), unique=True, nullable=True)
totp_secret = Column(String(32), nullable=True)
totp_temp_secret = Column(String(32), nullable=True) # Temporary secret until verified
hashed_password = Column(String)
public_key = Column(String, nullable=True)
encrypted_private_key = Column(String, nullable=True)
fcm_token = Column(String, nullable=True)
avatar_file_id = Column(String, nullable=True)
# Privacy settings
show_email = Column(Integer, nullable=False, server_default="1") # 1 = true, 0 = false
@ -100,6 +102,10 @@ def _ensure_sqlite_user_columns():
if "last_online" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN last_online DATETIME"))
conn.execute(text("UPDATE users SET last_online = datetime('now')"))
if "avatar_file_id" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN avatar_file_id VARCHAR(255)"))
if "totp_temp_secret" not in existing:
conn.execute(text("ALTER TABLE users ADD COLUMN totp_temp_secret VARCHAR(32)"))
conn.commit()

View File

@ -45,6 +45,10 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
db.query(models.User).filter(models.User.id == user_id).update({"last_online": datetime.now(timezone.utc)},
synchronize_session="fetch")
db.commit()
await manager.broadcast({
"type": "user_online",
"user_id": user_id,
})
try:
while True:
print("ОЖИДАНИЕ СООБЩЕНИЙ")
@ -246,15 +250,44 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
"message_id": message_id,
"timestamp": read_at.isoformat() if 'read_at' in locals() else datetime.now().isoformat(),
}, str(sender_id))
elif message_data.get("type") == "typing":
receiver_id = message_data.get("receiver_id")
if receiver_id is None:
continue
try:
receiver_id = int(receiver_id)
except (TypeError, ValueError):
continue
await manager.send_personal_message({
"type": "typing",
"sender_id": user_id,
}, str(receiver_id))
elif message_data.get("type") == "stop_typing":
receiver_id = message_data.get("receiver_id")
if receiver_id is None:
continue
try:
receiver_id = int(receiver_id)
except (TypeError, ValueError):
continue
await manager.send_personal_message({
"type": "stop_typing",
"sender_id": user_id,
}, str(receiver_id))
except WebSocketDisconnect:
pass
finally:
manager.disconnect(user_id)
db.query(models.User).filter(models.User.id == user_id).update(
{"last_online": datetime.now()})
{"last_online": datetime.now(timezone.utc)}, synchronize_session="fetch")
db.commit()
print("ОТКЛЮЧЕНИЕ")
await manager.broadcast({
"type": "user_offline",
"user_id": user_id,
})
def send_fcm_notification(token, user_id, username, public_key, encrypted_text, timestamp):
print(

View File

@ -4,13 +4,15 @@ from app.api.endpoints import users, auth, messages, media
from app.websocket.connection_manager import wsRouter
from fastapi.middleware.cors import CORSMiddleware
import os
import asyncio
from app.db import models
app = FastAPI()
app.include_router(auth.authRouter)
app.include_router(users.usersRouter)
app.include_router(messages.messagesRouter)
#app.include_router(media.mediaRouter)
app.include_router(media.mediaRouter)
app.include_router(wsRouter)
app.add_middleware(
@ -47,11 +49,37 @@ async def head_image():
if not os.path.exists(file_path):
return {"error": "Файл не найден"}
return FileResponse(
path=file_path,
filename="chepuhagram-release.apk",
media_type="application/vnd.android.package-archive"
)
return FileResponse(path=file_path, filename="chepuhagram-release.apk",
media_type="application/vnd.android.package-archive",)
@app.on_event("startup")
async def startup_event():
asyncio.create_task(cleanup_uploads())
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

View File

@ -4,3 +4,5 @@ sqlalchemy
passlib[bcrypt]
python-jose[cryptography]
python-multipart
pyotp
qrcode[pil]