Добавлены аватарки, обои, TOTP и многое другое
This commit is contained in:
parent
15af40fc64
commit
d33c41010d
|
|
@ -5,6 +5,8 @@
|
|||
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:label="Chepuhagram"
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ class ThemeProvider extends ChangeNotifier {
|
|||
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
Color _accentColor = const Color(0xFF24A1DE);
|
||||
String? _wallpaperPath;
|
||||
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
Color get accentColor => _accentColor;
|
||||
String? get wallpaperPath => _wallpaperPath;
|
||||
|
||||
bool isLight = false;
|
||||
|
||||
|
|
@ -20,12 +22,14 @@ class ThemeProvider extends ChangeNotifier {
|
|||
Future<void> _loadSettings() async {
|
||||
final mode = await _storage.read(key: 'theme_mode');
|
||||
final color = await _storage.read(key: 'accent_color');
|
||||
final wallpaper = await _storage.read(key: 'wallpaper_path');
|
||||
|
||||
if (mode != null) {
|
||||
_themeMode = mode == 'dark' ? ThemeMode.dark : ThemeMode.light;
|
||||
isLight = mode == 'light';
|
||||
}
|
||||
if (color != null) _accentColor = Color(int.parse(color));
|
||||
_wallpaperPath = wallpaper;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
|
@ -42,6 +46,16 @@ class ThemeProvider extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateWallpaper(String? path) {
|
||||
_wallpaperPath = path;
|
||||
if (path != null) {
|
||||
_storage.write(key: 'wallpaper_path', value: path);
|
||||
} else {
|
||||
_storage.delete(key: 'wallpaper_path');
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
ThemeData get themeData => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: _themeMode == ThemeMode.dark
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ class LocalDbService {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> clearDatabase() async {
|
||||
final db = await database;
|
||||
await db.delete('messages');
|
||||
}
|
||||
|
||||
// Сохранение списка сообщений (из истории)
|
||||
Future<void> saveMessages(List<dynamic> messages) async {
|
||||
final db = await database;
|
||||
|
|
|
|||
|
|
@ -5,14 +5,16 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
|||
import 'package:web_socket_channel/status.dart' as status;
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:chepuhagram/core/constants.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SocketService with WidgetsBindingObserver {
|
||||
|
||||
class SocketService {
|
||||
static final SocketService _instance = SocketService._internal();
|
||||
factory SocketService() => _instance;
|
||||
|
||||
factory SocketService() {
|
||||
return _instance;
|
||||
SocketService._internal() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
SocketService._internal();
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
final StreamController<Map<String, dynamic>> _messageController =
|
||||
|
|
@ -21,6 +23,19 @@ class SocketService {
|
|||
// Поток, который будут слушать провайдеры
|
||||
Stream<Map<String, dynamic>> get messages => _messageController.stream;
|
||||
|
||||
bool allowConnect = true; // Флаг для контроля подключения
|
||||
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
allowConnect = true;
|
||||
} else {
|
||||
allowConnect = false;
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connect(ApiService apiService) async {
|
||||
final token = await apiService.getAccessToken();
|
||||
if (_channel != null) return; // Уже подключены
|
||||
|
|
@ -28,6 +43,7 @@ class SocketService {
|
|||
print('❌ SocketService.connect: no access token, skipping connect');
|
||||
return;
|
||||
}
|
||||
if (!allowConnect) return; // Не разрешаем подключение
|
||||
|
||||
// В FastAPI эндпоинт обычно ожидает токен в URL или подзаголовке
|
||||
final uri = Uri.parse("${AppConstants.wsUrl}/ws?token=$token");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import '/core/constants.dart';
|
||||
|
||||
class Contact {
|
||||
final int id;
|
||||
final String username;
|
||||
final String name;
|
||||
final String surname;
|
||||
String name;
|
||||
String surname;
|
||||
final String? lastMessage;
|
||||
final String? avatarFileId;
|
||||
final String? avatarUrl;
|
||||
final DateTime? lastMessageTime;
|
||||
final bool isOnline;
|
||||
|
|
@ -11,12 +14,15 @@ class Contact {
|
|||
final String? publicKey;
|
||||
final bool isLastMsgDecrypted;
|
||||
|
||||
String? get effectiveAvatarUrl => avatarUrl ?? (avatarFileId != null ? '${AppConstants.baseUrl}/media/$avatarFileId' : null);
|
||||
|
||||
Contact({
|
||||
required this.id,
|
||||
required this.username,
|
||||
required this.name,
|
||||
required this.surname,
|
||||
this.lastMessage,
|
||||
this.avatarFileId,
|
||||
this.avatarUrl,
|
||||
this.lastMessageTime,
|
||||
this.isOnline = false,
|
||||
|
|
@ -31,6 +37,7 @@ class Contact {
|
|||
String? name,
|
||||
String? surname,
|
||||
String? lastMessage,
|
||||
String? avatarFileId,
|
||||
String? avatarUrl,
|
||||
DateTime? lastMessageTime,
|
||||
bool? isOnline,
|
||||
|
|
@ -44,6 +51,7 @@ class Contact {
|
|||
name: name ?? this.name,
|
||||
surname: surname ?? this.surname,
|
||||
lastMessage: lastMessage ?? this.lastMessage,
|
||||
avatarFileId: avatarFileId ?? this.avatarFileId,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
lastMessageTime: lastMessageTime ?? this.lastMessageTime,
|
||||
isOnline: isOnline ?? this.isOnline,
|
||||
|
|
@ -67,6 +75,7 @@ class Contact {
|
|||
name: json['name'] ?? json['first_name'] ?? 'Unknown',
|
||||
surname: json['surname'] ?? json['last_name'] ?? 'Unknown',
|
||||
lastMessage: json['last_message'] ?? json['lastMessage'],
|
||||
avatarFileId: json['avatar_file_id'] ?? json['avatarFileId'],
|
||||
avatarUrl: json['avatar_url'] ?? json['avatarUrl'],
|
||||
lastMessageTime: parseTime(json['last_message_time'] ?? json['lastMessageTime']),
|
||||
isOnline: (json['is_online'] ?? json['isOnline']) == true,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class ContactRepository {
|
|||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
print(data);
|
||||
List<Contact> contacts = data.map((json) => Contact.fromJson(json)).toList();
|
||||
for (var item in contacts) {
|
||||
if (item.lastMessageTime != null) {
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ class ApiService extends ChangeNotifier {
|
|||
bool? showAvatar,
|
||||
bool? showAbout,
|
||||
bool? showUsername,
|
||||
bool? showLastOnline,
|
||||
}) async {
|
||||
final token = await getAccessToken();
|
||||
final response = await _client.put(
|
||||
|
|
@ -293,6 +294,7 @@ class ApiService extends ChangeNotifier {
|
|||
if (showAvatar != null) 'show_avatar': showAvatar,
|
||||
if (showAbout != null) 'show_about': showAbout,
|
||||
if (showUsername != null) 'show_username': showUsername,
|
||||
if (showLastOnline != null) 'show_last_online': showLastOnline,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -315,4 +317,74 @@ class ApiService extends ChangeNotifier {
|
|||
}
|
||||
throw Exception('Не удалось получить настройки конфиденциальности');
|
||||
}
|
||||
|
||||
Future<bool> updateAvatar(String fileId) async {
|
||||
final token = await getAccessToken();
|
||||
final response = await _client.put(
|
||||
Uri.parse('${AppConstants.baseUrl}/users/me/avatar'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode({'avatar_file_id': fileId}),
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> enableTotp() async {
|
||||
final token = await getAccessToken();
|
||||
final response = await _client.post(
|
||||
Uri.parse('${AppConstants.baseUrl}/auth/totp/enable'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(utf8.decode(response.bodyBytes))
|
||||
as Map<String, dynamic>;
|
||||
}
|
||||
throw Exception(
|
||||
(jsonDecode(response.body) as Map<String, dynamic>)['detail'] ??
|
||||
'Failed to enable TOTP',
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> verifyTotp(String code) async {
|
||||
final token = await getAccessToken();
|
||||
final response = await _client.post(
|
||||
Uri.parse('${AppConstants.baseUrl}/auth/totp/verify'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode({'code': code}),
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<bool> disableTotp() async {
|
||||
final token = await getAccessToken();
|
||||
final response = await _client.post(
|
||||
Uri.parse('${AppConstants.baseUrl}/auth/totp/disable'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
|
||||
Future<bool> deleteAllMessages() async {
|
||||
final token = await getAccessToken();
|
||||
final response = await _client.delete(
|
||||
Uri.parse('${AppConstants.baseUrl}/messages/all'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import 'dart:typed_data';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'dart:convert';
|
||||
|
|
@ -195,8 +194,8 @@ class CryptoService {
|
|||
isLastMsgDecrypted: true,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
result.add(contact);
|
||||
} catch (e) {
|
||||
result.add(contact.copyWith(lastMessage: '[не удалось расшифровать: $e]', isLastMsgDecrypted: true));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import '/core/constants.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||
|
|
@ -32,6 +35,12 @@ class AuthProvider extends ChangeNotifier {
|
|||
String? _about;
|
||||
String? get about => _about;
|
||||
|
||||
String? _avatarPath;
|
||||
String? get avatarPath => _avatarPath;
|
||||
|
||||
String? _avatarUrl;
|
||||
String? get avatarUrl => _avatarUrl;
|
||||
|
||||
// Privacy settings
|
||||
bool? _showEmail;
|
||||
bool? get showEmail => _showEmail;
|
||||
|
|
@ -85,14 +94,19 @@ class AuthProvider extends ChangeNotifier {
|
|||
|
||||
SocketService get socketService => _socketService;
|
||||
|
||||
Future<bool> login(String username, String password) async {
|
||||
Future<bool> login(String username, String password, {String? totpCode}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final body = {'username': username, 'password': password};
|
||||
if (totpCode != null) {
|
||||
body['totp_code'] = totpCode;
|
||||
}
|
||||
final response = await _client.post(
|
||||
Uri.parse('${AppConstants.baseUrl}/auth/login'),
|
||||
body: {'username': username, 'password': password},
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
final decodedResponse =
|
||||
|
|
@ -135,7 +149,12 @@ class AuthProvider extends ChangeNotifier {
|
|||
Future<void> logout() async {
|
||||
final mode = await _storage.read(key: 'theme_mode');
|
||||
final color = await _storage.read(key: 'accent_color');
|
||||
final wallpaper = await _storage.read(key: 'wallpaper_path');
|
||||
final avatar = await _storage.read(key: 'avatar_path');
|
||||
await _storage.deleteAll();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.clear();
|
||||
await LocalDbService().clearDatabase();
|
||||
_currentUserId = null;
|
||||
_username = null;
|
||||
_firstName = null;
|
||||
|
|
@ -143,12 +162,20 @@ class AuthProvider extends ChangeNotifier {
|
|||
_phone = null;
|
||||
_email = null;
|
||||
_about = null;
|
||||
_avatarPath = null;
|
||||
_avatarUrl = null;
|
||||
if (mode != null) {
|
||||
await _storage.write(key: 'theme_mode', value: mode);
|
||||
}
|
||||
if (color != null) {
|
||||
await _storage.write(key: 'accent_color', value: color);
|
||||
}
|
||||
if (wallpaper != null) {
|
||||
await _storage.write(key: 'wallpaper_path', value: wallpaper);
|
||||
}
|
||||
if (avatar != null) {
|
||||
await _storage.write(key: 'avatar_path', value: avatar);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
|
@ -253,6 +280,11 @@ class AuthProvider extends ChangeNotifier {
|
|||
_phone = data['phone']?.toString();
|
||||
_email = data['email']?.toString();
|
||||
_about = data['about']?.toString();
|
||||
final avatarFileId = data['avatar_file_id']?.toString();
|
||||
_avatarUrl = avatarFileId != null ? '${AppConstants.baseUrl}/media/$avatarFileId' : null;
|
||||
|
||||
// Загружаем локальные настройки
|
||||
_avatarPath = await _storage.read(key: 'avatar_path');
|
||||
|
||||
// Проверяем наличие публичного ключа на сервере
|
||||
_hasPublicKeyOnServer =
|
||||
|
|
@ -312,4 +344,33 @@ class AuthProvider extends ChangeNotifier {
|
|||
_needsKeyRecovery = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateAvatarPath(String? path) {
|
||||
_avatarPath = path;
|
||||
if (path != null) {
|
||||
_storage.write(key: 'avatar_path', value: path);
|
||||
} else {
|
||||
_storage.delete(key: 'avatar_path');
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> updateAvatar(String path) async {
|
||||
try {
|
||||
final bytes = await File(path).readAsBytes();
|
||||
final fileId = await _apiService.uploadMedia(bytes);
|
||||
if (fileId != null) {
|
||||
final success = await _apiService.updateAvatar(fileId);
|
||||
if (success) {
|
||||
updateAvatarPath(path);
|
||||
await refreshMe(); // Обновить данные профиля, включая avatarUrl
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
print('Ошибка обновления аватарки: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '/data/models/contact_model.dart';
|
||||
import '/data/repositories/contact_repository.dart';
|
||||
import '/data/datasources/local_db_service.dart';
|
||||
import '/domain/services/crypto_service.dart';
|
||||
import 'dart:isolate';
|
||||
import 'dart:convert';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class ContactProvider extends ChangeNotifier {
|
||||
final ContactRepository _repository = ContactRepository();
|
||||
final LocalDbService _localDbService = LocalDbService();
|
||||
final CryptoService _cryptoService;
|
||||
|
||||
ContactProvider(this._cryptoService);
|
||||
|
|
@ -112,7 +109,9 @@ class ContactProvider extends ChangeNotifier {
|
|||
'cache': cacheCopy,
|
||||
},
|
||||
);
|
||||
|
||||
for (var contact in updatedContacts) {
|
||||
print('Decrypted contact: ${contact.name} ${contact.surname}, lastMessage: ${contact.lastMessage}, isDecrypted: ${contact.isLastMsgDecrypted}');
|
||||
}
|
||||
_contacts = updatedContacts;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
|
|
@ -120,4 +119,27 @@ class ContactProvider extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> updateContact(int userId) async {
|
||||
try {
|
||||
final updatedContact = await _repository.fetchContactById(userId);
|
||||
final index = _contacts.indexWhere((c) => c.id == userId);
|
||||
if (index != -1) {
|
||||
// Обновляем только поля профиля, сохраняя lastMessage и т.д.
|
||||
final existing = _contacts[index];
|
||||
_contacts[index] = existing.copyWith(
|
||||
username: updatedContact.username,
|
||||
name: updatedContact.name,
|
||||
surname: updatedContact.surname,
|
||||
avatarUrl: updatedContact.avatarUrl,
|
||||
avatarFileId: updatedContact.avatarFileId,
|
||||
isOnline: updatedContact.isOnline,
|
||||
publicKey: updatedContact.publicKey,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
print("Error updating contact: $e");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'data/datasources/ws_client.dart';
|
||||
import 'logic/auth_provider.dart';
|
||||
import 'logic/contact_provider.dart';
|
||||
|
|
@ -224,9 +222,7 @@ void main() async {
|
|||
Provider(create: (_) => SocketService()),
|
||||
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => ContactProvider(
|
||||
context.read<CryptoService>(),
|
||||
),
|
||||
create: (context) => ContactProvider(context.read<CryptoService>()),
|
||||
),
|
||||
],
|
||||
child: const MyApp(),
|
||||
|
|
@ -290,13 +286,42 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
|||
notificationText = 'Failed to decrypt: ${e.toString()}';
|
||||
}
|
||||
|
||||
final senderId = int.tryParse(
|
||||
message.data['sender_id']?.toString() ?? '',
|
||||
);
|
||||
|
||||
// 4. Показываем локальное уведомление
|
||||
final String groupKey = 'ru.chepuhagram.app.$senderId';
|
||||
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
senderId!,
|
||||
'',
|
||||
'',
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'Messages',
|
||||
'Новые сообщения',
|
||||
groupKey: groupKey,
|
||||
setAsGroupSummary: true,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
groupAlertBehavior: GroupAlertBehavior.all,
|
||||
),
|
||||
),
|
||||
);
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
message.hashCode,
|
||||
message.data['username'] ?? 'Unknown',
|
||||
notificationText,
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails('chat_id', 'Messages'),
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'chat_id',
|
||||
'Messages',
|
||||
groupKey: groupKey,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
showWhen: true,
|
||||
),
|
||||
),
|
||||
payload: jsonEncode({
|
||||
'type': 'enc_message',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '/data/models/message_model.dart';
|
||||
import '/data/models/contact_model.dart';
|
||||
|
|
@ -11,6 +9,7 @@ import 'package:chepuhagram/data/datasources/ws_client.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
import '/logic/contact_provider.dart';
|
||||
import '../../domain/services/api_service.dart';
|
||||
import 'dart:math';
|
||||
import 'package:chepuhagram/data/datasources/local_db_service.dart';
|
||||
import 'package:chepuhagram/main.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
|
@ -18,6 +17,8 @@ import 'contacts_screen.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'user_profile_screen.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '/core/theme_manager.dart';
|
||||
import 'dart:io';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
final Contact contact;
|
||||
|
|
@ -28,11 +29,11 @@ class ChatScreen extends StatefulWidget {
|
|||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
class _ChatScreenState extends State<ChatScreen> with RouteAware {
|
||||
static const String _notificationLaunchKey = 'notification_launch_data';
|
||||
int myId = 0;
|
||||
late Contact _currentContact;
|
||||
bool _isKeyLoading = false;
|
||||
bool _isKeyLoading = true;
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _inputFocusNode = FocusNode();
|
||||
final ContactRepository _contactRepository = ContactRepository();
|
||||
|
|
@ -44,26 +45,127 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
final LocalDbService _localDbService = LocalDbService();
|
||||
Uint8List? _pendingImageBytes;
|
||||
MessageModel? _replyTo;
|
||||
bool _isOnline = false;
|
||||
DateTime? _lastOnline;
|
||||
Timer? _onlineTimer;
|
||||
DateTime? _lastTypingSent;
|
||||
bool _isTyping = false;
|
||||
Timer? _typingTimer;
|
||||
late SocketService _socketService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentContact = widget.contact;
|
||||
|
||||
_socketService = Provider.of<SocketService>(context, listen: false);
|
||||
currentActiveChatContactId =
|
||||
_currentContact.id; // Устанавливаем активный чат
|
||||
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
|
||||
final contactProvider = context.read<ContactProvider>();
|
||||
myId = contactProvider.getCurrentUserId() ?? 0;
|
||||
// Если ключа нет, загружаем его при входе
|
||||
_loadLocalName();
|
||||
if (_currentContact.publicKey == null) {
|
||||
_loadContactKey();
|
||||
}
|
||||
_loadHistory();
|
||||
_loadOnlineStatus();
|
||||
startOnlineUpdates();
|
||||
_controller.addListener(_sendTypingStatus);
|
||||
|
||||
final socketService = Provider.of<SocketService>(context, listen: false);
|
||||
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
print("Пользователь вернулся на этот экран!");
|
||||
_loadLocalName();
|
||||
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
|
||||
}
|
||||
|
||||
Future<void> _loadLocalName() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final String? savedName = prefs.getString(
|
||||
'firstname_${_currentContact.id}',
|
||||
);
|
||||
final String? savedSurname = prefs.getString(
|
||||
'lastname_${_currentContact.id}',
|
||||
);
|
||||
print('Загружены имя $savedName, $savedSurname');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (savedName != null) {
|
||||
_currentContact.name = savedName;
|
||||
}
|
||||
if (savedSurname != null) {
|
||||
_currentContact.surname = savedSurname;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _sendTypingStatus() {
|
||||
final now = DateTime.now();
|
||||
if (_lastTypingSent == null ||
|
||||
now.difference(_lastTypingSent!) > const Duration(seconds: 3)) {
|
||||
_lastTypingSent = now;
|
||||
final socketService = Provider.of<SocketService>(context, listen: false);
|
||||
socketService.sendMessage({
|
||||
'type': 'typing',
|
||||
'receiver_id': _currentContact.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _sendStopTypingStatus() {
|
||||
_socketService.sendMessage({
|
||||
'type': 'stop_typing',
|
||||
'receiver_id': _currentContact.id,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadOnlineStatus() async {
|
||||
flutterLocalNotificationsPlugin.cancel(currentActiveChatContactId!);
|
||||
try {
|
||||
print(
|
||||
"🔍 Загружаем онлайн статус для контакта ${_currentContact.name} (ID: ${_currentContact.id})",
|
||||
);
|
||||
final data = await apiService.getUserById(_currentContact.id);
|
||||
if (!mounted) return;
|
||||
DateTime now = DateTime.now();
|
||||
|
||||
Duration offset = now.timeZoneOffset;
|
||||
print(
|
||||
"✅ Получен онлайн статус: ${data['online']}, last_online: ${data['last_online'] != null ? DateTime.tryParse(data['last_online']!)?.add(offset) : null}",
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isOnline = data['online'] ?? false;
|
||||
if (data['last_online'] != null)
|
||||
_lastOnline = DateTime.parse(data['last_online']).add(offset);
|
||||
else
|
||||
_lastOnline = null;
|
||||
});
|
||||
} catch (e) {
|
||||
print("❌ ОШИБКА ПРИ ЗАГРУЗКЕ СТАТУСА ОНЛАЙН: $e");
|
||||
// Игнорируем ошибки при загрузке статуса
|
||||
}
|
||||
}
|
||||
|
||||
void startOnlineUpdates() {
|
||||
_onlineTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
_loadOnlineStatus();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadContactKey() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _isKeyLoading = true);
|
||||
|
|
@ -84,11 +186,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
const SnackBar(
|
||||
content: Text("Не удалось получить ключ шифрования собеседника"),
|
||||
behavior: SnackBarBehavior.floating, // Обязательно для margin
|
||||
margin: EdgeInsets.only(
|
||||
bottom: 80.0 + 10.0, // 20px + стандартный отступ (по желанию)
|
||||
left: 10.0,
|
||||
right: 10.0,
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: 80.0 + 10.0, left: 10.0, right: 10.0),
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
|
|
@ -97,15 +195,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
currentActiveChatContactId = null; // Сбрасываем активный чат
|
||||
currentActiveChatContactId = null;
|
||||
_socketSubscription?.cancel();
|
||||
_controller.dispose();
|
||||
routeObserver.unsubscribe(this);
|
||||
_inputFocusNode.dispose();
|
||||
_onlineTimer?.cancel();
|
||||
_typingTimer?.cancel();
|
||||
_controller.removeListener(_sendTypingStatus);
|
||||
_sendStopTypingStatus();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeProv = context.watch<ThemeProvider>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
|
|
@ -133,22 +237,80 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
),
|
||||
);
|
||||
},
|
||||
child: Text(_currentContact.name),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${_currentContact.name} ${_currentContact.surname != 'Unknown' ? _currentContact.surname : ''}',
|
||||
),
|
||||
if (_isKeyLoading == true)
|
||||
const Text(
|
||||
'загрузка...',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color.fromARGB(255, 219, 219, 219),
|
||||
),
|
||||
)
|
||||
else if (_isTyping)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'печатает',
|
||||
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
TypingIndicator(),
|
||||
],
|
||||
)
|
||||
else if (_isOnline)
|
||||
const Text(
|
||||
'онлайн',
|
||||
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
|
||||
)
|
||||
else if (_lastOnline != null)
|
||||
Text(
|
||||
'был(а) в сети ${_formatLastOnline(_lastOnline!)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color.fromARGB(255, 219, 219, 219),
|
||||
),
|
||||
)
|
||||
else
|
||||
const Text(
|
||||
'был(а) недавно',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color.fromARGB(255, 219, 219, 219),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
reverse: true, // Сообщения растут снизу вверх
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = messages[messages.length - 1 - index];
|
||||
return MessageBubble(
|
||||
message: msg,
|
||||
onTap: () => _showMessageActions(msg),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
decoration: themeProv.wallpaperPath != null
|
||||
? BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: FileImage(File(themeProv.wallpaperPath!)),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: ListView.builder(
|
||||
reverse: true, // Сообщения растут снизу вверх
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = messages[messages.length - 1 - index];
|
||||
return MessageBubble(
|
||||
message: msg,
|
||||
onTap: () => _showMessageActions(msg),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildMessageInput(),
|
||||
|
|
@ -157,6 +319,35 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
String _formatLastOnline(DateTime lastOnline) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(lastOnline);
|
||||
|
||||
if (difference.inSeconds < 60) {
|
||||
return 'только что';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes} минут${_pluralize(difference.inMinutes, "у", "ы", "")} назад';
|
||||
} else if (difference.inHours < 24) {
|
||||
return '${difference.inHours} час${_pluralize(difference.inHours, "", "а", "ов")} назад';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
|
||||
} else {
|
||||
return 'давно';
|
||||
}
|
||||
}
|
||||
|
||||
String _pluralize(int count, String form1, String form2, String form5) {
|
||||
final mod10 = count % 10;
|
||||
final mod100 = count % 100;
|
||||
if (mod10 == 1 && mod100 != 11) {
|
||||
return form1;
|
||||
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
|
||||
return form2;
|
||||
} else {
|
||||
return form5;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showMessageActions(MessageModel msg) async {
|
||||
if (!mounted) return;
|
||||
|
||||
|
|
@ -496,7 +687,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
});
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => ChatScreen(contact: targetContact,)),
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatScreen(contact: targetContact),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
|
@ -630,6 +823,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
_sendStopTypingStatus();
|
||||
final rawText = _controller.text.trim();
|
||||
final hasImage = _pendingImageBytes != null;
|
||||
|
||||
|
|
@ -908,6 +1102,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
}
|
||||
|
||||
if (data['type'] == 'private_message') {
|
||||
setState(() {
|
||||
_typingTimer?.cancel();
|
||||
_isTyping = false;
|
||||
});
|
||||
final senderId = int.tryParse(data['sender_id']?.toString() ?? '');
|
||||
final receiverId = int.tryParse(
|
||||
(data['receiver_id'] ?? data['recipient_id'])?.toString() ?? '',
|
||||
|
|
@ -978,7 +1176,42 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
print(
|
||||
"Сообщение от другого пользователя (ID: $senderId), игнорируем в этом чате",
|
||||
);
|
||||
// Тут можно добавить логику уведомления для списка чатов
|
||||
}
|
||||
}
|
||||
if (data['type'] == 'user_online') {
|
||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
||||
if (userId == widget.contact.id) {
|
||||
setState(() => _isOnline = true);
|
||||
}
|
||||
}
|
||||
if (data['type'] == 'user_offline') {
|
||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
||||
if (userId == widget.contact.id) {
|
||||
setState(() {
|
||||
_isOnline = false;
|
||||
_lastOnline = DateTime.now();
|
||||
});
|
||||
|
||||
_loadOnlineStatus();
|
||||
}
|
||||
}
|
||||
|
||||
if (data['type'] == 'typing' && data['sender_id'] == _currentContact.id) {
|
||||
if (mounted) {
|
||||
setState(() => _isTyping = true);
|
||||
|
||||
_typingTimer?.cancel();
|
||||
_typingTimer = Timer(const Duration(seconds: 4), () {
|
||||
if (mounted) setState(() => _isTyping = false);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data['type'] == 'stop_typing' &&
|
||||
data['sender_id'] == _currentContact.id) {
|
||||
if (mounted) {
|
||||
setState(() => _isTyping = false);
|
||||
|
||||
_typingTimer?.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1146,3 +1379,73 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TypingIndicator extends StatefulWidget {
|
||||
const TypingIndicator({super.key});
|
||||
|
||||
@override
|
||||
State<TypingIndicator> createState() => _TypingIndicatorState();
|
||||
}
|
||||
|
||||
class _TypingIndicatorState extends State<TypingIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
)..repeat(reverse: true); // Анимация идет туда-сюда
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildDot(int index) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
// Рассчитываем смещение: только отрицательные значения (вверх)
|
||||
double delay = index * 0.5; // Увеличили задержку для плавности
|
||||
double shift = sin((_controller.value * 2 * pi) + delay);
|
||||
|
||||
// Используем clamp или abs, чтобы точка не уходила ниже базовой линии
|
||||
double yOffset = (shift < 0 ? shift : 0) * 4;
|
||||
|
||||
return SizedBox(
|
||||
width: 4, // Фиксированная зона для одной точки
|
||||
height: 5, // Фиксированная высота зоны анимации
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter, // Точка всегда прижата к низу
|
||||
child: Container(
|
||||
width: 2,
|
||||
height: 2,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.greenAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
transform: Matrix4.translationValues(0, yOffset, 0),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 12,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(3, (index) => _buildDot(index)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ import 'package:package_info_plus/package_info_plus.dart';
|
|||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import '/data/datasources/ws_client.dart';
|
||||
|
||||
class ContactsScreen extends StatefulWidget {
|
||||
final int? targetChatId;
|
||||
|
|
@ -46,6 +46,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
super.initState();
|
||||
print('ContactsScreen initState, targetChatId: ${widget.targetChatId}');
|
||||
_setupPushNotifications();
|
||||
final socketService = Provider.of<SocketService>(context, listen: false);
|
||||
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
final contactProvider = context.read<ContactProvider>();
|
||||
|
|
@ -207,12 +209,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
});
|
||||
|
||||
// Listen for foreground messages
|
||||
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
||||
print('Foreground message received: ${message.data}');
|
||||
if (message.data['type'] == 'enc_message') {
|
||||
_handleIncomingMessage(message);
|
||||
}
|
||||
});
|
||||
FirebaseMessaging.onMessage.listen(_handleIncomingMessage);
|
||||
|
||||
// Handle notification tap when app was terminated/backgrounded
|
||||
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
|
||||
|
|
@ -267,7 +264,24 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _handleIncomingMessage(RemoteMessage message) async {
|
||||
Future<void> _handleIncomingMessage(dynamic data) async {
|
||||
if (data is RemoteMessage) {
|
||||
// FCM message
|
||||
await _handleFCMMessage(data);
|
||||
} else if (data is Map<String, dynamic>) {
|
||||
// WebSocket message
|
||||
print('WebSocket message received: $data');
|
||||
if (data['type'] == 'user_updated') {
|
||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
||||
if (userId != null) {
|
||||
final contactProvider = context.read<ContactProvider>();
|
||||
contactProvider.updateContact(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleFCMMessage(RemoteMessage message) async {
|
||||
try {
|
||||
// Проверяем, не находимся ли мы уже в чате с отправителем
|
||||
final senderId = int.tryParse(
|
||||
|
|
@ -280,8 +294,8 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
|
||||
// Ensure notification channel exists
|
||||
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||
'chat_id',
|
||||
'Messages',
|
||||
'Новые сообщения',
|
||||
description: 'Chat messages notifications',
|
||||
importance: Importance.high,
|
||||
);
|
||||
|
|
@ -308,13 +322,53 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
sharedSecret,
|
||||
);
|
||||
|
||||
if (senderId == null) return;
|
||||
final String groupKey = 'ru.chepuhagram.app.$senderId';
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final String? firstName = prefs.getString(
|
||||
'firstname_${message.data['sender_id']}',
|
||||
);
|
||||
final String? lastName = prefs.getString(
|
||||
'lastname_${message.data['sender_id']}',
|
||||
);
|
||||
final String localFullName = '${firstName ?? ''} ${lastName ?? ''}'
|
||||
.trim();
|
||||
|
||||
final String title = localFullName.isNotEmpty
|
||||
? localFullName
|
||||
: (message.data['username'] ?? 'Unknown');
|
||||
// Show local notification
|
||||
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
senderId,
|
||||
'',
|
||||
'',
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'Messages',
|
||||
'Новые сообщения',
|
||||
groupKey: groupKey,
|
||||
setAsGroupSummary: true,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
groupAlertBehavior: GroupAlertBehavior.all,
|
||||
),
|
||||
),
|
||||
);
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
message.hashCode,
|
||||
message.data['username'] ?? 'Unknown',
|
||||
title,
|
||||
decryptedText,
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails('chat_id', 'Messages'),
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'Messages',
|
||||
'Новые сообщения',
|
||||
groupKey: groupKey,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
showWhen: true,
|
||||
),
|
||||
),
|
||||
payload: jsonEncode({
|
||||
'type': 'enc_message',
|
||||
|
|
@ -329,7 +383,7 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
contactProvider.loadContacts();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error processing foreground message: $e');
|
||||
print('Error processing foreground FCM message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -438,15 +492,24 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
),
|
||||
),
|
||||
currentAccountPicture: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
child: Text(
|
||||
initials.isEmpty ? 'U' : initials,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
backgroundColor: authProvider.avatarUrl == null && authProvider.avatarPath == null
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: null,
|
||||
backgroundImage: authProvider.avatarUrl != null
|
||||
? NetworkImage(authProvider.avatarUrl!)
|
||||
: authProvider.avatarPath != null
|
||||
? FileImage(File(authProvider.avatarPath!))
|
||||
: null,
|
||||
child: (authProvider.avatarUrl == null && authProvider.avatarPath == null)
|
||||
? Text(
|
||||
initials.isEmpty ? 'U' : initials,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.inversePrimary,
|
||||
|
|
@ -527,14 +590,6 @@ class _ContactsScreenState extends State<ContactsScreen> with RouteAware {
|
|||
}
|
||||
}
|
||||
|
||||
void _cancelDownload() {
|
||||
_cancelToken?.cancel("Отменено");
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_downloadProgress = 0.0;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildUpdateBanner() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,15 @@ class _KeyRecoveryScreenState extends State<KeyRecoveryScreen> {
|
|||
try {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
|
||||
// Удаляем все сообщения пользователя
|
||||
try {
|
||||
final api = ApiService();
|
||||
await api.deleteAllMessages();
|
||||
} catch (e) {
|
||||
print('Ошибка при удалении сообщений: $e');
|
||||
// Продолжаем даже если удаление сообщений не удалось
|
||||
}
|
||||
|
||||
// Удаляем старые ключи и создаем новые
|
||||
await authProvider.resetKeys();
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _totpController = TextEditingController();
|
||||
bool _showTotpField = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -85,6 +88,36 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
validator: (value) =>
|
||||
value!.length < 6 ? "Минимум 6 символов" : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Поле TOTP, если требуется
|
||||
if (_showTotpField)
|
||||
TextFormField(
|
||||
controller: _totpController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "TOTP код",
|
||||
prefixIcon: const Icon(Icons.security),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
fillColor: Theme.of(context).colorScheme.primary,
|
||||
iconColor: Theme.of(context).colorScheme.primary,
|
||||
hoverColor: Theme.of(context).colorScheme.primary,
|
||||
focusColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
validator: (value) => value!.isEmpty ? "Введите TOTP код" : null,
|
||||
),
|
||||
if (_showTotpField) const SizedBox(height: 16),
|
||||
|
||||
// Сообщение об ошибке
|
||||
if (_errorMessage != null)
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_errorMessage != null) const SizedBox(height: 16),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Кнопка Входа
|
||||
|
|
@ -120,6 +153,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
final success = await authProvider.login(
|
||||
_usernameController.text,
|
||||
_passwordController.text,
|
||||
totpCode: _showTotpField ? _totpController.text : null,
|
||||
);
|
||||
if (success && mounted) {
|
||||
await authProvider.initRealtime();
|
||||
|
|
@ -146,9 +180,25 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
);
|
||||
final error = e.toString().replaceAll('Exception: ', '');
|
||||
if (error.contains('TOTP код требуется')) {
|
||||
setState(() {
|
||||
_showTotpField = true;
|
||||
_errorMessage = error;
|
||||
});
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_totpController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '/data/models/contact_model.dart';
|
||||
import '/logic/contact_provider.dart';
|
||||
import '/logic/auth_provider.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
static const _showAvatarKey = 'privacy_show_avatar';
|
||||
static const _showAboutKey = 'privacy_show_about';
|
||||
static const _showUsernameKey = 'privacy_show_username';
|
||||
static const _showLastOnlineKey = 'privacy_show_last_online';
|
||||
|
||||
bool _showEmail = true;
|
||||
bool _showPhone = true;
|
||||
bool _showAvatar = true;
|
||||
bool _showAbout = true;
|
||||
bool _showUsername = true;
|
||||
bool _showLastOnline = true;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
|
|
@ -38,6 +40,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
_showAvatar = prefs.getBool(_showAvatarKey) ?? true;
|
||||
_showAbout = prefs.getBool(_showAboutKey) ?? true;
|
||||
_showUsername = prefs.getBool(_showUsernameKey) ?? true;
|
||||
_showLastOnline = prefs.getBool(_showLastOnlineKey) ?? true;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -51,6 +54,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
_showAvatar = data['show_avatar'] ?? true;
|
||||
_showAbout = data['show_about'] ?? true;
|
||||
_showUsername = data['show_username'] ?? true;
|
||||
_showLastOnline = data['show_last_online'] ?? true;
|
||||
});
|
||||
// Сохраняем локально для быстрого доступа
|
||||
await _savePreference(_showEmailKey, _showEmail);
|
||||
|
|
@ -58,6 +62,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
await _savePreference(_showAvatarKey, _showAvatar);
|
||||
await _savePreference(_showAboutKey, _showAbout);
|
||||
await _savePreference(_showUsernameKey, _showUsername);
|
||||
await _savePreference(_showLastOnlineKey, _showLastOnline);
|
||||
} catch (e) {
|
||||
// Если не удалось загрузить с сервера, используем локальные настройки
|
||||
print('Ошибка загрузки настроек с сервера: $e');
|
||||
|
|
@ -82,6 +87,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
showAvatar: _showAvatar,
|
||||
showAbout: _showAbout,
|
||||
showUsername: _showUsername,
|
||||
showLastOnline: _showLastOnline,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
|
|
@ -91,6 +97,7 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
await _savePreference(_showAvatarKey, _showAvatar);
|
||||
await _savePreference(_showAboutKey, _showAbout);
|
||||
await _savePreference(_showUsernameKey, _showUsername);
|
||||
await _savePreference(_showLastOnlineKey, _showLastOnline);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
@ -178,6 +185,13 @@ class _PrivacySettingsScreenState extends State<PrivacySettingsScreen> {
|
|||
setState(() => _showAbout = value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Показывать последний онлайн'),
|
||||
value: _showLastOnline,
|
||||
onChanged: (value) {
|
||||
setState(() => _showLastOnline = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Эти настройки влияют на то, какую информацию о вас видят другие пользователи приложения.',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
class SecuritySettingsScreen extends StatefulWidget {
|
||||
const SecuritySettingsScreen({super.key});
|
||||
|
|
@ -13,7 +15,7 @@ class SecuritySettingsScreen extends StatefulWidget {
|
|||
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
||||
final _passwordFormKey = GlobalKey<FormState>();
|
||||
final _encryptionFormKey = GlobalKey<FormState>();
|
||||
final _totpFormKey = GlobalKey<FormState>();
|
||||
//final _totpFormKey = GlobalKey<FormState>();
|
||||
|
||||
final _currentPasswordController = TextEditingController();
|
||||
final _newPasswordController = TextEditingController();
|
||||
|
|
@ -28,11 +30,15 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
bool _isSavingPassword = false;
|
||||
bool _isSavingEncryption = false;
|
||||
bool _isSavingTotp = false;
|
||||
bool _isTotpEnabled = false;
|
||||
String? _totpSecret;
|
||||
String? _totpQrCode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkBiometricSupport();
|
||||
_loadTotpStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -53,7 +59,8 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
final availableBiometrics = await _localAuth.getAvailableBiometrics();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isBiometricAvailable = canCheckBiometrics && isSupported && availableBiometrics.isNotEmpty;
|
||||
_isBiometricAvailable =
|
||||
canCheckBiometrics && isSupported && availableBiometrics.isNotEmpty;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
|
|
@ -63,6 +70,24 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTotpStatus() async {
|
||||
try {
|
||||
final api = ApiService();
|
||||
final userData = await api.getMe();
|
||||
print('TOTP status from getMe: ${userData['totp_enabled']}');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isTotpEnabled = userData['totp_enabled'] ?? false;
|
||||
});
|
||||
print('TOTP status set to: $_isTotpEnabled');
|
||||
} catch (e) {
|
||||
print('Error loading TOTP status: $e');
|
||||
// Ignore errors, assume TOTP is disabled
|
||||
if (!mounted) return;
|
||||
setState(() => _isTotpEnabled = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _authenticateBiometric() async {
|
||||
try {
|
||||
return await _localAuth.authenticate(
|
||||
|
|
@ -96,9 +121,9 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Пароль успешно изменён')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Пароль успешно изменён')));
|
||||
_currentPasswordController.clear();
|
||||
_newPasswordController.clear();
|
||||
_confirmPasswordController.clear();
|
||||
|
|
@ -143,7 +168,8 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
} else {
|
||||
final api = ApiService();
|
||||
final userData = await api.getMe();
|
||||
final encryptedPrivateKey = userData['encrypted_private_key']?.toString();
|
||||
final encryptedPrivateKey = userData['encrypted_private_key']
|
||||
?.toString();
|
||||
|
||||
if (encryptedPrivateKey == null || encryptedPrivateKey.isEmpty) {
|
||||
throw Exception('Зашифрованный ключ не найден на сервере.');
|
||||
|
|
@ -156,12 +182,12 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
await cryptoService.savePrivateKey(privateKeyBase64);
|
||||
}
|
||||
|
||||
final updatedEncryptedPrivateKey = await cryptoService.encryptPrivateKeyWithPassword(
|
||||
privateKeyBase64,
|
||||
newPassword,
|
||||
);
|
||||
final updatedEncryptedPrivateKey = await cryptoService
|
||||
.encryptPrivateKeyWithPassword(privateKeyBase64, newPassword);
|
||||
|
||||
final success = await ApiService().updateEncryptedPrivateKey(updatedEncryptedPrivateKey);
|
||||
final success = await ApiService().updateEncryptedPrivateKey(
|
||||
updatedEncryptedPrivateKey,
|
||||
);
|
||||
if (!success) {
|
||||
throw Exception('Не удалось обновить пароль шифрования на сервере.');
|
||||
}
|
||||
|
|
@ -185,12 +211,221 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
}
|
||||
|
||||
Future<void> _setupTotp() async {
|
||||
if (_isTotpEnabled) {
|
||||
// Показываем диалог с опциями
|
||||
_showTotpOptionsDialog();
|
||||
} else {
|
||||
// Enable TOTP
|
||||
setState(() => _isSavingTotp = true);
|
||||
try {
|
||||
final api = ApiService();
|
||||
final data = await api.enableTotp();
|
||||
setState(() {
|
||||
_totpSecret = data['secret'];
|
||||
_totpQrCode = data['qr_code'];
|
||||
});
|
||||
// Show dialog to scan QR and enter code
|
||||
_showTotpSetupDialog();
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isSavingTotp = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showTotpOptionsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('TOTP'),
|
||||
content: const Text('TOTP включён. Выберите действие:'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_reissueTotp();
|
||||
},
|
||||
child: const Text('Перевыпустить ключ'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_disableTotp();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
child: const Text('Отключить TOTP'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _reissueTotp() async {
|
||||
setState(() => _isSavingTotp = true);
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (!mounted) return;
|
||||
setState(() => _isSavingTotp = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('TOTP пока не подключён на сервере')),
|
||||
try {
|
||||
final api = ApiService();
|
||||
final data = await api.enableTotp();
|
||||
setState(() {
|
||||
_totpSecret = data['secret'];
|
||||
_totpQrCode = data['qr_code'];
|
||||
});
|
||||
// Show dialog to scan QR and enter code
|
||||
_showTotpSetupDialog(isReissue: true);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isSavingTotp = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disableTotp() async {
|
||||
setState(() => _isSavingTotp = true);
|
||||
try {
|
||||
final api = ApiService();
|
||||
final success = await api.disableTotp();
|
||||
if (success) {
|
||||
setState(() {
|
||||
_isTotpEnabled = false;
|
||||
_totpSecret = null;
|
||||
_totpQrCode = null;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('TOTP отключён')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isSavingTotp = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showTotpSetupDialog({bool isReissue = false}) {
|
||||
final codeController = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(isReissue ? 'Перевыпуск ключа TOTP' : 'Настройка TOTP'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(isReissue
|
||||
? 'Отсканируйте новый QR-код в приложении аутентификатора:'
|
||||
: 'Отсканируйте QR-код в приложении аутентификатора:'),
|
||||
const SizedBox(height: 16),
|
||||
if (_totpQrCode != null)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final base64String = _totpQrCode!.split(',').last;
|
||||
final bytes = base64Decode(base64String);
|
||||
return Image.memory(bytes, width: 200, height: 200);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Ключ: ${_totpSecret ?? ''}',
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () {
|
||||
if (_totpSecret != null) {
|
||||
Clipboard.setData(ClipboardData(text: _totpSecret!));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ключ скопирован')),
|
||||
);
|
||||
}
|
||||
},
|
||||
tooltip: 'Скопировать ключ',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: codeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Введите код из приложения',
|
||||
helperText: 'Обычно это 6 цифр',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
setState(() {
|
||||
_totpSecret = null;
|
||||
_totpQrCode = null;
|
||||
});
|
||||
},
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final code = codeController.text.trim();
|
||||
if (code.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Введите код')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final api = ApiService();
|
||||
final success = await api.verifyTotp(code);
|
||||
if (success) {
|
||||
Navigator.of(context).pop();
|
||||
setState(() {
|
||||
_isTotpEnabled = true;
|
||||
_totpSecret = null;
|
||||
_totpQrCode = null;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(isReissue ? 'Ключ перевыпущен' : 'TOTP включён')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Неверный код')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Подтвердить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +445,10 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text('Смена пароля аккаунта', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const Text(
|
||||
'Смена пароля аккаунта',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Form(
|
||||
key: _passwordFormKey,
|
||||
|
|
@ -218,10 +456,13 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
children: [
|
||||
TextFormField(
|
||||
controller: _currentPasswordController,
|
||||
decoration: const InputDecoration(labelText: 'Текущий пароль'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Текущий пароль',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return 'Введите текущий пароль';
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Введите текущий пароль';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
|
@ -231,7 +472,8 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
decoration: const InputDecoration(labelText: 'Новый пароль'),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return 'Введите новый пароль';
|
||||
if (value == null || value.isEmpty)
|
||||
return 'Введите новый пароль';
|
||||
if (value.length < 6) return 'Пароль слишком короткий';
|
||||
return null;
|
||||
},
|
||||
|
|
@ -239,23 +481,31 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
decoration: const InputDecoration(labelText: 'Повторите пароль'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Повторите пароль',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value != _newPasswordController.text) return 'Пароли не совпадают';
|
||||
if (value != _newPasswordController.text)
|
||||
return 'Пароли не совпадают';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ElevatedButton(
|
||||
onPressed: _isSavingPassword ? null : _savePassword,
|
||||
child: _isSavingPassword ? const CircularProgressIndicator(color: Colors.white) : const Text('Сохранить пароль'),
|
||||
child: _isSavingPassword
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('Сохранить пароль'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('Пароль шифрования сообщений', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const Text(
|
||||
'Пароль шифрования сообщений',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Form(
|
||||
key: _encryptionFormKey,
|
||||
|
|
@ -275,39 +525,54 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
|
|||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _newEncryptPasswordController,
|
||||
decoration: const InputDecoration(labelText: 'Новый пароль шифрования'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Новый пароль шифрования',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value == null || value.length < 6) return 'Пароль слишком короткий';
|
||||
if (value == null || value.length < 6)
|
||||
return 'Пароль слишком короткий';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _confirmEncryptPasswordController,
|
||||
decoration: const InputDecoration(labelText: 'Повторите новый пароль'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Повторите новый пароль',
|
||||
),
|
||||
obscureText: true,
|
||||
validator: (value) {
|
||||
if (value != _newEncryptPasswordController.text) return 'Пароли не совпадают';
|
||||
if (value != _newEncryptPasswordController.text)
|
||||
return 'Пароли не совпадают';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ElevatedButton(
|
||||
onPressed: _isSavingEncryption ? null : _saveEncryptionPassword,
|
||||
child: _isSavingEncryption ? const CircularProgressIndicator(color: Colors.white) : const Text('Сохранить пароль шифрования'),
|
||||
onPressed: _isSavingEncryption
|
||||
? null
|
||||
: _saveEncryptionPassword,
|
||||
child: _isSavingEncryption
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('Сохранить пароль шифрования'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('TOTP', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const Text(
|
||||
'TOTP',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Настройка одноразового кода (TOTP) пока не подключена на сервере.'),
|
||||
Text(_isTotpEnabled ? 'TOTP включён' : 'TOTP отключён'),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _isSavingTotp ? null : _setupTotp,
|
||||
child: _isSavingTotp ? const CircularProgressIndicator(color: Colors.white) : const Text('Установить TOTP код'),
|
||||
child: _isSavingTotp
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: Text(_isTotpEnabled ? 'Отключить TOTP' : 'Включить TOTP'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import 'package:chepuhagram/presentation/screens/account_settings_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/login_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/privacy_settings_menu_screen.dart';
|
||||
import 'package:chepuhagram/presentation/screens/appearance_settings_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '/logic/auth_provider.dart';
|
||||
import '/core/theme_manager.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'dart:io';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
|
@ -16,6 +19,7 @@ class SettingsScreen extends StatefulWidget {
|
|||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
String? versionCode;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -32,9 +36,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _pickAvatar() async {
|
||||
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
|
||||
if (image != null) {
|
||||
final success = await context.read<AuthProvider>().updateAvatar(image.path);
|
||||
if (!success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ошибка загрузки аватарки')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeProv = context.watch<ThemeProvider>();
|
||||
final authProv = context.watch<AuthProvider>();
|
||||
|
||||
final accountEmail = authProv.email?.isNotEmpty == true
|
||||
|
|
@ -67,13 +82,51 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
accountEmail,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
),
|
||||
currentAccountPicture: CircleAvatar(
|
||||
child: Text(
|
||||
initials.isEmpty ? 'U' : initials,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
currentAccountPicture: GestureDetector(
|
||||
onTap: _pickAvatar,
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: Stack(
|
||||
children: [
|
||||
authProv.avatarUrl != null
|
||||
? CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundImage: NetworkImage(authProv.avatarUrl!),
|
||||
)
|
||||
: authProv.avatarPath != null
|
||||
? CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundImage: FileImage(File(authProv.avatarPath!)),
|
||||
)
|
||||
: CircleAvatar(
|
||||
radius: 40,
|
||||
child: Text(
|
||||
initials.isEmpty ? 'U' : initials,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.camera_alt,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -111,40 +164,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
},
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.dark_mode),
|
||||
title: const Text("Ночной режим"),
|
||||
value: themeProv.themeMode == ThemeMode.dark,
|
||||
onChanged: (val) => themeProv.toggleTheme(val),
|
||||
),
|
||||
|
||||
// Выбор цвета акцента
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.palette_outlined,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
const Text("Цвет темы"),
|
||||
Spacer(),
|
||||
_colorCircle(context, const Color(0xFF24A1DE), themeProv),
|
||||
_colorCircle(context, const Color(0xFF3E8E7E), themeProv),
|
||||
_colorCircle(context, const Color(0xFF8E3E7E), themeProv),
|
||||
_colorCircle(context, const Color(0xFFFF9800), themeProv),
|
||||
_colorCircle(context, const Color(0xFFF44336), themeProv),
|
||||
],
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette),
|
||||
title: const Text('Оформление'),
|
||||
subtitle: const Text('Тема, цвета, обои'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const AppearanceSettingsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
const Divider(),
|
||||
|
||||
|
|
@ -183,22 +217,4 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _colorCircle(BuildContext context, Color color, ThemeProvider prov) {
|
||||
bool isSelected = prov.accentColor == color;
|
||||
return GestureDetector(
|
||||
onTap: () => prov.updateAccentColor(color),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected ? color : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: CircleAvatar(backgroundColor: color, radius: 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import '../../logic/auth_provider.dart';
|
||||
import '../../logic/contact_provider.dart';
|
||||
import 'login_screen.dart';
|
||||
|
|
@ -18,11 +14,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||
import 'dart:convert';
|
||||
import 'package:chepuhagram/domain/services/crypto_service.dart';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:chepuhagram/data/repositories/contact_repository.dart ';
|
||||
import 'package:chepuhagram/data/models/contact_model.dart';
|
||||
import 'dart:isolate';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:convert/convert.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
|
@ -140,6 +132,7 @@ class _SplashScreenState extends State<SplashScreen> {
|
|||
final myPrivKeyBase64 = await context
|
||||
.read<CryptoService>()
|
||||
.getPrivateKey();
|
||||
|
||||
if (myPrivKeyBase64 != null) {
|
||||
final Map<int, String> keysToCompute = {};
|
||||
for (var c in contactProvider.contacts) {
|
||||
|
|
@ -150,9 +143,7 @@ class _SplashScreenState extends State<SplashScreen> {
|
|||
'$_contactPublicKey${c.id}',
|
||||
);
|
||||
if (savedKeyHex != null && savedPubKey == c.publicKey) {
|
||||
final bytes = base64Decode(
|
||||
savedKeyHex,
|
||||
);
|
||||
final bytes = base64Decode(savedKeyHex);
|
||||
contactProvider.setSharedKey(c.id, SecretKey(bytes));
|
||||
} else if (c.publicKey != null) {
|
||||
keysToCompute[c.id] = c.publicKey!;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:chepuhagram/domain/services/api_service.dart';
|
||||
import 'package:chepuhagram/data/datasources/ws_client.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '/core/constants.dart';
|
||||
|
||||
class UserProfileScreen extends StatefulWidget {
|
||||
final int userId;
|
||||
|
|
@ -19,19 +24,46 @@ class UserProfileScreen extends StatefulWidget {
|
|||
|
||||
class _UserProfileScreenState extends State<UserProfileScreen> {
|
||||
Map<String, dynamic>? _userData;
|
||||
StreamSubscription<dynamic>? _socketSubscription;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
Duration? offset;
|
||||
Timer? _onlineTimer;
|
||||
String? firstName;
|
||||
String? lastName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadUserData();
|
||||
startOnlineUpdates();
|
||||
|
||||
DateTime now = DateTime.now();
|
||||
|
||||
offset = now.timeZoneOffset;
|
||||
|
||||
final socketService = Provider.of<SocketService>(context, listen: false);
|
||||
_socketSubscription = socketService.messages.listen(_handleIncomingMessage);
|
||||
}
|
||||
|
||||
void startOnlineUpdates() {
|
||||
_onlineTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
_loadUserData();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadUserData() async {
|
||||
try {
|
||||
final api = ApiService();
|
||||
final data = await api.getUserById(widget.userId);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
firstName = prefs.containsKey('firstname_${widget.userId}')
|
||||
? prefs.getString('firstname_${widget.userId}')
|
||||
: null;
|
||||
lastName = prefs.containsKey('lastname_${widget.userId}')
|
||||
? prefs.getString('lastname_${widget.userId}')
|
||||
: null;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_userData = data;
|
||||
|
|
@ -48,37 +80,51 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_onlineTimer?.cancel();
|
||||
_socketSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Информация о пользователе'),
|
||||
),
|
||||
appBar: AppBar(title: const Text('Информация о пользователе')),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadUserData,
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadUserData,
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
)
|
||||
: _buildUserInfo(),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _buildUserInfo(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserInfo() {
|
||||
if (_userData == null) return const SizedBox.shrink();
|
||||
|
||||
final String displayFN = firstName ?? _userData?['first_name'] ?? '';
|
||||
final String displayLN = lastName ?? _userData?['last_name'] ?? '';
|
||||
final String username = _userData?['username'] ?? '';
|
||||
|
||||
final rawAvatarUrl = _userData?['avatar_url']?.toString();
|
||||
final avatarUrl = rawAvatarUrl != null && rawAvatarUrl.startsWith('/')
|
||||
? '${AppConstants.baseUrl}$rawAvatarUrl'
|
||||
: rawAvatarUrl;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
|
|
@ -87,38 +133,83 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
child: CircleAvatar(
|
||||
radius: 50,
|
||||
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
child: Text(
|
||||
(_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty &&
|
||||
_userData!['last_name'] != null && _userData!['last_name'].isNotEmpty)
|
||||
? '${_userData!['first_name'][0]}${_userData!['last_name'][0]}'.toUpperCase()
|
||||
: (_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty)
|
||||
? _userData!['first_name'][0].toUpperCase()
|
||||
: (_userData!['username'] != null && _userData!['username'].isNotEmpty)
|
||||
? _userData!['username'][0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
|
||||
),
|
||||
backgroundImage:
|
||||
(avatarUrl != null && _userData?['show_avatar'] == true)
|
||||
? NetworkImage(avatarUrl)
|
||||
: null,
|
||||
child: (avatarUrl == null || _userData?['show_avatar'] != true)
|
||||
? Text(
|
||||
(displayFN.isNotEmpty && displayLN.isNotEmpty)
|
||||
? '${displayFN[0]}${displayLN[0]}'.toUpperCase()
|
||||
: (displayFN.isNotEmpty)
|
||||
? displayFN[0].toUpperCase()
|
||||
: (username.isNotEmpty)
|
||||
? username[0].toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Name
|
||||
if ((_userData!['first_name'] != null && _userData!['first_name'].isNotEmpty) ||
|
||||
(_userData!['last_name'] != null && _userData!['last_name'].isNotEmpty))
|
||||
Text(
|
||||
'${_userData!['first_name'] ?? ''} ${_userData!['last_name'] ?? ''}'.trim(),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
GestureDetector(
|
||||
onTap: () => {_editUserName(displayFN, displayLN)},
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
if ((displayFN.isNotEmpty) || (displayLN.isNotEmpty))
|
||||
Text(
|
||||
'$displayFN $displayLN'.trim(),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurface),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Username
|
||||
if (_userData!['username'] != null && _userData!['username'].isNotEmpty)
|
||||
Text(
|
||||
'@${_userData!['username']}',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: Colors.grey[600]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Last online status
|
||||
if (_userData!['online'] == true)
|
||||
const Text(
|
||||
'Онлайн',
|
||||
style: TextStyle(fontSize: 12, color: Colors.greenAccent),
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
else if (DateTime.tryParse(_userData!['last_online']) != null)
|
||||
Text(
|
||||
'Был(а) в сети ${_formatLastOnline(DateTime.tryParse(_userData!['last_online'])!.add(offset != null ? offset! : Duration.zero))}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color.fromARGB(255, 161, 161, 161),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
else
|
||||
const Text(
|
||||
'Был(а) недавно',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color.fromARGB(255, 161, 161, 161),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
|
@ -128,7 +219,11 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
|
||||
// Public Key (if available)
|
||||
if (_userData!['public_key'] != null)
|
||||
_buildInfoTile('Публичный ключ', _userData!['public_key'], maxLines: 3),
|
||||
_buildInfoTile(
|
||||
'Публичный ключ',
|
||||
_userData!['public_key'],
|
||||
maxLines: 3,
|
||||
),
|
||||
|
||||
// About
|
||||
if (_userData!['about'] != null && _userData!['about'].isNotEmpty)
|
||||
|
|
@ -143,9 +238,12 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
_buildInfoTile('Почта', _userData!['email']),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
if ((_userData!['username'] == null || _userData!['username'].isEmpty) &&
|
||||
(_userData!['first_name'] == null || _userData!['first_name'].isEmpty) &&
|
||||
(_userData!['last_name'] == null || _userData!['last_name'].isEmpty) &&
|
||||
if ((_userData!['username'] == null ||
|
||||
_userData!['username'].isEmpty) &&
|
||||
(_userData!['first_name'] == null ||
|
||||
_userData!['first_name'].isEmpty) &&
|
||||
(_userData!['last_name'] == null ||
|
||||
_userData!['last_name'].isEmpty) &&
|
||||
(_userData!['about'] == null || _userData!['about'].isEmpty) &&
|
||||
(_userData!['phone'] == null || _userData!['phone'].isEmpty) &&
|
||||
(_userData!['email'] == null || _userData!['email'].isEmpty))
|
||||
|
|
@ -158,6 +256,131 @@ class _UserProfileScreenState extends State<UserProfileScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _editUserName(String firstname, String lastname) async {
|
||||
final firstnameController = TextEditingController(text: firstname);
|
||||
final lastnameController = TextEditingController(text: lastname);
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Изменить имя пользователя'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: firstnameController,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(hintText: 'Имя'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: lastnameController,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(hintText: 'Фамилия'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Сбрость'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (result == true) {
|
||||
if (firstname != firstnameController.text) {
|
||||
prefs.setString('firstname_${widget.userId}', firstnameController.text);
|
||||
}
|
||||
if (lastname != lastnameController.text) {
|
||||
prefs.setString('lastname_${widget.userId}', lastnameController.text);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
_loadUserData();
|
||||
} else {
|
||||
prefs.remove('firstname_${widget.userId}');
|
||||
prefs.remove('lastname_${widget.userId}');
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
_loadUserData();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleIncomingMessage(Map<String, dynamic> data) async {
|
||||
if (data['type'] == 'user_online') {
|
||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
||||
if (userId == widget.userId) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_userData = _userData?..['online'] = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data['type'] == 'user_offline') {
|
||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
||||
if (userId == widget.userId) {
|
||||
setState(() {
|
||||
_userData = _userData?..['online'] = false;
|
||||
_userData = _userData
|
||||
?..['last_online'] = DateTime.now().toIso8601String();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data['type'] == 'user_updated') {
|
||||
print('User updated message received, refreshing contact list');
|
||||
final userId = int.tryParse(data['user_id']?.toString() ?? '');
|
||||
if (userId != null && userId == widget.userId) {
|
||||
_loadUserData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatLastOnline(DateTime lastOnline) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(lastOnline);
|
||||
|
||||
if (difference.inSeconds < 60) {
|
||||
return 'только что';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes} минут${_pluralize(difference.inMinutes, "у", "ы", "")} назад';
|
||||
} else if (difference.inHours < 24) {
|
||||
return '${difference.inHours} час${_pluralize(difference.inHours, "", "а", "ов")} назад';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays} ${_pluralize(difference.inDays, "день", "дня", "дней")} назад';
|
||||
} else {
|
||||
return 'давно';
|
||||
}
|
||||
}
|
||||
|
||||
String _pluralize(int count, String form1, String form2, String form5) {
|
||||
final mod10 = count % 10;
|
||||
final mod100 = count % 100;
|
||||
if (mod10 == 1 && mod100 != 11) {
|
||||
return form1;
|
||||
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
|
||||
return form2;
|
||||
} else {
|
||||
return form5;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildInfoTile(String label, String value, {int maxLines = 1}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
|
|
|||
|
|
@ -1,16 +1,51 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '/data/models/contact_model.dart';
|
||||
|
||||
class ContactTile extends StatelessWidget {
|
||||
class ContactTile extends StatefulWidget {
|
||||
final Contact contact;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ContactTile({super.key, required this.contact, this.onTap});
|
||||
|
||||
@override
|
||||
State<ContactTile> createState() => _ContactTileState();
|
||||
}
|
||||
|
||||
class _ContactTileState extends State<ContactTile> {
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initPrefs();
|
||||
}
|
||||
|
||||
Future<void> _initPrefs() async {
|
||||
final shared = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_prefs = shared;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String get displayName {
|
||||
final full = '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}'.trim();
|
||||
if (_prefs == null) return widget.contact.name;
|
||||
|
||||
final id = widget.contact.id;
|
||||
final savedName = _prefs!.getString('firstname_$id');
|
||||
final savedSurname = _prefs!.getString('lastname_$id');
|
||||
|
||||
final name = savedName ?? widget.contact.name;
|
||||
final surname = savedSurname ?? widget.contact.surname;
|
||||
|
||||
final full =
|
||||
'${name != 'Unknown' ? name : ''} ${surname != 'Unknown' ? surname : ''}'
|
||||
.trim();
|
||||
|
||||
if (full.isNotEmpty) return full;
|
||||
if ((contact.username != 'Unknown' ? contact.username : '').isNotEmpty) return contact.username!;
|
||||
if (widget.contact.username != 'Unknown') return widget.contact.username;
|
||||
return 'User';
|
||||
}
|
||||
|
||||
|
|
@ -18,35 +53,47 @@ class ContactTile extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
|
||||
final username = contact.username;
|
||||
final initials = (displayName.isNotEmpty ? displayName : (username != 'Unknown' ? username : 'U'))
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((p) => p.isNotEmpty)
|
||||
.take(2)
|
||||
.map((p) => p[0].toUpperCase())
|
||||
.join();
|
||||
final username = widget.contact.username;
|
||||
final initials =
|
||||
(displayName.isNotEmpty
|
||||
? displayName
|
||||
: (username != 'Unknown' ? username : 'U'))
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((p) => p.isNotEmpty)
|
||||
.take(2)
|
||||
.map((p) => p[0].toUpperCase())
|
||||
.join();
|
||||
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
onTap: widget.onTap,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
leading: CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: primary.withAlpha((0.1 * 255).round()),
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
backgroundImage: widget.contact.effectiveAvatarUrl != null
|
||||
? NetworkImage(widget.contact.effectiveAvatarUrl!)
|
||||
: null,
|
||||
child: widget.contact.effectiveAvatarUrl == null
|
||||
? Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
contact.name,
|
||||
displayName,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
subtitle: Text(
|
||||
contact.isLastMsgDecrypted ? contact.lastMessage ?? "Нет сообщений" : (contact.lastMessage != null ? "Ожидание дешифровки..." : "Нет сообщений"),
|
||||
widget.contact.isLastMsgDecrypted
|
||||
? widget.contact.lastMessage ?? "Нет сообщений"
|
||||
: (widget.contact.lastMessage != null
|
||||
? "Ожидание дешифровки..."
|
||||
: "Нет сообщений"),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
|
|
@ -56,14 +103,11 @@ class ContactTile extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(contact.lastMessageTime),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
_formatTime(widget.contact.lastMessageTime),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (contact.unreadCount > 0)
|
||||
if (widget.contact.unreadCount > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -71,7 +115,7 @@ class ContactTile extends StatelessWidget {
|
|||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'${contact.unreadCount}',
|
||||
'${widget.contact.unreadCount}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ from app.api import schemas
|
|||
from app.db import models
|
||||
from jose import JWTError, jwt
|
||||
from app.core.security import get_current_user
|
||||
import pyotp
|
||||
import qrcode
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from fastapi.responses import StreamingResponse
|
||||
# бд
|
||||
|
||||
|
||||
|
|
@ -61,17 +66,26 @@ async def register(password: str):
|
|||
|
||||
|
||||
@authRouter.post("/login")
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = db.query(models.User).filter(
|
||||
models.User.username == form_data.username).first()
|
||||
async def login(data: schemas.LoginRequest, db: Session = Depends(get_db)):
|
||||
print(f"Login attempt: username={data.username}, totp_code provided={bool(data.totp_code)}")
|
||||
|
||||
if not user or not security.verify_password(form_data.password, user.hashed_password):
|
||||
user = db.query(models.User).filter(
|
||||
models.User.username == data.username).first()
|
||||
|
||||
if not user or not security.verify_password(data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверный логин или пароль",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if user.totp_secret:
|
||||
if not data.totp_code:
|
||||
raise HTTPException(status_code=400, detail="TOTP код требуется")
|
||||
totp = pyotp.TOTP(user.totp_secret)
|
||||
if not totp.verify(data.totp_code):
|
||||
raise HTTPException(status_code=400, detail="Неверный TOTP код")
|
||||
|
||||
access_token = security.create_access_token(data={"sub": str(user.id)})
|
||||
refresh_token = security.create_refresh_token(data={"sub": str(user.id)})
|
||||
return {
|
||||
|
|
@ -82,6 +96,73 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session =
|
|||
}
|
||||
|
||||
|
||||
@authRouter.post("/totp/enable")
|
||||
async def enable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
# Загружаем свежую копию user из БД
|
||||
user = db.query(models.User).filter(models.User.id == current_user.id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
||||
|
||||
#if user.totp_secret:
|
||||
#raise HTTPException(status_code=400, detail="TOTP уже включен")
|
||||
|
||||
secret = pyotp.random_base32()
|
||||
user.totp_temp_secret = secret
|
||||
db.commit()
|
||||
print(f"TOTP enabled for user {user.id}, secret saved")
|
||||
|
||||
# Генерировать QR
|
||||
totp = pyotp.TOTP(secret)
|
||||
uri = totp.provisioning_uri(name=user.username, issuer_name="Chepuhagram")
|
||||
img = qrcode.make(uri)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
qr_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||
qr_data_url = f"data:image/png;base64,{qr_base64}"
|
||||
|
||||
return {"secret": secret, "qr_code": qr_data_url}
|
||||
|
||||
|
||||
@authRouter.post("/totp/verify")
|
||||
async def verify_totp(data: schemas.TOTPVerifyRequest, current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
# Загружаем свежую копию user из БД
|
||||
user = db.query(models.User).filter(models.User.id == current_user.id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Пользователь не найден")
|
||||
|
||||
if not user.totp_temp_secret:
|
||||
raise HTTPException(status_code=400, detail="TOTP не включен")
|
||||
|
||||
try:
|
||||
totp = pyotp.TOTP(user.totp_temp_secret)
|
||||
code_str = str(data.code).strip()
|
||||
is_valid = totp.verify(code_str)
|
||||
print(f"TOTP verify: user_id={user.id}, code={code_str}, secret_set={bool(user.totp_temp_secret)}, valid={is_valid}")
|
||||
|
||||
if is_valid:
|
||||
user.totp_secret = user.totp_temp_secret
|
||||
user.totp_temp_secret = None
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Неверный код")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"TOTP verify error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка верификации: {str(e)}")
|
||||
|
||||
|
||||
@authRouter.post("/totp/disable")
|
||||
async def disable_totp(current_user: models.User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
user = db.query(models.User).filter(models.User.id == current_user.id).first()
|
||||
if user:
|
||||
user.totp_secret = None
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@authRouter.post("/refresh")
|
||||
async def refresh_token(data: schemas.RefreshRequest):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from app.api import schemas
|
|||
from app.db import models
|
||||
from jose import JWTError, jwt
|
||||
from app.core.security import get_current_user
|
||||
from fastapi.responses import FileResponse
|
||||
import os
|
||||
import uuid
|
||||
# бд
|
||||
|
|
@ -51,3 +52,14 @@ async def upload_file(file: UploadFile = File(...)):
|
|||
"status": "ok",
|
||||
"file_id": file_id
|
||||
}
|
||||
|
||||
|
||||
@mediaRouter.get('/{file_id}')
|
||||
async def get_file(file_id: str):
|
||||
filename = f"{file_id}.enc"
|
||||
file_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
return FileResponse(file_path, media_type="application/octet-stream")
|
||||
|
|
@ -34,3 +34,18 @@ async def get_chat_history(
|
|||
|
||||
return jsonable_encoder(messages)
|
||||
|
||||
|
||||
@messagesRouter.delete("/all")
|
||||
async def delete_all_messages(
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Удалить все сообщения пользователя"""
|
||||
# Удаляем все сообщения, где пользователь либо отправитель, либо получатель
|
||||
db.query(models.Message).filter(
|
||||
(models.Message.sender_id == current_user.id) | (models.Message.receiver_id == current_user.id)
|
||||
).delete()
|
||||
db.commit()
|
||||
|
||||
return {"status": "ok", "detail": "Все сообщения удалены"}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
|
||||
from fastapi import Depends, APIRouter, HTTPException, Depends
|
||||
from fastapi import Depends, APIRouter, HTTPException, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db import models
|
||||
from app.core.security import get_current_user
|
||||
|
|
@ -9,6 +9,8 @@ from sqlalchemy.exc import IntegrityError
|
|||
from app.websocket import connection_manager
|
||||
|
||||
# бд
|
||||
|
||||
|
||||
def get_db():
|
||||
db = models.SessionLocal()
|
||||
try:
|
||||
|
|
@ -37,6 +39,8 @@ async def read_users_me(current_user: models.User = Depends(get_current_user)):
|
|||
"about": current_user.about,
|
||||
"public_key": current_user.public_key,
|
||||
"encrypted_private_key": current_user.encrypted_private_key,
|
||||
"avatar_file_id": current_user.avatar_file_id,
|
||||
"totp_enabled": bool(current_user.totp_secret != None),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -69,6 +73,7 @@ async def update_users_me(
|
|||
status_code=400, detail="phone/email already in use")
|
||||
|
||||
db.refresh(user_to_update)
|
||||
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
|
||||
return {
|
||||
"status": "ok",
|
||||
"user": {
|
||||
|
|
@ -100,6 +105,7 @@ async def update_encrypted_private_key(
|
|||
status_code=500, detail="Не удалось сохранить ключ шифрования")
|
||||
|
||||
db.refresh(user_to_update)
|
||||
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
|
@ -156,6 +162,7 @@ async def update_privacy_settings(
|
|||
status_code=500, detail="Не удалось сохранить настройки конфиденциальности")
|
||||
|
||||
db.refresh(user_to_update)
|
||||
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
|
@ -182,6 +189,7 @@ async def read_users_all(current_user: models.User = Depends(get_current_user),
|
|||
|
||||
@usersRouter.get("/chats")
|
||||
async def read_users_chats(
|
||||
request: Request,
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
|
|
@ -192,7 +200,6 @@ async def read_users_chats(
|
|||
Клиент должен расшифровать превью локально.
|
||||
"""
|
||||
|
||||
|
||||
users = (
|
||||
db.query(models.User)
|
||||
.filter(models.User.id != current_user.id)
|
||||
|
|
@ -243,6 +250,8 @@ async def read_users_chats(
|
|||
"username": user.username,
|
||||
"name": f"{user.first_name} {user.last_name or ''}".strip(),
|
||||
"public_key": user.public_key,
|
||||
"avatar_file_id": user.avatar_file_id,
|
||||
"avatar_url": str(request.url_for("get_file", file_id=user.avatar_file_id)) if user.show_avatar and user.avatar_file_id else None,
|
||||
"last_message": last_msg.content if last_msg else None,
|
||||
"last_message_time": (last_msg.timestamp.isoformat() if last_msg and last_msg.timestamp else None),
|
||||
"unread_count": unread_count,
|
||||
|
|
@ -256,6 +265,7 @@ async def read_users_chats(
|
|||
@usersRouter.get("/{user_id}", response_model=schemas.UserProfile)
|
||||
def get_user_by_id(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user)
|
||||
):
|
||||
|
|
@ -273,14 +283,20 @@ def get_user_by_id(
|
|||
"public_key": user.public_key,
|
||||
}
|
||||
|
||||
profile_data["first_name"] = user.first_name
|
||||
profile_data["last_name"] = user.last_name
|
||||
|
||||
# Проверяем настройки конфиденциальности
|
||||
if user.show_username:
|
||||
profile_data["username"] = user.username
|
||||
|
||||
if user.show_avatar:
|
||||
# Для аватара пока просто передаем имя, клиент сам сгенерирует аватар
|
||||
profile_data["first_name"] = user.first_name
|
||||
profile_data["last_name"] = user.last_name
|
||||
profile_data["avatar_url"] = str(request.url_for(
|
||||
"get_file", file_id=user.avatar_file_id)) if user.avatar_file_id else None
|
||||
|
||||
profile_data["show_avatar"] = bool(user.show_avatar)
|
||||
|
||||
profile_data["totp_enabled"] = bool(user.totp_secret)
|
||||
|
||||
if user.show_about:
|
||||
profile_data["about"] = user.about
|
||||
|
|
@ -291,11 +307,32 @@ def get_user_by_id(
|
|||
if user.show_email:
|
||||
profile_data["email"] = user.email
|
||||
|
||||
if user.id in connection_manager.active_connections:
|
||||
if str(user.id) in connection_manager.manager.active_connections:
|
||||
profile_data["online"] = True
|
||||
else:
|
||||
profile_data["online"] = False
|
||||
if user.show_last_online:
|
||||
profile_data["last_online"] = user.last_online.isoformat() if user.last_online else None
|
||||
if user.show_last_online:
|
||||
profile_data["last_online"] = user.last_online.isoformat(
|
||||
) if user.last_online else None
|
||||
|
||||
return profile_data
|
||||
|
||||
|
||||
@usersRouter.put("/me/avatar")
|
||||
async def update_user_avatar(
|
||||
data: dict,
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
user_to_update = db.merge(current_user)
|
||||
avatar_file_id = data.get("avatar_file_id")
|
||||
if avatar_file_id:
|
||||
user_to_update.avatar_file_id = avatar_file_id
|
||||
db.commit()
|
||||
print(
|
||||
f"Пользователь {user_to_update.id} обновил аватар: {avatar_file_id}")
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="avatar_file_id is required")
|
||||
await connection_manager.manager.broadcast({'type': 'user_updated', 'user_id': current_user.id})
|
||||
return {"message": "Avatar updated"}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ class SetPublicKey(BaseModel):
|
|||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
totp_code: Optional[str] = None
|
||||
|
||||
class SetupAccount(BaseModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
|
|
@ -45,6 +50,10 @@ class UpdatePrivacySettings(BaseModel):
|
|||
show_avatar: Optional[bool] = None
|
||||
show_about: Optional[bool] = None
|
||||
show_username: Optional[bool] = None
|
||||
show_last_online: Optional[bool] = None
|
||||
|
||||
class TOTPVerifyRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
id: int
|
||||
|
|
@ -54,7 +63,12 @@ class UserProfile(BaseModel):
|
|||
about: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
public_key: Optional[str] = None
|
||||
online: bool = False
|
||||
last_online: Optional[str] = None
|
||||
show_avatar: bool = False
|
||||
totp_enabled: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
|
|||
|
|
@ -21,10 +21,12 @@ class User(Base):
|
|||
phone = Column(String(20), unique=True, nullable=True)
|
||||
email = Column(String(255), unique=True, nullable=True)
|
||||
totp_secret = Column(String(32), nullable=True)
|
||||
totp_temp_secret = Column(String(32), nullable=True) # Temporary secret until verified
|
||||
hashed_password = Column(String)
|
||||
public_key = Column(String, nullable=True)
|
||||
encrypted_private_key = Column(String, nullable=True)
|
||||
fcm_token = Column(String, nullable=True)
|
||||
avatar_file_id = Column(String, nullable=True)
|
||||
|
||||
# Privacy settings
|
||||
show_email = Column(Integer, nullable=False, server_default="1") # 1 = true, 0 = false
|
||||
|
|
@ -100,6 +102,10 @@ def _ensure_sqlite_user_columns():
|
|||
if "last_online" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN last_online DATETIME"))
|
||||
conn.execute(text("UPDATE users SET last_online = datetime('now')"))
|
||||
if "avatar_file_id" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN avatar_file_id VARCHAR(255)"))
|
||||
if "totp_temp_secret" not in existing:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN totp_temp_secret VARCHAR(32)"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
db.query(models.User).filter(models.User.id == user_id).update({"last_online": datetime.now(timezone.utc)},
|
||||
synchronize_session="fetch")
|
||||
db.commit()
|
||||
await manager.broadcast({
|
||||
"type": "user_online",
|
||||
"user_id": user_id,
|
||||
})
|
||||
try:
|
||||
while True:
|
||||
print("ОЖИДАНИЕ СООБЩЕНИЙ")
|
||||
|
|
@ -246,15 +250,44 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), db:
|
|||
"message_id": message_id,
|
||||
"timestamp": read_at.isoformat() if 'read_at' in locals() else datetime.now().isoformat(),
|
||||
}, str(sender_id))
|
||||
elif message_data.get("type") == "typing":
|
||||
receiver_id = message_data.get("receiver_id")
|
||||
if receiver_id is None:
|
||||
continue
|
||||
try:
|
||||
receiver_id = int(receiver_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
await manager.send_personal_message({
|
||||
"type": "typing",
|
||||
"sender_id": user_id,
|
||||
}, str(receiver_id))
|
||||
elif message_data.get("type") == "stop_typing":
|
||||
receiver_id = message_data.get("receiver_id")
|
||||
if receiver_id is None:
|
||||
continue
|
||||
try:
|
||||
receiver_id = int(receiver_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
await manager.send_personal_message({
|
||||
"type": "stop_typing",
|
||||
"sender_id": user_id,
|
||||
}, str(receiver_id))
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.disconnect(user_id)
|
||||
db.query(models.User).filter(models.User.id == user_id).update(
|
||||
{"last_online": datetime.now()})
|
||||
{"last_online": datetime.now(timezone.utc)}, synchronize_session="fetch")
|
||||
db.commit()
|
||||
print("ОТКЛЮЧЕНИЕ")
|
||||
|
||||
await manager.broadcast({
|
||||
"type": "user_offline",
|
||||
"user_id": user_id,
|
||||
})
|
||||
|
||||
|
||||
def send_fcm_notification(token, user_id, username, public_key, encrypted_text, timestamp):
|
||||
print(
|
||||
|
|
|
|||
40
srv/main.py
40
srv/main.py
|
|
@ -4,13 +4,15 @@ from app.api.endpoints import users, auth, messages, media
|
|||
from app.websocket.connection_manager import wsRouter
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import os
|
||||
import asyncio
|
||||
from app.db import models
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.include_router(auth.authRouter)
|
||||
app.include_router(users.usersRouter)
|
||||
app.include_router(messages.messagesRouter)
|
||||
#app.include_router(media.mediaRouter)
|
||||
app.include_router(media.mediaRouter)
|
||||
app.include_router(wsRouter)
|
||||
|
||||
app.add_middleware(
|
||||
|
|
@ -47,11 +49,37 @@ async def head_image():
|
|||
if not os.path.exists(file_path):
|
||||
return {"error": "Файл не найден"}
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename="chepuhagram-release.apk",
|
||||
media_type="application/vnd.android.package-archive"
|
||||
)
|
||||
return FileResponse(path=file_path, filename="chepuhagram-release.apk",
|
||||
media_type="application/vnd.android.package-archive",)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
asyncio.create_task(cleanup_uploads())
|
||||
|
||||
|
||||
async def cleanup_uploads():
|
||||
while True:
|
||||
try:
|
||||
db = models.SessionLocal()
|
||||
# Получить все используемые file_id из avatar_file_id
|
||||
file_ids = db.query(models.User.avatar_file_id).filter(models.User.avatar_file_id.isnot(None)).all()
|
||||
used_files = set(f[0] for f in file_ids)
|
||||
db.close()
|
||||
|
||||
# Проверить файлы в uploads
|
||||
uploads_dir = 'uploads'
|
||||
if os.path.exists(uploads_dir):
|
||||
for filename in os.listdir(uploads_dir):
|
||||
if filename.endswith('.enc'):
|
||||
file_id = filename[:-4] # убрать .enc
|
||||
if file_id not in used_files:
|
||||
file_path = os.path.join(uploads_dir, filename)
|
||||
os.remove(file_path)
|
||||
print(f"Удален неиспользуемый файл: {file_path}")
|
||||
except Exception as e:
|
||||
print(f"Ошибка в cleanup: {e}")
|
||||
await asyncio.sleep(300) # каждые 5 минут
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
|
|
|||
|
|
@ -4,3 +4,5 @@ sqlalchemy
|
|||
passlib[bcrypt]
|
||||
python-jose[cryptography]
|
||||
python-multipart
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
Loading…
Reference in New Issue