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