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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,9 @@ class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController(); final _usernameController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _totpController = TextEditingController();
bool _showTotpField = false;
String? _errorMessage;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -85,6 +88,36 @@ class _LoginScreenState extends State<LoginScreen> {
validator: (value) => validator: (value) =>
value!.length < 6 ? "Минимум 6 символов" : null, 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), const SizedBox(height: 24),
// Кнопка Входа // Кнопка Входа
@ -120,6 +153,7 @@ class _LoginScreenState extends State<LoginScreen> {
final success = await authProvider.login( final success = await authProvider.login(
_usernameController.text, _usernameController.text,
_passwordController.text, _passwordController.text,
totpCode: _showTotpField ? _totpController.text : null,
); );
if (success && mounted) { if (success && mounted) {
await authProvider.initRealtime(); await authProvider.initRealtime();
@ -146,9 +180,25 @@ class _LoginScreenState extends State<LoginScreen> {
} }
} }
} catch (e) { } catch (e) {
final error = e.toString().replaceAll('Exception: ', '');
if (error.contains('TOTP код требуется')) {
setState(() {
_showTotpField = true;
_errorMessage = error;
});
} else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))), SnackBar(content: Text(error)),
); );
} }
} }
}
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_totpController.dispose();
super.dispose();
}
} }

View File

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

View File

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

View File

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

View File

@ -1,11 +1,14 @@
import 'package:chepuhagram/presentation/screens/account_settings_screen.dart'; import 'package:chepuhagram/presentation/screens/account_settings_screen.dart';
import 'package:chepuhagram/presentation/screens/login_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/privacy_settings_menu_screen.dart';
import 'package:chepuhagram/presentation/screens/appearance_settings_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '/logic/auth_provider.dart'; import '/logic/auth_provider.dart';
import '/core/theme_manager.dart'; import '/core/theme_manager.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@ -16,6 +19,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
String? versionCode; String? versionCode;
final ImagePicker _picker = ImagePicker();
@override @override
void initState() { void initState() {
@ -32,9 +36,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
} }
Future<void> _pickAvatar() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image != null) {
final success = await context.read<AuthProvider>().updateAvatar(image.path);
if (!success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ошибка загрузки аватарки')),
);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeProv = context.watch<ThemeProvider>();
final authProv = context.watch<AuthProvider>(); final authProv = context.watch<AuthProvider>();
final accountEmail = authProv.email?.isNotEmpty == true final accountEmail = authProv.email?.isNotEmpty == true
@ -67,7 +82,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
accountEmail, accountEmail,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
currentAccountPicture: CircleAvatar( 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( child: Text(
initials.isEmpty ? 'U' : initials, initials.isEmpty ? 'U' : initials,
style: TextStyle( style: TextStyle(
@ -77,6 +110,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
), ),
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,
),
),
),
],
),
),
),
decoration: const BoxDecoration(color: Colors.transparent), decoration: const BoxDecoration(color: Colors.transparent),
), ),
@ -111,40 +164,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
const Divider(), const Divider(),
ListTile(
SwitchListTile( leading: const Icon(Icons.palette),
secondary: const Icon(Icons.dark_mode), title: const Text('Оформление'),
title: const Text("Ночной режим"), subtitle: const Text('Тема, цвета, обои'),
value: themeProv.themeMode == ThemeMode.dark, trailing: const Icon(Icons.chevron_right),
onChanged: (val) => themeProv.toggleTheme(val), onTap: () {
), Navigator.push(
context,
// Выбор цвета акцента MaterialPageRoute(
Padding( builder: (_) => const AppearanceSettingsScreen(),
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),
],
),
],
), ),
);
},
), ),
const Divider(),
const Divider(), const Divider(),
@ -183,22 +217,4 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
); );
} }
Widget _colorCircle(BuildContext context, Color color, ThemeProvider prov) {
bool isSelected = prov.accentColor == color;
return GestureDetector(
onTap: () => prov.updateAccentColor(color),
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? color : Colors.transparent,
width: 2,
),
),
child: CircleAvatar(backgroundColor: color, radius: 15),
),
);
}
} }

View File

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

View File

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

View File

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

View File

@ -6,6 +6,11 @@ from app.api import schemas
from app.db import models from app.db import models
from jose import JWTError, jwt from jose import JWTError, jwt
from app.core.security import get_current_user 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") @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)):
user = db.query(models.User).filter( print(f"Login attempt: username={data.username}, totp_code provided={bool(data.totp_code)}")
models.User.username == form_data.username).first()
if not user or not security.verify_password(form_data.password, user.hashed_password): user = db.query(models.User).filter(
models.User.username == data.username).first()
if not user or not security.verify_password(data.password, user.hashed_password):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверный логин или пароль", detail="Неверный логин или пароль",
headers={"WWW-Authenticate": "Bearer"}, 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)}) access_token = security.create_access_token(data={"sub": str(user.id)})
refresh_token = security.create_refresh_token(data={"sub": str(user.id)}) refresh_token = security.create_refresh_token(data={"sub": str(user.id)})
return { 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") @authRouter.post("/refresh")
async def refresh_token(data: schemas.RefreshRequest): async def refresh_token(data: schemas.RefreshRequest):
try: try:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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