From d33c41010d7b441363cb38a90b5537b349bace2e Mon Sep 17 00:00:00 2001 From: Artur Date: Sat, 2 May 2026 20:02:46 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BA=D0=B8?= =?UTF-8?q?,=20=D0=BE=D0=B1=D0=BE=D0=B8,=20TOTP=20=D0=B8=20=D0=BC=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=B5=20=D0=B4=D1=80=D1=83=D0=B3=D0=BE=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 2 + lib/core/theme_manager.dart | 14 + lib/data/datasources/local_db_service.dart | 5 + lib/data/datasources/ws_client.dart | 26 +- lib/data/models/contact_model.dart | 13 +- lib/data/repositories/contact_repository.dart | 1 + lib/domain/services/api_service.dart | 72 ++++ lib/domain/services/crypto_service.dart | 5 +- lib/logic/auth_provider.dart | 65 +++- lib/logic/contact_provider.dart | 30 +- lib/main.dart | 39 +- .../screens/appearance_settings_screen.dart | 128 +++++++ lib/presentation/screens/chat_screen.dart | 351 ++++++++++++++++-- lib/presentation/screens/contacts_screen.dart | 115 ++++-- .../screens/key_recovery_screen.dart | 9 + lib/presentation/screens/login_screen.dart | 56 ++- lib/presentation/screens/new_chat_screen.dart | 1 - .../screens/privacy_settings_screen.dart | 14 + .../screens/security_settings_screen.dart | 331 +++++++++++++++-- lib/presentation/screens/settings_screen.dart | 132 ++++--- lib/presentation/screens/splash_screen.dart | 13 +- .../screens/user_profile_screen.dart | 313 +++++++++++++--- lib/presentation/widgets/contact_tile.dart | 100 +++-- srv/app/api/endpoints/auth.py | 87 ++++- srv/app/api/endpoints/media.py | 14 +- srv/app/api/endpoints/messages.py | 15 + srv/app/api/endpoints/users.py | 55 ++- srv/app/api/schemas.py | 14 + srv/app/db/models.py | 6 + srv/app/websocket/connection_manager.py | 35 +- srv/main.py | 40 +- srv/requirements.txt | 4 +- 32 files changed, 1828 insertions(+), 277 deletions(-) create mode 100644 lib/presentation/screens/appearance_settings_screen.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 92046af..b3e97d2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + _themeMode; Color get accentColor => _accentColor; + String? get wallpaperPath => _wallpaperPath; bool isLight = false; @@ -20,12 +22,14 @@ class ThemeProvider extends ChangeNotifier { Future _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 diff --git a/lib/data/datasources/local_db_service.dart b/lib/data/datasources/local_db_service.dart index 66a71d3..a36590a 100644 --- a/lib/data/datasources/local_db_service.dart +++ b/lib/data/datasources/local_db_service.dart @@ -56,6 +56,11 @@ class LocalDbService { ); } + Future clearDatabase() async { + final db = await database; + await db.delete('messages'); + } + // Сохранение списка сообщений (из истории) Future saveMessages(List messages) async { final db = await database; diff --git a/lib/data/datasources/ws_client.dart b/lib/data/datasources/ws_client.dart index d711114..f41e84f 100644 --- a/lib/data/datasources/ws_client.dart +++ b/lib/data/datasources/ws_client.dart @@ -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() { - return _instance; + factory SocketService() => _instance; + + SocketService._internal() { + WidgetsBinding.instance.addObserver(this); } - SocketService._internal(); WebSocketChannel? _channel; final StreamController> _messageController = @@ -21,6 +23,19 @@ class SocketService { // Поток, который будут слушать провайдеры Stream> get messages => _messageController.stream; + bool allowConnect = true; // Флаг для контроля подключения + + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + allowConnect = true; + } else { + allowConnect = false; + disconnect(); + } + } + Future 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"); diff --git a/lib/data/models/contact_model.dart b/lib/data/models/contact_model.dart index 7155161..ee16ad5 100644 --- a/lib/data/models/contact_model.dart +++ b/lib/data/models/contact_model.dart @@ -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, diff --git a/lib/data/repositories/contact_repository.dart b/lib/data/repositories/contact_repository.dart index a0acc86..541d925 100644 --- a/lib/data/repositories/contact_repository.dart +++ b/lib/data/repositories/contact_repository.dart @@ -30,6 +30,7 @@ class ContactRepository { if (response.statusCode == 200) { final List data = jsonDecode(utf8.decode(response.bodyBytes)); + print(data); List contacts = data.map((json) => Contact.fromJson(json)).toList(); for (var item in contacts) { if (item.lastMessageTime != null) { diff --git a/lib/domain/services/api_service.dart b/lib/domain/services/api_service.dart index b4f48b0..bee5236 100644 --- a/lib/domain/services/api_service.dart +++ b/lib/domain/services/api_service.dart @@ -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 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> 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; + } + throw Exception( + (jsonDecode(response.body) as Map)['detail'] ?? + 'Failed to enable TOTP', + ); + } + + Future 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 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 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; + } } diff --git a/lib/domain/services/crypto_service.dart b/lib/domain/services/crypto_service.dart index 3a627d7..6e92b0c 100644 --- a/lib/domain/services/crypto_service.dart +++ b/lib/domain/services/crypto_service.dart @@ -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; diff --git a/lib/logic/auth_provider.dart b/lib/logic/auth_provider.dart index 57e5c01..8234dd8 100644 --- a/lib/logic/auth_provider.dart +++ b/lib/logic/auth_provider.dart @@ -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 login(String username, String password) async { + Future 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 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 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; + } + } } diff --git a/lib/logic/contact_provider.dart b/lib/logic/contact_provider.dart index 09501ef..859dc60 100644 --- a/lib/logic/contact_provider.dart +++ b/lib/logic/contact_provider.dart @@ -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 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"); + } + } + } diff --git a/lib/main.dart b/lib/main.dart index a2466b5..89b3a93 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(), - ), + create: (context) => ContactProvider(context.read()), ), ], child: const MyApp(), @@ -290,13 +286,42 @@ Future _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', diff --git a/lib/presentation/screens/appearance_settings_screen.dart b/lib/presentation/screens/appearance_settings_screen.dart new file mode 100644 index 0000000..f27622f --- /dev/null +++ b/lib/presentation/screens/appearance_settings_screen.dart @@ -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 createState() => _AppearanceSettingsScreenState(); +} + +class _AppearanceSettingsScreenState extends State { + final ImagePicker _picker = ImagePicker(); + + Future _pickWallpaper() async { + final XFile? image = await _picker.pickImage(source: ImageSource.gallery); + if (image != null) { + context.read().updateWallpaper(image.path); + } + } + + @override + Widget build(BuildContext context) { + final themeProv = context.watch(); + + 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), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/screens/chat_screen.dart b/lib/presentation/screens/chat_screen.dart index 09a9064..16a6331 100644 --- a/lib/presentation/screens/chat_screen.dart +++ b/lib/presentation/screens/chat_screen.dart @@ -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 createState() => _ChatScreenState(); } -class _ChatScreenState extends State { +class _ChatScreenState extends State 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 { 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(context, listen: false); currentActiveChatContactId = _currentContact.id; // Устанавливаем активный чат + flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!); final contactProvider = context.read(); myId = contactProvider.getCurrentUserId() ?? 0; // Если ключа нет, загружаем его при входе + _loadLocalName(); if (_currentContact.publicKey == null) { _loadContactKey(); } _loadHistory(); + _loadOnlineStatus(); + startOnlineUpdates(); + _controller.addListener(_sendTypingStatus); final socketService = Provider.of(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 _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(context, listen: false); + socketService.sendMessage({ + 'type': 'typing', + 'receiver_id': _currentContact.id, + }); + } + } + + void _sendStopTypingStatus() { + _socketService.sendMessage({ + 'type': 'stop_typing', + 'receiver_id': _currentContact.id, + }); + } + + Future _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 _loadContactKey() async { if (!mounted) return; setState(() => _isKeyLoading = true); @@ -84,11 +186,7 @@ class _ChatScreenState extends State { 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 { @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(); return Scaffold( appBar: AppBar( leading: IconButton( @@ -133,22 +237,80 @@ class _ChatScreenState extends State { ), ); }, - 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 { ); } + 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 _showMessageActions(MessageModel msg) async { if (!mounted) return; @@ -496,7 +687,9 @@ class _ChatScreenState extends State { }); 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 { } Future _sendMessage() async { + _sendStopTypingStatus(); final rawText = _controller.text.trim(); final hasImage = _pendingImageBytes != null; @@ -908,6 +1102,10 @@ class _ChatScreenState extends State { } 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 { 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 { } } } + +class TypingIndicator extends StatefulWidget { + const TypingIndicator({super.key}); + + @override + State createState() => _TypingIndicatorState(); +} + +class _TypingIndicatorState extends State + 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)), + ), + ); + } +} diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 6797b68..f451de3 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -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 with RouteAware { super.initState(); print('ContactsScreen initState, targetChatId: ${widget.targetChatId}'); _setupPushNotifications(); + final socketService = Provider.of(context, listen: false); + _socketSubscription = socketService.messages.listen(_handleIncomingMessage); WidgetsBinding.instance.addPostFrameCallback((_) { final authProvider = context.read(); final contactProvider = context.read(); @@ -207,12 +209,7 @@ class _ContactsScreenState extends State 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 with RouteAware { } } - Future _handleIncomingMessage(RemoteMessage message) async { + Future _handleIncomingMessage(dynamic data) async { + if (data is RemoteMessage) { + // FCM message + await _handleFCMMessage(data); + } else if (data is Map) { + // WebSocket message + print('WebSocket message received: $data'); + if (data['type'] == 'user_updated') { + final userId = int.tryParse(data['user_id']?.toString() ?? ''); + if (userId != null) { + final contactProvider = context.read(); + contactProvider.updateContact(userId); + } + } + } + } + + Future _handleFCMMessage(RemoteMessage message) async { try { // Проверяем, не находимся ли мы уже в чате с отправителем final senderId = int.tryParse( @@ -280,8 +294,8 @@ class _ContactsScreenState extends State 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 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 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 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 with RouteAware { } } - void _cancelDownload() { - _cancelToken?.cancel("Отменено"); - setState(() { - _isDownloading = false; - _downloadProgress = 0.0; - }); - } - Widget _buildUpdateBanner() { return Container( margin: const EdgeInsets.fromLTRB( diff --git a/lib/presentation/screens/key_recovery_screen.dart b/lib/presentation/screens/key_recovery_screen.dart index 23046b4..5f0c46a 100644 --- a/lib/presentation/screens/key_recovery_screen.dart +++ b/lib/presentation/screens/key_recovery_screen.dart @@ -30,6 +30,15 @@ class _KeyRecoveryScreenState extends State { try { final authProvider = context.read(); + + // Удаляем все сообщения пользователя + try { + final api = ApiService(); + await api.deleteAllMessages(); + } catch (e) { + print('Ошибка при удалении сообщений: $e'); + // Продолжаем даже если удаление сообщений не удалось + } // Удаляем старые ключи и создаем новые await authProvider.resetKeys(); diff --git a/lib/presentation/screens/login_screen.dart b/lib/presentation/screens/login_screen.dart index 0e300a5..fc7e621 100644 --- a/lib/presentation/screens/login_screen.dart +++ b/lib/presentation/screens/login_screen.dart @@ -16,6 +16,9 @@ class _LoginScreenState extends State { final _formKey = GlobalKey(); 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 { 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 { 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 { } } } 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(); + } } diff --git a/lib/presentation/screens/new_chat_screen.dart b/lib/presentation/screens/new_chat_screen.dart index 7489e20..d12f136 100644 --- a/lib/presentation/screens/new_chat_screen.dart +++ b/lib/presentation/screens/new_chat_screen.dart @@ -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'; diff --git a/lib/presentation/screens/privacy_settings_screen.dart b/lib/presentation/screens/privacy_settings_screen.dart index 3376014..7b15e85 100644 --- a/lib/presentation/screens/privacy_settings_screen.dart +++ b/lib/presentation/screens/privacy_settings_screen.dart @@ -15,12 +15,14 @@ class _PrivacySettingsScreenState extends State { 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 { _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 { _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 { 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 { showAvatar: _showAvatar, showAbout: _showAbout, showUsername: _showUsername, + showLastOnline: _showLastOnline, ); if (success) { @@ -91,6 +97,7 @@ class _PrivacySettingsScreenState extends State { 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 { setState(() => _showAbout = value); }, ), + SwitchListTile( + title: const Text('Показывать последний онлайн'), + value: _showLastOnline, + onChanged: (value) { + setState(() => _showLastOnline = value); + }, + ), const SizedBox(height: 24), const Text( 'Эти настройки влияют на то, какую информацию о вас видят другие пользователи приложения.', diff --git a/lib/presentation/screens/security_settings_screen.dart b/lib/presentation/screens/security_settings_screen.dart index 3516c2c..faa33d3 100644 --- a/lib/presentation/screens/security_settings_screen.dart +++ b/lib/presentation/screens/security_settings_screen.dart @@ -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 { final _passwordFormKey = GlobalKey(); final _encryptionFormKey = GlobalKey(); - final _totpFormKey = GlobalKey(); + //final _totpFormKey = GlobalKey(); final _currentPasswordController = TextEditingController(); final _newPasswordController = TextEditingController(); @@ -28,11 +30,15 @@ class _SecuritySettingsScreenState extends State { 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 { 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 { } } + Future _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 _authenticateBiometric() async { try { return await _localAuth.authenticate( @@ -96,9 +121,9 @@ class _SecuritySettingsScreenState extends State { } 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 { } 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 { 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 { } Future _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 _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 _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 { 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 { 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 { 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 { 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 { 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'), ), ], ), diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index b1dd0c4..967df90 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -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 { String? versionCode; + final ImagePicker _picker = ImagePicker(); @override void initState() { @@ -32,9 +36,20 @@ class _SettingsScreenState extends State { } } + Future _pickAvatar() async { + final XFile? image = await _picker.pickImage(source: ImageSource.gallery); + if (image != null) { + final success = await context.read().updateAvatar(image.path); + if (!success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Ошибка загрузки аватарки')), + ); + } + } + } + @override Widget build(BuildContext context) { - final themeProv = context.watch(); final authProv = context.watch(); final accountEmail = authProv.email?.isNotEmpty == true @@ -67,13 +82,51 @@ class _SettingsScreenState extends State { 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 { }, ), 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 { ), ); } - - 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), - ), - ); - } } diff --git a/lib/presentation/screens/splash_screen.dart b/lib/presentation/screens/splash_screen.dart index 8326522..504f169 100644 --- a/lib/presentation/screens/splash_screen.dart +++ b/lib/presentation/screens/splash_screen.dart @@ -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 { final myPrivKeyBase64 = await context .read() .getPrivateKey(); + if (myPrivKeyBase64 != null) { final Map keysToCompute = {}; for (var c in contactProvider.contacts) { @@ -150,9 +143,7 @@ class _SplashScreenState extends State { '$_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!; diff --git a/lib/presentation/screens/user_profile_screen.dart b/lib/presentation/screens/user_profile_screen.dart index 0638a97..4408fd0 100644 --- a/lib/presentation/screens/user_profile_screen.dart +++ b/lib/presentation/screens/user_profile_screen.dart @@ -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 { Map? _userData; + StreamSubscription? _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(context, listen: false); + _socketSubscription = socketService.messages.listen(_handleIncomingMessage); + } + + void startOnlineUpdates() { + _onlineTimer = Timer.periodic(const Duration(minutes: 1), (_) { + _loadUserData(); + }); } Future _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 { } } + @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 { 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 { // 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,11 +238,14 @@ class _UserProfileScreenState extends State { _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) && + 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( 'Пользователь скрыл дополнительную информацию', @@ -158,6 +256,131 @@ class _UserProfileScreenState extends State { ); } + Future _editUserName(String firstname, String lastname) async { + final firstnameController = TextEditingController(text: firstname); + final lastnameController = TextEditingController(text: lastname); + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Изменить имя пользователя'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: firstnameController, + minLines: 1, + maxLines: 5, + autofocus: true, + decoration: const InputDecoration(hintText: 'Имя'), + textCapitalization: TextCapitalization.words, + ), + const SizedBox(height: 8), + TextField( + controller: lastnameController, + minLines: 1, + maxLines: 5, + decoration: const InputDecoration(hintText: 'Фамилия'), + textCapitalization: TextCapitalization.words, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Сбрость'), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Сохранить'), + ), + ], + ), + ); + + final prefs = await SharedPreferences.getInstance(); + if (result == true) { + if (firstname != firstnameController.text) { + prefs.setString('firstname_${widget.userId}', firstnameController.text); + } + if (lastname != lastnameController.text) { + prefs.setString('lastname_${widget.userId}', lastnameController.text); + } + if (mounted) { + setState(() {}); + } + _loadUserData(); + } else { + prefs.remove('firstname_${widget.userId}'); + prefs.remove('lastname_${widget.userId}'); + if (mounted) { + setState(() {}); + } + _loadUserData(); + } + } + + void _handleIncomingMessage(Map data) async { + if (data['type'] == 'user_online') { + final userId = int.tryParse(data['user_id']?.toString() ?? ''); + if (userId == widget.userId) { + if (mounted) { + setState(() { + _userData = _userData?..['online'] = true; + }); + } + } + } + if (data['type'] == 'user_offline') { + final userId = int.tryParse(data['user_id']?.toString() ?? ''); + if (userId == widget.userId) { + setState(() { + _userData = _userData?..['online'] = false; + _userData = _userData + ?..['last_online'] = DateTime.now().toIso8601String(); + }); + } + } + + if (data['type'] == 'user_updated') { + print('User updated message received, refreshing contact list'); + final userId = int.tryParse(data['user_id']?.toString() ?? ''); + if (userId != null && userId == widget.userId) { + _loadUserData(); + } + } + } + + String _formatLastOnline(DateTime lastOnline) { + final 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), @@ -184,4 +407,4 @@ class _UserProfileScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/presentation/widgets/contact_tile.dart b/lib/presentation/widgets/contact_tile.dart index b024024..512467e 100644 --- a/lib/presentation/widgets/contact_tile.dart +++ b/lib/presentation/widgets/contact_tile.dart @@ -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 createState() => _ContactTileState(); +} + +class _ContactTileState extends State { + SharedPreferences? _prefs; + + @override + void initState() { + super.initState(); + _initPrefs(); + } + + Future _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), ), ), diff --git a/srv/app/api/endpoints/auth.py b/srv/app/api/endpoints/auth.py index 705d7f5..0eb84be 100644 --- a/srv/app/api/endpoints/auth.py +++ b/srv/app/api/endpoints/auth.py @@ -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)): +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)}") + user = db.query(models.User).filter( - models.User.username == form_data.username).first() + models.User.username == data.username).first() - if not user or not security.verify_password(form_data.password, user.hashed_password): + 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: diff --git a/srv/app/api/endpoints/media.py b/srv/app/api/endpoints/media.py index 230895e..cb0c14c 100644 --- a/srv/app/api/endpoints/media.py +++ b/srv/app/api/endpoints/media.py @@ -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 # бд @@ -50,4 +51,15 @@ async def upload_file(file: UploadFile = File(...)): return { "status": "ok", "file_id": file_id - } \ No newline at end of file + } + + +@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") \ No newline at end of file diff --git a/srv/app/api/endpoints/messages.py b/srv/app/api/endpoints/messages.py index 5b1792f..4c5aeeb 100644 --- a/srv/app/api/endpoints/messages.py +++ b/srv/app/api/endpoints/messages.py @@ -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": "Все сообщения удалены"} + diff --git a/srv/app/api/endpoints/users.py b/srv/app/api/endpoints/users.py index d023bc4..75d0797 100644 --- a/srv/app/api/endpoints/users.py +++ b/srv/app/api/endpoints/users.py @@ -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 @@ -290,12 +306,33 @@ 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"} diff --git a/srv/app/api/schemas.py b/srv/app/api/schemas.py index b0b8a08..19b2bbc 100644 --- a/srv/app/api/schemas.py +++ b/srv/app/api/schemas.py @@ -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 diff --git a/srv/app/db/models.py b/srv/app/db/models.py index f0b44da..1b10887 100644 --- a/srv/app/db/models.py +++ b/srv/app/db/models.py @@ -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() diff --git a/srv/app/websocket/connection_manager.py b/srv/app/websocket/connection_manager.py index 7dd6816..c72f3c5 100644 --- a/srv/app/websocket/connection_manager.py +++ b/srv/app/websocket/connection_manager.py @@ -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,14 +250,43 @@ 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): diff --git a/srv/main.py b/srv/main.py index 2f680b1..0d577c4 100644 --- a/srv/main.py +++ b/srv/main.py @@ -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 diff --git a/srv/requirements.txt b/srv/requirements.txt index 44b3944..6bfe6f2 100644 --- a/srv/requirements.txt +++ b/srv/requirements.txt @@ -3,4 +3,6 @@ uvicorn[standard] sqlalchemy passlib[bcrypt] python-jose[cryptography] -python-multipart \ No newline at end of file +python-multipart +pyotp +qrcode[pil] \ No newline at end of file