Merge pull request #50 from KometTeam/feature/FullStack

Добавили просмотр аватарок и стабилизировать авторизацию/подключение
This commit is contained in:
klockky
2026-07-05 23:14:48 +03:00
committed by GitHub
20 changed files with 995 additions and 165 deletions
+162 -70
View File
@@ -70,10 +70,16 @@ class Api {
StreamSubscription<SocketState>? _socketStateSubscription; StreamSubscription<SocketState>? _socketStateSubscription;
Timer? _pingTimer; Timer? _pingTimer;
Timer? _reconnectTimer; Timer? _reconnectTimer;
Timer? _connectWatchdog;
int _connectGen = 0;
int _reconnectAttempts = 0; int _reconnectAttempts = 0;
bool _autoReconnect = false; bool _autoReconnect = false;
int _sessionEpoch = 0; int _sessionEpoch = 0;
static const Duration _connectWatchdogTimeout = Duration(seconds: 75);
static const Duration _shouldArmTimeout = Duration(seconds: 5);
static const Duration _endpointTimeout = Duration(seconds: 5);
int get sessionEpoch => _sessionEpoch; int get sessionEpoch => _sessionEpoch;
/// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля. /// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля.
@@ -83,87 +89,168 @@ class Api {
/// Подключается к серверу, шлёт хэндшейк, запускает пинг. /// Подключается к серверу, шлёт хэндшейк, запускает пинг.
Future<void> connect() async { Future<void> connect() async {
if (_sessionState != SessionState.disconnected) return; if (_sessionState != SessionState.disconnected) {
// Ставим автоматический реконнект и статус подключения logger.i('connect пропущен: состояние ${_sessionState.name}');
_autoReconnect = true;
_setSessionState(SessionState.connecting);
_dataSubscription = _connection.dataStream.listen(_onDataReceived);
_socketStateSubscription = _connection.stateStream.listen((socketState) {
if (socketState == SocketState.disconnected &&
_sessionState != SessionState.disconnected) {
_onDisconnected();
}
});
final bypassArmed = await VpnBypassService.instance.shouldArm();
if (!bypassArmed) _bypassActive = false;
final useBypass = _bypassActive && bypassArmed;
// Попытку через VPN ограничиваем по времени, чтобы быстро понять,
// что туннель не пропускает, и переключиться на обход.
final attemptTimeout = bypassArmed && !useBypass
? const Duration(seconds: 8)
: null;
try {
final endpoint = await ServerConfig.loadEndpoint();
await _connection.connect(
endpoint.host,
endpoint.port,
bypassVpn: useBypass,
timeout: attemptTimeout,
);
} catch (e) {
await _handleConnectFailure(
e,
phase: 'Не удалось подключиться',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'подключение не удалось',
);
return; return;
} }
_autoReconnect = true;
_setSessionState(SessionState.connected); final gen = ++_connectGen;
_reconnectAttempts = 0; _setSessionState(SessionState.connecting);
logger.i('connect: старт (поколение $gen)');
_armConnectWatchdog(gen);
try { try {
final response = await sendHandshake(); _dataSubscription = _connection.dataStream.listen(_onDataReceived);
if (response.isOk) { _socketStateSubscription = _connection.stateStream.listen((socketState) {
_callsSeed = response.payload['callsSeed'] as int?; if (socketState == SocketState.disconnected &&
_registrationCountries = _parseRegistrationCountries(response.payload); _sessionState != SessionState.disconnected) {
_sessionState = SessionState.online; _onDisconnected();
_sessionEpoch++;
_startPinging();
logger.i('Сессия онлайн, хэндшейк ок');
if (_onReconnectCallback != null) {
try {
await _onReconnectCallback!();
} catch (e) {
logger.w('Авто-логин при хэндшейке не удался: $e');
}
} }
if (_sessionState == SessionState.online) { });
_stateController.add(SessionState.online);
_handshakeSuccessController.add( bool bypassArmed;
response.payload['device_name'] as String? ?? 'Unknown', try {
bypassArmed = await VpnBypassService.instance.shouldArm().timeout(
_shouldArmTimeout,
);
} catch (e) {
logger.w('connect: shouldArm завис/упал ($e) — без обхода VPN');
bypassArmed = false;
}
if (gen != _connectGen) return;
if (!bypassArmed) _bypassActive = false;
final useBypass = _bypassActive && bypassArmed;
final attemptTimeout = bypassArmed && !useBypass
? const Duration(seconds: 8)
: null;
({String host, int port}) endpoint;
try {
endpoint = await ServerConfig.loadEndpoint().timeout(_endpointTimeout);
} catch (e) {
logger.w('connect: loadEndpoint завис/упал ($e) — дефолтный endpoint');
endpoint = (
host: ServerConfig.defaultHost,
port: ServerConfig.defaultPort,
);
}
if (gen != _connectGen) return;
logger.i(
'connect: endpoint ${endpoint.host}:${endpoint.port}, bypass=$useBypass',
);
try {
await _connection.connect(
endpoint.host,
endpoint.port,
bypassVpn: useBypass,
timeout: attemptTimeout,
);
} catch (e) {
if (gen != _connectGen) return;
await _handleConnectFailure(
e,
phase: 'Не удалось подключиться',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'подключение не удалось',
);
return;
}
if (gen != _connectGen) return;
_setSessionState(SessionState.connected);
_reconnectAttempts = 0;
try {
logger.i('connect: сокет готов, отправляю хэндшейк');
final response = await sendHandshake();
if (gen != _connectGen) return;
if (response.isOk) {
_callsSeed = response.payload['callsSeed'] as int?;
_registrationCountries = _parseRegistrationCountries(
response.payload,
);
_sessionState = SessionState.online;
_sessionEpoch++;
_cancelConnectWatchdog();
_startPinging();
logger.i('Сессия онлайн, хэндшейк ок');
if (_onReconnectCallback != null) {
try {
await _onReconnectCallback!();
} catch (e) {
logger.w('Авто-логин при хэндшейке не удался: $e');
}
}
if (_sessionState == SessionState.online) {
_stateController.add(SessionState.online);
_handshakeSuccessController.add(
response.payload['device_name'] as String? ?? 'Unknown',
);
}
} else {
logger.e('Хэндшейк отклонён: ${response.payload}');
await _handleConnectFailure(
StateError('хэндшейк отклонён сервером'),
phase: 'Хэндшейк отклонён',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'хэндшейк отклонён',
disconnectSocket: true,
); );
} }
} else { } catch (e) {
logger.e('Хэндшейк отклонён: ${response.payload}'); if (gen != _connectGen) return;
await _handleConnectFailure(
e,
phase: 'Ошибка хэндшейка',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'хэндшейк не прошёл',
disconnectSocket: true,
);
} }
} catch (e) { } catch (e, st) {
await _handleConnectFailure( logger.e('connect: непредвиденная ошибка: $e\n$st');
e, if (gen == _connectGen) await _resetStuckConnect(gen);
phase: 'Ошибка хэндшейка',
bypassArmed: bypassArmed,
useBypass: useBypass,
bypassWhy: 'хэндшейк не прошёл',
disconnectSocket: true,
);
} }
} }
void _armConnectWatchdog(int gen) {
_connectWatchdog?.cancel();
_connectWatchdog = Timer(_connectWatchdogTimeout, () {
if (gen != _connectGen) return;
if (_sessionState == SessionState.online ||
_sessionState == SessionState.disconnected) {
return;
}
logger.e(
'connect: watchdog ${_connectWatchdogTimeout.inSeconds}с — застряли в '
'${_sessionState.name}, принудительный сброс',
);
unawaited(_resetStuckConnect(gen));
});
}
void _cancelConnectWatchdog() {
_connectWatchdog?.cancel();
_connectWatchdog = null;
}
Future<void> _resetStuckConnect(int gen) async {
if (gen != _connectGen) return;
_connectGen++;
_cancelConnectWatchdog();
_cleanup();
try {
await _connection.disconnect();
} catch (_) {}
_setSessionState(SessionState.disconnected);
if (_autoReconnect) _scheduleReconnect();
}
Future<void> _handleConnectFailure( Future<void> _handleConnectFailure(
Object error, { Object error, {
required String phase, required String phase,
@@ -173,6 +260,7 @@ class Api {
bool disconnectSocket = false, bool disconnectSocket = false,
}) async { }) async {
logger.e('$phase: $error'); logger.e('$phase: $error');
_cancelConnectWatchdog();
if (_sessionState != SessionState.disconnected) { if (_sessionState != SessionState.disconnected) {
_cleanup(); _cleanup();
if (disconnectSocket) await _connection.disconnect(); if (disconnectSocket) await _connection.disconnect();
@@ -193,6 +281,7 @@ class Api {
Future<void> disconnect() async { Future<void> disconnect() async {
_autoReconnect = false; _autoReconnect = false;
_bypassActive = false; _bypassActive = false;
_connectGen++;
_reconnectTimer?.cancel(); _reconnectTimer?.cancel();
_cleanup(); _cleanup();
await _connection.disconnect(); await _connection.disconnect();
@@ -441,6 +530,7 @@ class Api {
} }
void _onDisconnected() { void _onDisconnected() {
_connectGen++;
_cleanup(); _cleanup();
_setSessionState(SessionState.disconnected); _setSessionState(SessionState.disconnected);
if (_autoReconnect) _scheduleReconnect(); if (_autoReconnect) _scheduleReconnect();
@@ -463,6 +553,7 @@ class Api {
} }
Future<void> _forceReconnect() async { Future<void> _forceReconnect() async {
_connectGen++;
_cleanup(); _cleanup();
await _connection.disconnect(); await _connection.disconnect();
_reconnectAttempts = 0; _reconnectAttempts = 0;
@@ -472,6 +563,7 @@ class Api {
} }
void _cleanup() { void _cleanup() {
_cancelConnectWatchdog();
_pingTimer?.cancel(); _pingTimer?.cancel();
_dataSubscription?.cancel(); _dataSubscription?.cancel();
_socketStateSubscription?.cancel(); _socketStateSubscription?.cancel();
+43 -17
View File
@@ -228,7 +228,9 @@ class AccountModule {
throw Exception('completeRegistration: отсутствует id аккаунта'); throw Exception('completeRegistration: отсутствует id аккаунта');
} }
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>()); final profile = ProfileData.fromServerProfile(
profileMap.cast<dynamic, dynamic>(),
);
await AppDatabase.saveProfile(profile, isActive: true); await AppDatabase.saveProfile(profile, isActive: true);
await TokenStorage.setActiveAccount(accountId); await TokenStorage.setActiveAccount(accountId);
await SpoofingService.commitPendingSpoof(accountId); await SpoofingService.commitPendingSpoof(accountId);
@@ -269,16 +271,15 @@ class AccountModule {
final dataMap = data.cast<dynamic, dynamic>(); final dataMap = data.cast<dynamic, dynamic>();
if (resolvedAccountId == null) { if (resolvedAccountId == null) {
final profileMap = dataMap['profile']; resolvedAccountId = extractAccountId(dataMap);
if (profileMap is Map) {
final contact = profileMap['contact'];
if (contact is Map) {
resolvedAccountId = contact['id'] as int?;
}
}
if (resolvedAccountId == null) { if (resolvedAccountId == null) {
logger.e(
'login: accountId не найден в ответе; '
'${describeResponseShape(dataMap)}',
);
throw Exception('login: не удалось определить accountId из ответа'); throw Exception('login: не удалось определить accountId из ответа');
} }
logger.i('login: accountId=$resolvedAccountId определён из ответа');
await TokenStorage.saveToken(authToken, resolvedAccountId); await TokenStorage.saveToken(authToken, resolvedAccountId);
await TokenStorage.setActiveAccount(resolvedAccountId); await TokenStorage.setActiveAccount(resolvedAccountId);
await SpoofingService.commitPendingSpoof(resolvedAccountId); await SpoofingService.commitPendingSpoof(resolvedAccountId);
@@ -447,8 +448,25 @@ class AccountModule {
throw Exception('checkPassword: отсутствует токен в ответе'); throw Exception('checkPassword: отсутствует токен в ответе');
} }
final accountId = extractAccountId(data);
if (accountId == null) {
throw Exception('checkPassword: отсутствует accountId в ответе');
}
final profileMap = data['profile'];
if (profileMap is Map) {
final profile = ProfileData.fromServerProfile(
profileMap.cast<dynamic, dynamic>(),
);
await AppDatabase.saveProfile(profile, isActive: true);
}
await TokenStorage.saveToken(loginToken, accountId);
await TokenStorage.setActiveAccount(accountId);
await SpoofingService.commitPendingSpoof(accountId);
logger.i('2FA пройдена, получен login-токен'); logger.i('2FA пройдена, получен login-токен');
return TwoFactorResult(loginToken: loginToken); return TwoFactorResult(loginToken: loginToken, accountId: accountId);
} }
Map<dynamic, dynamic> buildLoginPayload( Map<dynamic, dynamic> buildLoginPayload(
@@ -501,16 +519,24 @@ class AccountModule {
await TokenStorage.saveToken(updatedToken, accountId); await TokenStorage.saveToken(updatedToken, accountId);
} }
ProfileData profile;
final profileMap = data['profile']; final profileMap = data['profile'];
if (profileMap is! Map) { if (profileMap is Map) {
throw Exception('login: отсутствует profile в ответе'); final contact = profileMap['contact'];
if (contact is! Map) {
throw Exception('login: отсутствует profile.contact в ответе');
}
profile = ProfileData.fromServerProfile(
profileMap.cast<dynamic, dynamic>(),
);
await AppDatabase.saveProfile(profile, isActive: true);
} else {
final cachedProfile = await AppDatabase.loadProfile(accountId);
if (cachedProfile == null) {
throw Exception('login: отсутствует profile в ответе');
}
profile = cachedProfile;
} }
final contact = profileMap['contact'];
if (contact is! Map) {
throw Exception('login: отсутствует profile.contact в ответе');
}
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
await AppDatabase.saveProfile(profile, isActive: true);
await AppDatabase.setActiveAccount(profile.id); await AppDatabase.setActiveAccount(profile.id);
await _saveSyncState(data, serverTime, profile.id); await _saveSyncState(data, serverTime, profile.id);
@@ -2,6 +2,70 @@ import 'dart:convert';
import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database.dart';
int? _coerceAccountId(dynamic value) {
if (value is int) return value;
if (value is double) return value.toInt();
if (value is String) return int.tryParse(value.trim());
return null;
}
int? extractAccountId(dynamic response) {
if (response is! Map) return null;
final profile = response['profile'];
if (profile is Map) {
final contact = profile['contact'];
if (contact is Map) {
final cid =
_coerceAccountId(contact['id']) ??
_coerceAccountId(contact['contactId']) ??
_coerceAccountId(contact['accountId']);
if (cid != null) return cid;
}
final pid =
_coerceAccountId(profile['id']) ??
_coerceAccountId(profile['accountId']);
if (pid != null) return pid;
}
final contact = response['contact'];
if (contact is Map) {
final cid =
_coerceAccountId(contact['id']) ??
_coerceAccountId(contact['contactId']);
if (cid != null) return cid;
}
final account = response['account'];
if (account is Map) {
final aid =
_coerceAccountId(account['id']) ??
_coerceAccountId(account['accountId']);
if (aid != null) return aid;
}
return _coerceAccountId(response['accountId']) ??
_coerceAccountId(response['account_id']);
}
String describeResponseShape(dynamic response) {
if (response is! Map) return 'не-Map (${response.runtimeType})';
final sb = StringBuffer('keys=${response.keys.toList()}');
final profile = response['profile'];
if (profile is Map) {
sb.write(' profile.keys=${profile.keys.toList()}');
final contact = profile['contact'];
if (contact is Map) {
sb.write(' contact.keys=${contact.keys.toList()}');
} else {
sb.write(' profile.contact=${contact.runtimeType}');
}
} else {
sb.write(' profile=${profile.runtimeType}');
}
return sb.toString();
}
class PrivacyConfig { class PrivacyConfig {
final String searchByPhone; final String searchByPhone;
final String incomingCall; final String incomingCall;
@@ -295,13 +359,7 @@ class VerifyCodeResult {
String? get challengeHint => passwordChallenge?['hint'] as String?; String? get challengeHint => passwordChallenge?['hint'] as String?;
int? get accountId { int? get accountId => extractAccountId(payload);
final profileData = payload['profile'];
if (profileData is! Map) return null;
final contact = profileData['contact'];
if (contact is! Map) return null;
return contact['id'] as int?;
}
String? _nestedToken(String key) { String? _nestedToken(String key) {
final attrs = payload['tokenAttrs']; final attrs = payload['tokenAttrs'];
@@ -314,8 +372,9 @@ class VerifyCodeResult {
class TwoFactorResult { class TwoFactorResult {
final String loginToken; final String loginToken;
final int accountId;
const TwoFactorResult({required this.loginToken}); const TwoFactorResult({required this.loginToken, required this.accountId});
} }
class LoginSyncParams { class LoginSyncParams {
@@ -18,8 +18,8 @@ class ProfileModule extends AccountApiBase {
if (profile == null) throw Exception('No profile in response'); if (profile == null) throw Exception('No profile in response');
final contact = profile['contact'] as Map?; final contact = profile['contact'] as Map?;
if (contact == null) throw Exception('No contact in response'); if (contact == null) throw Exception('No contact in response');
final newProfile = ProfileData.fromServerMap( final newProfile = ProfileData.fromServerProfile(
contact.cast<dynamic, dynamic>(), profile.cast<dynamic, dynamic>(),
); );
await AppDatabase.saveProfile(newProfile, isActive: true); await AppDatabase.saveProfile(newProfile, isActive: true);
return newProfile; return newProfile;
@@ -88,7 +88,7 @@ class ProfileModule extends AccountApiBase {
final contact = profile['contact']; final contact = profile['contact'];
if (contact is! Map) return; if (contact is! Map) return;
completer.complete( completer.complete(
ProfileData.fromServerMap(contact.cast<dynamic, dynamic>()), ProfileData.fromServerProfile(profile.cast<dynamic, dynamic>()),
); );
}); });
final timer = Timer(const Duration(seconds: 15), () { final timer = Timer(const Duration(seconds: 15), () {
+29
View File
@@ -62,6 +62,15 @@ class PhoneLookupResult {
const PhoneLookupResult({required this.id, this.name, this.avatarUrl}); const PhoneLookupResult({required this.id, this.name, this.avatarUrl});
} }
class ContactPhotos {
final List<String> urls;
final int total;
const ContactPhotos({required this.urls, required this.total});
static const empty = ContactPhotos(urls: [], total: 0);
}
class ContactsModule { class ContactsModule {
static final ValueNotifier<int> revision = ValueNotifier<int>(0); static final ValueNotifier<int> revision = ValueNotifier<int>(0);
@@ -194,6 +203,26 @@ class ContactsModule {
} }
} }
static Future<ContactPhotos> fetchPhotos(
Api api,
int contactId, {
int from = 0,
int count = 25,
}) async {
final map = await api.sendRequestMap(Opcode.contactPhotos, {
'contactId': contactId,
'from': from,
'count': count,
});
if (map == null) return ContactPhotos.empty;
final rawUrls = map['urls'];
final urls = rawUrls is List
? rawUrls.whereType<String>().toList()
: <String>[];
final total = map['total'] is int ? map['total'] as int : urls.length;
return ContactPhotos(urls: urls, total: total);
}
static Future<List<CachedContact>> getContacts(int accountId) async { static Future<List<CachedContact>> getContacts(int accountId) async {
final rows = await AppDatabase.loadContacts(accountId); final rows = await AppDatabase.loadContacts(accountId);
return rows.map(CachedContact.fromDbRow).toList(); return rows.map(CachedContact.fromDbRow).toList();
+37 -8
View File
@@ -1,6 +1,9 @@
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../storage/token_storage.dart'; import '../storage/token_storage.dart';
import '../utils/logger.dart';
enum ProxyType { none, socks5, httpConnect } enum ProxyType { none, socks5, httpConnect }
@@ -35,26 +38,52 @@ abstract class ProxyConfig {
static const String _prefUsername = 'proxy_username'; static const String _prefUsername = 'proxy_username';
static const String _prefPassword = 'proxy_password'; static const String _prefPassword = 'proxy_password';
static const Duration _secureReadTimeout = Duration(seconds: 5);
static Future<String?> _readSecureSafe(String key) async {
try {
return await TokenStorage.readSecure(key).timeout(_secureReadTimeout);
} catch (e) {
logger.w('ProxyConfig: чтение secure "$key" не удалось/зависло: $e');
return null;
}
}
static Future<void> _migrateLegacySecure(String key, String value) async {
try {
await TokenStorage.writeSecure(key, value).timeout(_secureReadTimeout);
final prefs = await SharedPreferences.getInstance();
await prefs.remove(key);
} catch (e) {
logger.w('ProxyConfig: миграция legacy "$key" не удалась: $e');
}
}
static Future<ProxySettings> load() async { static Future<ProxySettings> load() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final typeIndex = prefs.getInt(_prefType) ?? 0; final typeIndex = prefs.getInt(_prefType) ?? 0;
final host = prefs.getString(_prefHost) ?? ''; final host = prefs.getString(_prefHost) ?? '';
final port = prefs.getInt(_prefPort) ?? 1080; final port = prefs.getInt(_prefPort) ?? 1080;
var username = await TokenStorage.readSecure(_prefUsername); if (ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)] ==
var password = await TokenStorage.readSecure(_prefPassword); ProxyType.none ||
host.isEmpty) {
return ProxySettings(
type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)],
host: host,
port: port,
);
}
var username = await _readSecureSafe(_prefUsername);
var password = await _readSecureSafe(_prefPassword);
final legacyUsername = prefs.getString(_prefUsername); final legacyUsername = prefs.getString(_prefUsername);
final legacyPassword = prefs.getString(_prefPassword); final legacyPassword = prefs.getString(_prefPassword);
if (username == null && legacyUsername != null) { if (username == null && legacyUsername != null) {
username = legacyUsername; username = legacyUsername;
await TokenStorage.writeSecure(_prefUsername, legacyUsername); unawaited(_migrateLegacySecure(_prefUsername, legacyUsername));
} }
if (password == null && legacyPassword != null) { if (password == null && legacyPassword != null) {
password = legacyPassword; password = legacyPassword;
await TokenStorage.writeSecure(_prefPassword, legacyPassword); unawaited(_migrateLegacySecure(_prefPassword, legacyPassword));
}
if (legacyUsername != null || legacyPassword != null) {
await prefs.remove(_prefUsername);
await prefs.remove(_prefPassword);
} }
return ProxySettings( return ProxySettings(
type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)], type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)],
+26 -8
View File
@@ -36,7 +36,21 @@ class ProfileData {
this.profileOptions, this.profileOptions,
}); });
factory ProfileData.fromServerMap(Map<dynamic, dynamic> contact) { factory ProfileData.fromServerProfile(Map<dynamic, dynamic> profile) {
final contact = profile['contact'];
if (contact is! Map) {
throw const FormatException('No contact in profile');
}
return ProfileData.fromServerMap(
contact.cast<dynamic, dynamic>(),
profileOptions: _parseProfileOptions(profile['profileOptions']),
);
}
factory ProfileData.fromServerMap(
Map<dynamic, dynamic> contact, {
List<int>? profileOptions,
}) {
final names = contact['names']; final names = contact['names'];
String firstName = ''; String firstName = '';
String? lastName; String? lastName;
@@ -52,12 +66,6 @@ class ProfileData {
lastName = name['lastName'] as String?; lastName = name['lastName'] as String?;
} }
final profileOptionsRaw = contact['profileOptions'];
List<int>? profileOptions;
if (profileOptionsRaw is List) {
profileOptions = profileOptionsRaw.map((e) => e as int).toList();
}
return ProfileData( return ProfileData(
id: contact['id'] as int, id: contact['id'] as int,
firstName: firstName, firstName: firstName,
@@ -69,10 +77,20 @@ class ProfileData {
country: (contact['country'] as String?) ?? '', country: (contact['country'] as String?) ?? '',
accountStatus: (contact['accountStatus'] as int?) ?? 0, accountStatus: (contact['accountStatus'] as int?) ?? 0,
updateTime: (contact['updateTime'] as int?) ?? 0, updateTime: (contact['updateTime'] as int?) ?? 0,
profileOptions: profileOptions, profileOptions:
profileOptions ?? _parseProfileOptions(contact['profileOptions']),
); );
} }
static List<int>? _parseProfileOptions(dynamic raw) {
if (raw is! List) return null;
final options = raw
.map((e) => e is int ? e : int.tryParse(e.toString()))
.whereType<int>()
.toList();
return options.isEmpty ? null : options;
}
factory ProfileData.fromDbRow(Map<String, dynamic> row) { factory ProfileData.fromDbRow(Map<String, dynamic> row) {
final profileOptionsStr = row['profile_options'] as String?; final profileOptionsStr = row['profile_options'] as String?;
List<int>? profileOptions; List<int>? profileOptions;
+38 -10
View File
@@ -15,6 +15,8 @@ enum SocketState { disconnected, connecting, connected }
/// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver]. /// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver].
class Connection { class Connection {
static const Duration _defaultConnectTimeout = Duration(seconds: 15); static const Duration _defaultConnectTimeout = Duration(seconds: 15);
static const Duration _proxyLoadTimeout = Duration(seconds: 8);
static const Duration _vpnCallTimeout = Duration(seconds: 5);
SecureSocket? _socket; SecureSocket? _socket;
StreamSubscription<Uint8List>? _subscription; StreamSubscription<Uint8List>? _subscription;
@@ -40,20 +42,41 @@ class Connection {
bool bypassVpn = false, bool bypassVpn = false,
Duration? timeout, Duration? timeout,
}) async { }) async {
if (_state != SocketState.disconnected) return; if (_state != SocketState.disconnected) {
logger.w('Connection.connect пропущен: state=$_state (уже $_state)');
return;
}
_setState(SocketState.connecting); _setState(SocketState.connecting);
try { try {
final proxySettings = await ProxyConfig.load(); logger.i('Connection: загрузка прокси-конфига');
ProxySettings proxySettings;
// Решение «обходить VPN или нет» принимает вызывающий (Api): try {
// первая попытка идёт через VPN, при её провале — мимо туннеля. proxySettings = await ProxyConfig.load().timeout(_proxyLoadTimeout);
if (bypassVpn) { } catch (e) {
await VpnBypassService.instance.bind(); logger.w('Connection: ProxyConfig.load завис/упал ($e) — без прокси');
} else { proxySettings = const ProxySettings();
await VpnBypassService.instance.restoreDefault();
} }
logger.i(
'Connection: VPN ${bypassVpn ? 'bind (обход)' : 'restoreDefault'}',
);
try {
if (bypassVpn) {
await VpnBypassService.instance.bind().timeout(_vpnCallTimeout);
} else {
await VpnBypassService.instance
.restoreDefault()
.timeout(_vpnCallTimeout);
}
} catch (e) {
logger.w('Connection: VPN-вызов завис/упал ($e) — продолжаю');
}
logger.i(
'Connection: открываю сокет $host:$port '
'(прокси: ${proxySettings.isEnabled ? proxySettings.type.name : 'нет'})',
);
final socket = await _openSecureSocket( final socket = await _openSecureSocket(
host, host,
port, port,
@@ -109,7 +132,9 @@ class Connection {
socket = await connector.connect(host, port).timeout(connectTimeout); socket = await connector.connect(host, port).timeout(connectTimeout);
logger.i('Подключено через прокси ${proxySettings.type.name}'); logger.i('Подключено через прокси ${proxySettings.type.name}');
} else { } else {
logger.i('Connection: TCP connect $host:$port (лимит ${connectTimeout.inSeconds}с)');
socket = await Socket.connect(host, port, timeout: connectTimeout); socket = await Socket.connect(host, port, timeout: connectTimeout);
logger.i('Connection: TCP установлен, начинаю TLS');
} }
final allowInsecure = await TlsConfig.isInsecureAllowed(); final allowInsecure = await TlsConfig.isInsecureAllowed();
if (allowInsecure) { if (allowInsecure) {
@@ -121,8 +146,11 @@ class Connection {
? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true) ? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true)
: SecureSocket.secure(socket, host: host); : SecureSocket.secure(socket, host: host);
try { try {
return await secured.timeout(connectTimeout); final result = await secured.timeout(connectTimeout);
logger.i('Connection: TLS-handshake завершён');
return result;
} on TimeoutException { } on TimeoutException {
logger.w('Connection: TLS-handshake таймаут ${connectTimeout.inSeconds}с');
socket.destroy(); socket.destroy();
rethrow; rethrow;
} }
+58 -12
View File
@@ -57,11 +57,15 @@ class _SessionData {
final DateTime startedAt; final DateTime startedAt;
final List<_LogEntry> entries; final List<_LogEntry> entries;
final bool truncated; final bool truncated;
final List<String> logLines;
final bool logsTruncated;
_SessionData({ _SessionData({
required this.startedAt, required this.startedAt,
required this.entries, required this.entries,
this.truncated = false, this.truncated = false,
this.logLines = const [],
this.logsTruncated = false,
}); });
static _SessionData fromJson(Map data) { static _SessionData fromJson(Map data) {
@@ -72,37 +76,43 @@ class _SessionData {
if (e is Map) entries.add(_LogEntry.fromJson(e)); if (e is Map) entries.add(_LogEntry.fromJson(e));
} }
} }
final rawLogs = data['logs'];
final logLines = <String>[];
if (rawLogs is List) {
for (final l in rawLogs) {
logLines.add(l.toString());
}
}
return _SessionData( return _SessionData(
startedAt: startedAt:
DateTime.tryParse(data['startedAt']?.toString() ?? '') ?? DateTime.tryParse(data['startedAt']?.toString() ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0), DateTime.fromMillisecondsSinceEpoch(0),
entries: entries, entries: entries,
truncated: data['truncated'] == true, truncated: data['truncated'] == true,
logLines: logLines,
logsTruncated: data['logsTruncated'] == true,
); );
} }
} }
/// Персистентный лог запросов для кнопки «Отладочный лог».
///
/// Хранит ВСЕ запросы (с payload) за последние [_maxSessions] заходов в
/// приложение: каждый запуск — отдельная сессия-файл, старые ротируются.
/// Пинги идут мимо [Api.sendRequest], поэтому в лог не попадают.
///
/// Payload сохраняется уже отредактированным — токен скрыт целиком, от номера
/// остаются первые 3 символа, остальное видно. Поэтому секреты не лежат на диске.
class DebugSessionLog { class DebugSessionLog {
DebugSessionLog._(); DebugSessionLog._();
static final DebugSessionLog instance = DebugSessionLog._(); static final DebugSessionLog instance = DebugSessionLog._();
static const int _maxSessions = 3; static const int _maxSessions = 3;
static const int _maxEntriesPerSession = 2000; static const int _maxEntriesPerSession = 2000;
static const int _maxLogLinesPerSession = 5000;
static const Duration _flushDebounce = Duration(seconds: 3); static const Duration _flushDebounce = Duration(seconds: 3);
static final RegExp _ansiEscape = RegExp(r'\x1B\[[0-9;]*m');
Directory? _dir; Directory? _dir;
File? _currentFile; File? _currentFile;
DateTime? _currentStart; DateTime? _currentStart;
final List<_LogEntry> _entries = []; final List<_LogEntry> _entries = [];
final List<String> _logLines = [];
bool _truncated = false; bool _truncated = false;
bool _logsTruncated = false;
bool _initialized = false; bool _initialized = false;
bool _dirty = false; bool _dirty = false;
Timer? _flushTimer; Timer? _flushTimer;
@@ -120,12 +130,23 @@ class DebugSessionLog {
_currentFile = File( _currentFile = File(
'${dir.path}/session_${_currentStart!.millisecondsSinceEpoch}.json', '${dir.path}/session_${_currentStart!.millisecondsSinceEpoch}.json',
); );
if (_dirty) _scheduleFlush();
} catch (_) { } catch (_) {
_dir = null; _dir = null;
_currentFile = null; _currentFile = null;
} }
} }
void recordLogLine(String line) {
final clean = line.replaceAll(_ansiEscape, '');
_logLines.add(clean);
if (_logLines.length > _maxLogLinesPerSession) {
_logLines.removeRange(0, _logLines.length - _maxLogLinesPerSession);
_logsTruncated = true;
}
_scheduleFlush();
}
void recordRequest(int opcode, int seq, dynamic payload) { void recordRequest(int opcode, int seq, dynamic payload) {
_entries.add( _entries.add(
_LogEntry( _LogEntry(
@@ -189,6 +210,8 @@ class DebugSessionLog {
'startedAt': _currentStart?.toIso8601String(), 'startedAt': _currentStart?.toIso8601String(),
'truncated': _truncated, 'truncated': _truncated,
'entries': _entries.map((e) => e.toJson()).toList(), 'entries': _entries.map((e) => e.toJson()).toList(),
'logsTruncated': _logsTruncated,
'logs': List.of(_logLines),
}); });
await file.writeAsString(data); await file.writeAsString(data);
} catch (_) {} } catch (_) {}
@@ -249,6 +272,8 @@ class DebugSessionLog {
startedAt: _currentStart ?? DateTime.now(), startedAt: _currentStart ?? DateTime.now(),
entries: List.of(_entries), entries: List.of(_entries),
truncated: _truncated, truncated: _truncated,
logLines: List.of(_logLines),
logsTruncated: _logsTruncated,
), ),
); );
sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt)); sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt));
@@ -256,7 +281,8 @@ class DebugSessionLog {
? sessions.sublist(sessions.length - _maxSessions) ? sessions.sublist(sessions.length - _maxSessions)
: sessions; : sessions;
final totalEntries = lastN.fold<int>(0, (sum, s) => sum + s.entries.length); final totalEntries = lastN.fold<int>(0, (sum, s) => sum + s.entries.length);
if (totalEntries == 0) return null; final totalLogs = lastN.fold<int>(0, (sum, s) => sum + s.logLines.length);
if (totalEntries == 0 && totalLogs == 0) return null;
final buffer = StringBuffer(); final buffer = StringBuffer();
buffer.writeln('Komet — отладочный лог'); buffer.writeln('Komet — отладочный лог');
@@ -264,6 +290,7 @@ class DebugSessionLog {
buffer.writeln('Экспортирован: ${DateTime.now().toIso8601String()}'); buffer.writeln('Экспортирован: ${DateTime.now().toIso8601String()}');
buffer.writeln('Заходов в приложение: ${lastN.length}'); buffer.writeln('Заходов в приложение: ${lastN.length}');
buffer.writeln('Всего запросов: $totalEntries'); buffer.writeln('Всего запросов: $totalEntries');
buffer.writeln('Всего строк лога: $totalLogs');
buffer.writeln('Скрыто: токен полностью, номер кроме первых 3 символов'); buffer.writeln('Скрыто: токен полностью, номер кроме первых 3 символов');
buffer.writeln(); buffer.writeln();
@@ -275,12 +302,31 @@ class DebugSessionLog {
); );
buffer.writeln( buffer.writeln(
'запросов: ${session.entries.length}' 'запросов: ${session.entries.length}'
'${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}', '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}'
' · строк лога: ${session.logLines.length}'
'${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}',
); );
buffer.writeln('=================================================='); buffer.writeln('==================================================');
buffer.writeln(); buffer.writeln();
for (var i = 0; i < session.entries.length; i++) {
_writeEntry(buffer, i + 1, session.entries[i]); buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----');
if (session.logLines.isEmpty) {
buffer.writeln('(пусто)');
} else {
for (final line in session.logLines) {
buffer.writeln(line);
}
}
buffer.writeln();
buffer.writeln('----- ЗАПРОСЫ -----');
if (session.entries.isEmpty) {
buffer.writeln('(пусто)');
buffer.writeln();
} else {
for (var i = 0; i < session.entries.length; i++) {
_writeEntry(buffer, i + 1, session.entries[i]);
}
} }
} }
return buffer.toString(); return buffer.toString();
+12 -1
View File
@@ -3,6 +3,8 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'debug_session_log.dart';
Level _minimumLogLevel() { Level _minimumLogLevel() {
const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: ''); const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: '');
switch (raw.toLowerCase()) { switch (raw.toLowerCase()) {
@@ -41,9 +43,18 @@ final logger = Logger(
filter: _logFilter(), filter: _logFilter(),
level: _minimumLogLevel(), level: _minimumLogLevel(),
printer: KometLogPrinter(), printer: KometLogPrinter(),
output: ConsoleOutput(), output: MultiOutput([ConsoleOutput(), DebugSessionLogOutput()]),
); );
class DebugSessionLogOutput extends LogOutput {
@override
void output(OutputEvent event) {
for (final line in event.lines) {
DebugSessionLog.instance.recordLogLine(line);
}
}
}
int _importanceSortKey(Level level) { int _importanceSortKey(Level level) {
final v = level.value; final v = level.value;
if (v >= 5999) { if (v >= 5999) {
+60
View File
@@ -0,0 +1,60 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
import 'media_cache.dart';
class MediaSaveResult {
final bool ok;
final bool toGallery;
final String? location;
final String? error;
const MediaSaveResult({
required this.ok,
this.toGallery = false,
this.location,
this.error,
});
}
Future<MediaSaveResult> saveImageFromUrl(String url) async {
if (url.isEmpty) {
return const MediaSaveResult(ok: false, error: 'нет ссылки');
}
try {
final cacheName = 'avatar_${url.hashCode & 0x7fffffff}.jpg';
final file = await MediaCache.getOrDownload(cacheName, url);
if (file == null) {
return const MediaSaveResult(ok: false, error: 'не удалось загрузить');
}
final saveName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg';
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
final state = await PhotoManager.requestPermissionExtend();
if (!state.isAuth && !state.hasAccess) {
return const MediaSaveResult(ok: false, error: 'нет доступа к галерее');
}
final bytes = await file.readAsBytes();
await PhotoManager.editor.saveImage(bytes, filename: saveName);
return const MediaSaveResult(ok: true, toGallery: true);
}
final dir = await _targetDirectory();
final target = File('${dir.path}${Platform.pathSeparator}$saveName');
await file.copy(target.path);
return MediaSaveResult(ok: true, location: target.path);
} catch (e) {
return MediaSaveResult(ok: false, error: e.toString());
}
}
Future<Directory> _targetDirectory() async {
try {
final downloads = await getDownloadsDirectory();
if (downloads != null) return downloads;
} catch (_) {}
return getApplicationDocumentsDirectory();
}
@@ -49,7 +49,7 @@ class DebugQuickActionsSection extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'Все запросы за последние 3 захода в приложение', 'Все логи и запросы за последние 3 захода в приложение',
style: TextStyle( style: TextStyle(
color: cs.onSurfaceVariant, color: cs.onSurfaceVariant,
fontSize: 13, fontSize: 13,
@@ -67,7 +67,10 @@ class _Password2FAScreenState extends State<Password2FAScreen>
if (!mounted) return; if (!mounted) return;
final loginResult = await accountModule.login(token: result.loginToken); final loginResult = await accountModule.login(
accountId: result.accountId,
token: result.loginToken,
);
if (!mounted) return; if (!mounted) return;
@@ -10,6 +10,7 @@ import '../../../core/utils/format.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../models/chat_info.dart'; import '../../../models/chat_info.dart';
import '../../../models/contact_info.dart'; import '../../../models/contact_info.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/connection_status.dart'; import '../../widgets/connection_status.dart';
import '../../widgets/glossy_pill.dart'; import '../../widgets/glossy_pill.dart';
import '../../widgets/komet_avatar.dart'; import '../../widgets/komet_avatar.dart';
@@ -1130,12 +1131,23 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
} }
Widget _avatar() { Widget _avatar() {
return KometAvatar( final avatar = KometAvatar(
name: widget.name, name: widget.name,
imageUrl: widget.imageUrl, imageUrl: widget.imageUrl,
size: 96, size: 96,
fontSize: 36, fontSize: 36,
); );
final peerId = widget.chatType == 'DIALOG' ? _otherId : null;
if (peerId == null || widget.imageUrl.isEmpty) return avatar;
return GestureDetector(
onTap: () => AvatarHistoryScreen.open(
context,
contactId: peerId,
name: widget.name,
currentAvatarUrl: widget.imageUrl,
),
child: avatar,
);
} }
Widget _buildShimmer(ColorScheme cs) { Widget _buildShimmer(ColorScheme cs) {
@@ -7,6 +7,7 @@ import '../../../core/storage/token_storage.dart';
import '../../../core/utils/format.dart'; import '../../../core/utils/format.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../models/contact_info.dart'; import '../../../models/contact_info.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/glossy_pill.dart'; import '../../widgets/glossy_pill.dart';
import '../../widgets/komet_avatar.dart'; import '../../widgets/komet_avatar.dart';
@@ -146,11 +147,19 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( child: Column(
children: [ children: [
KometAvatar( GestureDetector(
name: _displayName(), onTap: () => AvatarHistoryScreen.open(
imageUrl: _avatarUrl(), context,
size: 96, contactId: widget.contactId,
fontSize: 36, name: _displayName(),
currentAvatarUrl: _avatarUrl(),
),
child: KometAvatar(
name: _displayName(),
imageUrl: _avatarUrl(),
size: 96,
fontSize: 36,
),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
_buildNameRow(cs), _buildNameRow(cs),
@@ -206,7 +215,11 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
label: l10n.contactProfileActionChat, label: l10n.contactProfileActionChat,
onTap: _openChat, onTap: _openChat,
), ),
(icon: Symbols.notifications, label: l10n.contactProfileActionSound, onTap: null), (
icon: Symbols.notifications,
label: l10n.contactProfileActionSound,
onTap: null,
),
if (!_isBot) if (!_isBot)
(icon: Symbols.call, label: l10n.contactProfileActionCall, onTap: null), (icon: Symbols.call, label: l10n.contactProfileActionCall, onTap: null),
]; ];
@@ -250,17 +263,23 @@ class _ContactProfileScreenState extends State<ContactProfileScreen> {
final phoneStr = formatPhone(c.raw['phone']); final phoneStr = formatPhone(c.raw['phone']);
if (phoneStr != null) { if (phoneStr != null) {
rows.add(_infoRow(cs, Symbols.phone, l10n.contactProfileInfoPhone, phoneStr)); rows.add(
_infoRow(cs, Symbols.phone, l10n.contactProfileInfoPhone, phoneStr),
);
} }
final country = c.raw['country'] as String?; final country = c.raw['country'] as String?;
if (country != null && country.isNotEmpty) { if (country != null && country.isNotEmpty) {
rows.add(_infoRow(cs, Symbols.public, l10n.contactProfileInfoCountry, country)); rows.add(
_infoRow(cs, Symbols.public, l10n.contactProfileInfoCountry, country),
);
} }
final genderStr = formatGender(c.raw['gender']); final genderStr = formatGender(c.raw['gender']);
if (genderStr != null) { if (genderStr != null) {
rows.add(_infoRow(cs, Symbols.wc, l10n.contactProfileInfoGender, genderStr)); rows.add(
_infoRow(cs, Symbols.wc, l10n.contactProfileInfoGender, genderStr),
);
} }
final regTime = c.raw['registrationTime'] as int?; final regTime = c.raw['registrationTime'] as int?;
@@ -73,7 +73,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
endpoint: TrafficMonitor.instance.activeEndpoint, endpoint: TrafficMonitor.instance.activeEndpoint,
); );
if (content == null) { if (content == null) {
if (mounted) showCustomNotification(context, 'Нет запросов для лога'); if (mounted) showCustomNotification(context, 'Лог пуст');
return; return;
} }
final bytes = Uint8List.fromList(utf8.encode(content)); final bytes = Uint8List.fromList(utf8.encode(content));
+23 -14
View File
@@ -11,6 +11,7 @@ import '../../../core/storage/app_database.dart';
import '../../../core/utils/format.dart'; import '../../../core/utils/format.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../main.dart'; import '../../../main.dart';
import '../../widgets/avatar_history_screen.dart';
import '../../widgets/connection_status.dart'; import '../../widgets/connection_status.dart';
import '../../widgets/info_action_sheet.dart'; import '../../widgets/info_action_sheet.dart';
import '../../widgets/komet_avatar.dart'; import '../../widgets/komet_avatar.dart';
@@ -557,21 +558,29 @@ class _SettingsTabState extends State<SettingsTab> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( GestureDetector(
width: 88, onTap: () => AvatarHistoryScreen.open(
height: 88, context,
decoration: BoxDecoration( contactId: _profile?.id ?? 0,
shape: BoxShape.circle,
border: Border.all(
color: cs.primary.withValues(alpha: 0.5),
width: 2.5,
),
),
child: KometAvatar(
name: name, name: name,
imageUrl: _profile?.baseUrl, currentAvatarUrl: _profile?.baseUrl,
size: 88, ),
fontSize: 32, child: Container(
width: 88,
height: 88,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: cs.primary.withValues(alpha: 0.5),
width: 2.5,
),
),
child: KometAvatar(
name: name,
imageUrl: _profile?.baseUrl,
size: 88,
fontSize: 32,
),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -0,0 +1,350 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/contacts.dart';
import '../../core/utils/media_saver.dart';
import '../../main.dart';
import 'custom_notification.dart';
class AvatarHistoryScreen extends StatefulWidget {
final int contactId;
final String? name;
final String? currentAvatarUrl;
const AvatarHistoryScreen({
super.key,
required this.contactId,
this.name,
this.currentAvatarUrl,
});
static Future<void> open(
BuildContext context, {
required int contactId,
String? name,
String? currentAvatarUrl,
}) {
final url = currentAvatarUrl;
if (url == null || url.isEmpty) return Future.value();
return Navigator.of(context).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => AvatarHistoryScreen(
contactId: contactId,
name: name,
currentAvatarUrl: url,
),
),
);
}
@override
State<AvatarHistoryScreen> createState() => _AvatarHistoryScreenState();
}
class _AvatarHistoryScreenState extends State<AvatarHistoryScreen> {
static const int _pageSize = 50;
static const int _maxDots = 10;
final PageController _pageController = PageController();
String? _current;
final List<String> _history = [];
List<String> _pages = const [];
int _historyTotal = 0;
int _index = 0;
bool _loading = true;
bool _loadingMore = false;
bool _saving = false;
@override
void initState() {
super.initState();
final current = widget.currentAvatarUrl;
_current = (current != null && current.isNotEmpty) ? current : null;
_rebuildPages();
_load();
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _rebuildPages() {
_pages = _current == null ? List.of(_history) : [_current!, ..._history];
}
void _addHistory(List<String> urls) {
for (final url in urls) {
if (url == _current || _history.contains(url)) continue;
_history.add(url);
}
}
Future<void> _load() async {
final photos = await ContactsModule.fetchPhotos(
api,
widget.contactId,
count: _pageSize,
);
if (!mounted) return;
setState(() {
_addHistory(photos.urls);
_historyTotal = photos.total;
_rebuildPages();
_loading = false;
});
}
Future<void> _loadMore() async {
if (_loadingMore || _history.length >= _historyTotal) return;
_loadingMore = true;
final photos = await ContactsModule.fetchPhotos(
api,
widget.contactId,
from: _history.length,
count: _pageSize,
);
if (!mounted) {
_loadingMore = false;
return;
}
setState(() {
_addHistory(photos.urls);
if (photos.total > _historyTotal) _historyTotal = photos.total;
_rebuildPages();
});
_loadingMore = false;
}
void _onPageChanged(int index) {
setState(() => _index = index);
if (index >= _pages.length - 2) _loadMore();
}
void _prev() {
if (_index <= 0) return;
_pageController.previousPage(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
);
}
void _next() {
if (_index >= _pages.length - 1) return;
_pageController.nextPage(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
);
}
Future<void> _save() async {
if (_saving || _index >= _pages.length) return;
setState(() => _saving = true);
final result = await saveImageFromUrl(_pages[_index]);
if (!mounted) return;
setState(() => _saving = false);
final message = result.ok
? (result.toGallery
? 'Сохранено в галерею'
: 'Сохранено: ${result.location}')
: 'Не удалось сохранить: ${result.error}';
showCustomNotification(context, message);
}
int get _count {
final total = _historyTotal + (_current != null ? 1 : 0);
return total > _pages.length ? total : _pages.length;
}
@override
Widget build(BuildContext context) {
final topPad = MediaQuery.of(context).padding.top;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(child: _buildBody()),
if (_pages.length > 1 && _index > 0)
_navButton(
alignLeft: true,
icon: Symbols.chevron_left,
onTap: _prev,
),
if (_pages.length > 1 && _index < _pages.length - 1)
_navButton(
alignLeft: false,
icon: Symbols.chevron_right,
onTap: _next,
),
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
height: topPad + 76,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.55),
Colors.transparent,
],
),
),
),
),
Positioned(
top: topPad + 4,
left: 4,
right: 4,
child: Row(
children: [
IconButton(
icon: const Icon(Symbols.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
Expanded(child: _buildCounter()),
IconButton(
icon: _saving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: Colors.white,
),
)
: const Icon(Symbols.download, color: Colors.white),
onPressed: _pages.isEmpty || _saving ? null : _save,
),
],
),
),
if (_pages.length > 1 && _pages.length <= _maxDots)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 18,
left: 0,
right: 0,
child: _buildDots(),
),
],
),
);
}
Widget _buildCounter() {
final hasName = widget.name != null && widget.name!.isNotEmpty;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_pages.length > 1)
Text(
'${_index + 1} из $_count',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
)
else if (hasName)
Text(
widget.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
if (_pages.length > 1 && hasName)
Text(
widget.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
);
}
Widget _buildBody() {
if (_pages.isEmpty) {
return Center(
child: _loading
? const CircularProgressIndicator(color: Colors.white)
: const Text(
'Нет фотографий',
style: TextStyle(color: Colors.white54, fontSize: 15),
),
);
}
return PageView.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
itemCount: _pages.length,
itemBuilder: (context, i) => Center(
child: CachedNetworkImage(
imageUrl: _pages[i],
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 120),
placeholder: (_, _) => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
errorWidget: (_, _, _) =>
const Icon(Symbols.broken_image, color: Colors.white54, size: 64),
),
),
);
}
Widget _navButton({
required bool alignLeft,
required IconData icon,
required VoidCallback onTap,
}) {
return Positioned(
top: 0,
bottom: 0,
left: alignLeft ? 8 : null,
right: alignLeft ? null : 8,
child: Center(
child: Material(
color: Colors.black.withValues(alpha: 0.35),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(icon, color: Colors.white, size: 30),
),
),
),
),
);
}
Widget _buildDots() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < _pages.length; i++)
AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: i == _index ? 8 : 6,
height: i == _index ? 8 : 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == _index ? Colors.white : Colors.white38,
),
),
],
);
}
}
+39
View File
@@ -105,8 +105,47 @@ Future<Locale> _loadInitialLocale() async {
return const Locale('ru'); return const Locale('ru');
} }
void _installLogCapture() {
final previousDebugPrint = debugPrint;
debugPrint = (String? message, {int? wrapWidth}) {
if (message != null) {
final t = DateTime.now();
final stamp =
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}.${t.millisecond.toString().padLeft(3, '0')}';
DebugSessionLog.instance.recordLogLine(' |$stamp P $message');
}
previousDebugPrint(message, wrapWidth: wrapWidth);
};
final previousFlutterOnError = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
DebugSessionLog.instance.recordLogLine(
' | FlutterError: ${details.exceptionAsString()}',
);
if (details.stack != null) {
DebugSessionLog.instance.recordLogLine(details.stack.toString());
}
if (previousFlutterOnError != null) {
previousFlutterOnError(details);
} else {
FlutterError.presentError(details);
}
};
final previousPlatformOnError = ui.PlatformDispatcher.instance.onError;
ui.PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
DebugSessionLog.instance.recordLogLine(' | Uncaught: $error');
DebugSessionLog.instance.recordLogLine(stack.toString());
if (previousPlatformOnError != null) {
return previousPlatformOnError(error, stack);
}
return false;
};
}
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
_installLogCapture();
VideoPlayerMediaKit.ensureInitialized( VideoPlayerMediaKit.ensureInitialized(
windows: true, windows: true,
linux: true, linux: true,
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.5.0+13 version: 0.5.0+14
environment: environment:
sdk: ^3.10.4 sdk: ^3.10.4